shopify_toolkit 0.3.4 → 0.4.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 +4 -4
- data/README.md +31 -18
- data/exe/shopify-toolkit +0 -67
- data/lib/shopify_toolkit/admin_client.rb +2 -0
- data/lib/shopify_toolkit/command_line.rb +89 -0
- data/lib/shopify_toolkit/metafield_statements.rb +17 -4
- data/lib/shopify_toolkit/metaobject_statements.rb +117 -0
- data/lib/shopify_toolkit/migration.rb +63 -0
- data/lib/shopify_toolkit/migrator.rb +152 -0
- data/lib/shopify_toolkit/schema.rb +1 -0
- data/lib/shopify_toolkit/version.rb +1 -1
- data/lib/shopify_toolkit.rb +0 -1
- metadata +7 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: a1d6624a536a67ce6e7861ae95c0eed57e2713974b4901b9eca1720ecbd063ad
|
4
|
+
data.tar.gz: ee1bffe4a027352bdf58a39d9aa4412c5c0151da4784b56006c62b3b8b33dd30
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: a3b3b9d85c5cd37f51531f433bd039f6830bdab42c2bc4a4b52a8b6b4cc7f6ec22cd2a52bc2faec59c5777077b6f615651987afffc8c468aa6b34563cd46c9be
|
7
|
+
data.tar.gz: 58b3102b6915f4344e532d2d329b3cabce6028412ccf5676eafc174a0479222320f1bd63af972a8613442c9f9c4c72a0db05e3b407040b4a624e369926da1e1c
|
data/README.md
CHANGED
@@ -11,9 +11,9 @@ A toolkit for working with Custom Shopify Apps built on Rails.
|
|
11
11
|
## Features/Roadmap
|
12
12
|
|
13
13
|
- [x] Shopify/Matrixify CSV tools
|
14
|
-
- [
|
14
|
+
- [x] Metafield/Metaobject migrations (just like ActiveRecord migrations, but for Shopify!)
|
15
15
|
- [x] Metafield Definitions management API
|
16
|
-
- [
|
16
|
+
- [x] Metaobject Definitions management API
|
17
17
|
- [ ] GraphQL Admin API code generation (syntax checking, etc)
|
18
18
|
- [ ] GraphQL Admin API client with built-in rate limiting
|
19
19
|
- [ ] GraphQL Admin API client with built-in caching
|
@@ -30,38 +30,51 @@ bundle add shopify_toolkit
|
|
30
30
|
|
31
31
|
## Usage
|
32
32
|
|
33
|
-
### Migrating Metafields
|
33
|
+
### Migrating Metafields and Metaobjects
|
34
34
|
|
35
35
|
Within a Rails application created with ShopifyApp, generate a new migration file:
|
36
36
|
|
37
37
|
```bash
|
38
|
-
|
38
|
+
touch config/shopify/migrate/20250528130134_add_product_press_releases.rb
|
39
39
|
```
|
40
40
|
|
41
|
-
|
42
|
-
in order to use the metafield statements:
|
43
|
-
|
41
|
+
Then, add the following code to the migration file:
|
44
42
|
```ruby
|
45
|
-
|
46
|
-
|
47
|
-
|
43
|
+
# config/shopify/migrate/20250528130134_add_product_press_releases.rb
|
44
|
+
class AddProductPressReleases < ShopifyToolkit::Migration
|
48
45
|
def up
|
49
|
-
|
50
|
-
|
51
|
-
|
46
|
+
create_metaobject_definition :press_release,
|
47
|
+
name: "Press Release",
|
48
|
+
displayNameKey: "name",
|
49
|
+
access: { storefront: "PUBLIC_READ" },
|
50
|
+
capabilities: {
|
51
|
+
onlineStore: { enabled: false },
|
52
|
+
publishable: { enabled: true },
|
53
|
+
translatable: { enabled: true },
|
54
|
+
renderable: { enabled: false },
|
55
|
+
},
|
56
|
+
fieldDefinitions: [
|
57
|
+
{ key: "name", name: "Title", required: true, type: "single_line_text_field" },
|
58
|
+
{ key: "body", name: "Body", required: true, type: "multi_line_text_field" },
|
59
|
+
]
|
60
|
+
|
61
|
+
metaobject_definition_id = get_metaobject_definition_gid :press_release
|
62
|
+
|
63
|
+
create_metafield :products, :press_release, :metaobject_reference, name: "Press Release", validations: [
|
64
|
+
{ name: "metaobject_definition_id", value: metaobject_definition_id }
|
65
|
+
]
|
52
66
|
end
|
53
67
|
|
54
68
|
def down
|
55
|
-
|
56
|
-
remove_metafield :products, :my_metafield
|
57
|
-
end
|
69
|
+
# Noop. We don't want to remove the metaobject definition, since it might be populated with data.
|
58
70
|
end
|
59
71
|
end
|
60
72
|
```
|
61
|
-
|
73
|
+
|
74
|
+
Then run the migrations:
|
62
75
|
|
63
76
|
```bash
|
64
|
-
|
77
|
+
bundle exec shopify-toolkit migrate
|
65
78
|
```
|
66
79
|
|
67
80
|
### Creating a Metafield Schema Definition
|
data/exe/shopify-toolkit
CHANGED
@@ -1,72 +1,5 @@
|
|
1
1
|
#!/usr/bin/env ruby
|
2
2
|
|
3
|
-
require 'csv'
|
4
|
-
require 'active_record'
|
5
|
-
require 'thor'
|
6
|
-
require 'tmpdir'
|
7
3
|
require 'shopify_toolkit'
|
8
4
|
|
9
|
-
class ShopifyToolkit::CommandLine < Thor
|
10
|
-
RESERVED_COLUMN_NAMES = %w[select type id]
|
11
|
-
|
12
|
-
class Result < ActiveRecord::Base
|
13
|
-
def self.comments
|
14
|
-
distinct.pluck(:import_comment)
|
15
|
-
end
|
16
|
-
|
17
|
-
def self.with_comment(text)
|
18
|
-
where("import_comment LIKE ?", "%#{text}%")
|
19
|
-
end
|
20
|
-
end
|
21
|
-
|
22
|
-
desc "analyze CSV_PATH", "Analyze results file at path CSV_PATH"
|
23
|
-
method_option :force_import, type: :boolean, default: false
|
24
|
-
method_option :tmp_dir, type: :string, default: Dir.tmpdir
|
25
|
-
def analyze(csv_path)
|
26
|
-
csv_path = File.expand_path(csv_path)
|
27
|
-
underscore = ->(string) { string.downcase.gsub(/[^a-z0-9]+/, "_").gsub(/(^_+|_+$)/, "") }
|
28
|
-
csv = CSV.open(csv_path, liberal_parsing:true )
|
29
|
-
header_to_column = -> { RESERVED_COLUMN_NAMES.include?(_1.to_s) ? "#{_1}_1" : _1 }
|
30
|
-
headers = csv.shift.map(&underscore).map(&header_to_column)
|
31
|
-
basename = File.basename csv_path
|
32
|
-
database = "#{options[:tmp_dir]}/shopify-toolkit-analyze-#{underscore[basename]}.sqlite3"
|
33
|
-
should_import = options[:force_import] || !File.exist?(database)
|
34
|
-
to_record = ->(row) { headers.zip(row.each{ |c| c.delete!("\u0000") if String === c }).to_h.transform_keys(&header_to_column) }
|
35
|
-
|
36
|
-
File.delete(database) if should_import && File.exist?(database)
|
37
|
-
|
38
|
-
ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database:)
|
39
|
-
|
40
|
-
if should_import
|
41
|
-
puts "==> Importing #{csv_path} into #{database}"
|
42
|
-
ActiveRecord::Schema.define do
|
43
|
-
create_table :results, force: true do |t|
|
44
|
-
t.json :data
|
45
|
-
headers.each { |header| t.string header }
|
46
|
-
end
|
47
|
-
add_index :results, :import_result if headers.include?('import_result')
|
48
|
-
end
|
49
|
-
csv.each_slice(5000) { |rows| print "."; Result.insert_all(rows.map(&to_record)) }
|
50
|
-
puts
|
51
|
-
end
|
52
|
-
|
53
|
-
puts "==> Starting console for #{basename}"
|
54
|
-
require "irb"
|
55
|
-
IRB.conf[:IRB_NAME] = basename
|
56
|
-
Result.class_eval { binding.irb(show_code: false) }
|
57
|
-
end
|
58
|
-
|
59
|
-
desc "schema_load", 'Load schema from "config/shopify/schema.rb"'
|
60
|
-
def schema_load
|
61
|
-
require "./config/environment"
|
62
|
-
::Shop.sole.with_shopify_session { ShopifyToolkit::Schema.load! }
|
63
|
-
end
|
64
|
-
|
65
|
-
desc "schema_dump", 'Dump schema to "config/shopify/schema.rb"'
|
66
|
-
def schema_dump
|
67
|
-
require "./config/environment"
|
68
|
-
::Shop.sole.with_shopify_session { ShopifyToolkit::Schema.dump! }
|
69
|
-
end
|
70
|
-
end
|
71
|
-
|
72
5
|
ShopifyToolkit::CommandLine.start(ARGV)
|
@@ -0,0 +1,89 @@
|
|
1
|
+
require 'csv'
|
2
|
+
require 'active_record'
|
3
|
+
require 'thor'
|
4
|
+
require 'tmpdir'
|
5
|
+
|
6
|
+
class ShopifyToolkit::CommandLine < Thor
|
7
|
+
RESERVED_COLUMN_NAMES = %w[select type id]
|
8
|
+
|
9
|
+
class Result < ActiveRecord::Base
|
10
|
+
def self.comments
|
11
|
+
distinct.pluck(:import_comment)
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.with_comment(text)
|
15
|
+
where("import_comment LIKE ?", "%#{text}%")
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
desc "analyze CSV_PATH", "Analyze results file at path CSV_PATH"
|
20
|
+
method_option :force_import, type: :boolean, default: false
|
21
|
+
method_option :tmp_dir, type: :string, default: Dir.tmpdir
|
22
|
+
def analyze(csv_path)
|
23
|
+
csv_path = File.expand_path(csv_path)
|
24
|
+
underscore = ->(string) { string.downcase.gsub(/[^a-z0-9]+/, "_").gsub(/(^_+|_+$)/, "") }
|
25
|
+
csv = CSV.open(csv_path, liberal_parsing:true )
|
26
|
+
header_to_column = -> { RESERVED_COLUMN_NAMES.include?(_1.to_s) ? "#{_1}_1" : _1 }
|
27
|
+
headers = csv.shift.map(&underscore).map(&header_to_column)
|
28
|
+
basename = File.basename csv_path
|
29
|
+
database = "#{options[:tmp_dir]}/shopify-toolkit-analyze-#{underscore[basename]}.sqlite3"
|
30
|
+
should_import = options[:force_import] || !File.exist?(database)
|
31
|
+
to_record = ->(row) { headers.zip(row.each{ |c| c.delete!("\u0000") if String === c }).to_h.transform_keys(&header_to_column) }
|
32
|
+
|
33
|
+
File.delete(database) if should_import && File.exist?(database)
|
34
|
+
|
35
|
+
ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database:)
|
36
|
+
|
37
|
+
if should_import
|
38
|
+
puts "==> Importing #{csv_path} into #{database}"
|
39
|
+
ActiveRecord::Schema.define do
|
40
|
+
create_table :results, force: true do |t|
|
41
|
+
t.json :data
|
42
|
+
headers.each { |header| t.string header }
|
43
|
+
end
|
44
|
+
add_index :results, :import_result if headers.include?('import_result')
|
45
|
+
end
|
46
|
+
csv.each_slice(5000) { |rows| print "."; Result.insert_all(rows.map(&to_record)) }
|
47
|
+
puts
|
48
|
+
end
|
49
|
+
|
50
|
+
puts "==> Starting console for #{basename}"
|
51
|
+
require "irb"
|
52
|
+
IRB.conf[:IRB_NAME] = basename
|
53
|
+
Result.class_eval { binding.irb(show_code: false) }
|
54
|
+
end
|
55
|
+
|
56
|
+
desc "migrate", "Run migrations"
|
57
|
+
def migrate
|
58
|
+
require "./config/environment"
|
59
|
+
::Shop.sole.with_shopify_session { ShopifyToolkit::Migrator.new.up }
|
60
|
+
end
|
61
|
+
|
62
|
+
desc "down", "Run migrations down"
|
63
|
+
def down
|
64
|
+
require "./config/environment"
|
65
|
+
::Shop.sole.with_shopify_session { ShopifyToolkit::Migrator.new.down }
|
66
|
+
end
|
67
|
+
|
68
|
+
desc "redo", "Run migrations down and up again"
|
69
|
+
def redo
|
70
|
+
require "./config/environment"
|
71
|
+
::Shop.sole.with_shopify_session { ShopifyToolkit::Migrator.new.redo }
|
72
|
+
end
|
73
|
+
|
74
|
+
desc "schema_load", 'Load schema from "config/shopify/schema.rb"'
|
75
|
+
def schema_load
|
76
|
+
require "./config/environment"
|
77
|
+
::Shop.sole.with_shopify_session { ShopifyToolkit::Schema.load! }
|
78
|
+
end
|
79
|
+
|
80
|
+
desc "schema_dump", 'Dump schema to "config/shopify/schema.rb"'
|
81
|
+
def schema_dump
|
82
|
+
require "./config/environment"
|
83
|
+
::Shop.sole.with_shopify_session { ShopifyToolkit::Schema.dump! }
|
84
|
+
end
|
85
|
+
|
86
|
+
def self.exit_on_failure?
|
87
|
+
true
|
88
|
+
end
|
89
|
+
end
|
@@ -1,7 +1,5 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require "shopify_toolkit/migration/logging"
|
4
|
-
require "shopify_toolkit/admin_client"
|
5
3
|
require "active_support/concern"
|
6
4
|
|
7
5
|
module ShopifyToolkit::MetafieldStatements
|
@@ -22,6 +20,12 @@ module ShopifyToolkit::MetafieldStatements
|
|
22
20
|
def create_metafield(owner_type, key, type, namespace: :custom, name:, **options)
|
23
21
|
ownerType = owner_type.to_s.singularize.upcase # Eg. "PRODUCT"
|
24
22
|
|
23
|
+
# Skip creation if metafield already exists
|
24
|
+
if get_metafield_gid(owner_type, key, namespace: namespace)
|
25
|
+
say "Metafield #{namespace}:#{key} already exists for #{owner_type}, skipping creation"
|
26
|
+
return
|
27
|
+
end
|
28
|
+
|
25
29
|
# https://shopify.dev/docs/api/admin-graphql/2024-10/mutations/metafieldDefinitionCreate
|
26
30
|
query =
|
27
31
|
"# GraphQL
|
@@ -68,8 +72,7 @@ module ShopifyToolkit::MetafieldStatements
|
|
68
72
|
.tap { handle_shopify_admin_client_errors(_1) }
|
69
73
|
.body
|
70
74
|
|
71
|
-
result.dig("data", "metafieldDefinitions", "nodes", 0, "id")
|
72
|
-
raise "Metafield not found for #{owner_type}##{namespace}:#{key}"
|
75
|
+
result.dig("data", "metafieldDefinitions", "nodes", 0, "id")
|
73
76
|
end
|
74
77
|
|
75
78
|
log_time \
|
@@ -79,6 +82,11 @@ module ShopifyToolkit::MetafieldStatements
|
|
79
82
|
"For reserved namespaces, you must delete all associated metafields (delete_associated_metafields: true)"
|
80
83
|
end
|
81
84
|
|
85
|
+
unless get_metafield_gid(owner_type, key, namespace: namespace)
|
86
|
+
say "Metafield #{namespace}:#{key} not found for #{owner_type}, skipping deletion"
|
87
|
+
return
|
88
|
+
end
|
89
|
+
|
82
90
|
shopify_admin_client
|
83
91
|
.query(
|
84
92
|
# Documentation: https://shopify.dev/docs/api/admin-graphql/2024-10/mutations/metafieldDefinitionDelete
|
@@ -104,6 +112,11 @@ module ShopifyToolkit::MetafieldStatements
|
|
104
112
|
|
105
113
|
log_time \
|
106
114
|
def update_metafield(owner_type, key, namespace: :custom, **options)
|
115
|
+
unless get_metafield_gid(owner_type, key, namespace: namespace)
|
116
|
+
say "Metafield #{namespace}:#{key} not found for #{owner_type}, skipping update"
|
117
|
+
return
|
118
|
+
end
|
119
|
+
|
107
120
|
shopify_admin_client
|
108
121
|
.query(
|
109
122
|
# Documentation: https://shopify.dev/docs/api/admin-graphql/2024-10/mutations/metafieldDefinitionUpdate
|
@@ -0,0 +1,117 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support/concern"
|
4
|
+
|
5
|
+
module ShopifyToolkit::MetaobjectStatements
|
6
|
+
extend ActiveSupport::Concern
|
7
|
+
include ShopifyToolkit::Migration::Logging
|
8
|
+
include ShopifyToolkit::AdminClient
|
9
|
+
|
10
|
+
def self.log_time(method_name)
|
11
|
+
current_method = instance_method(method_name)
|
12
|
+
define_method(method_name) do |*args, **kwargs, &block|
|
13
|
+
say_with_time("#{method_name}(#{args.map(&:inspect).join(', ')})") { current_method.bind(self).call(*args, **kwargs, &block) }
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
# create_metafield :products, :my_metafield, :single_line_text_field, name: "Prova"
|
18
|
+
# @param namespace: if nil the metafield will be app-specific (default: :custom)
|
19
|
+
log_time \
|
20
|
+
def create_metaobject_definition(type, **options)
|
21
|
+
# Skip creation if metafield already exists
|
22
|
+
existing_gid = get_metaobject_definition_gid(type)
|
23
|
+
if existing_gid
|
24
|
+
say "Metaobject #{type} already exists, skipping creation"
|
25
|
+
return existing_gid
|
26
|
+
end
|
27
|
+
|
28
|
+
# https://shopify.dev/docs/api/admin-graphql/2024-10/mutations/metafieldDefinitionCreate
|
29
|
+
query =
|
30
|
+
"# GraphQL
|
31
|
+
mutation CreateMetaobjectDefinition($definition: MetaobjectDefinitionCreateInput!) {
|
32
|
+
metaobjectDefinitionCreate(definition: $definition) {
|
33
|
+
metaobjectDefinition {
|
34
|
+
id
|
35
|
+
name
|
36
|
+
type
|
37
|
+
fieldDefinitions {
|
38
|
+
name
|
39
|
+
key
|
40
|
+
}
|
41
|
+
}
|
42
|
+
userErrors {
|
43
|
+
field
|
44
|
+
message
|
45
|
+
code
|
46
|
+
}
|
47
|
+
}
|
48
|
+
}
|
49
|
+
"
|
50
|
+
variables = { definition: { type:, **options } }
|
51
|
+
|
52
|
+
shopify_admin_client
|
53
|
+
.query(query:, variables:)
|
54
|
+
.tap { handle_shopify_admin_client_errors(_1, "data.metaobjectDefinitionCreate.userErrors") }
|
55
|
+
end
|
56
|
+
|
57
|
+
def get_metaobject_definition_gid(type)
|
58
|
+
result =
|
59
|
+
shopify_admin_client
|
60
|
+
.query(
|
61
|
+
query:
|
62
|
+
"# GraphQL
|
63
|
+
query GetMetaobjectDefinitionID($type: String!) {
|
64
|
+
metaobjectDefinitionByType(type: $type) {
|
65
|
+
id
|
66
|
+
}
|
67
|
+
}",
|
68
|
+
variables: { type: type.to_s },
|
69
|
+
)
|
70
|
+
.tap { handle_shopify_admin_client_errors(_1) }
|
71
|
+
.body
|
72
|
+
|
73
|
+
result.dig("data", "metaobjectDefinitionByType", "id")
|
74
|
+
end
|
75
|
+
|
76
|
+
def update_metaobject_definition(type, **options)
|
77
|
+
existing_gid = get_metaobject_definition_gid(type)
|
78
|
+
|
79
|
+
raise "Metaobject #{type} does not exist" unless existing_gid
|
80
|
+
|
81
|
+
# https://shopify.dev/docs/api/admin-graphql/2024-10/mutations/metaobjectDefinitionUpdate
|
82
|
+
query =
|
83
|
+
"# GraphQL
|
84
|
+
mutation UpdateMetaobjectDefinition($id: ID!, $definition: MetaobjectDefinitionUpdateInput!) {
|
85
|
+
metaobjectDefinitionUpdate(id: $id, definition: $definition) {
|
86
|
+
metaobjectDefinition {
|
87
|
+
id
|
88
|
+
name
|
89
|
+
type
|
90
|
+
fieldDefinitions {
|
91
|
+
name
|
92
|
+
key
|
93
|
+
}
|
94
|
+
}
|
95
|
+
userErrors {
|
96
|
+
field
|
97
|
+
message
|
98
|
+
code
|
99
|
+
}
|
100
|
+
}
|
101
|
+
}
|
102
|
+
"
|
103
|
+
variables = { id: existing_gid, definition: { **options } }
|
104
|
+
|
105
|
+
shopify_admin_client
|
106
|
+
.query(query:, variables:)
|
107
|
+
.tap { handle_shopify_admin_client_errors(_1, "data.metaobjectDefinitionUpdate.userErrors") }
|
108
|
+
end
|
109
|
+
|
110
|
+
def self.define(&block)
|
111
|
+
context = Object.new
|
112
|
+
context.extend(self)
|
113
|
+
|
114
|
+
context.instance_eval(&block) if block_given?(&block)
|
115
|
+
context
|
116
|
+
end
|
117
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support/benchmark"
|
4
|
+
|
5
|
+
class ShopifyToolkit::Migration
|
6
|
+
include ShopifyToolkit::AdminClient
|
7
|
+
include ShopifyToolkit::MetafieldStatements
|
8
|
+
include ShopifyToolkit::MetaobjectStatements
|
9
|
+
include ShopifyToolkit::Migration::Logging
|
10
|
+
|
11
|
+
class IrreversibleMigration < StandardError
|
12
|
+
end
|
13
|
+
|
14
|
+
def logger
|
15
|
+
@logger ||= ActiveSupport::Logger.new(STDOUT).tap do |logger|
|
16
|
+
logger.formatter = proc do |severity, datetime, progname, msg|
|
17
|
+
"#{severity}: #{msg}\n"
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.[](api_version)
|
23
|
+
klass = Class.new(self)
|
24
|
+
klass.const_set(:API_VERSION, api_version)
|
25
|
+
klass
|
26
|
+
end
|
27
|
+
|
28
|
+
attr_reader :name, :version
|
29
|
+
|
30
|
+
def initialize(name, version)
|
31
|
+
@name = name
|
32
|
+
@version = version
|
33
|
+
end
|
34
|
+
|
35
|
+
def announce(message)
|
36
|
+
super "#{version} #{name}: #{message}"
|
37
|
+
end
|
38
|
+
|
39
|
+
def migrate(direction)
|
40
|
+
case direction
|
41
|
+
when :up
|
42
|
+
announce("migrating")
|
43
|
+
time_elapsed = ActiveSupport::Benchmark.realtime { up }
|
44
|
+
announce("migrated (%.4fs)" % time_elapsed)
|
45
|
+
|
46
|
+
when :down
|
47
|
+
announce("reverting")
|
48
|
+
time_elapsed = ActiveSupport::Benchmark.realtime { down }
|
49
|
+
announce("reverted (%.4fs)" % time_elapsed)
|
50
|
+
|
51
|
+
else
|
52
|
+
raise ArgumentError, "Unknown migration direction: #{direction}"
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def up
|
57
|
+
# Implement in subclass
|
58
|
+
end
|
59
|
+
|
60
|
+
def down
|
61
|
+
raise IrreversibleMigration
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,152 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support/benchmarkable"
|
4
|
+
require "active_support/core_ext/module/delegation"
|
5
|
+
|
6
|
+
class ShopifyToolkit::Migrator # :nodoc:
|
7
|
+
include ShopifyToolkit::AdminClient
|
8
|
+
include ShopifyToolkit::MetafieldStatements
|
9
|
+
|
10
|
+
singleton_class.attr_accessor :migrations_paths
|
11
|
+
self.migrations_paths = ["config/shopify/migrate"]
|
12
|
+
|
13
|
+
attr_reader :migrated_versions, :migrations, :migrations_paths
|
14
|
+
|
15
|
+
def initialize(migrations_paths: self.class.migrations_paths)
|
16
|
+
@migrations_paths = migrations_paths
|
17
|
+
@migrated_versions = read_or_create_metafield["migrated_versions"]
|
18
|
+
@migrations = load_migrations # [MigrationProxy<Migration1>, MigrationProxy<Migration2>, ...]
|
19
|
+
end
|
20
|
+
|
21
|
+
def current_version
|
22
|
+
migrated.max || 0
|
23
|
+
end
|
24
|
+
|
25
|
+
def up
|
26
|
+
pending_migrations = migrations.reject { migrated_versions.include?(_1.version) }
|
27
|
+
|
28
|
+
pending_migrations.each do |migration|
|
29
|
+
migration.migrate(:up)
|
30
|
+
migrated_versions << migration.version
|
31
|
+
end
|
32
|
+
ensure
|
33
|
+
update_metafield
|
34
|
+
end
|
35
|
+
|
36
|
+
def executed_migrations
|
37
|
+
migrations.select { migrated_versions.include?(_1.version) }
|
38
|
+
end
|
39
|
+
|
40
|
+
def down
|
41
|
+
# For now we'll just rollback the last one
|
42
|
+
executed_migrations.last(1).each do |migration|
|
43
|
+
migration.migrate(:down)
|
44
|
+
migrated_versions.delete(migration.version)
|
45
|
+
end
|
46
|
+
ensure
|
47
|
+
update_metafield
|
48
|
+
end
|
49
|
+
|
50
|
+
def query(query, **variables)
|
51
|
+
shopify_admin_client
|
52
|
+
.query(query:, variables:)
|
53
|
+
.tap { handle_shopify_admin_client_errors(_1) }
|
54
|
+
.body
|
55
|
+
.dig("data")
|
56
|
+
end
|
57
|
+
|
58
|
+
def update_metafield
|
59
|
+
namespace = :shopify_toolkit
|
60
|
+
key = :migrations
|
61
|
+
|
62
|
+
value = { "migrated_versions" => migrated_versions.to_a.sort }.to_json
|
63
|
+
|
64
|
+
owner_id = query(%(query GetShopGId { shop { id } })).dig("shop", "id")
|
65
|
+
|
66
|
+
query = <<~GRAPHQL
|
67
|
+
mutation metafieldsSet($metafields: [MetafieldsSetInput!]!) {
|
68
|
+
metafieldsSet(metafields: $metafields) {
|
69
|
+
userErrors {
|
70
|
+
field
|
71
|
+
message
|
72
|
+
}
|
73
|
+
}
|
74
|
+
}
|
75
|
+
GRAPHQL
|
76
|
+
query(query, metafields: [{ namespace:, key:, ownerId: owner_id, value: }])
|
77
|
+
|
78
|
+
nil
|
79
|
+
end
|
80
|
+
|
81
|
+
def read_or_create_metafield
|
82
|
+
namespace = :shopify_toolkit
|
83
|
+
key = :migrations
|
84
|
+
|
85
|
+
value = query(
|
86
|
+
"query ShopMetafield($namespace: String!, $key: String!) {shop {metafield(namespace: $namespace, key: $key) {value}}}",
|
87
|
+
namespace:, key:,
|
88
|
+
).dig("shop", "metafield", "value")
|
89
|
+
|
90
|
+
if value.nil?
|
91
|
+
create_metafield :shop, key, :json, namespace:, name: "Migrations metadata"
|
92
|
+
return { "migrated_versions" => [] }
|
93
|
+
end
|
94
|
+
|
95
|
+
JSON.parse(value)
|
96
|
+
end
|
97
|
+
|
98
|
+
def redo
|
99
|
+
down
|
100
|
+
up
|
101
|
+
end
|
102
|
+
|
103
|
+
def migration_files
|
104
|
+
paths = Array(migrations_paths)
|
105
|
+
Dir[*paths.flat_map { |path| "#{path}/**/[0-9]*_*.rb" }]
|
106
|
+
end
|
107
|
+
|
108
|
+
def parse_migration_filename(filename)
|
109
|
+
File.basename(filename).scan(/\A([0-9]+)_([_a-z0-9]*)\.?([_a-z0-9]*)?\.rb\z/).first
|
110
|
+
end
|
111
|
+
|
112
|
+
def load_migrations
|
113
|
+
migrations = migration_files.map do |file|
|
114
|
+
version, name, scope = parse_migration_filename(file)
|
115
|
+
raise "missing version #{file}" unless version
|
116
|
+
raise "missing name #{file}" unless name
|
117
|
+
version = version.to_i
|
118
|
+
name = name.camelize
|
119
|
+
|
120
|
+
MigrationProxy.new(name, version, file, scope)
|
121
|
+
end
|
122
|
+
|
123
|
+
migrations.sort_by(&:version)
|
124
|
+
end
|
125
|
+
|
126
|
+
# MigrationProxy is used to defer loading of the actual migration classes
|
127
|
+
# until they are needed
|
128
|
+
MigrationProxy = Struct.new(:name, :version, :filename, :scope) do
|
129
|
+
def initialize(name, version, filename, scope)
|
130
|
+
super
|
131
|
+
@migration = nil
|
132
|
+
end
|
133
|
+
|
134
|
+
def basename
|
135
|
+
File.basename(filename)
|
136
|
+
end
|
137
|
+
|
138
|
+
delegate :migrate, :up, :down, :announce, :say, :say_with_time, to: :migration
|
139
|
+
|
140
|
+
private
|
141
|
+
def migration
|
142
|
+
@migration ||= load_migration
|
143
|
+
end
|
144
|
+
|
145
|
+
def load_migration
|
146
|
+
Object.send(:remove_const, name) rescue nil
|
147
|
+
|
148
|
+
load(File.expand_path(filename))
|
149
|
+
name.constantize.new(name, version)
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|
data/lib/shopify_toolkit.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: shopify_toolkit
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.4.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-
|
11
|
+
date: 2025-05-27 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: railties
|
@@ -109,8 +109,12 @@ files:
|
|
109
109
|
- exe/shopify-toolkit
|
110
110
|
- lib/shopify_toolkit.rb
|
111
111
|
- lib/shopify_toolkit/admin_client.rb
|
112
|
+
- lib/shopify_toolkit/command_line.rb
|
112
113
|
- lib/shopify_toolkit/metafield_statements.rb
|
114
|
+
- lib/shopify_toolkit/metaobject_statements.rb
|
115
|
+
- lib/shopify_toolkit/migration.rb
|
113
116
|
- lib/shopify_toolkit/migration/logging.rb
|
117
|
+
- lib/shopify_toolkit/migrator.rb
|
114
118
|
- lib/shopify_toolkit/schema.rb
|
115
119
|
- lib/shopify_toolkit/version.rb
|
116
120
|
- sig/shopify_toolkit.rbs
|
@@ -137,7 +141,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
137
141
|
- !ruby/object:Gem::Version
|
138
142
|
version: '0'
|
139
143
|
requirements: []
|
140
|
-
rubygems_version: 3.6.
|
144
|
+
rubygems_version: 3.6.2
|
141
145
|
specification_version: 4
|
142
146
|
summary: A collection of tools for dealing with Shopify apps.
|
143
147
|
test_files: []
|