exwiw 0.2.4 → 0.2.6

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: 95749db3a2093fe305a67c6caacf1b6861f791d99f70283a7fa63ce765b9f846
4
- data.tar.gz: '087a7260b7d5cd3809fc72c8a783f408e87091e1e6bc816eab4a965e489b4404'
3
+ metadata.gz: 2ff943aae77c0ed0f79dc80c2e68527badcae7c6b330e89aec6a019adf0dd1e5
4
+ data.tar.gz: 8a146a76aea7ab0bd3aeabb27ccf789f575f86e1d965b64b2432c250addb003b
5
5
  SHA512:
6
- metadata.gz: cafb2801b708bf26124f12f6a13799f35d6b5f17ec2977fc49464ae56bb6482434bf9d1a155b176968d4f7b545f172a1fc56c3114958206da0605c426194aebf
7
- data.tar.gz: c9c05f6da15c12ee45963d0552122310cda5a3261f210d2dbb069986751b9d245bad9661ef9ca5d8ed7fbec04afbe00e637bc9b852d0d15b0eea4429db4c6ec2
6
+ metadata.gz: 6dbd0212c4ca02aba3839f17bfe7fc45283f17cb7264d903527fbafda04a4cc9b5c756762a1422fae549b143a60ec825669581311ada63972c2288bdb744e47c
7
+ data.tar.gz: 6364942e8ee51055eb2ed2bffc16995b1a19c64b977790fa8ff4222742f034ae6d4b0b7410f779f70655bb2d6d3ea33af7321853727a2c9300adcdcbd45fc763
data/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.2.6] - 2026-05-29
6
+
7
+ ### Added
8
+
9
+ - `schema:generate` now supports Rails multiple-database setups (`connects_to`). Models are bucketed by their database (`connection_db_config.name`, e.g. `primary` / `analytics`) and each database's config files are written into its own subdirectory under `OUTPUT_DIR_PATH` (`exwiw/primary/`, `exwiw/analytics/`, ...). Rails-managed tables (`schema_migrations` / `ar_internal_metadata`) are emitted under whichever database actually owns them. Single-database apps are unaffected and still write flat into the output directory. This replaces the previous behavior of raising `MultipleDatabasesNotSupportedError`.
10
+
11
+ ## [0.2.5] - 2026-05-29
12
+
13
+ ### Added
14
+
15
+ - `schema:generate` now auto-emits config entries for Rails-managed tables (`schema_migrations` and `ar_internal_metadata`), tagged `type: "rails_managed_schema_migrations"` / `"rails_managed_internal_metadata"`. Extraction uses `SELECT *`, INSERT omits the column list, and DELETE is not generated. Defining `primary_key`, `columns`, or `belongs_tos` on such an entry is rejected at load time, and these tables cannot be used as `--target-table`.
16
+ - Added optional `type` and `comment` fields to `TableConfig`. `primary_key` is now optional in JSON (still required for non-rails-managed tables, enforced at load time).
17
+
5
18
  ## [0.2.4] - 2026-05-28
6
19
 
7
20
  ### Added
data/README.md CHANGED
@@ -128,6 +128,22 @@ By default, the schema files will be saved in the `exwiw` directory. You can spe
128
128
  OUTPUT_DIR_PATH=custom_directory bundle exec rake exwiw:schema:generate
129
129
  ```
130
130
 
131
+ #### Multiple databases
132
+
133
+ If the application uses Rails' multiple-database support (`connects_to`), `schema:generate` buckets models by the database they connect to and writes each database's config files into its own subdirectory of the output directory, named after the database config name (`primary`, `analytics`, ...):
134
+
135
+ ```
136
+ exwiw/
137
+ primary/
138
+ shops.json
139
+ users.json
140
+ schema_migrations.json
141
+ analytics/
142
+ analytics_events.json
143
+ ```
144
+
145
+ Rails-managed tables (`schema_migrations` / `ar_internal_metadata`) are emitted under whichever database actually contains them. Single-database applications are unaffected and continue to write files flat into the output directory.
146
+
131
147
  ### Configuration
132
148
 
133
149
  This is an example of the one table schema:
@@ -221,6 +237,37 @@ Constraints:
221
237
  - Specifying a skipped table as `--target-table` raises `ArgumentError`.
222
238
  - `skip: true` is preserved by `exwiw:schema:generate` regenerations (the receiver value wins over the auto-generated config).
223
239
 
240
+ ### Rails-managed tables (special `type` values)
241
+
242
+ Some tables are owned by Rails itself rather than the application — they have no ActiveRecord model and Rails reserves the right to evolve their column shape between versions (e.g. `schema_migrations`, `ar_internal_metadata`). exwiw treats them as a distinct category via the `type` field on a table config:
243
+
244
+ - `type: "rails_managed_schema_migrations"` — Rails' migration history table (`ActiveRecord::Base.schema_migrations_table_name`).
245
+ - `type: "rails_managed_internal_metadata"` — Rails' internal metadata table (`ActiveRecord::Base.internal_metadata_table_name`).
246
+
247
+ `exwiw:schema:generate` emits these entries automatically when the corresponding tables exist on the connection — they are NOT pulled from `ActiveRecord::Base.descendants` because they have no model class.
248
+
249
+ A rails-managed entry has a minimal shape (no `primary_key`, no `belongs_tos`, no `columns`):
250
+
251
+ ```json
252
+ {
253
+ "name": "schema_migrations",
254
+ "type": "rails_managed_schema_migrations",
255
+ "comment": "Managed internally by Rails. Tracks applied schema migrations."
256
+ }
257
+ ```
258
+
259
+ Behavior at dump time:
260
+
261
+ - Extraction uses `SELECT *` so the dump is robust against Rails-side column additions.
262
+ - `INSERT` statements omit the column list (`INSERT INTO schema_migrations VALUES (...)`). For PostgreSQL `--output-format=copy`, the `COPY` header similarly omits the column list (`COPY schema_migrations FROM stdin;`).
263
+ - No `delete-*.sql` file is generated for rails-managed tables, to avoid wiping migration history on the import target.
264
+
265
+ Constraints:
266
+
267
+ - Defining `primary_key`, `columns`, or `belongs_tos` on a rails-managed entry is rejected with `ArgumentError` on load.
268
+ - A rails-managed table cannot be used as `--target-table`.
269
+ - In multi-database setups, the rails-managed entry is emitted under whichever database's connection actually contains the table (see [Multiple databases](#multiple-databases)). The table name itself is still derived from the global `ActiveRecord::Base.schema_migrations_table_name` / `internal_metadata_table_name` (prefix/suffix) accessors.
270
+
224
271
  ### Bulk insert chunk size
225
272
 
226
273
  `bulk_insert_chunk_size` splits the generated `INSERT` statement into multiple statements, each containing at most the specified number of rows. This is useful when the number of records per table is large enough to hit limits like MySQL's `max_allowed_packet`.
@@ -0,0 +1,344 @@
1
+ # Plan: `schema:generate` で Rails 管理テーブル(`schema_migrations` / `ar_internal_metadata`)を出力する
2
+
3
+ ## Context(背景)
4
+
5
+ `Exwiw::SchemaGenerator` は現状 `ActiveRecord::Base.descendants` を辿って dumpable なテーブルを列挙している。`schema_migrations` や `ar_internal_metadata` のような Rails が内部で管理するテーブルは **AR モデルが存在しない** ため、生成される config に出てこず、結果として dump 出力にも含まれない。マイグレーション履歴や Rails のメタ情報を含めたい利用者は JSON を手書きする必要がある。
6
+
7
+ 本変更では、Rails の標準アクセサ(`ActiveRecord::Base.schema_migrations_table_name` と `ActiveRecord::Base.internal_metadata_table_name`)からテーブル名を取得して、それぞれ `schema_migrations.json` / `ar_internal_metadata.json` を自動生成する。新しい `type` フィールドに `rails_managed_schema_migrations` / `rails_managed_internal_metadata` を付与し、この **type フィールドを唯一のトリガ**として `SELECT *` 形式の extract を行う。利用者はこれらのエントリに対して `columns` / `belongs_tos` / `primary_key` を定義できず、generator とロード時バリデーションでそれを保証する。
8
+
9
+ ## Decisions(設計判断)
10
+
11
+ - **取得経路**:
12
+ - `schema_migrations` → `ActiveRecord::Base.schema_migrations_table_name`(= `config.active_record.schema_migrations_table_name`)
13
+ - `ar_internal_metadata` → `ActiveRecord::Base.internal_metadata_table_name`
14
+ - どちらも Rails 6.1〜8.x で安定。
15
+ - **`type` と `comment` は `TableConfig` の汎用 optional フィールド**として追加する。
16
+ - **`type` の値は rails_managed_xxx 系で 2 種類**:
17
+ - `rails_managed_schema_migrations`
18
+ - `rails_managed_internal_metadata`
19
+ 両者の挙動は同じだが、テーブルの役割が違うので type 値を分け、`TableConfig#rails_managed?` ヘルパで一括判定する。
20
+ - **rails_managed_xxx を唯一のトリガ**として以下を発行する:
21
+ - extract 時は `SELECT *`(カラム列挙なし)
22
+ - insert 時は `INSERT INTO <t> VALUES ...`(カラム列挙なし)
23
+ - DELETE は生成しない
24
+ 通常テーブルで `columns: []` でも `SELECT *` にはしない(既存挙動を維持)。
25
+ - **rails_managed_xxx のときは `columns` / `belongs_tos` / `primary_key` を定義できない**。`TableConfig` ロード時に `ArgumentError` を上げる。
26
+ - **rails_managed_xxx の JSON 出力では `columns` / `belongs_tos` / `primary_key` フィールドを出さない**。
27
+ - `primary_key` は optional 化することで `skip_serializing_if_nil` が効く → 自動的に消える。
28
+ - `belongs_tos` / `columns` は required な配列なので Serdes 標準では空配列が出てしまう。`TableConfig#to_hash` を override して `rails_managed?` のときだけ両キーを strip する。ラウンドトリップのため属性宣言に `default: []` を付け、欠落 JSON も `[]` でロードできるようにする。
29
+ - **`primary_key` を optional 化**する(rails_managed_xxx で省略可)。非 rails_managed では presence をロード時にバリデーション(既存挙動を壊さない)。
30
+ - **`merge` の所有関係**: `type` は generator 由来。`comment` は receiver 由来(`filter` と対称)。
31
+
32
+ ## Critical files(主に変更するファイル)
33
+
34
+ | File | 変更内容 |
35
+ |---|---|
36
+ | `lib/exwiw/table_config.rb` | `type` / `comment` 属性追加、`primary_key` を optional 化、`rails_managed?` 追加、ロード時バリデーション、`merge` 更新 |
37
+ | `lib/exwiw/schema_generator.rb` | descendants ループの後に rails-managed エントリ群を append |
38
+ | `lib/exwiw/query_ast.rb` | `QueryAst::Select` に `select_all` モードを追加 |
39
+ | `lib/exwiw/query_ast_builder.rb` | rails-managed テーブルを `select_all` モードに振り分け |
40
+ | `lib/exwiw/runner.rb` | `table.rails_managed?` のとき DELETE 生成をスキップ |
41
+ | `lib/exwiw/adapter/mysql2_adapter.rb` | `compile_ast` が `select_all` を尊重、`to_bulk_insert` は `table.rails_managed?` で分岐 |
42
+ | `lib/exwiw/adapter/postgresql_adapter.rb` | 同上 |
43
+ | `lib/exwiw/adapter/sqlite3_adapter.rb` | 同上 |
44
+ | `spec/schema_generator_spec.rb` | スナップショット列挙に 2 つ追加 |
45
+ | `spec/schema_output_snapshots/schema_migrations.json` | 新規 |
46
+ | `spec/schema_output_snapshots/ar_internal_metadata.json` | 新規 |
47
+ | `spec/table_config_spec.rb` | `type` / `comment` / `primary_key` optional / merge / バリデーションをカバー |
48
+ | `CHANGELOG.md` | Unreleased エントリ |
49
+
50
+ ## Implementation(実装方針)
51
+
52
+ ### 1. `lib/exwiw/table_config.rb`
53
+
54
+ 属性宣言を更新。`primary_key` を optional 化し、`type` / `comment` を追加。`belongs_tos` / `columns` には `default: []` を付与してラウンドトリップを担保:
55
+
56
+ ```ruby
57
+ attribute :name, String
58
+ attribute :primary_key, optional(String), skip_serializing_if_nil: true # ← optional化
59
+ attribute :type, optional(String), skip_serializing_if_nil: true # 新規
60
+ attribute :comment, optional(String), skip_serializing_if_nil: true # 新規
61
+ attribute :filter, optional(String), skip_serializing_if_nil: true
62
+ attribute :belongs_tos, array(BelongsTo), default: [] # ← default追加
63
+ attribute :columns, array(TableColumn), default: [] # ← default追加
64
+ attribute :bulk_insert_chunk_size, optional(Integer), skip_serializing_if_nil: true
65
+ attribute :skip, Serdes::OptionalType.new(Serdes::ConcreteType.new(Boolean)), skip_serializing_if_nil: true
66
+ ```
67
+
68
+ `to_hash` を override して、rails_managed のときは `belongs_tos` / `columns` を JSON 出力から取り除く(`primary_key` は optional 化により自動で省略される):
69
+
70
+ ```ruby
71
+ def to_hash
72
+ hash = super
73
+ if rails_managed?
74
+ hash.delete("belongs_tos")
75
+ hash.delete("columns")
76
+ end
77
+ hash
78
+ end
79
+ ```
80
+
81
+ 定数と判定ヘルパ:
82
+
83
+ ```ruby
84
+ RAILS_MANAGED_SCHEMA_MIGRATIONS = "rails_managed_schema_migrations"
85
+ RAILS_MANAGED_INTERNAL_METADATA = "rails_managed_internal_metadata"
86
+ RAILS_MANAGED_TYPES = [
87
+ RAILS_MANAGED_SCHEMA_MIGRATIONS,
88
+ RAILS_MANAGED_INTERNAL_METADATA,
89
+ ].freeze
90
+
91
+ def rails_managed?
92
+ RAILS_MANAGED_TYPES.include?(type)
93
+ end
94
+ ```
95
+
96
+ ロード時バリデーション。`from` を override して全構築経路を通す:
97
+
98
+ ```ruby
99
+ def self.from(hash)
100
+ config = super
101
+ config.send(:validate_after_load!)
102
+ config
103
+ end
104
+
105
+ private def validate_after_load!
106
+ if rails_managed?
107
+ raise ArgumentError, "Table '#{name}' has type=#{type}; primary_key must not be defined." if primary_key
108
+ raise ArgumentError, "Table '#{name}' has type=#{type}; belongs_tos must not be defined." if !belongs_tos.empty?
109
+ raise ArgumentError, "Table '#{name}' has type=#{type}; columns must not be defined." if !columns.empty?
110
+ else
111
+ raise ArgumentError, "Table '#{name}' requires primary_key." if primary_key.nil?
112
+ end
113
+ end
114
+ ```
115
+
116
+ `merge`(lines 70–94)を更新:
117
+ - `merged_table.type = passed_table.type`(generator 由来)
118
+ - `merged_table.comment = comment`(receiver 由来、`filter` と対称)
119
+
120
+ `build_extract_query`(lines 27–68)はそのまま放置(dead code、別件で削除)。
121
+
122
+ ### 2. `lib/exwiw/schema_generator.rb`
123
+
124
+ ヘルパーを切り出して `build_tables` で concat:
125
+
126
+ ```ruby
127
+ def build_tables
128
+ models = concrete_models
129
+ validate_single_database!(models)
130
+
131
+ tables_from_models = models.group_by(&:table_name).map do |table_name, model_group|
132
+ representative = model_group.first
133
+ TableConfig.from_symbol_keys(
134
+ name: table_name,
135
+ primary_key: representative.primary_key,
136
+ belongs_tos: aggregate_belongs_tos(model_group),
137
+ columns: representative.column_names.map { |name| { name: name } },
138
+ )
139
+ end
140
+
141
+ tables_from_models + build_rails_managed_tables
142
+ end
143
+
144
+ private def build_rails_managed_tables
145
+ conn = ActiveRecord::Base.connection
146
+ result = []
147
+
148
+ schema_migrations_name = ActiveRecord::Base.schema_migrations_table_name
149
+ if conn.table_exists?(schema_migrations_name)
150
+ result << TableConfig.from_symbol_keys(
151
+ name: schema_migrations_name,
152
+ type: TableConfig::RAILS_MANAGED_SCHEMA_MIGRATIONS,
153
+ comment: "Managed internally by Rails. Tracks applied schema migrations.",
154
+ belongs_tos: [],
155
+ columns: [],
156
+ )
157
+ end
158
+
159
+ internal_metadata_name = ActiveRecord::Base.internal_metadata_table_name
160
+ if conn.table_exists?(internal_metadata_name)
161
+ result << TableConfig.from_symbol_keys(
162
+ name: internal_metadata_name,
163
+ type: TableConfig::RAILS_MANAGED_INTERNAL_METADATA,
164
+ comment: "Managed internally by Rails. Stores environment and schema metadata.",
165
+ belongs_tos: [],
166
+ columns: [],
167
+ )
168
+ end
169
+
170
+ result
171
+ end
172
+ ```
173
+
174
+ メモ:
175
+ - `table_exists?` で防御。マイグレーションを無効化したアプリでも壊れない。
176
+ - `primary_key` は省略(rails_managed_xxx の規約)。
177
+ - belongs_tos / columns は明示的に `[]` を渡す(Serdes の required な配列属性なので)。
178
+
179
+ ### 3. `lib/exwiw/query_ast.rb`
180
+
181
+ `QueryAst::Select` に `select_all` フラグを追加:
182
+
183
+ ```ruby
184
+ class Select
185
+ attr_reader :from_table_name, :columns, :where_clauses, :join_clauses, :select_all
186
+
187
+ def initialize
188
+ @from_table_name = nil
189
+ @columns = []
190
+ @where_clauses = []
191
+ @join_clauses = []
192
+ @select_all = false
193
+ end
194
+
195
+ def select_all!
196
+ @select_all = true
197
+ end
198
+ # ... 他のメソッドはそのまま ...
199
+ end
200
+ ```
201
+
202
+ opt-in なフラグ。default false なので既存挙動には影響しない。
203
+
204
+ ### 4. `lib/exwiw/query_ast_builder.rb`
205
+
206
+ line 24–29 で rails-managed テーブルのときにフラグを立てる:
207
+
208
+ ```ruby
209
+ QueryAst::Select.new.tap do |ast|
210
+ ast.from(table.name)
211
+ if table.rails_managed?
212
+ ast.select_all!
213
+ else
214
+ ast.select(table.columns)
215
+ end
216
+ join_clauses.each { |join_clause| ast.join(join_clause) }
217
+ where_clauses.each { |where_clause| ast.where(where_clause) }
218
+ end
219
+ ```
220
+
221
+ rails-managed テーブルは `belongs_tos` が空なので、`build_where_clauses` も `build_join_clauses` も自然に空配列を返す。`build_where_clauses` の `table.primary_key` 参照(line 80)は `table.name == dump_target.table_name` のときだけ動くが、rails-managed テーブルは dump_target にできない(後述の防御)。
222
+
223
+ ### 5. `lib/exwiw/runner.rb`
224
+
225
+ DELETE 生成をスキップ(line 97):
226
+
227
+ ```ruby
228
+ if adapter.supports_bulk_delete? && !@insert_only && !table.rails_managed?
229
+ # ... 既存の DELETE ブロック ...
230
+ end
231
+ ```
232
+
233
+ `validate_skipped`(line 132)と `dumpable?` フィルタ(line 41)は変更不要。rails-managed テーブルは `belongs_tos` が空で、何にも参照されないし、`ordered_tables` に残しておかないと `dump_schema` で DDL が出力されないため。
234
+
235
+ `@dump_target` の防御として、line 149 付近の `skip` チェックと同様に rails-managed テーブルを target に指定された場合のチェックを追加:
236
+
237
+ ```ruby
238
+ if @dump_target.table_name && (target = configs.find { |c| c.name == @dump_target.table_name }) && target.rails_managed?
239
+ raise ArgumentError,
240
+ "--target-table '#{@dump_target.table_name}' is a Rails-managed table and cannot be used as a dump target."
241
+ end
242
+ ```
243
+
244
+ ### 6. Adapter 変更(mysql2 / postgresql / sqlite3)
245
+
246
+ 3 つの SQL アダプタで同じパターン。
247
+
248
+ **`compile_ast`** — SELECT のカラムリスト部分(mysql2:144, postgresql:181, sqlite3:131):
249
+
250
+ ```ruby
251
+ sql += if query_ast.select_all
252
+ "*"
253
+ else
254
+ query_ast.columns.map { |col| compile_column_name(query_ast, col) }.join(', ')
255
+ end
256
+ ```
257
+
258
+ **`to_bulk_insert`** — カラム名 join 部分(mysql2:90, postgresql:87/92, sqlite3:77)。`table.rails_managed?` で分岐:
259
+
260
+ ```ruby
261
+ if table.rails_managed?
262
+ "INSERT INTO #{table_name} VALUES\n#{values};"
263
+ else
264
+ column_names = table.columns.map(&:name).join(', ')
265
+ "INSERT INTO #{table_name} (#{column_names}) VALUES\n#{values};"
266
+ end
267
+ ```
268
+
269
+ `to_bulk_delete` は Runner 側でガードしているので rails-managed では到達しない。変更不要。
270
+
271
+ PostgreSQL の `post_insert_sql`(postgresql_adapter.rb:114–129)は安全。`pg_get_serial_sequence` の引数に渡しても nil を返して no-op。
272
+
273
+ ### 7. Tests
274
+
275
+ **`spec/schema_output_snapshots/schema_migrations.json`**(新規):
276
+
277
+ ```json
278
+ {
279
+ "name": "schema_migrations",
280
+ "type": "rails_managed_schema_migrations",
281
+ "comment": "Managed internally by Rails. Tracks applied schema migrations."
282
+ }
283
+ ```
284
+
285
+ **`spec/schema_output_snapshots/ar_internal_metadata.json`**(新規):
286
+
287
+ ```json
288
+ {
289
+ "name": "ar_internal_metadata",
290
+ "type": "rails_managed_internal_metadata",
291
+ "comment": "Managed internally by Rails. Stores environment and schema metadata."
292
+ }
293
+ ```
294
+
295
+ `primary_key` / `belongs_tos` / `columns` キーは JSON から完全に省略される。
296
+
297
+ key の順序は `TableConfig` の attribute 宣言順に揃える(`spec/schema_generator_spec.rb:223` の snapshot 一致テストで落ちないように)。
298
+
299
+ **`spec/schema_generator_spec.rb`** — line 188–191 の `contain_exactly` に `"schema_migrations.json"` と `"ar_internal_metadata.json"` を追加。line 211–225 の snapshot テストは新 fixture を自動的に拾う。
300
+
301
+ **`spec/table_config_spec.rb`** — 以下を追加:
302
+ - merge が user 設定の `comment` を保持する
303
+ - merge が `type` を常に generator 値で上書きする
304
+ - `type: rails_managed_*` で `columns` 非空のロード → `ArgumentError`
305
+ - `type: rails_managed_*` で `belongs_tos` 非空のロード → `ArgumentError`
306
+ - `type: rails_managed_*` で `primary_key` 指定あり → `ArgumentError`
307
+ - 非 rails_managed で `primary_key` 欠落 → `ArgumentError`(既存挙動の維持確認)
308
+ - rails_managed の `to_hash` 出力に `belongs_tos` / `columns` / `primary_key` キーが含まれないこと
309
+ - rails_managed の JSON(これらキー欠落)が round-trip でロードできること
310
+
311
+ **Adapter / query specs** — rails-managed 経路:
312
+ - `query_ast.select_all` true → `SELECT *`
313
+ - `table.rails_managed?` true → `INSERT INTO t VALUES (...)`(カラムリストなし)
314
+ - **通常テーブル**で `columns: []` のとき `SELECT *` には**ならない**(negative test)
315
+
316
+ ### 8. `CHANGELOG.md`
317
+
318
+ Unreleased エントリ:
319
+
320
+ ```
321
+ - schema:generate が Rails 管理テーブル(`schema_migrations` と `ar_internal_metadata`)の config を自動生成するようになった。それぞれ `type: "rails_managed_schema_migrations"` / `"rails_managed_internal_metadata"` を付与し、extract は `SELECT *`、INSERT はカラムリスト省略、DELETE は生成しない。これらのエントリでは `primary_key` / `columns` / `belongs_tos` を定義するとロード時に拒否される。
322
+ - TableConfig に optional な `type` / `comment` フィールドを追加。`primary_key` を optional に変更(非 rails_managed では依然必須)。
323
+ ```
324
+
325
+ ## Verification(確認手順)
326
+
327
+ 1. **Unit spec**: `bundle exec rspec spec/schema_generator_spec.rb spec/table_config_spec.rb spec/query_ast_builder_spec.rb`
328
+ 2. **全体テスト**: `bundle exec rspec` — adapter の rails-managed SQL 生成と通常テーブルの挙動非破壊を確認。
329
+ 3. **dummy アプリでの end-to-end**:
330
+ - `cd spec/dummy && bin/rails db:migrate`
331
+ - リポジトリルートで `rake exwiw:schema:generate OUTPUT_DIR_PATH=tmp/schema`
332
+ - `tmp/schema/schema_migrations.json` と `tmp/schema/ar_internal_metadata.json` が想定の形(primary_key/columns/belongs_tos なし or 空)で出力されることを確認。
333
+ - `tmp/schema/schema_migrations.json` に手動で `primary_key` を足して dump 系コマンドを叩くとバリデーションエラーが出ることを確認。
334
+ - 適当な target テーブル指定で dump を実行:
335
+ - `insert-NNN-schema_migrations.sql` / `insert-NNN-ar_internal_metadata.sql` が `INSERT INTO t VALUES (...);` 形式(カラムリストなし)になる
336
+ - 対応する `delete-NNN-*.sql` が生成されないことを確認
337
+ 4. **Snapshot diff**: `git diff spec/schema_output_snapshots/` — 新規 2 ファイルのみ差分として出る。
338
+
339
+ ## Out of scope(やらないこと)
340
+
341
+ - INSERT の重複ハンドリング(`ON CONFLICT DO NOTHING` 等)— 別件。
342
+ - dead code `TableConfig#build_extract_query` の削除 — 別件。
343
+ - アダプタ側の per-table フック(`supports_bulk_delete_for?` 等)— 必要になったら導入。
344
+ - 非 rails_managed テーブルからの `columns: []` / `belongs_tos: []` 省略 — 既存スナップショット(`shops.json` でも `"belongs_tos": []` が出ている)と整合させるため、空配列はそのまま出す。省略は rails_managed のみ。
@@ -87,8 +87,12 @@ module Exwiw
87
87
  end
88
88
  values = value_list.join(",\n")
89
89
 
90
- column_names = table.columns.map(&:name).join(', ')
91
- "INSERT INTO #{table_name} (#{column_names}) VALUES\n#{values};"
90
+ if table.rails_managed?
91
+ "INSERT INTO #{table_name} VALUES\n#{values};"
92
+ else
93
+ column_names = table.columns.map(&:name).join(', ')
94
+ "INSERT INTO #{table_name} (#{column_names}) VALUES\n#{values};"
95
+ end
92
96
  end
93
97
 
94
98
  def to_bulk_delete(select_query_ast, table)
@@ -141,7 +145,11 @@ module Exwiw
141
145
  raise NotImplementedError unless query_ast.is_a?(Exwiw::QueryAst::Select)
142
146
 
143
147
  sql = "SELECT "
144
- sql += query_ast.columns.map { |col| compile_column_name(query_ast, col) }.join(', ')
148
+ sql += if query_ast.select_all
149
+ "*"
150
+ else
151
+ query_ast.columns.map { |col| compile_column_name(query_ast, col) }.join(', ')
152
+ end
145
153
  sql += " FROM #{query_ast.from_table_name}"
146
154
 
147
155
  query_ast.join_clauses.each do |join|
@@ -84,13 +84,22 @@ module Exwiw
84
84
  end
85
85
  values = value_list.join(",\n")
86
86
 
87
- column_names = table.columns.map(&:name).join(', ')
88
- "INSERT INTO #{table_name} (#{column_names}) VALUES\n#{values};"
87
+ if table.rails_managed?
88
+ "INSERT INTO #{table_name} VALUES\n#{values};"
89
+ else
90
+ column_names = table.columns.map(&:name).join(', ')
91
+ "INSERT INTO #{table_name} (#{column_names}) VALUES\n#{values};"
92
+ end
89
93
  end
90
94
 
91
95
  def to_copy_from_stdin(results, table)
92
- column_names = table.columns.map(&:name).join(', ')
93
- lines = ["COPY #{table.name} (#{column_names}) FROM stdin;"]
96
+ header = if table.rails_managed?
97
+ "COPY #{table.name} FROM stdin;"
98
+ else
99
+ column_names = table.columns.map(&:name).join(', ')
100
+ "COPY #{table.name} (#{column_names}) FROM stdin;"
101
+ end
102
+ lines = [header]
94
103
  results.each do |row|
95
104
  lines << row.map { |v| escape_copy_value(v) }.join("\t")
96
105
  end
@@ -178,7 +187,11 @@ module Exwiw
178
187
  raise NotImplementedError unless query_ast.is_a?(Exwiw::QueryAst::Select)
179
188
 
180
189
  sql = "SELECT "
181
- sql += query_ast.columns.map { |col| compile_column_name(query_ast, col) }.join(', ')
190
+ sql += if query_ast.select_all
191
+ "*"
192
+ else
193
+ query_ast.columns.map { |col| compile_column_name(query_ast, col) }.join(', ')
194
+ end
182
195
  sql += " FROM #{query_ast.from_table_name}"
183
196
 
184
197
  query_ast.join_clauses.each do |join|
@@ -74,8 +74,12 @@ module Exwiw
74
74
  end
75
75
  values = value_list.join(",\n")
76
76
 
77
- column_names = table.columns.map(&:name).join(', ')
78
- "INSERT INTO #{table_name} (#{column_names}) VALUES\n#{values};"
77
+ if table.rails_managed?
78
+ "INSERT INTO #{table_name} VALUES\n#{values};"
79
+ else
80
+ column_names = table.columns.map(&:name).join(', ')
81
+ "INSERT INTO #{table_name} (#{column_names}) VALUES\n#{values};"
82
+ end
79
83
  end
80
84
 
81
85
  def to_bulk_delete(select_query_ast, table)
@@ -128,7 +132,11 @@ module Exwiw
128
132
  raise NotImplementedError unless query_ast.is_a?(Exwiw::QueryAst::Select)
129
133
 
130
134
  sql = "SELECT "
131
- sql += query_ast.columns.map { |col| compile_column_name(query_ast, col) }.join(', ')
135
+ sql += if query_ast.select_all
136
+ "*"
137
+ else
138
+ query_ast.columns.map { |col| compile_column_name(query_ast, col) }.join(', ')
139
+ end
132
140
  sql += " FROM #{query_ast.from_table_name}"
133
141
 
134
142
  query_ast.join_clauses.each do |join|
@@ -45,13 +45,14 @@ module Exwiw
45
45
  end
46
46
 
47
47
  class Select
48
- attr_reader :from_table_name, :columns, :where_clauses, :join_clauses
48
+ attr_reader :from_table_name, :columns, :where_clauses, :join_clauses, :select_all
49
49
 
50
50
  def initialize
51
51
  @from_table_name = nil
52
52
  @columns = []
53
53
  @where_clauses = []
54
54
  @join_clauses = []
55
+ @select_all = false
55
56
  end
56
57
 
57
58
  def from(table)
@@ -62,6 +63,10 @@ module Exwiw
62
63
  @columns = map_column_value(columns)
63
64
  end
64
65
 
66
+ def select_all!
67
+ @select_all = true
68
+ end
69
+
65
70
  def where(where_clause)
66
71
  @where_clauses << where_clause
67
72
  end
@@ -23,7 +23,11 @@ module Exwiw
23
23
 
24
24
  QueryAst::Select.new.tap do |ast|
25
25
  ast.from(table.name)
26
- ast.select(table.columns)
26
+ if table.rails_managed?
27
+ ast.select_all!
28
+ else
29
+ ast.select(table.columns)
30
+ end
27
31
  join_clauses.each { |join_clause| ast.join(join_clause) }
28
32
  where_clauses.each { |where_clause| ast.where(where_clause) }
29
33
  end
data/lib/exwiw/runner.rb CHANGED
@@ -31,6 +31,7 @@ module Exwiw
31
31
  configs = load_table_config(adapter.class.table_config_class)
32
32
 
33
33
  validate_skipped(configs)
34
+ validate_rails_managed_target!(configs)
34
35
 
35
36
  table_by_name = configs.each_with_object({}) { |config, hash| hash[config.name] = config }
36
37
 
@@ -94,7 +95,7 @@ module Exwiw
94
95
  end
95
96
  end
96
97
 
97
- if adapter.supports_bulk_delete? && !@insert_only
98
+ if adapter.supports_bulk_delete? && !@insert_only && !(table.respond_to?(:rails_managed?) && table.rails_managed?)
98
99
  @logger.debug(" Generate DELETE statement...")
99
100
  delete_sql = adapter.to_bulk_delete(query_ast, table)
100
101
  if @logger.debug?
@@ -153,5 +154,16 @@ module Exwiw
153
154
 
154
155
  skipped_names.each { |n| @logger.info("Table '#{n}' is marked skip:true (schema will be included, data extraction skipped)") }
155
156
  end
157
+
158
+ private def validate_rails_managed_target!(configs)
159
+ return if @dump_target.table_name.nil?
160
+
161
+ target = configs.find { |c| c.name == @dump_target.table_name }
162
+ return if target.nil?
163
+ return unless target.respond_to?(:rails_managed?) && target.rails_managed?
164
+
165
+ raise ArgumentError,
166
+ "--target-table '#{@dump_target.table_name}' is a Rails-managed table (type=#{target.type}) and cannot be used as a dump target."
167
+ end
156
168
  end
157
169
  end
@@ -5,8 +5,6 @@ require "json"
5
5
 
6
6
  module Exwiw
7
7
  class SchemaGenerator
8
- class MultipleDatabasesNotSupportedError < StandardError; end
9
-
10
8
  def self.from_rails_application(output_dir:)
11
9
  Rails.application.eager_load!
12
10
  new(models: ActiveRecord::Base.descendants, output_dir: output_dir)
@@ -18,31 +16,53 @@ module Exwiw
18
16
  end
19
17
 
20
18
  def generate!
21
- tables = build_tables
22
- write_files(tables)
23
- tables
19
+ groups = build_table_groups
20
+ write_groups(groups)
21
+ groups
24
22
  end
25
23
 
26
- def build_tables
24
+ # Returns a Hash keyed by the database name.
25
+ #
26
+ # - Single-database setup: the only key is `nil`, signalling that the table
27
+ # configs should be written flat into `output_dir` (backwards compatible).
28
+ # - Multi-database setup (Rails `connects_to`): one key per database
29
+ # (`connection_db_config.name`, e.g. "primary" / "analytics"), each
30
+ # mapping to that database's table configs. They are written into
31
+ # `output_dir/<db_name>/`.
32
+ def build_table_groups
27
33
  models = concrete_models
28
- validate_single_database!(models)
34
+ grouped = models.group_by { |model| database_name_for(model) }
29
35
 
30
- models.group_by(&:table_name).map do |table_name, model_group|
31
- representative = model_group.first
32
- TableConfig.from_symbol_keys(
33
- name: table_name,
34
- primary_key: representative.primary_key,
35
- belongs_tos: aggregate_belongs_tos(model_group),
36
- columns: representative.column_names.map { |name| { name: name } },
37
- )
36
+ if grouped.size <= 1
37
+ conn = models.empty? ? ActiveRecord::Base.connection : models.first.connection
38
+ return { nil => build_tables_for(models, conn) }
39
+ end
40
+
41
+ grouped.each_with_object({}) do |(db_name, group_models), result|
42
+ conn = group_models.first.connection
43
+ result[db_name] = build_tables_for(group_models, conn)
38
44
  end
39
45
  end
40
46
 
41
- def write_files(tables)
42
- FileUtils.mkdir_p(@output_dir)
47
+ # Backwards-compatible flat list of all table configs. Only meaningful for
48
+ # a single-database setup; for multi-database setups prefer
49
+ # `#build_table_groups` so the database association is preserved.
50
+ def build_tables
51
+ build_table_groups.values.flatten
52
+ end
53
+
54
+ def write_groups(groups)
55
+ groups.each do |db_name, tables|
56
+ dir = db_name.nil? ? @output_dir : File.join(@output_dir, db_name)
57
+ write_files(dir, tables)
58
+ end
59
+ end
60
+
61
+ def write_files(dir, tables)
62
+ FileUtils.mkdir_p(dir)
43
63
 
44
64
  tables.each do |table|
45
- path = File.join(@output_dir, "#{table.name}.json")
65
+ path = File.join(dir, "#{table.name}.json")
46
66
  config_to_write =
47
67
  if File.exist?(path)
48
68
  TableConfig.from(JSON.parse(File.read(path))).merge(table)
@@ -53,10 +73,58 @@ module Exwiw
53
73
  end
54
74
  end
55
75
 
76
+ private def build_tables_for(models, conn)
77
+ tables_from_models = models.group_by(&:table_name).map do |table_name, model_group|
78
+ representative = model_group.first
79
+ TableConfig.from_symbol_keys(
80
+ name: table_name,
81
+ primary_key: representative.primary_key,
82
+ belongs_tos: aggregate_belongs_tos(model_group),
83
+ columns: representative.column_names.map { |name| { name: name } },
84
+ )
85
+ end
86
+
87
+ tables_from_models + build_rails_managed_tables(conn)
88
+ end
89
+
56
90
  private def concrete_models
57
91
  @models.reject(&:abstract_class?).select(&:table_exists?)
58
92
  end
59
93
 
94
+ # rails-managed テーブル (`schema_migrations` / `ar_internal_metadata`) は
95
+ # モデルクラスを持たないため `ActiveRecord::Base.descendants` からは拾えない。
96
+ # multi-DB 構成では各 connection が独立した migration 履歴テーブルを持つので、
97
+ # 対象 connection を受け取り、その connection 上に該当テーブルが存在する場合のみ
98
+ # エントリを生成する。テーブル名そのものは prefix/suffix を含むグローバル設定
99
+ # (`ActiveRecord::Base.schema_migrations_table_name` 等) から得る。
100
+ private def build_rails_managed_tables(conn)
101
+ result = []
102
+
103
+ schema_migrations_name = ActiveRecord::Base.schema_migrations_table_name
104
+ if conn.table_exists?(schema_migrations_name)
105
+ result << TableConfig.from_symbol_keys(
106
+ name: schema_migrations_name,
107
+ type: TableConfig::RAILS_MANAGED_SCHEMA_MIGRATIONS,
108
+ comment: "Managed internally by Rails. Tracks applied schema migrations.",
109
+ belongs_tos: [],
110
+ columns: [],
111
+ )
112
+ end
113
+
114
+ internal_metadata_name = ActiveRecord::Base.internal_metadata_table_name
115
+ if conn.table_exists?(internal_metadata_name)
116
+ result << TableConfig.from_symbol_keys(
117
+ name: internal_metadata_name,
118
+ type: TableConfig::RAILS_MANAGED_INTERNAL_METADATA,
119
+ comment: "Managed internally by Rails. Stores environment and schema metadata.",
120
+ belongs_tos: [],
121
+ columns: [],
122
+ )
123
+ end
124
+
125
+ result
126
+ end
127
+
60
128
  private def aggregate_belongs_tos(models)
61
129
  pairs = models
62
130
  .flat_map { |m| m.reflect_on_all_associations(:belongs_to) }
@@ -69,22 +137,13 @@ module Exwiw
69
137
  end
70
138
  end
71
139
 
72
- # `connection_specification_name` is a quasi-private API but has been stable
73
- # across Rails 6.1 - 8.x. With Rails multi-DB (`connects_to`), every
74
- # descendant of the same abstract base shares one spec name regardless of
75
- # role/shard, so distinct values across concrete models indicate genuinely
76
- # separate databases.
77
- private def validate_single_database!(models)
78
- return if models.empty?
79
-
80
- specs = models.map(&:connection_specification_name).uniq
81
- return if specs.size <= 1
82
-
83
- raise MultipleDatabasesNotSupportedError, <<~MSG
84
- exwiw does not yet support Rails multiple-database setup.
85
- Detected connection specifications: #{specs.inspect}
86
- Track progress at https://github.com/riseshia/exwiw/issues
87
- MSG
140
+ # Identifies which database a model belongs to. With Rails multi-DB
141
+ # (`connects_to` backed by `database.yml`), `connection_db_config.name`
142
+ # returns the configuration name ("primary", "analytics", ...) which is
143
+ # stable across roles/shards and makes a natural per-database directory
144
+ # name. Single-database apps all share one name, collapsing into one group.
145
+ private def database_name_for(model)
146
+ model.connection_db_config.name
88
147
  end
89
148
  end
90
149
  end
@@ -4,18 +4,46 @@ module Exwiw
4
4
  class TableConfig
5
5
  include Serdes
6
6
 
7
+ RAILS_MANAGED_SCHEMA_MIGRATIONS = "rails_managed_schema_migrations"
8
+ RAILS_MANAGED_INTERNAL_METADATA = "rails_managed_internal_metadata"
9
+ RAILS_MANAGED_TYPES = [
10
+ RAILS_MANAGED_SCHEMA_MIGRATIONS,
11
+ RAILS_MANAGED_INTERNAL_METADATA,
12
+ ].freeze
13
+
7
14
  attribute :name, String
8
- attribute :primary_key, String
15
+ attribute :primary_key, optional(String), skip_serializing_if_nil: true
16
+ attribute :type, optional(String), skip_serializing_if_nil: true
17
+ attribute :comment, optional(String), skip_serializing_if_nil: true
9
18
  attribute :filter, optional(String), skip_serializing_if_nil: true
10
- attribute :belongs_tos, array(BelongsTo)
11
- attribute :columns, array(TableColumn)
19
+ attribute :belongs_tos, array(BelongsTo), default: []
20
+ attribute :columns, array(TableColumn), default: []
12
21
  attribute :bulk_insert_chunk_size, optional(Integer), skip_serializing_if_nil: true
13
22
  attribute :skip, Serdes::OptionalType.new(Serdes::ConcreteType.new(Boolean)), skip_serializing_if_nil: true
14
23
 
24
+ def self.from(hash)
25
+ config = super
26
+ config.send(:validate_after_load!)
27
+ config
28
+ end
29
+
15
30
  def self.from_symbol_keys(hash)
16
31
  from(JSON.parse(hash.to_json))
17
32
  end
18
33
 
34
+ def rails_managed?
35
+ RAILS_MANAGED_TYPES.include?(type)
36
+ end
37
+
38
+ def to_hash
39
+ hash = super
40
+ if rails_managed?
41
+ hash.delete("belongs_tos")
42
+ hash.delete("columns")
43
+ end
44
+ hash
45
+ end
46
+
19
47
  def column_names
20
48
  columns.map(&:name)
21
49
  end
@@ -74,6 +102,8 @@ module Exwiw
74
102
  TableConfig.new.tap do |merged_table|
75
103
  merged_table.name = name
76
104
  merged_table.primary_key = passed_table.primary_key
105
+ merged_table.type = passed_table.type
106
+ merged_table.comment = comment
77
107
  merged_table.filter = filter
78
108
  merged_table.belongs_tos = passed_table.belongs_tos
79
109
  merged_table.bulk_insert_chunk_size = passed_table.bulk_insert_chunk_size
@@ -93,6 +123,27 @@ module Exwiw
93
123
  end
94
124
  end
95
125
 
126
+ private def validate_after_load!
127
+ if rails_managed?
128
+ if primary_key
129
+ raise ArgumentError,
130
+ "Table '#{name}' has type=#{type}; primary_key must not be defined."
131
+ end
132
+ if !belongs_tos.empty?
133
+ raise ArgumentError,
134
+ "Table '#{name}' has type=#{type}; belongs_tos must not be defined."
135
+ end
136
+ if !columns.empty?
137
+ raise ArgumentError,
138
+ "Table '#{name}' has type=#{type}; columns must not be defined."
139
+ end
140
+ else
141
+ if primary_key.nil?
142
+ raise ArgumentError, "Table '#{name}' requires primary_key."
143
+ end
144
+ end
145
+ end
146
+
96
147
  private def compute_dependency_to_table(target_table_name, tables_by_name)
97
148
  return [] if belongs_tos.empty?
98
149
 
data/lib/exwiw/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Exwiw
4
- VERSION = "0.2.4"
4
+ VERSION = "0.2.6"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: exwiw
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.4
4
+ version: 0.2.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shia
@@ -39,6 +39,7 @@ files:
39
39
  - docs/plans/2026-05-16-mongodb-from-clean-scenario.md
40
40
  - docs/plans/2026-05-22-after-insert-hook.md
41
41
  - docs/plans/2026-05-22-postgres-copy-mode-scenario-test.md
42
+ - docs/plans/2026-05-29-rails-managed-tables.md
42
43
  - exe/exwiw
43
44
  - lib/exwiw.rb
44
45
  - lib/exwiw/adapter.rb