schema_ferry 0.2.0 → 0.3.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.
Files changed (30) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +20 -1
  3. data/README.md +34 -32
  4. data/lib/schema_ferry/cli.rb +19 -10
  5. data/lib/schema_ferry/config.rb +109 -0
  6. data/lib/schema_ferry/{converter → core}/column_converter.rb +4 -4
  7. data/lib/schema_ferry/{converter → core}/enum_check_builder.rb +8 -6
  8. data/lib/schema_ferry/{converter/identifier_shortener.rb → core/identifier_shortenable.rb} +4 -14
  9. data/lib/schema_ferry/core/schema_model.rb +60 -0
  10. data/lib/schema_ferry/core/schemafile_renderer.rb +109 -0
  11. data/lib/schema_ferry/core/table_converter.rb +159 -0
  12. data/lib/schema_ferry/core/translator.rb +53 -0
  13. data/lib/schema_ferry/{converter → core}/type_mapper.rb +2 -2
  14. data/lib/schema_ferry/errors.rb +2 -0
  15. data/lib/schema_ferry/io/mysql_reader.rb +80 -0
  16. data/lib/schema_ferry/{target/ridgepole_runner.rb → io/postgres_writer.rb} +11 -6
  17. data/lib/schema_ferry/pipeline.rb +24 -7
  18. data/lib/schema_ferry/support/drop_detectable.rb +17 -0
  19. data/lib/schema_ferry/support/warnings.rb +13 -0
  20. data/lib/schema_ferry/version.rb +1 -1
  21. data/lib/schema_ferry.rb +14 -14
  22. metadata +20 -14
  23. data/lib/schema_ferry/converter/schema_converter.rb +0 -172
  24. data/lib/schema_ferry/dsl/config.rb +0 -77
  25. data/lib/schema_ferry/dsl/table_rule.rb +0 -34
  26. data/lib/schema_ferry/schema_model.rb +0 -56
  27. data/lib/schema_ferry/source/connection_registry.rb +0 -29
  28. data/lib/schema_ferry/source/mysql_reader.rb +0 -88
  29. data/lib/schema_ferry/target/schemafile_renderer.rb +0 -143
  30. data/lib/schema_ferry/warnings.rb +0 -13
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SchemaFerry
4
+ module Core
5
+ class TableConverter
6
+ include Support::Warnings
7
+ include IdentifierShortenable
8
+
9
+ UNSUPPORTED_INDEX_TYPES = %i[fulltext spatial].freeze
10
+ SPATIAL_SQL_TYPES = /\A(?:geometry|point|linestring|polygon|multipoint|multilinestring|multipolygon|
11
+ geometrycollection)\z/ix
12
+
13
+ def initialize(raw, rule:, foreign_keys:, fk_columns:, column_converter:, enum_check:)
14
+ @raw = raw
15
+ @name = raw[:name]
16
+ @rule = rule
17
+ @foreign_keys = foreign_keys
18
+ @fk_columns = fk_columns
19
+ @column_converter = column_converter
20
+ @enum_check = enum_check
21
+ end
22
+
23
+ def convert
24
+ check_table_name_length!
25
+
26
+ TableSchema.new(
27
+ name: @name,
28
+ primary_key: @raw[:primary_key],
29
+ pk_type: convert_pk_type,
30
+ pk_limit: convert_pk_limit,
31
+ comment: @raw[:comment].presence,
32
+ columns: convert_columns,
33
+ indexes: convert_indexes,
34
+ foreign_keys: convert_foreign_keys,
35
+ check_constraints: build_check_constraints
36
+ )
37
+ end
38
+
39
+ private
40
+
41
+ def check_table_name_length!
42
+ return if @name.bytesize <= MAX_BYTES
43
+
44
+ raise ConversionError, "table name #{@name.inspect} exceeds PostgreSQL's #{MAX_BYTES}-byte " \
45
+ "identifier limit. Exclude it with ignore_table :#{@name}, or rename it in MySQL."
46
+ end
47
+
48
+ def pk_column
49
+ pk = @raw[:primary_key]
50
+ pk.is_a?(String) ? @raw[:columns].find { |c| c[:name] == pk } : nil
51
+ end
52
+
53
+ # A single-column PK is rendered via create_table's id: option, so it is
54
+ # excluded from the column list. Composite PK columns must stay:
55
+ # primary_key: [...] does not create them.
56
+ def data_columns
57
+ @raw[:columns].reject { |c| c[:name] == @raw[:primary_key] }
58
+ end
59
+
60
+ # MySQL BIGINT comes through AR as :integer with limit 8, so the sql_type
61
+ # is the only reliable source for the id: option.
62
+ def convert_pk_type
63
+ col = pk_column
64
+ return nil if col.nil?
65
+ return col[:type] unless col[:type] == :integer
66
+
67
+ sql_type = col[:sql_type].to_s
68
+ if sql_type.include?("unsigned")
69
+ if sql_type.start_with?("bigint")
70
+ emit_warning "table #{@name}: BIGINT UNSIGNED primary key has no PostgreSQL " \
71
+ "equivalent; using signed bigint (values above 2^63-1 will not fit)."
72
+ end
73
+ :bigint
74
+ elsif sql_type.start_with?("bigint")
75
+ :bigint
76
+ else
77
+ :integer
78
+ end
79
+ end
80
+
81
+ def convert_pk_limit
82
+ col = pk_column
83
+ col[:limit] if col && col[:type] == :string
84
+ end
85
+
86
+ def convert_columns
87
+ data_columns
88
+ .reject { |c| @rule.ignored_columns.include?(c[:name]) }
89
+ .map do |c|
90
+ check_spatial_type!(c)
91
+ @column_converter.call(c, @name, @rule, @fk_columns)
92
+ end
93
+ end
94
+
95
+ def check_spatial_type!(col)
96
+ return if @rule.column_type_overrides.key?(col[:name]) # handled downstream, like any other override
97
+
98
+ if col[:sql_type].to_s.match?(SPATIAL_SQL_TYPES)
99
+ raise ConversionError, "#{@name}.#{col[:name]}: MySQL #{col[:sql_type]} columns have no " \
100
+ "PostgreSQL equivalent without PostGIS, which schema_ferry does not " \
101
+ "manage. Exclude it with ignore_column :#{col[:name]}."
102
+ end
103
+ end
104
+
105
+ def convert_indexes
106
+ @raw[:indexes]
107
+ .reject { |idx| @rule.ignored_indexes.include?(idx[:name]) }
108
+ .reject { |idx| idx[:columns].intersect?(@rule.ignored_columns) }
109
+ .map do |idx|
110
+ check_unsupported_index_type!(idx)
111
+ build_index_schema(idx)
112
+ end
113
+ end
114
+
115
+ def check_unsupported_index_type!(idx)
116
+ if UNSUPPORTED_INDEX_TYPES.include?(idx[:type])
117
+ raise ConversionError, "#{@name}: #{idx[:type].to_s.upcase} index #{idx[:name].inspect} " \
118
+ "has no PostgreSQL equivalent. Exclude it with ignore_index :#{idx[:name]}."
119
+ end
120
+ end
121
+
122
+ def build_index_schema(idx)
123
+ IndexSchema.new(
124
+ name: shorten_identifier(idx[:name], kind: "index", table: @name),
125
+ columns: idx[:columns],
126
+ unique: idx[:unique],
127
+ using: idx[:using],
128
+ # AR reports "no prefix lengths / orders" as an empty hash; rendering
129
+ # a literal lengths: {} would make ridgepole re-create the index on
130
+ # every run.
131
+ lengths: idx[:lengths].presence,
132
+ orders: idx[:orders].presence
133
+ )
134
+ end
135
+
136
+ def convert_foreign_keys
137
+ @foreign_keys.map { |fk| build_fk_schema(fk) }
138
+ end
139
+
140
+ def build_fk_schema(foreign_key)
141
+ ForeignKeySchema.new(
142
+ from_table: foreign_key[:from_table],
143
+ to_table: foreign_key[:to_table],
144
+ column: foreign_key[:column],
145
+ primary_key: foreign_key[:primary_key],
146
+ on_update: foreign_key[:on_update],
147
+ on_delete: foreign_key[:on_delete],
148
+ name: shorten_identifier(foreign_key[:name], kind: "foreign key", table: foreign_key[:from_table])
149
+ )
150
+ end
151
+
152
+ def build_check_constraints
153
+ return [] unless @enum_check
154
+
155
+ @enum_check.call(@raw, @rule)
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SchemaFerry
4
+ module Core
5
+ class Translator
6
+ def initialize(config)
7
+ @config = config
8
+ @column_converter = ColumnConverter.new(TypeMapper.new(config.global_type_overrides))
9
+ @enum_check = (EnumCheckBuilder.new if config.enum_mode == :check)
10
+ end
11
+
12
+ def translate(raw_tables)
13
+ kept_tables = raw_tables.reject { |t| @config.ignored_tables.include?(t[:name]) }
14
+ foreign_keys = kept_tables.to_h { |t| [t[:name], surviving_foreign_keys(t)] }
15
+ fk_columns = collect_fk_columns(foreign_keys)
16
+
17
+ pg_tables = kept_tables.map do |raw|
18
+ TableConverter.new(
19
+ raw,
20
+ rule: @config.table_rules[raw[:name]],
21
+ foreign_keys: foreign_keys.fetch(raw[:name]),
22
+ fk_columns: fk_columns.fetch(raw[:name], []),
23
+ column_converter: @column_converter,
24
+ enum_check: @enum_check
25
+ ).convert
26
+ end
27
+
28
+ SchemafileRenderer.new.render(pg_tables)
29
+ end
30
+
31
+ private
32
+
33
+ def surviving_foreign_keys(raw)
34
+ ignored = @config.table_rules[raw[:name]].ignored_columns
35
+ raw[:foreign_keys]
36
+ .reject { |fk| @config.ignored_tables.include?(fk[:to_table]) }
37
+ .reject { |fk| ignored.include?(fk[:column]) }
38
+ end
39
+
40
+ # Columns on either side of a surviving foreign key must land in
41
+ # PostgreSQL's integer type family: a numeric(20, 0) column cannot
42
+ # reference a bigint key.
43
+ def collect_fk_columns(foreign_keys_by_table)
44
+ foreign_keys_by_table.each_with_object({}) do |(table_name, fks), acc|
45
+ fks.each do |fk|
46
+ (acc[table_name] ||= []) << fk[:column]
47
+ (acc[fk[:to_table]] ||= []) << fk[:primary_key]
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SchemaFerry
4
- module Converter
4
+ module Core
5
5
  class TypeMapper
6
6
  # PG has no limit concept for text/binary/bigint/float; drop the
7
7
  # MySQL-derived limits (float's limit: 53 is DOUBLE's internal bit
@@ -36,7 +36,7 @@ module SchemaFerry
36
36
  def call(ar_type, options = {})
37
37
  unless KNOWN_TYPES.include?(ar_type)
38
38
  raise ConversionError, "Unknown MySQL AR type: #{ar_type.inspect}. " \
39
- "Use map_type or map_column to specify a PostgreSQL type."
39
+ "Use map_type or column's map_type_to: to specify a PostgreSQL type."
40
40
  end
41
41
 
42
42
  pg_type = @overrides.fetch(ar_type, ar_type)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SchemaFerry
4
+ # Base class so the CLI can rescue every schema_ferry error in one clause.
4
5
  class Error < StandardError; end
5
6
  class ConfigError < Error; end
6
7
  class ConnectionError < Error; end
@@ -8,4 +9,5 @@ module SchemaFerry
8
9
  class ConversionError < Error; end
9
10
  class RidgepoleNotFoundError < Error; end
10
11
  class RidgepoleError < Error; end
12
+ class DropNotAllowedError < Error; end
11
13
  end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SchemaFerry
4
+ module IO
5
+ class MysqlReader
6
+ def initialize(url)
7
+ @url = url
8
+ end
9
+
10
+ def read_all
11
+ with_connection do |conn|
12
+ conn.tables.sort.map { |table_name| read_table(conn, table_name) }
13
+ end
14
+ rescue ConnectionError
15
+ raise
16
+ rescue StandardError => e
17
+ raise ReadError, e.message
18
+ end
19
+
20
+ private
21
+
22
+ # Uses a fresh ConnectionHandler per call so the pool is fully isolated
23
+ # from ActiveRecord::Base and any host Rails app connections.
24
+ # AR 7.2+ banned anonymous AR subclasses, so we bypass that path entirely.
25
+ def with_connection
26
+ handler = ActiveRecord::ConnectionAdapters::ConnectionHandler.new
27
+ begin
28
+ pool = handler.establish_connection(@url, owner_name: "schema_ferry")
29
+ conn = pool.checkout
30
+ conn.verify!
31
+ rescue ActiveRecord::ActiveRecordError => e
32
+ raise ConnectionError, e.message
33
+ end
34
+
35
+ begin
36
+ yield conn
37
+ ensure
38
+ pool.checkin(conn)
39
+ end
40
+ ensure
41
+ handler&.clear_all_connections!
42
+ end
43
+
44
+ def read_table(conn, name)
45
+ {
46
+ name: name,
47
+ primary_key: conn.primary_key(name),
48
+ comment: conn.table_comment(name),
49
+ columns: serialize_columns(conn.columns(name)),
50
+ indexes: serialize_indexes(conn.indexes(name)),
51
+ foreign_keys: serialize_foreign_keys(conn.foreign_keys(name))
52
+ }
53
+ end
54
+
55
+ def serialize_columns(columns)
56
+ fields = %i[name type sql_type limit precision scale null default default_function comment]
57
+ columns.map { |c| fields.to_h { |field| [field, c.public_send(field)] } }
58
+ end
59
+
60
+ def serialize_indexes(indexes)
61
+ indexes.map do |idx|
62
+ {
63
+ name: idx.name,
64
+ columns: Array(idx.columns), # idx.columns can be an Array or a String
65
+ unique: idx.unique,
66
+ using: idx.using,
67
+ type: idx.type,
68
+ lengths: idx.lengths,
69
+ orders: idx.orders
70
+ }
71
+ end
72
+ end
73
+
74
+ def serialize_foreign_keys(fkeys)
75
+ fields = %i[from_table to_table column primary_key on_update on_delete name]
76
+ fkeys.map { |fk| fields.to_h { |field| [field, fk.public_send(field)] } }
77
+ end
78
+ end
79
+ end
80
+ end
@@ -4,12 +4,12 @@ require "open3"
4
4
  require "tempfile"
5
5
 
6
6
  module SchemaFerry
7
- module Target
7
+ module IO
8
8
  # Runs ridgepole as a subprocess rather than via Ridgepole::Client:
9
- # the client calls ActiveRecord::Base.establish_connection and patches AR
10
- # in-process, which would hijack a host Rails app's database connection.
11
- # The tempfile exists because the CLI only reads the schema from -f FILE.
12
- class RidgepoleRunner
9
+ # Ridgepole::Client calls ActiveRecord::Base.establish_connection and
10
+ # patches AR in-process, which would hijack a host Rails app's database
11
+ # connection.
12
+ class PostgresWriter
13
13
  def initialize(target_url)
14
14
  @target_url = target_url
15
15
  end
@@ -17,12 +17,17 @@ module SchemaFerry
17
17
  def run(schema_content, dry_run: false)
18
18
  bin = find_ridgepole!
19
19
 
20
+ # ridgepole only reads the schema from -f FILE.
20
21
  Tempfile.create(["schema_ferry_", ".rb"]) do |f|
21
22
  f.write(schema_content)
22
23
  f.flush
23
24
 
24
25
  cmd = [bin, "--apply", "-c", @target_url, "-f", f.path]
25
- cmd << "--dry-run" if dry_run
26
+ if dry_run
27
+ cmd << "--dry-run"
28
+ else
29
+ cmd += ["--pre-query", "BEGIN", "--post-query", "COMMIT"]
30
+ end
26
31
 
27
32
  stdout, stderr, status = Open3.capture3(*cmd)
28
33
  raise RidgepoleError, [stdout, stderr].reject { |s| s.strip.empty? }.join("\n") unless status.success?
@@ -2,22 +2,39 @@
2
2
 
3
3
  module SchemaFerry
4
4
  class Pipeline
5
+ include Support::DropDetectable
6
+
5
7
  def initialize(config)
6
8
  @config = config
7
9
  end
8
10
 
9
11
  def dry_run
10
- Target::RidgepoleRunner.new(@config.target_url).run(schemafile, dry_run: true)
12
+ write(build_schemafile, dry_run: true)
13
+ end
14
+
15
+ def apply!(allow_drops: true)
16
+ schemafile = build_schemafile
17
+
18
+ unless allow_drops
19
+ drops = detect_drops(write(schemafile, dry_run: true))
20
+ unless drops.empty?
21
+ raise DropNotAllowedError,
22
+ "refused: the diff contains drop(s):\n#{drops.join("\n")}"
23
+ end
24
+ end
25
+
26
+ write(schemafile, dry_run: false)
11
27
  end
12
28
 
13
- def apply!
14
- Target::RidgepoleRunner.new(@config.target_url).run(schemafile, dry_run: false)
29
+ private
30
+
31
+ def build_schemafile
32
+ IO::MysqlReader.new(@config.source_url).read_all
33
+ .then { |mysql_tables| Core::Translator.new(@config).translate(mysql_tables) }
15
34
  end
16
35
 
17
- def schemafile
18
- mysql_tables = Source::MysqlReader.new(@config.source_url).read_all
19
- pg_tables = Converter::SchemaConverter.new(@config).convert(mysql_tables)
20
- Target::SchemafileRenderer.new.render(pg_tables)
36
+ def write(schemafile, dry_run:)
37
+ IO::PostgresWriter.new(@config.target_url).run(schemafile, dry_run: dry_run)
21
38
  end
22
39
  end
23
40
  end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SchemaFerry
4
+ module Support
5
+ module DropDetectable
6
+ DROP_LINE = /^(?:drop_table|remove_column|remove_index|remove_foreign_key|
7
+ remove_check_constraint|remove_exclusion_constraint|
8
+ remove_unique_constraint)\(.*\)$/x
9
+
10
+ private
11
+
12
+ def detect_drops(ddl)
13
+ ddl.each_line.map(&:chomp).grep(DROP_LINE)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SchemaFerry
4
+ module Support
5
+ module Warnings
6
+ private
7
+
8
+ def emit_warning(message)
9
+ Kernel.warn("[schema_ferry] #{message}")
10
+ end
11
+ end
12
+ end
13
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SchemaFerry
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/schema_ferry.rb CHANGED
@@ -4,23 +4,23 @@ require "active_record"
4
4
 
5
5
  require_relative "schema_ferry/version"
6
6
  require_relative "schema_ferry/errors"
7
- require_relative "schema_ferry/warnings"
8
- require_relative "schema_ferry/schema_model"
9
- require_relative "schema_ferry/dsl/table_rule"
10
- require_relative "schema_ferry/dsl/config"
11
- require_relative "schema_ferry/source/connection_registry"
12
- require_relative "schema_ferry/source/mysql_reader"
13
- require_relative "schema_ferry/converter/type_mapper"
14
- require_relative "schema_ferry/converter/identifier_shortener"
15
- require_relative "schema_ferry/converter/column_converter"
16
- require_relative "schema_ferry/converter/enum_check_builder"
17
- require_relative "schema_ferry/converter/schema_converter"
18
- require_relative "schema_ferry/target/schemafile_renderer"
19
- require_relative "schema_ferry/target/ridgepole_runner"
7
+ require_relative "schema_ferry/support/warnings"
8
+ require_relative "schema_ferry/support/drop_detectable"
9
+ require_relative "schema_ferry/config"
10
+ require_relative "schema_ferry/io/mysql_reader"
11
+ require_relative "schema_ferry/core/schema_model"
12
+ require_relative "schema_ferry/core/type_mapper"
13
+ require_relative "schema_ferry/core/identifier_shortenable"
14
+ require_relative "schema_ferry/core/column_converter"
15
+ require_relative "schema_ferry/core/enum_check_builder"
16
+ require_relative "schema_ferry/core/table_converter"
17
+ require_relative "schema_ferry/core/translator"
18
+ require_relative "schema_ferry/core/schemafile_renderer"
19
+ require_relative "schema_ferry/io/postgres_writer"
20
20
  require_relative "schema_ferry/pipeline"
21
21
 
22
22
  module SchemaFerry
23
23
  def self.define(&)
24
- Pipeline.new(DSL::Config.build(&))
24
+ Pipeline.new(Config.build(&))
25
25
  end
26
26
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: schema_ferry
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - kyuuri1791
@@ -65,6 +65,11 @@ dependencies:
65
65
  - - "~>"
66
66
  - !ruby/object:Gem::Version
67
67
  version: '3.0'
68
+ description: schema_ferry keeps a PostgreSQL schema in sync with a MySQL source during
69
+ a gradual migration. It reads the MySQL schema through ActiveRecord, converts it
70
+ to PostgreSQL equivalents — with a declarative DSL for type mappings, enum handling,
71
+ and per-table overrides — and applies only the diff, idempotently, via ridgepole.
72
+ Ships a Ruby API and a cron-friendly CLI.
68
73
  executables:
69
74
  - schema_ferry
70
75
  extensions: []
@@ -76,28 +81,29 @@ files:
76
81
  - exe/schema_ferry
77
82
  - lib/schema_ferry.rb
78
83
  - lib/schema_ferry/cli.rb
79
- - lib/schema_ferry/converter/column_converter.rb
80
- - lib/schema_ferry/converter/enum_check_builder.rb
81
- - lib/schema_ferry/converter/identifier_shortener.rb
82
- - lib/schema_ferry/converter/schema_converter.rb
83
- - lib/schema_ferry/converter/type_mapper.rb
84
- - lib/schema_ferry/dsl/config.rb
85
- - lib/schema_ferry/dsl/table_rule.rb
84
+ - lib/schema_ferry/config.rb
85
+ - lib/schema_ferry/core/column_converter.rb
86
+ - lib/schema_ferry/core/enum_check_builder.rb
87
+ - lib/schema_ferry/core/identifier_shortenable.rb
88
+ - lib/schema_ferry/core/schema_model.rb
89
+ - lib/schema_ferry/core/schemafile_renderer.rb
90
+ - lib/schema_ferry/core/table_converter.rb
91
+ - lib/schema_ferry/core/translator.rb
92
+ - lib/schema_ferry/core/type_mapper.rb
86
93
  - lib/schema_ferry/errors.rb
94
+ - lib/schema_ferry/io/mysql_reader.rb
95
+ - lib/schema_ferry/io/postgres_writer.rb
87
96
  - lib/schema_ferry/pipeline.rb
88
- - lib/schema_ferry/schema_model.rb
89
- - lib/schema_ferry/source/connection_registry.rb
90
- - lib/schema_ferry/source/mysql_reader.rb
91
- - lib/schema_ferry/target/ridgepole_runner.rb
92
- - lib/schema_ferry/target/schemafile_renderer.rb
97
+ - lib/schema_ferry/support/drop_detectable.rb
98
+ - lib/schema_ferry/support/warnings.rb
93
99
  - lib/schema_ferry/version.rb
94
- - lib/schema_ferry/warnings.rb
95
100
  homepage: https://github.com/kyuuri1791/schema_ferry
96
101
  licenses:
97
102
  - MIT
98
103
  metadata:
99
104
  homepage_uri: https://github.com/kyuuri1791/schema_ferry
100
105
  source_code_uri: https://github.com/kyuuri1791/schema_ferry
106
+ changelog_uri: https://github.com/kyuuri1791/schema_ferry/blob/main/CHANGELOG.md
101
107
  rubygems_mfa_required: 'true'
102
108
  rdoc_options: []
103
109
  require_paths: