shopify_api-graphql-metafields 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5e493b7cb3098ec7dd032a55e47d6ddf3a9537c7c98a4ad057f81ea236c25ed0
4
+ data.tar.gz: 4de07ec76fc07d3cfac3f75a32fdb938db3342d6ad122ffda1f0b0f7a53f852b
5
+ SHA512:
6
+ metadata.gz: b601b92ce0407b819fcf72c9fe8921eeee55bc55a0ac45ab53f6764dfeac61e0001d715ab3e51f86e9f7beab6a2e9c7e7a9d9a6bd6ff7b00b5cea29eeb1eca6b
7
+ data.tar.gz: b24e57743b0d5db7b4c31231e86820b76dca0513ba96c1726bfb97640410c8ad4e9a5c4411006c3a2663b308044f9bc9a432241ba7546e6ca647ea04272241ca
data/.env.template ADDED
@@ -0,0 +1,2 @@
1
+ SHOPIFY_DOMAIN=
2
+ SHOPIFY_ADMIN_TOKEN=
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 ScreenStaring
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,239 @@
1
+ # ShopifyAPI::GraphQL::Metafields
2
+
3
+ User-friendly metafield wrapper around Shopify's GraphQL Admin API with built-in retry, batching, and error handling.
4
+
5
+ [![CI](https://github.com/ScreenStaring/shopify_api-graphql-metafields/actions/workflows/main.yml/badge.svg)](https://github.com/ScreenStaring/shopify_api-graphql-metafields/actions)
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ bundle add shopify_api-graphql-metafields
11
+ ```
12
+
13
+ If bundler is not being used to manage dependencies:
14
+
15
+ ```bash
16
+ gem install shopify_api-graphql-metafields
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ `ShopifyAPI::GraphQL::Metafields` supports CRUD operations on Metafields and Metafield Definitions, and supports setting metafields in bulk.
22
+
23
+ ### Metafields
24
+
25
+ #### Upsert Metafields
26
+
27
+ You can upsert a single metafield or multiple metafields:
28
+
29
+ ```rb
30
+ require "shopify_api/graphql/metafields"
31
+
32
+ metafields = [
33
+ {
34
+ :owner_id => "gid://shopify/Product/123",
35
+ :namespace => "something",
36
+ :key => "foo",
37
+ # Not required if there's a metafield definition
38
+ :type => "number_integer",
39
+ :value => "123"
40
+ },
41
+ {
42
+ :owner_id => "gid://shopify/Shop/123",
43
+ :namespace => "whatever",
44
+ :key => "bar",
45
+ :type => "json",
46
+ :value => JSON.dump(:a => 123, :b => "bee")
47
+ }
48
+ ]
49
+
50
+ mf = ShopifyAPI::GraphQL::Metafields.new(shop, token)
51
+
52
+ begin
53
+ results = mf.set(metafields)
54
+ results.each do |result|
55
+ # result is a Hash with underscored Symbol keys
56
+ p result.slice(:id, :value)
57
+ end
58
+ rescue ShopifyAPI::GraphQL::Metafields::Error => e
59
+ p e
60
+ end
61
+ ```
62
+
63
+ To override the fields returned see [Field Selection](#field-selection).
64
+
65
+ #### Delete Metafields
66
+
67
+ ```rb
68
+ metafields = [
69
+ {
70
+ :owner_id => "gid://shopify/Product/123",
71
+ :namespace => "something",
72
+ :key => "foo"
73
+ },
74
+ {
75
+ :owner_id => "gid://shopify/Shop/123",
76
+ :namespace => "whatever",
77
+ :key => "bar"
78
+ }
79
+ ]
80
+
81
+ mf = ShopifyAPI::GraphQL::Metafields.new(shop, token)
82
+
83
+ begin
84
+ results = mf.delete(metafields)
85
+ results.each do |result|
86
+ # result is a Hash
87
+ p result.slice(:key, :namespace)
88
+ end
89
+ rescue ShopifyAPI::GraphQL::Metafields::Error => e
90
+ p e
91
+ end
92
+ ```
93
+
94
+ To override the fields returned see [Field Selection](#field-selection).
95
+
96
+ ### Field Selection
97
+
98
+ Use the `:fields` option to select what fields are returned. This accepts an `Array` of field names:
99
+
100
+ ```rb
101
+ mf = ShopifyAPI::GraphQL::Metafields.new(shop, token)
102
+ mf.set(metafield, :fields => %w[compare_digest legacy_resource_id description])
103
+ ```
104
+
105
+ Or a String containing literal GraphQL fields:
106
+
107
+ ```rb
108
+ mf.set(metafield, :fields => "legacyResourceId constraints { key }")
109
+ ```
110
+
111
+ ### Setting Metafields in Bulk
112
+
113
+ ```rb
114
+ require "shopify_api/graphql/metafields"
115
+
116
+ set = ShopifyAPI::GraphQL::Metafields::Batch::Set.new(shop, token)
117
+ set << metafield1
118
+ set << metafield2
119
+ # ...
120
+ set << metafield100
121
+
122
+ begin
123
+ result = set.flush!
124
+ rescue ShopifyAPI::GraphQL::Metafields::Batch::Error => e
125
+ p "Bulk set failed: #{e}"
126
+ if e.results.any?
127
+ p "We did have _some_ success:"
128
+ pp e.results
129
+ end
130
+
131
+ if e.errors.any?
132
+ p "We encountered some errors:"
133
+ pp e.errors
134
+ end
135
+
136
+ if e.user_errors.any?
137
+ p "We encountered some user errors:"
138
+ pp e.user_errors
139
+ end
140
+ end
141
+ ```
142
+
143
+ Note that this _does not_ use the Shopify Bulk API. If you want to use the Bulk API for metafields or other operations
144
+ see [`ShopifyAPI::GraphQL::Bulk`](https://github.com/ScreenStaring/shopify_api-graphql-bulk).
145
+
146
+ Once `#flush!` is called worker threads (if needed) will be spawned and metafields will be set 25 at a time, per Shopify's `Mutations.metafieldsSet` call limit.
147
+ You can control the number of threads via the `:worker_count` option:
148
+
149
+ ```rb
150
+ set = ShopifyAPI::GraphQL::Metafields::Bulk.new(shop, token, :worker_count => 8)
151
+ ```
152
+
153
+ Workers are spawned based on the batch size. If you have 50 metafields only 2 workers will be spawned. If you have 25 or less only no workers are spawned.
154
+
155
+ The default number of workers is 4.
156
+
157
+ The return value is an `Array` of results like one gets with `Metafield#set`. The indices will align with the input metafields' indices.
158
+ A `nil` value means that position errored.
159
+
160
+ ### Deleting Metafields in Bulk
161
+
162
+ TODO!
163
+
164
+ If you need this you can use Shopify's Bulk API via [`ShopifyAPI::GraphQL::Bulk`](https://github.com/ScreenStaring/shopify_api-graphql-bulk).
165
+
166
+ ### Metafield Definitions
167
+
168
+ ```rb
169
+ require "shopify_api/graphql/metafields"
170
+
171
+ dfn = ShopifyAPI::GraphQL::Metafields::Definition.new(shop, token)
172
+ result = dfn.create(
173
+ :name => "My amaaaazing definition",
174
+ :namespace => "sshaw",
175
+ :key => "ingredients",
176
+ :description => "A product's ingredients",
177
+ :type => "multi_line_text_field",
178
+ :owner_type => "PRODUCT"
179
+ )
180
+
181
+ id = result.dig(:created_definition, :id)
182
+
183
+ result = dfn.update(
184
+ :namespace => "sshaw",
185
+ :key => "ingredients",
186
+ :owner_type => "PRODUCT",
187
+ :description => "A products SUPER SECRET ingredients"
188
+ )
189
+
190
+ # More CRUD operations...
191
+ ```
192
+
193
+ #### Field Selection
194
+
195
+ Similar to other metafield operation you can specify the returned fields:
196
+
197
+ ```rb
198
+ id = { :namespace => "sshaw", :key => "ingredients", :owner_type => "PRODUCT" }
199
+ dfn.get(id, :fields => %w[id pinned_position])
200
+
201
+ # Or, specify a String:
202
+ dfn.get(id, :fields => "id pinnedPosition constraints { key value }")
203
+ ```
204
+
205
+ ## Why Use This Instead of Shopify's API Client?
206
+
207
+ - Easy-to-use
208
+ - Built-in retry
209
+ - Batching
210
+ - Lightweight
211
+ - You can use :snake_case hash keys
212
+
213
+ Overall, Shopify's API client is bloated trash that will give you development headaches and long-term maintenance nightmares.
214
+
215
+ We used to use it, staring way back in 2015, but eventually had to pivot away from their Ruby libraries due to developer frustration
216
+ and high maintenance cost (and don't get us started on the ShopifyApp gem!@#).
217
+
218
+ For more information see: Shopify/shopify-api-ruby#1181
219
+
220
+ ## Development
221
+
222
+ [Shopify Development Tools](https://github.com/ScreenStaring/shopify-dev-tools/) is used to import the product fixtures and retrieve values. The `sdt` executable must be in your path.
223
+
224
+ A Shopify test store is required. `cp .env.template .env` and fill-in the values. Requests will be made to the given store. The token needs permission to create metafields and metafield definitions.
225
+
226
+ ## See Also
227
+
228
+ - [Shopify Dev Tools](https://github.com/ScreenStaring/shopify-dev-tools/) - Command-line program to assist with the development and/or maintenance of Shopify apps and stores
229
+ - [`ShopifyAPI::GraphQL::Tiny`](https://github.com/ScreenStaring/shopify_api-graphql-tiny/) - Lightweight, no-nonsense, Shopify GraphQL Admin API client with built-in pagination and retry
230
+ - [`ShopifyAPI::GraphQL::Request`](https://github.com/ScreenStaring/shopify_api-graphql-request) - A higher-level wrapper around `Tiny` with improved exception handling and `:snake_case` hash key conversion
231
+ - [`TinyGID`](https://github.com/sshaw/tiny_gid/) - Build Global ID (`gid://`) URI strings from scalar values
232
+
233
+ ## License
234
+
235
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
236
+
237
+ ---
238
+
239
+ Made by [ScreenStaring](https://screenstaring.com)
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shopify_api/graphql/request"
4
+
5
+ module ShopifyAPI
6
+ module GraphQL
7
+ module Metafields
8
+ module Batch
9
+ # TODO: Similar operation for delete
10
+ class Set < ShopifyAPI::GraphQL::Request
11
+ DEFAULT_UPLOAD_GROUPS = 4
12
+
13
+ def initialize(shop, token, options = nil)
14
+ super(shop, token, options)
15
+
16
+ @shop = shop
17
+ @token = token
18
+
19
+ @metafields = []
20
+ @options = options || {}
21
+
22
+ raise ArgumentError, "worker_count must be greater than 0" if @options[:worker_count] && @options[:worker_count].to_i < 1
23
+ end
24
+
25
+ def <<(*metafields)
26
+ metafields.flatten!
27
+
28
+ # TODO: autoflush?
29
+ @metafields.concat(metafields)
30
+
31
+ nil
32
+ end
33
+
34
+ def flush!
35
+ return false unless @metafields.any?
36
+
37
+ batches = @metafields.each_slice(Metafields::Set::MAX)
38
+ # Round so we don't end up with a fractional (or 0) slice size
39
+ groups = (batches.size / worker_count.to_f).round
40
+ groups = 1 if groups < 1
41
+
42
+ workers = batches.each_slice(groups).with_index.map { |batch, i| spawn_worker(batch, i) }
43
+ workers.each(&:join)
44
+
45
+ collect_results(workers)
46
+ ensure
47
+ @metafields.clear
48
+ end
49
+
50
+ private
51
+
52
+ def worker_count
53
+ @options[:worker_count] || DEFAULT_UPLOAD_GROUPS
54
+ end
55
+
56
+ def collect_results(workers)
57
+ results = []
58
+ errors = []
59
+ user_errors = []
60
+
61
+ # Name contains its position within the batch, zero-padded so sorting by name
62
+ # is the same as sorting by position.
63
+ # The goal is for the success results' indices to align with input values'
64
+ workers.sort_by(&:name).each do |worker|
65
+ worker_results, worker_errors = worker.value
66
+
67
+ results.concat(worker_results)
68
+ errors.concat(worker_errors)
69
+
70
+ worker_errors.each do |error|
71
+ user_errors.concat(error.errors) if error.is_a?(ShopifyAPI::GraphQL::Request::UserError)
72
+ end
73
+ end
74
+
75
+ if errors.any?
76
+ message = errors.map(&:message).join(", ")
77
+ raise Error.new("Failed to set all metafields: #{errors.size} error(s): #{message}", results, errors, user_errors)
78
+ end
79
+
80
+ results
81
+ end
82
+
83
+ def spawn_worker(batches, id)
84
+ Thread.new do
85
+ Thread.current.name = sprintf("%s-batch-%06d", self.class, id)
86
+
87
+ errors = []
88
+ # We treat the result like the metafieldSet mutation where each position holds the input position's result.
89
+ # If there was no result the position is nil. This will be put back together across all threads and returned to
90
+ # the caller as if it was a single metafieldSet call
91
+ results = Array.new(batches.sum(&:size))
92
+
93
+ set = Metafields::Set.new(@shop, @token, @options)
94
+
95
+ offset = 0
96
+
97
+ batches.each do |batch|
98
+ begin
99
+ results[offset, batch.size] = set.execute(batch).dig(:data, :metafields_set, :metafields)
100
+ rescue => e
101
+ errors << e
102
+ end
103
+
104
+ offset += batch.size
105
+ end
106
+
107
+ [results, errors]
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "batch/set"
4
+
5
+ module ShopifyAPI
6
+ module GraphQL
7
+ module Metafields
8
+ module Batch
9
+ class Error < ShopifyAPI::GraphQL::Metafields::Error
10
+ # Array containing results of any successfully executed set calls
11
+ attr_reader :results
12
+
13
+ # Array containing errors, if any
14
+ attr_reader :errors
15
+
16
+ # Array containing user errors, if any
17
+ attr_reader :user_errors
18
+
19
+ def initialize(message, results = nil, errors = nil, user_errors = nil)
20
+ super message
21
+
22
+ @results = results || []
23
+ @errors = errors || []
24
+ @user_errors = user_errors || []
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShopifyAPI
4
+ module GraphQL
5
+ module Metafields
6
+ class Definition < Request
7
+ GET = <<-GQL
8
+ query MetafieldsCount($id: ID $identifier: MetafieldDefinitionIdentifierInput) {
9
+ metafieldDefinition(id: $id identifier: $identifier) {
10
+ %s
11
+ }
12
+ }
13
+ GQL
14
+
15
+ DEFAULT_GET_FIELDS = %w[id namespace key owner_type name description type { name }].freeze
16
+
17
+ CREATE =<<-GQL
18
+ mutation CreateMetafieldDefinition($definition: MetafieldDefinitionInput!) {
19
+ metafieldDefinitionCreate(definition: $definition) {
20
+ createdDefinition { %s }
21
+ #{Metafields::USER_ERRORS}
22
+ }
23
+ }
24
+ GQL
25
+
26
+ DEFAULT_CREATE_FIELDS = DEFAULT_GET_FIELDS.dup
27
+
28
+ UPDATE =<<-GQL
29
+ mutation UpdateMetafieldDefinition($definition: MetafieldDefinitionUpdateInput!) {
30
+ metafieldDefinitionUpdate(definition: $definition) {
31
+ updatedDefinition { %s }
32
+ #{Metafields::USER_ERRORS}
33
+ }
34
+ }
35
+ GQL
36
+
37
+ DEFAULT_UPDATE_FIELDS = DEFAULT_CREATE_FIELDS.dup.freeze
38
+
39
+ DELETE =<<-GQL
40
+ mutation DeleteMetafieldDefinition($id: ID $identifier: MetafieldDefinitionIdentifierInput $deleteMetafields: Boolean) {
41
+ metafieldDefinitionDelete(
42
+ id: $id
43
+ identifier: $identifier
44
+ deleteAllAssociatedMetafields: $deleteMetafields
45
+ ) {
46
+ %s
47
+ #{Metafields::USER_ERRORS}
48
+ }
49
+ }
50
+ GQL
51
+
52
+ DEFAULT_DELETE_FIELDS =<<-GQL
53
+ deletedDefinitionId
54
+ deletedDefinition {
55
+ key
56
+ namespace
57
+ ownerType
58
+ }
59
+ GQL
60
+
61
+ def create(definition, options = nil)
62
+ fields = options&.dig(:fields) || DEFAULT_CREATE_FIELDS
63
+
64
+ result = execute(
65
+ sprintf(CREATE, field_string(fields)),
66
+ :definition => definition
67
+ ).dig(:data, :metafield_definition_create)
68
+
69
+ # We can use #except after 2.7
70
+ result.delete(:user_errors)
71
+ result
72
+ end
73
+
74
+ def delete(id_or_identifier, options = nil)
75
+ fields = options&.dig(:fields) || DEFAULT_DELETE_FIELDS
76
+
77
+ result = execute(
78
+ sprintf(DELETE, field_string(fields)),
79
+ id_key(id_or_identifier) => id_or_identifier,
80
+ :delete_metafields => options && options[:delete_metafields] == true
81
+ ).dig(:data, :metafield_definition_delete)
82
+
83
+ # We can use #except after 2.7
84
+ result.delete(:user_errors)
85
+ result
86
+ end
87
+
88
+ def get(id_or_identifier, options = nil)
89
+ fields = options&.dig(:fields) || DEFAULT_GET_FIELDS
90
+
91
+ execute(
92
+ sprintf(GET, field_string(fields)),
93
+ id_key(id_or_identifier) => id_or_identifier,
94
+ ).dig(:data, :metafield_definition)
95
+ end
96
+
97
+ def update(definition, options = nil)
98
+ fields = options&.dig(:fields) || DEFAULT_UPDATE_FIELDS
99
+
100
+ result = execute(
101
+ sprintf(UPDATE, field_string(fields)),
102
+ :definition => definition
103
+ ).dig(:data, :metafield_definition_update)
104
+
105
+ # We can use #except after 2.7
106
+ result.delete(:user_errors)
107
+ result
108
+ end
109
+
110
+ private
111
+
112
+ def id_key(id_or_identifier)
113
+ id_or_identifier.is_a?(Hash) ? :identifier : :id
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "request"
4
+
5
+ module ShopifyAPI
6
+ module GraphQL
7
+ module Metafields
8
+ class Delete < Request # :nodoc: all
9
+ MAX = 250
10
+
11
+ MUTATION =<<-GQL
12
+ mutation metafieldsDelete($metafields: [MetafieldIdentifierInput!]!) {
13
+ metafieldsDelete(metafields: $metafields) {
14
+ deletedMetafields { %s }
15
+ userErrors { field message }
16
+ }
17
+ }
18
+ GQL
19
+
20
+ DEFAULT_FIELDS = %w[key namespace owner_id].freeze
21
+
22
+ def execute(metafields, options = nil)
23
+ metafields = wrap_array(metafields)
24
+ raise ArgumentError, "metafields required" if metafields.size == 0
25
+ raise ArgumentError, "#{metafields.size} metafields provided: Shopify allows a maximum of #{MAX}" if metafields.size > MAX
26
+
27
+ options = (options || {}).dup
28
+ fields = options.delete(:fields) || DEFAULT_FIELDS
29
+ super(
30
+ sprintf(MUTATION, field_string(fields)),
31
+ { :metafields => metafields },
32
+ options
33
+ )
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "delete"
4
+ require_relative "set"
5
+
6
+ module ShopifyAPI
7
+ module GraphQL
8
+ module Metafields
9
+ class Metafield # :nodoc: all
10
+ def initialize(shop, token, options = nil)
11
+ @delete = Metafields::Delete.new(shop, token, options)
12
+ @set = Metafields::Set.new(shop, token, options)
13
+ end
14
+
15
+ def set(metafields, options = nil)
16
+ @set.execute(metafields, options).dig(:data, :metafields_set, :metafields)
17
+ rescue *WRAPPED_ERRORS => e
18
+ raise Error, "failed to set metafields: #{e}"
19
+ end
20
+
21
+ def delete(metafields, options = nil)
22
+ @delete.execute(metafields, options).dig(:data, :metafields_delete, :deleted_metafields)
23
+ rescue *WRAPPED_ERRORS => e
24
+ raise Error, "failed to delete metafields: #{e}"
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shopify_api/graphql/request"
4
+
5
+ module ShopifyAPI
6
+ module GraphQL
7
+ module Metafields
8
+ class Request < ShopifyAPI::GraphQL::Request
9
+ protected
10
+
11
+ def wrap_array(object)
12
+ (object.is_a?(Array) ? object : [object]).compact
13
+ end
14
+
15
+ def field_string(fields)
16
+ return fields unless fields.respond_to?(:map)
17
+ # TODO: check for breakage across the set of accepted fields
18
+ fields.map { |f| Strings::Case.camelcase(f.to_s) }.join("\n")
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "request"
4
+
5
+ module ShopifyAPI
6
+ module GraphQL
7
+ module Metafields
8
+ class Set < Request # :nodoc: all
9
+ # Shopify limit per call
10
+ MAX = 25
11
+
12
+ MUTATION =<<-GQL
13
+ mutation metafieldsSet($metafields: [MetafieldsSetInput!]!) {
14
+ metafieldsSet(metafields: $metafields) {
15
+ metafields { %s }
16
+ #{Metafields::USER_ERRORS}
17
+ }
18
+ }
19
+ GQL
20
+
21
+ DEFAULT_FIELDS = %w[id namespace key value].freeze
22
+
23
+ def execute(metafields, options = nil)
24
+ metafields = wrap_array(metafields)
25
+ raise ArgumentError, "metafields required" if metafields.size == 0
26
+ raise ArgumentError, "#{metafields.size} metafields provided: Shopify allows a maximum of #{MAX}. Use the Batch::Set class instead" if metafields.size > MAX
27
+
28
+ options = (options || {}).dup
29
+ fields = options.delete(:fields) || DEFAULT_FIELDS
30
+ super(
31
+ sprintf(MUTATION, field_string(fields)),
32
+ { :metafields => metafields },
33
+ options
34
+ )
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShopifyAPI
4
+ module GraphQL
5
+ module Metafields
6
+ VERSION = "0.0.1"
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shopify_api/graphql/tiny"
4
+ require "shopify_api/graphql/request"
5
+
6
+ require_relative "metafields/version"
7
+
8
+ module ShopifyAPI
9
+ module GraphQL
10
+ module Metafields
11
+ Error = Class.new(StandardError)
12
+
13
+ WRAPPED_ERRORS = [
14
+ ShopifyAPI::GraphQL::Tiny::Error,
15
+ ShopifyAPI::GraphQL::Request::Error
16
+ ].freeze
17
+
18
+ USER_ERRORS =<<-GQL
19
+ userErrors {
20
+ field
21
+ message
22
+ code
23
+ }
24
+ GQL
25
+
26
+ def self.new(shop, token, options = nil)
27
+ Metafield.new(shop, token, options)
28
+ end
29
+ end
30
+ end
31
+ end
32
+
33
+ require_relative "metafields/metafield"
34
+ require_relative "metafields/batch"
35
+ require_relative "metafields/definition"
metadata ADDED
@@ -0,0 +1,174 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: shopify_api-graphql-metafields
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Skye Shaw
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-10-16 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: shopify_api-graphql-request
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: shopify_api-graphql-tiny
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: strings-case
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: csv
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: dotenv
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rake
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rspec
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: tiny_gid
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: '0'
125
+ description:
126
+ email:
127
+ - skye.shaw@gmail.com
128
+ executables: []
129
+ extensions: []
130
+ extra_rdoc_files: []
131
+ files:
132
+ - ".env.template"
133
+ - LICENSE.txt
134
+ - README.md
135
+ - Rakefile
136
+ - lib/shopify_api/graphql/metafields.rb
137
+ - lib/shopify_api/graphql/metafields/batch.rb
138
+ - lib/shopify_api/graphql/metafields/batch/set.rb
139
+ - lib/shopify_api/graphql/metafields/definition.rb
140
+ - lib/shopify_api/graphql/metafields/delete.rb
141
+ - lib/shopify_api/graphql/metafields/metafield.rb
142
+ - lib/shopify_api/graphql/metafields/request.rb
143
+ - lib/shopify_api/graphql/metafields/set.rb
144
+ - lib/shopify_api/graphql/metafields/version.rb
145
+ homepage: https://github.com/ScreenStaring/shopify_api-graphql-metafields
146
+ licenses:
147
+ - MIT
148
+ metadata:
149
+ bug_tracker_uri: https://github.com/ScreenStaring/shopify_api-graphql-metafields/issues
150
+ changelog_uri: https://github.com/ScreenStaring/shopify_api-graphql-metafields/blob/master/Changes
151
+ documentation_uri: https://rubydoc.info/gems/shopify_api-graphql-metafields
152
+ source_code_uri: https://github.com/ScreenStaring/shopify_api-graphql-metafields
153
+ homepage_uri: https://github.com/ScreenStaring/shopify_api-graphql-metafields
154
+ post_install_message:
155
+ rdoc_options: []
156
+ require_paths:
157
+ - lib
158
+ required_ruby_version: !ruby/object:Gem::Requirement
159
+ requirements:
160
+ - - ">="
161
+ - !ruby/object:Gem::Version
162
+ version: 2.5.0
163
+ required_rubygems_version: !ruby/object:Gem::Requirement
164
+ requirements:
165
+ - - ">="
166
+ - !ruby/object:Gem::Version
167
+ version: '0'
168
+ requirements: []
169
+ rubygems_version: 3.5.16
170
+ signing_key:
171
+ specification_version: 4
172
+ summary: User-friendly metafield wrapper around Shopify's GraphQL Admin API with built-in
173
+ retry, batching, and error handling
174
+ test_files: []