schema_ferry 0.1.0 → 0.1.2

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: aacec6bc39b272cc96454401749eea5b880bb7c3a3d08e4666927f00f92d0379
4
- data.tar.gz: eb407968fdbb8b848a1c4f942fb824cef3a80a063a1effb81bf02389342a69ce
3
+ metadata.gz: a34cb64f18cd33a93ebbc3636bd1e76e170279d98ec5d9c59b8ef14e8f141fd9
4
+ data.tar.gz: f3ae1fbea45f7b7f5c4d5c79e5704fcc680955a77a9123a73975618c32752b8f
5
5
  SHA512:
6
- metadata.gz: 2e4663eb185b31ab7724e574c01c9b23fb49b8844a3e20bcebcba2ebc467d1abe8b8e7e7272fa73b0cf122f4a74cc6433811b4598a328b8847edad41b73963ae
7
- data.tar.gz: db642c4079f769cc430a79bbe7174a4dd1b13b89000bfcdc0ae9b4ce3e47c29e57db2c756820c543ab06531b3ce6a430f90861d8ed70a46c91bc7bc2f1d82b82
6
+ metadata.gz: 9fa8d644af36d128f1922258299fc96ba682a068f1195fced8e8446e64067c63e5d5ee81b3541f07e162d0e2588495946e111ca5076da608ab9054491f9a8cdd
7
+ data.tar.gz: 18dd78144c3e9ccbba232da685b0248eb22be16a73ac55090edfe6638a7d93d8ef794bcf1babad248a95f324b9bf8a3e4bac4e3db19a2b4d735d09dd45a9cffe
data/CHANGELOG.md CHANGED
@@ -6,3 +6,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
8
  ## [Unreleased]
9
+
10
+ ## [0.1.2] - 2026-07-06
11
+
12
+ ### Fixed
13
+
14
+ - `map_type :datetime, to: :timestamptz` no longer produces a schema that never converges: ActiveRecord's PostgreSQL adapter silently ignores a `precision:` option on `:timestamptz` columns (unlike `:datetime`/`:timestamp`/`:time`, where it's honored), so declaring one caused `apply!` to re-run `change_column` on every single run. `precision` is no longer emitted for `:timestamptz` columns.
15
+ - `BIGINT UNSIGNED` columns with a default value (e.g. `DEFAULT 0`) no longer cause `apply!` to re-run `change_column` on every run after the initial `apply!`. The default is now converted to match the string form ActiveRecord's schema dumper uses for decimal defaults, so it matches ridgepole's exported state instead of diffing against it forever.
16
+
17
+ ## [0.1.1] - 2026-07-06
18
+
19
+ ### Fixed
20
+
21
+ - `apply!` no longer fails with `PG::DatatypeMismatch` when a `BIGINT UNSIGNED` column takes part in a foreign key — the standard Rails-on-MySQL primary key layout. Such columns are now mapped to signed `bigint` (matching the referenced primary key) instead of `numeric(20)`, with a warning.
22
+ - Foreign keys whose column follows the Rails naming convention (`<table>_id`) are no longer dropped and re-created on every run: the generated `add_foreign_key` now omits `column:` for conventional names, matching ridgepole's export format.
23
+
24
+ ## [0.1.0] - 2026-07-06
25
+
26
+ ### Added
27
+
28
+ - Initial release
29
+ - `SchemaFerry.define` DSL — `source` / `target`, `map_type`, `enum_as :check`, `ignore_table`, and per-table rules (`map_column`, `ignore_column`, `ignore_index`, `add_index`)
30
+ - `Pipeline#dry_run` / `#apply!` — reads the MySQL schema via ActiveRecord, converts it with the default type mappings plus your rules, and applies the diff to PostgreSQL via ridgepole
31
+ - `schema_ferry` CLI (`apply` / `dry-run`) driven by a `Ferryfile`, with a one-line summary and cron-friendly exit status
32
+ - Automatic adjustments with warnings: unsigned integer widening, FULLTEXT/SPATIAL index skipping, 63-byte identifier shortening, zero-date default removal
data/README.md CHANGED
@@ -128,7 +128,7 @@ Ignoring a column also drops indexes and foreign keys that reference it. Renamin
128
128
  | `TINYINT(1)` | `boolean` | see the caveat above if a column holds more than 0/1 |
129
129
  | `TINYINT`…`BIGINT` (signed) | `smallint` / `integer` / `bigint` | widths normalized to PostgreSQL's three integer sizes |
130
130
  | `TINYINT`…`INT` `UNSIGNED` | one size larger | e.g. `INT UNSIGNED` → `bigint` |
131
- | `BIGINT UNSIGNED` | `numeric(20)` | PostgreSQL has no unsigned 8-byte integer; emitted with a warning |
131
+ | `BIGINT UNSIGNED` | `numeric(20)` | PostgreSQL has no unsigned 8-byte integer; emitted with a warning. Columns on a foreign key become signed `bigint` instead — see below |
132
132
  | `FLOAT` / `DOUBLE` | `double precision` | |
133
133
  | `DECIMAL(p,s)` | `numeric(p,s)` | |
134
134
  | `DATETIME` / `TIMESTAMP` | `timestamp` | use `map_type :datetime, to: :timestamptz` for `timestamptz` |
@@ -147,6 +147,7 @@ Some MySQL constructs have no PostgreSQL equivalent. schema_ferry handles them a
147
147
  - **Index prefix lengths** (`KEY (col(10))`) are dropped silently — PostgreSQL indexes the full column.
148
148
  - **Identifiers over 63 bytes** (MySQL allows 64): index and foreign key names are shortened deterministically (`first 54 bytes + _ + 8-char digest`), so repeated runs stay stable. Overlong table names are only warned about — rename those yourself.
149
149
  - **Zero-date defaults** (`'0000-00-00 00:00:00'`) are invalid in PostgreSQL and are dropped.
150
+ - **BIGINT UNSIGNED columns on a foreign key** (either side) become signed `bigint` instead of `numeric(20)` — a numeric column cannot reference a bigint primary key. Values above 2⁶³−1 will not fit, the same trade-off as for `BIGINT UNSIGNED` primary keys.
150
151
 
151
152
  ## How it works
152
153
 
@@ -13,8 +13,8 @@ module SchemaFerry
13
13
  @type_mapper = type_mapper
14
14
  end
15
15
 
16
- def call(raw, table_name, rule)
17
- raw = bump_unsigned_integer(raw)
16
+ def call(raw, table_name, rule, fk_columns = [])
17
+ raw = bump_unsigned_integer(raw, table_name, fk_columns.include?(raw[:name]))
18
18
  raw = drop_zero_date_default(raw, table_name)
19
19
  override = rule&.column_type_overrides&.[](raw[:name])
20
20
  col_opts = raw.slice(:limit, :precision, :scale, :null, :default, :default_function, :comment)
@@ -30,15 +30,26 @@ module SchemaFerry
30
30
 
31
31
  private
32
32
 
33
- def bump_unsigned_integer(raw)
33
+ def bump_unsigned_integer(raw, table_name, fk_column)
34
34
  return raw unless raw[:type] == :integer && raw[:sql_type].to_s.include?("unsigned")
35
35
 
36
- if raw[:limit] == 8
36
+ if raw[:limit] != 8
37
+ raw.merge(limit: UNSIGNED_LIMIT_BUMP.fetch(raw[:limit], raw[:limit]))
38
+ elsif fk_column
39
+ # decimal(20, 0) cannot be a foreign key to a bigint primary key, so
40
+ # FK columns follow the primary-key conversion (signed bigint) instead.
41
+ emit_warning "#{table_name}.#{raw[:name]}: BIGINT UNSIGNED takes part in a foreign key; " \
42
+ "mapped to signed bigint to match the referenced key " \
43
+ "(values above 2^63-1 will not fit)."
44
+ raw
45
+ else
37
46
  emit_warning "column #{raw[:name].inspect}: BIGINT UNSIGNED has no PostgreSQL " \
38
47
  "integer equivalent; mapped to decimal(20, 0)."
39
- raw.merge(type: :decimal, limit: nil, precision: 20, scale: 0)
40
- else
41
- raw.merge(limit: UNSIGNED_LIMIT_BUMP.fetch(raw[:limit], raw[:limit]))
48
+ # AR's schema dumper renders decimal defaults as a string (e.g.
49
+ # `default: "0"`), not a numeric literal — match that representation
50
+ # or ridgepole re-applies the default on every run.
51
+ new_default = raw[:default]&.to_s
52
+ raw.merge(type: :decimal, limit: nil, precision: 20, scale: 0, default: new_default)
42
53
  end
43
54
  end
44
55
 
@@ -16,14 +16,14 @@ module SchemaFerry
16
16
  end
17
17
 
18
18
  def convert(raw_tables)
19
- raw_tables
20
- .reject { |t| @ignored_tables.include?(t[:name]) }
21
- .map { |t| convert_table(t) }
19
+ kept_tables = raw_tables.reject { |t| @ignored_tables.include?(t[:name]) }
20
+ fk_columns = collect_fk_columns(kept_tables)
21
+ kept_tables.map { |t| convert_table(t, fk_columns.fetch(t[:name], [])) }
22
22
  end
23
23
 
24
24
  private
25
25
 
26
- def convert_table(raw)
26
+ def convert_table(raw, fk_columns)
27
27
  rule = @table_rules[raw[:name]]
28
28
  ignored = rule&.ignored_columns || []
29
29
  IdentifierShortener.warn_long_table_name(raw[:name])
@@ -34,13 +34,32 @@ module SchemaFerry
34
34
  pk_type: convert_pk_type(raw),
35
35
  pk_limit: raw[:pk_limit],
36
36
  comment: raw[:comment],
37
- columns: convert_columns(raw[:columns], raw[:name], rule, ignored),
37
+ columns: convert_columns(raw[:columns], raw[:name], rule, ignored, fk_columns),
38
38
  indexes: convert_indexes(raw[:indexes], raw[:name], rule, ignored),
39
- foreign_keys: convert_foreign_keys(raw[:foreign_keys], ignored),
39
+ foreign_keys: convert_foreign_keys(raw),
40
40
  check_constraints: build_check_constraints(raw, rule, ignored)
41
41
  )
42
42
  end
43
43
 
44
+ # Columns on either side of a surviving foreign key must land in
45
+ # PostgreSQL's integer type family: a numeric(20, 0) column cannot
46
+ # reference a bigint key.
47
+ def collect_fk_columns(raw_tables)
48
+ raw_tables.each_with_object({}) do |raw, acc|
49
+ surviving_foreign_keys(raw).each do |fk|
50
+ (acc[raw[:name]] ||= []) << fk[:column]
51
+ (acc[fk[:to_table]] ||= []) << fk[:primary_key]
52
+ end
53
+ end
54
+ end
55
+
56
+ def surviving_foreign_keys(raw)
57
+ ignored = @table_rules[raw[:name]]&.ignored_columns || []
58
+ raw[:foreign_keys]
59
+ .reject { |fk| @ignored_tables.include?(fk[:to_table]) }
60
+ .reject { |fk| ignored.include?(fk[:column]) }
61
+ end
62
+
44
63
  # MySQL BIGINT comes through AR as :integer with limit 8, so the sql_type
45
64
  # is the only reliable source for the id: option.
46
65
  def convert_pk_type(raw)
@@ -60,10 +79,10 @@ module SchemaFerry
60
79
  end
61
80
  end
62
81
 
63
- def convert_columns(raw_columns, table_name, rule, ignored)
82
+ def convert_columns(raw_columns, table_name, rule, ignored, fk_columns)
64
83
  raw_columns
65
84
  .reject { |c| ignored.include?(c[:name]) }
66
- .map { |c| @column_converter.call(c, table_name, rule) }
85
+ .map { |c| @column_converter.call(c, table_name, rule, fk_columns) }
67
86
  end
68
87
 
69
88
  def convert_indexes(raw_indexes, table_name, rule, ignored)
@@ -92,11 +111,8 @@ module SchemaFerry
92
111
  end
93
112
  end
94
113
 
95
- def convert_foreign_keys(raw_fkeys, ignored)
96
- raw_fkeys
97
- .reject { |fk| @ignored_tables.include?(fk[:to_table]) }
98
- .reject { |fk| ignored.include?(fk[:column]) }
99
- .map { |fk| build_fk_schema(fk) }
114
+ def convert_foreign_keys(raw)
115
+ surviving_foreign_keys(raw).map { |fk| build_fk_schema(fk) }
100
116
  end
101
117
 
102
118
  def skip_unsupported_index?(idx, table_name)
@@ -9,9 +9,15 @@ module SchemaFerry
9
9
 
10
10
  # PG's default timestamp precision is 6. Spelling it out makes ridgepole
11
11
  # see a diff against the PG export (which omits it) on every run.
12
- DEFAULT_PRECISION_TYPES = %i[datetime timestamptz time].freeze
12
+ DEFAULT_PRECISION_TYPES = %i[datetime time].freeze
13
13
  PG_DEFAULT_PRECISION = 6
14
14
 
15
+ # ActiveRecord's PostgreSQL adapter never honors a precision option on
16
+ # :timestamptz (only :datetime/:timestamp/:time do — see
17
+ # ActiveRecord::ConnectionAdapters::SchemaStatements#type_to_sql).
18
+ # Declaring one is always a lie, so it must never be emitted.
19
+ NO_PRECISION_TYPES = %i[timestamptz].freeze
20
+
15
21
  DEFAULTS = {
16
22
  json: :jsonb
17
23
  }.freeze
@@ -58,6 +64,7 @@ module SchemaFerry
58
64
  end
59
65
 
60
66
  def strip_default_precision(pg_type, options)
67
+ options[:precision] = nil if NO_PRECISION_TYPES.include?(pg_type)
61
68
  return unless DEFAULT_PRECISION_TYPES.include?(pg_type)
62
69
 
63
70
  options[:precision] = nil if options[:precision] == PG_DEFAULT_PRECISION
@@ -125,7 +125,7 @@ module SchemaFerry
125
125
 
126
126
  def render_foreign_key(foreign_key)
127
127
  parts = [foreign_key.from_table.inspect, foreign_key.to_table.inspect]
128
- parts << "column: #{foreign_key.column.inspect}" if foreign_key.column
128
+ parts << "column: #{foreign_key.column.inspect}" if custom_fk_column?(foreign_key)
129
129
  non_default_pk = foreign_key.primary_key && foreign_key.primary_key != "id"
130
130
  parts << "primary_key: #{foreign_key.primary_key.inspect}" if non_default_pk
131
131
  parts << "name: #{foreign_key.name.inspect}" if foreign_key.name
@@ -133,6 +133,13 @@ module SchemaFerry
133
133
  parts << "on_delete: #{foreign_key.on_delete.inspect}" if foreign_key.on_delete
134
134
  "add_foreign_key #{parts.join(", ")}"
135
135
  end
136
+
137
+ # ridgepole's PostgreSQL export omits column: when it follows the Rails
138
+ # convention, and it compares foreign keys by their literal options —
139
+ # emitting column: for conventional names re-creates the FK every run.
140
+ def custom_fk_column?(foreign_key)
141
+ foreign_key.column && foreign_key.column != "#{foreign_key.to_table.singularize}_id"
142
+ end
136
143
  end
137
144
  end
138
145
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SchemaFerry
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.2"
5
5
  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.1.0
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - kyuuri1791