exwiw 0.2.4 → 0.2.5

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: d4b63b89a56ddce55ea6dc835bde4052b9140cff4b24da6cb9c05d5cac7d0f59
4
+ data.tar.gz: a567c52917014798129638281c90d66f9f7102db624f8b601dd9e025d4351677
5
5
  SHA512:
6
- metadata.gz: cafb2801b708bf26124f12f6a13799f35d6b5f17ec2977fc49464ae56bb6482434bf9d1a155b176968d4f7b545f172a1fc56c3114958206da0605c426194aebf
7
- data.tar.gz: c9c05f6da15c12ee45963d0552122310cda5a3261f210d2dbb069986751b9d245bad9661ef9ca5d8ed7fbec04afbe00e637bc9b852d0d15b0eea4429db4c6ec2
6
+ metadata.gz: 0d782a227fb06e75ac28d35a636f28ccb224dd1c1eb1aad7015863bd8365082be6f51c948f3e7beafbca12d10ac1162af9170e1e064f07a7ca7b64e1433a71d4
7
+ data.tar.gz: 994f8795368c96bc9f773526da33cf8f3d024eac6fcb0665e028f3fc8bd2f67fc5424d4e2e3ada7e91d7a3eb4e2906bd2464d18c4da01d134ba051426e49b9e0
data/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.2.5] - 2026-05-29
6
+
7
+ ### Added
8
+
9
+ - `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`.
10
+ - 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).
11
+
5
12
  ## [0.2.4] - 2026-05-28
6
13
 
7
14
  ### Added
data/README.md CHANGED
@@ -221,6 +221,37 @@ Constraints:
221
221
  - Specifying a skipped table as `--target-table` raises `ArgumentError`.
222
222
  - `skip: true` is preserved by `exwiw:schema:generate` regenerations (the receiver value wins over the auto-generated config).
223
223
 
224
+ ### Rails-managed tables (special `type` values)
225
+
226
+ 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:
227
+
228
+ - `type: "rails_managed_schema_migrations"` — Rails' migration history table (`ActiveRecord::Base.schema_migrations_table_name`).
229
+ - `type: "rails_managed_internal_metadata"` — Rails' internal metadata table (`ActiveRecord::Base.internal_metadata_table_name`).
230
+
231
+ `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.
232
+
233
+ A rails-managed entry has a minimal shape (no `primary_key`, no `belongs_tos`, no `columns`):
234
+
235
+ ```json
236
+ {
237
+ "name": "schema_migrations",
238
+ "type": "rails_managed_schema_migrations",
239
+ "comment": "Managed internally by Rails. Tracks applied schema migrations."
240
+ }
241
+ ```
242
+
243
+ Behavior at dump time:
244
+
245
+ - Extraction uses `SELECT *` so the dump is robust against Rails-side column additions.
246
+ - `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;`).
247
+ - No `delete-*.sql` file is generated for rails-managed tables, to avoid wiping migration history on the import target.
248
+
249
+ Constraints:
250
+
251
+ - Defining `primary_key`, `columns`, or `belongs_tos` on a rails-managed entry is rejected with `ArgumentError` on load.
252
+ - A rails-managed table cannot be used as `--target-table`.
253
+ - Multi-database setups are not yet supported — the table name is read from the global `ActiveRecord::Base` accessor.
254
+
224
255
  ### Bulk insert chunk size
225
256
 
226
257
  `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
@@ -27,7 +27,7 @@ module Exwiw
27
27
  models = concrete_models
28
28
  validate_single_database!(models)
29
29
 
30
- models.group_by(&:table_name).map do |table_name, model_group|
30
+ tables_from_models = models.group_by(&:table_name).map do |table_name, model_group|
31
31
  representative = model_group.first
32
32
  TableConfig.from_symbol_keys(
33
33
  name: table_name,
@@ -36,6 +36,8 @@ module Exwiw
36
36
  columns: representative.column_names.map { |name| { name: name } },
37
37
  )
38
38
  end
39
+
40
+ tables_from_models + build_rails_managed_tables
39
41
  end
40
42
 
41
43
  def write_files(tables)
@@ -57,6 +59,43 @@ module Exwiw
57
59
  @models.reject(&:abstract_class?).select(&:table_exists?)
58
60
  end
59
61
 
62
+ # NOTE: multi-database setup には未対応。`ActiveRecord::Base.schema_migrations_table_name`
63
+ # と `internal_metadata_table_name` はクラスレベルのグローバル設定を返すため、
64
+ # connection 毎にテーブル名が違うケース (`connects_to` で別 DB を扱う場合や
65
+ # `ActiveRecord::Base` 以外で `connection.schema_migration.table_name` を上書きしている場合)
66
+ # を拾えない。現状は `validate_single_database!` で multi-DB を弾いているので
67
+ # ここに到達するのは単一 DB 構成のみという前提で動いている。
68
+ # multi-DB 対応する際は、対象の connection に紐づく schema_migration から
69
+ # テーブル名を取り、connection 毎にエントリを生成する必要がある。
70
+ private def build_rails_managed_tables
71
+ conn = ActiveRecord::Base.connection
72
+ result = []
73
+
74
+ schema_migrations_name = ActiveRecord::Base.schema_migrations_table_name
75
+ if conn.table_exists?(schema_migrations_name)
76
+ result << TableConfig.from_symbol_keys(
77
+ name: schema_migrations_name,
78
+ type: TableConfig::RAILS_MANAGED_SCHEMA_MIGRATIONS,
79
+ comment: "Managed internally by Rails. Tracks applied schema migrations.",
80
+ belongs_tos: [],
81
+ columns: [],
82
+ )
83
+ end
84
+
85
+ internal_metadata_name = ActiveRecord::Base.internal_metadata_table_name
86
+ if conn.table_exists?(internal_metadata_name)
87
+ result << TableConfig.from_symbol_keys(
88
+ name: internal_metadata_name,
89
+ type: TableConfig::RAILS_MANAGED_INTERNAL_METADATA,
90
+ comment: "Managed internally by Rails. Stores environment and schema metadata.",
91
+ belongs_tos: [],
92
+ columns: [],
93
+ )
94
+ end
95
+
96
+ result
97
+ end
98
+
60
99
  private def aggregate_belongs_tos(models)
61
100
  pairs = models
62
101
  .flat_map { |m| m.reflect_on_all_associations(:belongs_to) }
@@ -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.5"
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.5
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