shopify_toolkit 0.1.0.pre → 0.1.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cece3645d02f939f0c4e534edd97ad5150843e85c5c54f639f07ddd480921105
4
- data.tar.gz: 7e2e8a0bca62733fe38415178e08dedbf01e62455902d0c82044d25f46cdfab4
3
+ metadata.gz: e21a90f2ee3abbe3ca01f52a8b3f559437d357e41e649d2351a5740c83dfcb1c
4
+ data.tar.gz: 5e731cba7ace9bf63e2e68203efe6a12db03642a2fbe88d3db34ed91cc903450
5
5
  SHA512:
6
- metadata.gz: 0042f15676cbc63112d7c7b68ef249a657e37c6a5dce70e4d496479bc8c1146f5ca2240210162a57ef07038b25fbbe01a59226632d28e366546e4a35ef8b539e
7
- data.tar.gz: 9927b5dbcb3c072c263b81fef3a6d7775ecf6fbed67fca668cccb8a8116002c2e42c522b06469608cdce7a7d7ab86a69303765329424e1c5b15c44c8180dea6b
6
+ metadata.gz: 87dd02b8491e762cf1e3ffa8e2cada8b8c243d921b35e69f7978dad66d333fe767b463b61e9920600bfbd312c438734342adc6306d656efe07efa63bab918b83
7
+ data.tar.gz: e1a5daad1b1dd2f8239f8e2c45b5b988c8864a9536533656fc76a66cd7c420df9be66bc441f58e5a1786019bac4d284e515367d6aa3d1b3ce672f0354023fdbc
data/README.md CHANGED
@@ -4,8 +4,10 @@ A toolkit for working with Custom Shopify Apps built on Rails.
4
4
 
5
5
  ## Features/Roadmap
6
6
 
7
+ - [x] Shopify/Matrixify CSV tools
7
8
  - [ ] Metafield/Metaobject migrations (just like ActiveRecord migrations, but for Shopify!)
8
- - [ ] Shopify CSV Import/Export tools
9
+ - [x] Metafield Definitions management API
10
+ - [ ] Metaobject Definitions management API
9
11
  - [ ] GraphQL Admin API code generation (syntax checking, etc)
10
12
  - [ ] GraphQL Admin API client with built-in rate limiting
11
13
  - [ ] GraphQL Admin API client with built-in caching
@@ -22,7 +24,87 @@ bundle add shopify_toolkit
22
24
 
23
25
  ## Usage
24
26
 
25
- TODO
27
+ ### Migrating Metafields definitions using ActiveRecord Migrations
28
+
29
+ Within a Rails application created with ShopifyApp, generate a new migration file:
30
+
31
+ ```bash
32
+ rails generate migration AddMetafieldDefinitions
33
+ ```
34
+
35
+ Include the `ShopifyToolkit::MetafieldStatements` module in your migration file
36
+ in order to use the metafield statements:
37
+
38
+ ```ruby
39
+ class AddMetafieldDefinitions < ActiveRecord::Migration[7.0]
40
+ include ShopifyToolkit::MetafieldStatements
41
+
42
+ def up
43
+ Shop.first!.with_shopify_session do
44
+ create_metafield :products, :my_metafield, :single_line_text_field, name: "My Metafield"
45
+ end
46
+ end
47
+
48
+ def down
49
+ Shop.first!.with_shopify_session do
50
+ remove_metafield :products, :my_metafield
51
+ end
52
+ end
53
+ end
54
+ ```
55
+ Then run the migration:
56
+
57
+ ```bash
58
+ rails db:migrate
59
+ ```
60
+
61
+ ### Creating a Metafield Schema Definition
62
+
63
+ You can also create a metafield schema definition file to define your metafields in a more structured way. This is useful for keeping track of your metafields and their definitions.
64
+
65
+ ```rb
66
+ # config/shopify/schema.rb
67
+
68
+ ShopifyToolkit::MetafieldSchema.define do
69
+ # Define your metafield schema here
70
+ # For example:
71
+ create_metafield :products, :my_metafield, :single_line_text_field, name: "My Metafield"
72
+ end
73
+ ```
74
+
75
+ ### Analyzing a Matrixify CSV Result files
76
+
77
+ Matrixify is a popular Shopify app that allows you to import/export data from Shopify using CSV files. The CSV files that Matrixify generates are very verbose and can be difficult to work with. This tool allows you to analyze the CSV files and extract the data you need.
78
+
79
+ The tool will import the file into a local SQLite database
80
+ and open a console for you to run queries against the data
81
+ using ActiveRecord.
82
+
83
+ ```shell
84
+ shopify-csv analyze products-result.csv --force-import
85
+ ==> Importing products-result.csv into /var/folders/hn/z7b7s1kj3js4k7_qk3lj27kr0000gn/T/shopify-toolkit-analyze-products_result_csv.sqlite3
86
+ -- create_table(:results, {:force=>true})
87
+ -> 0.0181s
88
+ -- add_index(:results, :import_result)
89
+ -> 0.0028s
90
+ ........................
91
+ ==> Starting console for products-result.csv
92
+ >> comments
93
+ =>
94
+ ["UPDATE: Found by Handle | Assuming MERGE for Variant Command | Assuming MERGE for Tags Command | Variants updated by SKU: 1",
95
+ "UPDATE: Found by Handle | Assuming MERGE for Variant Command | Assuming MERGE for Tags Command | Variants updated by SKU: 1 | Warning: The following media were not uploaded to Shopify: [https://mymedia.com/image.jpg: Error downloading from Web: 302 Moved Temporarily]",
96
+ ...]
97
+ >> first
98
+ #<ShopifyCSV::Result:0x0000000300bf1668
99
+ id: 1,
100
+ data: nil,
101
+ handle: "my-product",
102
+ title: "My Product",
103
+ import_result: "OK",
104
+ import_comment: "UPDATE: Found by Handle | Assuming MERGE for Varia...">
105
+ >> count
106
+ => 116103
107
+ ```
26
108
 
27
109
  ## Development
28
110
 
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'csv'
4
+ require 'active_record'
5
+ require 'thor'
6
+ require 'tmpdir'
7
+
8
+ class ShopifyToolkit::CommandLine < Thor
9
+ RESERVED_COLUMN_NAMES = %w[select type id]
10
+
11
+ class Result < ActiveRecord::Base
12
+ def self.comments
13
+ distinct.pluck(:import_comment)
14
+ end
15
+
16
+ def self.with_comment(text)
17
+ where("import_comment LIKE ?", "%#{text}%")
18
+ end
19
+ end
20
+
21
+ desc "analyze CSV_PATH", "Analyze results file at path CSV_PATH"
22
+ method_option :force_import, type: :boolean, default: false
23
+ method_option :tmp_dir, type: :string, default: Dir.tmpdir
24
+ def analyze(csv_path)
25
+ csv_path = File.expand_path(csv_path)
26
+ underscore = ->(string) { string.downcase.gsub(/[^a-z0-9]+/, "_").gsub(/(^_+|_+$)/, "") }
27
+ csv = CSV.open(csv_path, liberal_parsing:true )
28
+ header_to_column = -> { RESERVED_COLUMN_NAMES.include?(_1.to_s) ? "#{_1}_1" : _1 }
29
+ headers = csv.shift.map(&underscore).map(&header_to_column)
30
+ basename = File.basename csv_path
31
+ database = "#{options[:tmp_dir]}/shopify-toolkit-analyze-#{underscore[basename]}.sqlite3"
32
+ should_import = options[:force_import] || !File.exist?(database)
33
+ to_record = ->(row) { headers.zip(row.each{ |c| c.delete!("\u0000") if String === c }).to_h.transform_keys(&header_to_column) }
34
+
35
+ File.delete(database) if should_import && File.exist?(database)
36
+
37
+ ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database:)
38
+
39
+ if should_import
40
+ puts "==> Importing #{csv_path} into #{database}"
41
+ ActiveRecord::Schema.define do
42
+ create_table :results, force: true do |t|
43
+ t.json :data
44
+ headers.each { |header| t.string header }
45
+ end
46
+ add_index :results, :import_result if headers.include?('import_result')
47
+ end
48
+ csv.each_slice(5000) { |rows| print "."; Result.insert_all(rows.map(&to_record)) }
49
+ puts
50
+ end
51
+
52
+ puts "==> Starting console for #{basename}"
53
+ require "irb"
54
+ IRB.conf[:IRB_NAME] = basename
55
+ Result.class_eval { binding.irb(show_code: false) }
56
+ end
57
+ end
58
+
59
+ ShopifyToolkit::CommandLine.start(ARGV)
@@ -0,0 +1,39 @@
1
+ module ShopifyToolkit::AdminClient
2
+ API_VERSION = "2024-10"
3
+
4
+ def api_version
5
+ API_VERSION
6
+ end
7
+
8
+ def shopify_admin_client
9
+ @shopify_admin_client ||=
10
+ ShopifyAPI::Clients::Graphql::Admin.new(session: ShopifyAPI::Context.active_session, api_version:)
11
+ end
12
+
13
+ def handle_shopify_admin_client_errors(response, *user_error_paths)
14
+ if response.code != 200
15
+ logger.error "Error querying Shopify Admin API: #{response.inspect}"
16
+ raise "Error querying Shopify Admin API: #{response.inspect}"
17
+ end
18
+
19
+ response
20
+ .body
21
+ .dig("errors")
22
+ .to_a
23
+ .each do |error|
24
+ logger.error "Error querying Shopify Admin API: #{error.inspect}"
25
+ raise "Error querying Shopify Admin API: #{error.inspect}"
26
+ end
27
+
28
+ user_error_paths.each do |path|
29
+ response
30
+ .body
31
+ .dig(*path.split("."))
32
+ .to_a
33
+ .each do |error|
34
+ logger.error "Error querying Shopify Admin API: #{error.inspect} (#{path})"
35
+ raise "Error querying Shopify Admin API: #{error.inspect} (#{path})"
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shopify_toolkit/migration/logging"
4
+ require "shopify_toolkit/admin_client"
5
+
6
+ module ShopifyToolkit::MetafieldStatements
7
+ extend ActiveSupport::Concern
8
+ include ShopifyToolkit::Migration::Logging
9
+ include ShopifyToolkit::AdminClient
10
+
11
+ def self.log_time(method_name)
12
+ current_method = instance_method(method_name)
13
+ define_method(method_name) do |*args, **kwargs, &block|
14
+ say_with_time("#{method_name}(#{args.map(&:inspect).join(', ')})") { current_method.bind(self).call(*args, **kwargs, &block) }
15
+ end
16
+ end
17
+
18
+ # create_metafield :products, :my_metafield, :single_line_text_field, name: "Prova"
19
+ # @param namespace: if nil the metafield will be app-specific (default: :custom)
20
+ log_time \
21
+ def create_metafield(owner_type, key, type, namespace: :custom, name:, **options)
22
+ ownerType = owner_type.to_s.singularize.upcase # Eg. "PRODUCT"
23
+
24
+ # https://shopify.dev/docs/api/admin-graphql/2024-10/mutations/metafieldDefinitionCreate
25
+ query =
26
+ "# GraphQL
27
+ mutation CreateMetafieldDefinition($definition: MetafieldDefinitionInput!) {
28
+ metafieldDefinitionCreate(definition: $definition) {
29
+ createdDefinition {
30
+ id
31
+ key
32
+ }
33
+ userErrors {
34
+ field
35
+ message
36
+ code
37
+ }
38
+ }
39
+ }
40
+ "
41
+ variables = { definition: { ownerType:, key:, type:, name:, namespace:, **options } }
42
+
43
+ shopify_admin_client
44
+ .query(query:, variables:)
45
+ .tap { handle_shopify_admin_client_errors(_1, "metafieldDefinitionCreate.userErrors") }
46
+ end
47
+
48
+ def get_metafield_gid(owner_type, key, namespace: :custom)
49
+ ownerType = owner_type.to_s.singularize.upcase # Eg. "PRODUCT"
50
+
51
+ result =
52
+ shopify_admin_client
53
+ .query(
54
+ query:
55
+ "# GraphQL
56
+ query FindMetafieldDefinition($ownerType: MetafieldOwnerType!, $key: String!) {
57
+ metafieldDefinitions(first: 1, ownerType: $ownerType, key: $key) {
58
+ nodes { id }
59
+ }
60
+ }",
61
+ variables: {
62
+ ownerType:,
63
+ key:,
64
+ namespace:,
65
+ },
66
+ )
67
+ .tap { handle_shopify_admin_client_errors(_1) }
68
+ .body
69
+
70
+ result.dig("data", "metafieldDefinitions", "nodes", 0, "id") or
71
+ raise "Metafield not found for #{owner_type}##{namespace}:#{key}"
72
+ end
73
+
74
+ log_time \
75
+ def remove_metafield(owner_type, key, namespace: :custom, delete_associated_metafields: false, **options)
76
+ if namespace == nil && delete_associated_metafields == false
77
+ raise ArgumentError,
78
+ "For reserved namespaces, you must delete all associated metafields (delete_associated_metafields: true)"
79
+ end
80
+
81
+ shopify_admin_client
82
+ .query(
83
+ # Documentation: https://shopify.dev/docs/api/admin-graphql/2024-10/mutations/metafieldDefinitionDelete
84
+ query:
85
+ "# GraphQL
86
+ mutation DeleteMetafieldDefinition($id: ID!, $deleteAllAssociatedMetafields: Boolean!) {
87
+ metafieldDefinitionDelete(id: $id, deleteAllAssociatedMetafields: $deleteAllAssociatedMetafields) {
88
+ deletedDefinitionId
89
+ userErrors {
90
+ field
91
+ message
92
+ code
93
+ }
94
+ }
95
+ }",
96
+ variables: {
97
+ id: get_metafield_gid(owner_type, key, namespace: namespace),
98
+ deleteAllAssociatedMetafields: delete_associated_metafields,
99
+ },
100
+ )
101
+ .tap { handle_shopify_admin_client_errors(_1, "metafieldDefinitionDelete.userErrors") }
102
+ end
103
+
104
+ def update_metafield(owner_type, key, namespace: :custom, **options)
105
+ log_time \
106
+ shopify_admin_client
107
+ .query(
108
+ # Documentation: https://shopify.dev/docs/api/admin-graphql/2024-10/mutations/metafieldDefinitionUpdate
109
+ query:
110
+ "# GraphQL
111
+ mutation UpdateMetafieldDefinition($definition: MetafieldDefinitionUpdateInput!) {
112
+ metafieldDefinitionUpdate(definition: $definition) {
113
+ updatedDefinition {
114
+ id
115
+ name
116
+ }
117
+ userErrors {
118
+ field
119
+ message
120
+ code
121
+ }
122
+ }
123
+ }",
124
+ variables: {
125
+ definition: {
126
+ ownerType: owner_type.to_s.singularize.upcase,
127
+ key:,
128
+ namespace:,
129
+ **options,
130
+ },
131
+ },
132
+ )
133
+ .tap { handle_shopify_admin_client_errors(_1, "metafieldDefinitionUpdate.userErrors") }
134
+ end
135
+
136
+ def self.define(&block)
137
+ context = Object.new
138
+ context.extend(self)
139
+
140
+ context.instance_eval(&block) if block_given?(&block)
141
+ context
142
+ end
143
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ShopifyToolkit::Migration::Logging
4
+ def write(text = "")
5
+ puts(text)
6
+ end
7
+
8
+ def announce(message)
9
+ text = "#{version} #{name}: #{message}"
10
+ length = [0, 75 - text.length].max
11
+ write "== %s %s" % [text, "=" * length]
12
+ end
13
+
14
+ # Takes a message argument and outputs it as is.
15
+ # A second boolean argument can be passed to specify whether to indent or not.
16
+ def say(message, subitem = false)
17
+ write "#{subitem ? " ->" : "--"} #{message}"
18
+ end
19
+
20
+ # Outputs text along with how long it took to run its block.
21
+ # If the block returns an integer it assumes it is the number of rows affected.
22
+ def say_with_time(message)
23
+ say(message)
24
+ result = nil
25
+ time_elapsed = ActiveSupport::Benchmark.realtime { result = yield }
26
+ say "%.4fs" % time_elapsed, :subitem
27
+ result
28
+ end
29
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ShopifyToolkit
4
- VERSION = "0.1.0.pre"
4
+ VERSION = "0.1.0"
5
5
  end
@@ -1,8 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "shopify_toolkit/version"
4
+ require "zeitwerk"
4
5
 
5
6
  module ShopifyToolkit
6
- class Error < StandardError; end
7
- # Your code goes here...
7
+
8
+ def self.loader
9
+ @loader ||= Zeitwerk::Loader.for_gem
10
+ end
11
+
12
+ loader.setup
13
+ # loader.eager_load # optionally
8
14
  end
metadata CHANGED
@@ -1,18 +1,75 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: shopify_toolkit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0.pre
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Elia Schito
8
8
  - Nebulab Team
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-03-14 00:00:00.000000000 Z
12
- dependencies: []
11
+ date: 2025-04-02 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: thor
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '1.3'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '1.3'
27
+ - !ruby/object:Gem::Dependency
28
+ name: zeitwerk
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '2.7'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '2.7'
41
+ - !ruby/object:Gem::Dependency
42
+ name: activesupport
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '7.0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '7.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: shopify_api
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '14.8'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '14.8'
13
69
  email:
14
70
  - elia@schito.me
15
- executables: []
71
+ executables:
72
+ - shopify-toolkit
16
73
  extensions: []
17
74
  extra_rdoc_files: []
18
75
  files:
@@ -20,7 +77,11 @@ files:
20
77
  - LICENSE.txt
21
78
  - README.md
22
79
  - Rakefile
80
+ - exe/shopify-toolkit
23
81
  - lib/shopify_toolkit.rb
82
+ - lib/shopify_toolkit/admin_client.rb
83
+ - lib/shopify_toolkit/metafield_statements.rb
84
+ - lib/shopify_toolkit/migration/logging.rb
24
85
  - lib/shopify_toolkit/version.rb
25
86
  - sig/shopify_toolkit.rbs
26
87
  homepage: https://github.com/nebulab/shopify_toolkit?tab=readme-ov-file#readme