From a83923b52dca938eea885c2371c47d147b145fd1 Mon Sep 17 00:00:00 2001 From: Si Le Date: Thu, 22 Feb 2024 01:49:55 -0500 Subject: [PATCH 01/12] Adds attributes comparator and test case --- lib/shopify_api/rest/base.rb | 67 +++++++++++--- .../utils/attributes_comparator.rb | 64 +++++++++++++ test/utils/attributes_comparator_test.rb | 92 +++++++++++++++++++ 3 files changed, 212 insertions(+), 11 deletions(-) create mode 100644 lib/shopify_api/utils/attributes_comparator.rb create mode 100644 test/utils/attributes_comparator_test.rb diff --git a/lib/shopify_api/rest/base.rb b/lib/shopify_api/rest/base.rb index 536d46983..931cfd365 100644 --- a/lib/shopify_api/rest/base.rb +++ b/lib/shopify_api/rest/base.rb @@ -3,6 +3,7 @@ require "active_support/inflector" require "hash_diff" +require "pry-byebug" module ShopifyAPI module Rest @@ -187,6 +188,21 @@ def get_path(http_method:, operation:, entity: nil, ids: {}) custom_prefix ? "#{T.must(custom_prefix).sub(%r{\A/}, "")}/#{match}" : match end + sig do + params( + http_method: Symbol, + operation: Symbol, + ).returns(T.nilable(T::Array[Symbol])) + end + def get_path_ids(http_method:, operation:) + found_path = @paths.find do |path| + http_method == path[:http_method] && operation == path[:operation] + end + return nil if found_path.nil? + + T.cast(found_path[:ids], T::Array[Symbol]) + end + sig do params( http_method: Symbol, @@ -355,9 +371,14 @@ def save! sig { params(update_object: T::Boolean).void } def save(update_object: false) method = deduce_write_verb + + body = { + self.class.json_body_name => attributes_to_update.merge(build_required_attributes(http_method: method)), + } + response = @client.public_send( method, - body: { self.class.json_body_name => attributes_to_update }, + body: body, path: deduce_write_path(method), ) @@ -374,22 +395,34 @@ def save(update_object: false) sig { returns(T::Hash[String, String]) } def attributes_to_update - original_state_for_update = original_state.reject do |attribute, _| + updatable_attributes = original_state.reject do |attribute, _| self.class.read_only_attributes&.include?("@#{attribute}".to_sym) end - diff = HashDiff::Comparison.new( - deep_stringify_keys(original_state_for_update), - deep_stringify_keys(to_hash(true)), - ).left_diff + stringified_updatable_attributes = deep_stringify_keys(updatable_attributes) + stringified_new_attributes = deep_stringify_keys(to_hash(true)) + ShopifyAPI::Utils::AttributesComparator.compare( + stringified_updatable_attributes, + stringified_new_attributes, + ) + end - diff.each do |attribute, value| - if value.is_a?(Hash) && value[0] == HashDiff::NO_VALUE - diff[attribute] = send(attribute) - end + sig { params(http_method: Symbol).returns(T::Hash[String, T.untyped]) } + def build_required_attributes(http_method:) + required_attributes = {} + + primary_key_value = send(self.class.primary_key) + unless primary_key_value.nil? + required_attributes[self.class.primary_key] = primary_key_value end - diff + path_ids = deduce_path_ids(http_method) + path_ids&.each do |path_id| + path_id_value = send(path_id) + required_attributes[path_id.to_s] = path_id_value unless path_id_value.nil? + end + + required_attributes end sig { returns(Symbol) } @@ -409,6 +442,18 @@ def deduce_write_path(method) path end + sig { params(method: Symbol).returns(T.nilable(T::Array[Symbol])) } + def deduce_path_ids(method) + path_ids = self.class.get_path_ids(http_method: method, operation: method) + + if path_ids.nil? + method = method == :post ? :put : :post + path_ids = self.class.get_path_ids(http_method: method, operation: method) + end + + path_ids + end + sig { params(hash: T::Hash[T.any(String, Symbol), T.untyped]).returns(T::Hash[String, String]) } def deep_stringify_keys(hash) hash.each_with_object({}) do |(key, value), result| diff --git a/lib/shopify_api/utils/attributes_comparator.rb b/lib/shopify_api/utils/attributes_comparator.rb new file mode 100644 index 000000000..4db5e48c1 --- /dev/null +++ b/lib/shopify_api/utils/attributes_comparator.rb @@ -0,0 +1,64 @@ +# typed: strict +# frozen_string_literal: true + +module ShopifyAPI + module Utils + module AttributesComparator + class << self + extend T::Sig + + sig do + params( + original_attributes: T::Hash[String, T.untyped], + updated_attributes: T::Hash[String, T.untyped], + ).returns(T::Hash[String, T.untyped]) + end + def compare(original_attributes, updated_attributes) + attributes_diff = HashDiff::Comparison.new( + original_attributes, + updated_attributes, + ).left_diff + + update_value = build_update_value( + attributes_diff, + reference_values: updated_attributes, + ) + + update_value + end + + sig do + params( + diff: T::Hash[String, T.untyped], + path: T::Array[String], + reference_values: T::Hash[String, T.untyped], + ).returns(T::Hash[String, T.untyped]) + end + def build_update_value(diff, path: [], reference_values: {}) + new_hash = {} + + diff.each do |key, value| + current_path = path + [key.to_s] + + if value.is_a?(Hash) + has_numbered_key = value.keys.any? { |k| k.is_a?(Integer) } + ref_value = T.unsafe(reference_values).dig(*current_path) + + if has_numbered_key && ref_value.is_a?(Array) + new_hash[key] = ref_value + else + new_value = build_update_value(value, path: current_path, reference_values: reference_values) + + new_hash[key] = new_value unless new_value.empty? && !ref_value.empty? + end + elsif value != HashDiff::NO_VALUE + new_hash[key] = value + end + end + + new_hash + end + end + end + end +end diff --git a/test/utils/attributes_comparator_test.rb b/test/utils/attributes_comparator_test.rb new file mode 100644 index 000000000..bd375e1aa --- /dev/null +++ b/test/utils/attributes_comparator_test.rb @@ -0,0 +1,92 @@ +# typed: false +# frozen_string_literal: true + +require_relative "../test_helper" + +module ShopifyAPITest + module Utils + class AttributesComparatorTest < Test::Unit::TestCase + def test_attributes_comparator + test_cases = [ + { + name: "returns an empty hash when both are the same", + original_attributes: {}, + updated_attributes: {}, + expected: {}, + }, + { + name: "returns the same hash when diff does not contain a hash", + original_attributes: {}, + updated_attributes: { "a" => 1, "b" => 2 }, + expected: { "a" => 1, "b" => 2 }, + }, + { + name: "always use updated arrays", + original_attributes: { "a" => [] }, + updated_attributes: { "a" => ["x", "y"] }, + expected: { "a" => ["x", "y"] }, + }, + { + name: "overwrites nested arrays with updated array values", + original_attributes: { "a" => { "b" => [{ "c" => 1 }, { "c" => 2 }] } }, + updated_attributes: { "a" => { "b" => [{ "c" => 1 }] } }, + expected: { "a" => { "b" => [{ "c" => 1 }] } }, + }, + { + name: "returns diff values for nested hashes", + original_attributes: { "a" => { "x" => 99 } }, + updated_attributes: { "a" => { "x" => 1, "y" => 2 } }, + expected: { "a" => { "x" => 1, "y" => 2 } }, + }, + { + name: "ignores unchanged attributes", + original_attributes: { "a" => 1, "b" => 2 }, + updated_attributes: { "b" => 3 }, + expected: { "b" => 3 }, + }, + { + name: "ignore nested unchanged attributes", + original_attributes: { "a" => { "b" => 2, "c" => 3 } }, + updated_attributes: { "a" => { "b" => 2 } }, + expected: {}, + }, + { + name: "only updates explicitly changed values", + original_attributes: { "a" => 1, "b" => 2 }, + updated_attributes: { "b" => 2 }, + expected: {}, + }, + { + name: "updates explicitly empty value", + original_attributes: { "a" => { "b" => 2 } }, + updated_attributes: { "a" => {} }, + expected: { "a" => {} }, + }, + { + name: "returns nil values", + original_attributes: {}, + updated_attributes: { "a" => nil, "b" => 2 }, + expected: { "a" => nil, "b" => 2 }, + }, + { + name: "returns nil value in nested hash", + original_attributes: { "a" => { "b" => 1 } }, + updated_attributes: { "a" => { "b" => nil } }, + expected: { "a" => { "b" => nil } }, + }, + ] + + test_cases.each do |test_case| + assert_equal( + test_case[:expected], + ShopifyAPI::Utils::AttributesComparator.compare( + test_case[:original_attributes], + test_case[:updated_attributes], + ), + test_case[:name], + ) + end + end + end + end +end From 9004239dc17ae6f763bd47e9b1505505a8212132 Mon Sep 17 00:00:00 2001 From: Si Le Date: Thu, 22 Feb 2024 09:53:01 -0500 Subject: [PATCH 02/12] Update some test cases to cover more bases --- test/utils/attributes_comparator_test.rb | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/test/utils/attributes_comparator_test.rb b/test/utils/attributes_comparator_test.rb index bd375e1aa..896ffd77b 100644 --- a/test/utils/attributes_comparator_test.rb +++ b/test/utils/attributes_comparator_test.rb @@ -29,8 +29,8 @@ def test_attributes_comparator { name: "overwrites nested arrays with updated array values", original_attributes: { "a" => { "b" => [{ "c" => 1 }, { "c" => 2 }] } }, - updated_attributes: { "a" => { "b" => [{ "c" => 1 }] } }, - expected: { "a" => { "b" => [{ "c" => 1 }] } }, + updated_attributes: { "a" => { "b" => [{ "c" => 2 }] } }, + expected: { "a" => { "b" => [{ "c" => 2 }] } }, }, { name: "returns diff values for nested hashes", @@ -58,9 +58,9 @@ def test_attributes_comparator }, { name: "updates explicitly empty value", - original_attributes: { "a" => { "b" => 2 } }, - updated_attributes: { "a" => {} }, - expected: { "a" => {} }, + original_attributes: { "a" => { "b" => { "c" => 1 } } }, + updated_attributes: { "a" => { "b" => {} } }, + expected: { "a" => { "b" => {} } }, }, { name: "returns nil values", @@ -74,6 +74,12 @@ def test_attributes_comparator updated_attributes: { "a" => { "b" => nil } }, expected: { "a" => { "b" => nil } }, }, + { + name: "hash with numbered key not overwritten if updated value not array", + original_attributes: { "a" => { 0 => "test", 1 => "test2" } }, + updated_attributes: { "a" => { 0 => "test3", 1 => "test4" } }, + expected: { "a" => { 0 => "test3", 1 => "test4" } }, + }, ] test_cases.each do |test_case| From 8ad39864def90ca87e58a21b9af41543376f58c7 Mon Sep 17 00:00:00 2001 From: Si Le Date: Thu, 22 Feb 2024 10:04:54 -0500 Subject: [PATCH 03/12] Removes byebug --- lib/shopify_api/rest/base.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/shopify_api/rest/base.rb b/lib/shopify_api/rest/base.rb index 931cfd365..3d3d638a4 100644 --- a/lib/shopify_api/rest/base.rb +++ b/lib/shopify_api/rest/base.rb @@ -3,7 +3,6 @@ require "active_support/inflector" require "hash_diff" -require "pry-byebug" module ShopifyAPI module Rest From 0971d72b983a2279d373e01e90449b87366c5105 Mon Sep 17 00:00:00 2001 From: Si Le Date: Thu, 22 Feb 2024 10:06:08 -0500 Subject: [PATCH 04/12] Moves hash_diff to comparator --- lib/shopify_api/rest/base.rb | 1 - lib/shopify_api/utils/attributes_comparator.rb | 2 ++ 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/shopify_api/rest/base.rb b/lib/shopify_api/rest/base.rb index 3d3d638a4..28c8fff24 100644 --- a/lib/shopify_api/rest/base.rb +++ b/lib/shopify_api/rest/base.rb @@ -2,7 +2,6 @@ # frozen_string_literal: true require "active_support/inflector" -require "hash_diff" module ShopifyAPI module Rest diff --git a/lib/shopify_api/utils/attributes_comparator.rb b/lib/shopify_api/utils/attributes_comparator.rb index 4db5e48c1..0f01e5261 100644 --- a/lib/shopify_api/utils/attributes_comparator.rb +++ b/lib/shopify_api/utils/attributes_comparator.rb @@ -1,6 +1,8 @@ # typed: strict # frozen_string_literal: true +require "hash_diff" + module ShopifyAPI module Utils module AttributesComparator From bfbb2d496a49882ccee7e02da038591e8c106666 Mon Sep 17 00:00:00 2001 From: Si Le Date: Thu, 22 Feb 2024 13:26:28 -0500 Subject: [PATCH 05/12] Fixes resource base tests --- test/clients/base_rest_resource_test.rb | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/test/clients/base_rest_resource_test.rb b/test/clients/base_rest_resource_test.rb index fae4ad6f8..08125ac28 100644 --- a/test/clients/base_rest_resource_test.rb +++ b/test/clients/base_rest_resource_test.rb @@ -237,7 +237,7 @@ def test_saves_removing_children body = draft_order_data.dup body["line_items"] = [] stubbed_request = stub_request(:put, "#{@prefix}/draft_orders/#{draft_order_data.dig("id")}.json") - .with(body: hash_including("draft_order": { line_items: [] })) + .with(body: hash_including("draft_order": { line_items: [], id: 1124601987358 })) .to_return(status: 200) draft_order.line_items = [] @@ -416,15 +416,17 @@ def test_put_requests_only_modify_changed_attributes headers: { "X-Shopify-Access-Token" => "this_is_a_test_token", "Accept" => "application/json", "Content-Type" => "application/json", }, body: { "product" => hash_including({ "metafields" => [{ "key" => "new", "value" => "newvalue", - "type" => "single_line_text_field", "namespace" => "global", }] }) }, + "type" => "single_line_text_field", "namespace" => "global", }], + "id" => 632910392, }) }, ) .to_return(status: 200, body: JSON.generate({ "product" => { "id" => 632910392, "title" => "IPod Nano - 8GB", "body_html" => "

It's the small iPod with one very big idea: Video. Now the world's most popular music player, available in 4GB and 8GB models, lets you enjoy TV shows, movies, video podcasts, and more. The larger, brighter display means amazing picture quality. In six eye-catching colors, iPod nano is stunning all around. And with models starting at just $149, little speaks volumes.

", "vendor" => "Apple", "product_type" => "Cult Products", "created_at" => "2023-02-02T09:09:49-05:00", "handle" => "ipod-nano", "updated_at" => "2023-02-02T09:28:31-05:00", "published_at" => "2007-12-31T19:00:00-05:00", "template_suffix" => nil, "status" => "active", "published_scope" => "web", "tags" => "Emotive, Flash Memory, MP3, Music", "admin_graphql_api_id" => "gid://shopify/Product/632910392", "variants" => [{ "id" => 808950810, "product_id" => 632910392, "title" => "Pink", "price" => "199.00", "sku" => "IPOD2008PINK", "position" => 1, "inventory_policy" => "continue", "compare_at_price" => nil, "fulfillment_service" => "manual", "inventory_management" => "shopify", "option1" => "Pink", "option2" => nil, "option3" => nil, "created_at" => "2023-02-02T09:09:49-05:00", "updated_at" => "2023-02-02T09:09:49-05:00", "taxable" => true, "barcode" => "1234_pink", "grams" => 567, "image_id" => 562641783, "weight" => 1.25, "weight_unit" => "lb", "inventory_item_id" => 808950810, "inventory_quantity" => 10, "old_inventory_quantity" => 10, "presentment_prices" => [{ "price" => { "amount" => "199.00", "currency_code" => "USD" }, "compare_at_price" => nil }], "requires_shipping" => true, "admin_graphql_api_id" => "gid://shopify/ProductVariant/808950810" }, { "id" => 49148385, "product_id" => 632910392, "title" => "Red", "price" => "199.00", "sku" => "IPOD2008RED", "position" => 2, "inventory_policy" => "continue", "compare_at_price" => nil, "fulfillment_service" => "manual", "inventory_management" => "shopify", "option1" => "Red", "option2" => nil, "option3" => nil, "created_at" => "2023-02-02T09:09:49-05:00", "updated_at" => "2023-02-02T09:09:49-05:00", "taxable" => true, "barcode" => "1234_red", "grams" => 567, "image_id" => nil, "weight" => 1.25, "weight_unit" => "lb", "inventory_item_id" => 49148385, "inventory_quantity" => 20, "old_inventory_quantity" => 20, "presentment_prices" => [{ "price" => { "amount" => "199.00", "currency_code" => "USD" }, "compare_at_price" => nil }], "requires_shipping" => true, "admin_graphql_api_id" => "gid://shopify/ProductVariant/49148385" }, { "id" => 39072856, "product_id" => 632910392, "title" => "Green", "price" => "199.00", "sku" => "IPOD2008GREEN", "position" => 3, "inventory_policy" => "continue", "compare_at_price" => nil, "fulfillment_service" => "manual", "inventory_management" => "shopify", "option1" => "Green", "option2" => nil, "option3" => nil, "created_at" => "2023-02-02T09:09:49-05:00", "updated_at" => "2023-02-02T09:09:49-05:00", "taxable" => true, "barcode" => "1234_green", "grams" => 567, "image_id" => nil, "weight" => 1.25, "weight_unit" => "lb", "inventory_item_id" => 39072856, "inventory_quantity" => 30, "old_inventory_quantity" => 30, "presentment_prices" => [{ "price" => { "amount" => "199.00", "currency_code" => "USD" }, "compare_at_price" => nil }], "requires_shipping" => true, "admin_graphql_api_id" => "gid://shopify/ProductVariant/39072856" }, { "id" => 457924702, "product_id" => 632910392, "title" => "Black", "price" => "199.00", "sku" => "IPOD2008BLACK", "position" => 4, "inventory_policy" => "continue", "compare_at_price" => nil, "fulfillment_service" => "manual", "inventory_management" => "shopify", "option1" => "Black", "option2" => nil, "option3" => nil, "created_at" => "2023-02-02T09:09:49-05:00", "updated_at" => "2023-02-02T09:09:49-05:00", "taxable" => true, "barcode" => "1234_black", "grams" => 567, "image_id" => nil, "weight" => 1.25, "weight_unit" => "lb", "inventory_item_id" => 457924702, "inventory_quantity" => 40, "old_inventory_quantity" => 40, "presentment_prices" => [{ "price" => { "amount" => "199.00", "currency_code" => "USD" }, "compare_at_price" => nil }], "requires_shipping" => true, "admin_graphql_api_id" => "gid://shopify/ProductVariant/457924702" }], "options" => [{ "id" => 594680422, "product_id" => 632910392, "name" => "Color", "position" => 1, "values" => ["Pink", "Red", "Green", "Black"] }], "images" => [{ "id" => 850703190, "product_id" => 632910392, "position" => 1, "created_at" => "2023-02-02T09:09:49-05:00", "updated_at" => "2023-02-02T09:09:49-05:00", "alt" => nil, "width" => 123, "height" => 456, "src" => "https://cdn.shopify.com/s/files/1/0005/4838/0009/products/ipod-nano.png?v=1675346989", "variant_ids" => [], "admin_graphql_api_id" => "gid://shopify/ProductImage/850703190" }, { "id" => 562641783, "product_id" => 632910392, "position" => 2, "created_at" => "2023-02-02T09:09:49-05:00", "updated_at" => "2023-02-02T09:09:49-05:00", "alt" => nil, "width" => 123, "height" => 456, "src" => "https://cdn.shopify.com/s/files/1/0005/4838/0009/products/ipod-nano-2.png?v=1675346989", "variant_ids" => [808950810], "admin_graphql_api_id" => "gid://shopify/ProductImage/562641783" }, { "id" => 378407906, "product_id" => 632910392, "position" => 3, "created_at" => "2023-02-02T09:09:49-05:00", "updated_at" => "2023-02-02T09:09:49-05:00", "alt" => nil, "width" => 123, "height" => 456, "src" => "https://cdn.shopify.com/s/files/1/0005/4838/0009/products/ipod-nano.png?v=1675346989", "variant_ids" => [], "admin_graphql_api_id" => "gid://shopify/ProductImage/378407906" }], "image" => { "id" => 850703190, "product_id" => 632910392, "position" => 1, "created_at" => "2023-02-02T09:09:49-05:00", "updated_at" => "2023-02-02T09:09:49-05:00", "alt" => nil, "width" => 123, "height" => 456, "src" => "https://cdn.shopify.com/s/files/1/0005/4838/0009/products/ipod-nano.png?v=1675346989", "variant_ids" => [], "admin_graphql_api_id" => "gid://shopify/ProductImage/850703190" }, } }), headers: {}) product = ShopifyAPI::Product.find(id: 632910392, session: @session) + product.client.expects(:put).with( body: { "product" => { "metafields" => [{ "key" => "new", "value" => "newvalue", "type" => "single_line_text_field", - "namespace" => "global", }] } }, + "namespace" => "global", }], "id" => 632910392, } }, path: "products/632910392.json", ) product.metafields = [ @@ -438,7 +440,7 @@ def test_put_requests_only_modify_changed_attributes product.save end - def test_put_request_for_has_one_associaiton_works + def test_put_request_for_has_one_association_works stub_request(:get, "https://test-shop.myshopify.com/admin/api/#{ShopifyAPI::Context.api_version}/customers/207119551.json") .to_return(status: 200, body: JSON.generate({ "customer" => { "id" => 207119551, "email" => "bob.norman@mail.example.com", "accepts_marketing" => false, "created_at" => "2023-02-02T09:42:27-05:00", "updated_at" => "2023-02-02T09:42:27-05:00", "first_name" => "Bob", "last_name" => "Norman", "orders_count" => 1, "state" => "disabled", "total_spent" => "199.65", "last_order_id" => 450789469, "note" => nil, "verified_email" => true, "multipass_identifier" => nil, "tax_exempt" => false, "tags" => "L\u00E9on, No\u00EBl", "last_order_name" => "#1001", "currency" => "USD", "phone" => "+16136120707", "addresses" => [{ "id" => 207119551, "customer_id" => 207119551, "first_name" => nil, "last_name" => nil, "company" => nil, "address1" => "Chestnut Street 92", "address2" => "", "city" => "Louisville", "province" => "Kentucky", "country" => "United States", "zip" => "40202", "phone" => "555-625-1199", "name" => "", "province_code" => "KY", "country_code" => "US", "country_name" => "United States", "default" => true }], "accepts_marketing_updated_at" => "2005-06-12T11:57:11-04:00", "marketing_opt_in_level" => nil, "tax_exemptions" => [], "email_marketing_consent" => { "state" => "not_subscribed", "opt_in_level" => nil, "consent_updated_at" => "2004-06-13T11:57:11-04:00" }, "sms_marketing_consent" => { "state" => "not_subscribed", "opt_in_level" => "single_opt_in", "consent_updated_at" => "2023-02-02T09:42:27-05:00", "consent_collected_from" => "OTHER" }, "admin_graphql_api_id" => "gid://shopify/Customer/207119551", "default_address" => { "id" => 207119551, "customer_id" => 207119551, "first_name" => nil, "last_name" => nil, "company" => nil, "address1" => "Chestnut Street 92", "address2" => "", "city" => "Louisville", "province" => "Kentucky", "country" => "United States", "zip" => "40202", "phone" => "555-625-1199", "name" => "", "province_code" => "KY", "country_code" => "US", "country_name" => "United States", "default" => true }, } }), headers: {}) @@ -448,7 +450,7 @@ def test_put_request_for_has_one_associaiton_works session: @session, ) customer.client.expects(:put).with( - body: { "customer" => { "tags" => "New Customer, Repeat Customer" } }, + body: { "customer" => { "tags" => "New Customer, Repeat Customer", "id" => 207119551 } }, path: "customers/207119551.json", ) customer.tags = "New Customer, Repeat Customer" @@ -496,7 +498,7 @@ def test_put_requests_for_resource_with_read_only_attributes variant = ShopifyAPI::Variant.find(id: 169, session: @session) variant.client.expects(:put).with( - body: { "variant" => { "barcode" => "1234" } }, + body: { "variant" => { "barcode" => "1234", "id" => 169 } }, path: "variants/169.json", ) variant.barcode = "1234" From 910e33acd4617badc6442401a541f66c7061777e Mon Sep 17 00:00:00 2001 From: Si Le Date: Thu, 22 Feb 2024 13:29:32 -0500 Subject: [PATCH 06/12] Adds changelog entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cb132707..5ce496d12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ Note: For changes to the API, see https://shopify.dev/changelog?filter=api ## Unreleased +- [#1282](https://github.com/Shopify/shopify-api-ruby/pull/1282) Fixes a bug where diffing attributes to update not take into account of Array changes and required ids. - [#1254](https://github.com/Shopify/shopify-api-ruby/pull/1254) Introduce token exchange API for fetching access tokens. This feature is currently unstable and cannot be used yet. - [#1268](https://github.com/Shopify/shopify-api-ruby/pull/1268) Add [new webhook handler interface](https://github.com/Shopify/shopify-api-ruby/blob/main/docs/usage/webhooks.md#create-a-webhook-handler) to provide `webhook_id ` and `api_version` information to webhook handlers. - [#1274](https://github.com/Shopify/shopify-api-ruby/pull/1274) Update sorbet and rbi dependencies. Remove support for ruby 2.7. From c0621c712e68f74377e354c24a570412ba543aba Mon Sep 17 00:00:00 2001 From: Si Le Date: Fri, 23 Feb 2024 10:49:25 -0500 Subject: [PATCH 07/12] Changes `unless` condition to `if` and add some comments to explain --- .../utils/attributes_comparator.rb | 21 ++++++++++++++++++- test/utils/attributes_comparator_test.rb | 6 ++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/lib/shopify_api/utils/attributes_comparator.rb b/lib/shopify_api/utils/attributes_comparator.rb index 0f01e5261..8ae0d155b 100644 --- a/lib/shopify_api/utils/attributes_comparator.rb +++ b/lib/shopify_api/utils/attributes_comparator.rb @@ -51,7 +51,26 @@ def build_update_value(diff, path: [], reference_values: {}) else new_value = build_update_value(value, path: current_path, reference_values: reference_values) - new_hash[key] = new_value unless new_value.empty? && !ref_value.empty? + # Only add to new_hash if the user intentionally updates + # to empty value like `{}` or `[]`. For example: + # + # original = { "a" => { "foo" => 1 } } + # updated = { "a" => {} } + # diff = { "a" => { "foo" => HashDiff::NO_VALUE } } + # key = "a", new_value = {}, ref_value = {} + # new_hash = { "a" => {} } + # + # In addition, we omit cases where after removing `HashDiff::NO_VALUE` + # we only have `{}` left. For example: + # + # original = { "a" => { "foo" => 1, "bar" => 2} } + # updated = { "a" => { "foo" => 1 } } + # diff = { "a" => { "bar" => HashDiff::NO_VALUE } } + # key = "a", new_value = {}, ref_value = { "foo" => 1 } + # new_hash = {} + # + # new_hash is empty because nothing changes + new_hash[key] = new_value if !new_value.empty? || ref_value.empty? end elsif value != HashDiff::NO_VALUE new_hash[key] = value diff --git a/test/utils/attributes_comparator_test.rb b/test/utils/attributes_comparator_test.rb index 896ffd77b..e01a6c665 100644 --- a/test/utils/attributes_comparator_test.rb +++ b/test/utils/attributes_comparator_test.rb @@ -26,6 +26,12 @@ def test_attributes_comparator updated_attributes: { "a" => ["x", "y"] }, expected: { "a" => ["x", "y"] }, }, + { + name: "allows setting empty array", + original_attributes: { "a" => ["x", "y"] }, + updated_attributes: { "a" => [] }, + expected: { "a" => [] }, + }, { name: "overwrites nested arrays with updated array values", original_attributes: { "a" => { "b" => [{ "c" => 1 }, { "c" => 2 }] } }, From 327a8193df58e6469d79584ec20eec82c614e4b7 Mon Sep 17 00:00:00 2001 From: Si Le Date: Fri, 23 Feb 2024 14:27:13 -0500 Subject: [PATCH 08/12] Updates rest.md to explain how updating resource works --- docs/usage/rest.md | 78 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 68 insertions(+), 10 deletions(-) diff --git a/docs/usage/rest.md b/docs/usage/rest.md index b4d9dafa8..a5c2d08d3 100644 --- a/docs/usage/rest.md +++ b/docs/usage/rest.md @@ -69,18 +69,18 @@ Typical methods provided for each resources are: Full list of methods can be found on each of the resource class. - Path: - https://github.com/Shopify/shopify-api-ruby/blob/main/lib/shopify_api/rest/resources/#{version}/#{resource}.rb -- Example for `Order` resource on `2023-04` version: - - https://github.com/Shopify/shopify-api-ruby/blob/main/lib/shopify_api/rest/resources/2023_04/order.rb +- Example for `Order` resource on `2024-01` version: + - https://github.com/Shopify/shopify-api-ruby/blob/main/lib/shopify_api/rest/resources/2024_01/order.rb -### Usage Examples -⚠️ Reference documentation on [shopify.dev](https://shopify.dev/docs/api/admin-rest) contains more examples on how to use each REST Resources. +### The `save` method -```Ruby -# Find and update a customer email -customer = ShopifyAPI::Customer.find(id: customer_id) -customer.email = "steve-lastnameson@example.com" -customer.save! +The `save` or `save!` method on a resource allows you to `create` or `update` that resource. + +#### Create a new resource +To create a new resource using the `save` or `save!` method, you can initialize the resource with a hash of values or simply assigning them manually. For example: + +```Ruby # Create a new product from hash product_properties = { title: "My awesome product" @@ -88,10 +88,68 @@ product_properties = { product = ShopifyAPI::Product.new(from_hash: product_properties) product.save! -# Create a product manually +# Create a new product manually product = ShopifyAPI::Product.new product.title = "Another one" product.save! +``` + +#### Update an existing resource + +To update an existing resource using the `save` or `save!` method, you'll need to fetch the resource from Shopify first. Then, you can manually assign new values to the resource before calling `save` or `save!`. For example: + +```Ruby +# Update a product's title +product = ShopifyAPI::Product.find(id: product_id) +product.title = "My new title" +product.save! + +# Remove a line item from a draft order +draft_order = ShopifyAPI::DraftOrder.find(id: draft_order_id) + +new_line_items = draft_order.line_items.reject { |line_item| line_item["id"] == 12345 } +draft_order.line_items = new_line_items + +draft_order.save! +``` + +> [!IMPORTANT] +> If you need to unset an existing value, +> please explicitly set that attribute to `nil` or empty values such as `[]` or `{}`. For example: +> +> ```Ruby +> # Removes shipping address from draft_order +> draft_order.shipping_address = {} +> draft_order.save! +> ``` +> +> This is because only changed values are sent to the API, so if `shipping_address` is not "changed" to `{}`. It won't be part of the PUT request payload + +When `save` is called to update a resource, only changed attributes, the resource's primary key and required parameters are sent to the API. Most resources' primary key will be its `id` value. It could be different if the `primary_key` method in the `ShopifyAPI::Rest::Base` class is overwritten. Required parameters are determined using the `ids` key in each resource's `@path` instance variable. For example: + +The `put` operation in the [Asset resource](https://github.com/Shopify/shopify-api-ruby/blob/main/lib/shopify_api/rest/resources/2024_01/asset.rb)'s `@paths` instance variable has `theme_id` as part of the `ids` array. It has a `primary_key` method overwrite to use `key` as its primary key. Therefore, the `theme_id`, and `key` values are attached to the payload whenever you call `save` or `save!` to update an Asset resource. + +This results in a payload as follow: + +```Ruby +PUT "themes/12345/assets.json" +{ + "asset" => { + "key" => "template/index.liquid", # primary key + "theme_id" => 12345, # required parameter + # ... any changed values + } +} +``` + +### Usage Examples +⚠️ Reference documentation on [shopify.dev](https://shopify.dev/docs/api/admin-rest) contains more examples on how to use each REST Resources. + +```Ruby +# Find and update a customer email +customer = ShopifyAPI::Customer.find(id: customer_id) +customer.email = "steve-lastnameson@example.com" +customer.save! # Get all orders orders = ShopifyAPI::Orders.all From 930e582806c89fb6b0b542e6fc6e3461794ae950 Mon Sep 17 00:00:00 2001 From: Si Le Date: Fri, 23 Feb 2024 14:32:53 -0500 Subject: [PATCH 09/12] Minor content format --- docs/usage/rest.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/usage/rest.md b/docs/usage/rest.md index a5c2d08d3..d4b29b3fd 100644 --- a/docs/usage/rest.md +++ b/docs/usage/rest.md @@ -125,9 +125,9 @@ draft_order.save! > > This is because only changed values are sent to the API, so if `shipping_address` is not "changed" to `{}`. It won't be part of the PUT request payload -When `save` is called to update a resource, only changed attributes, the resource's primary key and required parameters are sent to the API. Most resources' primary key will be its `id` value. It could be different if the `primary_key` method in the `ShopifyAPI::Rest::Base` class is overwritten. Required parameters are determined using the `ids` key in each resource's `@path` instance variable. For example: +When `save` is called to update a resource, only changed attributes, the resource's primary key and required parameters are sent to the API. Most resources' primary key will be its `id` value. It could be different if the `primary_key` method in the `ShopifyAPI::Rest::Base` class is overwritten. Required parameters are determined using the `ids` key in each resource's `@path` instance variable. -The `put` operation in the [Asset resource](https://github.com/Shopify/shopify-api-ruby/blob/main/lib/shopify_api/rest/resources/2024_01/asset.rb)'s `@paths` instance variable has `theme_id` as part of the `ids` array. It has a `primary_key` method overwrite to use `key` as its primary key. Therefore, the `theme_id`, and `key` values are attached to the payload whenever you call `save` or `save!` to update an Asset resource. +For example, the `put` operation in the [Asset resource](https://github.com/Shopify/shopify-api-ruby/blob/main/lib/shopify_api/rest/resources/2024_01/asset.rb)'s `@paths` instance variable has `theme_id` as part of the `ids` array. It has a `primary_key` method overwrite to use `key` as its primary key. Therefore, the `theme_id`, and `key` values are attached to the payload whenever you call `save` or `save!` to update an Asset resource. This results in a payload as follow: From a09db3634746165976a7f30f45c565c61db4e701 Mon Sep 17 00:00:00 2001 From: Si Le Date: Fri, 23 Feb 2024 14:36:51 -0500 Subject: [PATCH 10/12] Fixes typo --- docs/usage/rest.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/usage/rest.md b/docs/usage/rest.md index d4b29b3fd..900773654 100644 --- a/docs/usage/rest.md +++ b/docs/usage/rest.md @@ -125,7 +125,7 @@ draft_order.save! > > This is because only changed values are sent to the API, so if `shipping_address` is not "changed" to `{}`. It won't be part of the PUT request payload -When `save` is called to update a resource, only changed attributes, the resource's primary key and required parameters are sent to the API. Most resources' primary key will be its `id` value. It could be different if the `primary_key` method in the `ShopifyAPI::Rest::Base` class is overwritten. Required parameters are determined using the `ids` key in each resource's `@path` instance variable. +When `save` is called to update a resource, only changed attributes, the resource's primary key and required parameters are sent to the API. Most resources' primary key will be its `id` value. It could be different if the `primary_key` method in the `ShopifyAPI::Rest::Base` class is overwritten. Required parameters are determined using the `ids` key in each resource's `@paths` instance variable. For example, the `put` operation in the [Asset resource](https://github.com/Shopify/shopify-api-ruby/blob/main/lib/shopify_api/rest/resources/2024_01/asset.rb)'s `@paths` instance variable has `theme_id` as part of the `ids` array. It has a `primary_key` method overwrite to use `key` as its primary key. Therefore, the `theme_id`, and `key` values are attached to the payload whenever you call `save` or `save!` to update an Asset resource. From 221f06e08f3def561e08a6876379006d0d318573 Mon Sep 17 00:00:00 2001 From: Si Le <10522258+sle-c@users.noreply.github.com> Date: Fri, 23 Feb 2024 14:48:10 -0500 Subject: [PATCH 11/12] Update docs/usage/rest.md Co-authored-by: Paulo Margarido <64600052+paulomarg@users.noreply.github.com> --- docs/usage/rest.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/usage/rest.md b/docs/usage/rest.md index 900773654..fe6365679 100644 --- a/docs/usage/rest.md +++ b/docs/usage/rest.md @@ -143,7 +143,7 @@ PUT "themes/12345/assets.json" ``` ### Usage Examples -⚠️ Reference documentation on [shopify.dev](https://shopify.dev/docs/api/admin-rest) contains more examples on how to use each REST Resources. +⚠️ The [API reference documentation](https://shopify.dev/docs/api/admin-rest) contains more examples on how to use each REST Resources. ```Ruby # Find and update a customer email From 938aece28c69d6e2187c765f1ef74e5e90faa95d Mon Sep 17 00:00:00 2001 From: Si Le Date: Fri, 23 Feb 2024 15:19:01 -0500 Subject: [PATCH 12/12] Updates rest.md to omit implementation details --- docs/usage/rest.md | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/docs/usage/rest.md b/docs/usage/rest.md index fe6365679..20114a1ee 100644 --- a/docs/usage/rest.md +++ b/docs/usage/rest.md @@ -123,24 +123,9 @@ draft_order.save! > draft_order.save! > ``` > -> This is because only changed values are sent to the API, so if `shipping_address` is not "changed" to `{}`. It won't be part of the PUT request payload +> This is because only modified values are sent to the API, so if `shipping_address` is not "modified" to `{}`. It won't be part of the PUT request payload -When `save` is called to update a resource, only changed attributes, the resource's primary key and required parameters are sent to the API. Most resources' primary key will be its `id` value. It could be different if the `primary_key` method in the `ShopifyAPI::Rest::Base` class is overwritten. Required parameters are determined using the `ids` key in each resource's `@paths` instance variable. - -For example, the `put` operation in the [Asset resource](https://github.com/Shopify/shopify-api-ruby/blob/main/lib/shopify_api/rest/resources/2024_01/asset.rb)'s `@paths` instance variable has `theme_id` as part of the `ids` array. It has a `primary_key` method overwrite to use `key` as its primary key. Therefore, the `theme_id`, and `key` values are attached to the payload whenever you call `save` or `save!` to update an Asset resource. - -This results in a payload as follow: - -```Ruby -PUT "themes/12345/assets.json" -{ - "asset" => { - "key" => "template/index.liquid", # primary key - "theme_id" => 12345, # required parameter - # ... any changed values - } -} -``` +When updating a resource, only the modified attributes, the resource's primary key, and required parameters are sent to the API. The primary key is usually the `id` attribute of the resource, but it can vary if the `primary_key` method is overwritten in the resource's class. The required parameters are identified using the path parameters of the `PUT` endpoint of the resource. ### Usage Examples ⚠️ The [API reference documentation](https://shopify.dev/docs/api/admin-rest) contains more examples on how to use each REST Resources.