exwiw 0.2.7 → 0.2.8

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: 9ad123bcfe1cadde34a031d1db34fb4592892b88765d3b979c2fb827b53578f7
4
- data.tar.gz: 59b2263b51e51f95e9f8d79cbc214dc0f385202e7ca120d108f3f64e18832a52
3
+ metadata.gz: 21a9432692220a23b0109497b0ffcb3f1c7f0fc360248c3590f730a4a98452ec
4
+ data.tar.gz: bf0847f90a24c8da3cf078f1c18e5d22f9827b705bc13945ddfc28f8e42460f1
5
5
  SHA512:
6
- metadata.gz: f79b7338d57d93eebbfba42d81ff6a3f3c232dbb9163118e42d4f67b73844c50234910d3add9eafe72ff4f854d5497191ac7cb37a4ec1da1647dfb1987c118be
7
- data.tar.gz: 22c97b02f4cb68badb0bc3e404996af4a48f46f0c32f0366cf0845a6e683b5d4a6a1af3ed499dae425d9adafe131f5c86eca933d5f51eac3d3d4458e508f36a8
6
+ metadata.gz: c59e201f1180e2fb6191506647b78a82c43d7d124117c0d87e2cdcbfd8fa68ff30653accc9a22d82434d7e680496eed41d91c7b150b8b884b3b545b344b91d72
7
+ data.tar.gz: 600e4a3b8d134d43182b81017b8b4ad9a352824a76a351300ca3fc22ee4942ceccf6f8404eb6e04e2a2320a79849ddc6b3cacf0c3510c7b8acca0c06820eaa22
data/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.2.8] - 2026-05-31
6
+
7
+ ### Added
8
+
9
+ - `schema:generate` now supports polymorphic `belongs_to` associations. Each polymorphic relation (`belongs_to :reviewable, polymorphic: true`) is expanded into one `belongs_to` entry per concrete target table — discovered from the other models' `has_many` / `has_one ..., as:` declarations — carrying `foreign_type` (the type column, e.g. `reviewable_type`) and `type_value` (the stored type, e.g. `"Product"`). Targets are ordered by table name so the generated output is stable across Ruby versions. At dump time, a polymorphic `belongs_to` on the path to the dump target is constrained by both the foreign key and the type column — in both the `SELECT` and the `delete-*.sql` bulk-delete subquery — so only rows of the matching type are extracted. ([#43](https://github.com/heyinc/exwiw/pull/43))
10
+
5
11
  ## [0.2.7] - 2026-05-30
6
12
 
7
13
  ### Added
data/README.md CHANGED
@@ -241,6 +241,47 @@ Constraints:
241
241
  - Specifying a skipped table as `--target-table` raises `ArgumentError`.
242
242
  - `skip: true` is preserved by `exwiw:schema:generate` regenerations (the receiver value wins over the auto-generated config).
243
243
 
244
+ ### Polymorphic `belongs_to`
245
+
246
+ A Rails polymorphic association (`belongs_to :reviewable, polymorphic: true`) does not point at a single table — the target row is selected at runtime by a type column. exwiw models this as **one `belongs_to` entry per concrete target table**, each carrying two extra fields:
247
+
248
+ - `foreign_type` — the type column on *this* table (e.g. `reviewable_type`).
249
+ - `type_value` — the value stored in that column for this target (e.g. `"Product"`), i.e. the target model's `polymorphic_name`.
250
+
251
+ ```json
252
+ {
253
+ "name": "reviews",
254
+ "primary_key": "id",
255
+ "belongs_tos": [
256
+ {
257
+ "table_name": "products",
258
+ "foreign_key": "reviewable_id",
259
+ "foreign_type": "reviewable_type",
260
+ "type_value": "Product"
261
+ },
262
+ {
263
+ "table_name": "shops",
264
+ "foreign_key": "reviewable_id",
265
+ "foreign_type": "reviewable_type",
266
+ "type_value": "Shop"
267
+ }
268
+ ],
269
+ "columns": [{ "name": "id" }, { "name": "reviewable_type" }, { "name": "reviewable_id" }]
270
+ }
271
+ ```
272
+
273
+ `exwiw:schema:generate` expands a polymorphic `belongs_to` automatically: it finds every model that registers the association as a target via `has_many` / `has_one ..., as: :reviewable` and emits one entry per target table (ordered by table name so the output is stable across Ruby versions). A plain (non-polymorphic) `belongs_to` simply omits `foreign_type` / `type_value`.
274
+
275
+ At dump time, when a polymorphic `belongs_to` lies on the path to the dump target, exwiw constrains **both** the foreign key and the type column, so only rows of the matching type are extracted. For example, dumping `products` pulls only reviews whose `reviewable_type = 'Product'`:
276
+
277
+ ```sql
278
+ SELECT reviews.* FROM reviews
279
+ WHERE reviews.reviewable_id IN (/* products subquery */)
280
+ AND reviews.reviewable_type = 'Product'
281
+ ```
282
+
283
+ The same type filter is applied on the join path — and in the matching `delete-*.sql` bulk-delete subquery — when the polymorphic table is an intermediate hop rather than the directly-dumped table.
284
+
244
285
  ### Rails-managed tables (special `type` values)
245
286
 
246
287
  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:
@@ -136,7 +136,18 @@ module Exwiw
136
136
 
137
137
  foreign_key = first_join.foreign_key
138
138
  subquery_sql = compile_ast(subquery_ast)
139
- sql += "\nWHERE #{select_query_ast.from_table_name}.#{foreign_key} IN (#{subquery_sql});"
139
+ sql += "\nWHERE #{select_query_ast.from_table_name}.#{foreign_key} IN (#{subquery_sql})"
140
+
141
+ # first_join.base_where_clauses は外側の削除対象テーブル
142
+ # (from_table_name) 上の条件 (polymorphic 型カラム等)。subquery には
143
+ # 含まれないため、外側の WHERE に追加する。これにより、別の
144
+ # polymorphic 型に属する行まで削除してしまうのを防ぐ。
145
+ first_join.base_where_clauses.each do |where|
146
+ next unless where.is_a?(Exwiw::QueryAst::WhereClause)
147
+
148
+ sql += " AND #{compile_where_condition(where, select_query_ast.from_table_name)}"
149
+ end
150
+ sql += ";"
140
151
 
141
152
  sql
142
153
  end
@@ -159,6 +170,13 @@ module Exwiw
159
170
  compiled_where_condition = compile_where_condition(where, join.join_table_name)
160
171
  sql += " AND #{compiled_where_condition}"
161
172
  end
173
+
174
+ # base_where_clauses は結合元テーブル (base_table_name) に対して
175
+ # コンパイルする。polymorphic な結合元テーブルの型カラム絞り込み等。
176
+ join.base_where_clauses.each do |where|
177
+ compiled_where_condition = compile_where_condition(where, join.base_table_name)
178
+ sql += " AND #{compiled_where_condition}"
179
+ end
162
180
  end
163
181
 
164
182
  if query_ast.where_clauses.any?
@@ -178,7 +178,18 @@ module Exwiw
178
178
 
179
179
  foreign_key = first_join.foreign_key
180
180
  subquery_sql = compile_ast(subquery_ast)
181
- sql += "\nWHERE #{select_query_ast.from_table_name}.#{foreign_key} IN (#{subquery_sql});"
181
+ sql += "\nWHERE #{select_query_ast.from_table_name}.#{foreign_key} IN (#{subquery_sql})"
182
+
183
+ # first_join.base_where_clauses は外側の削除対象テーブル
184
+ # (from_table_name) 上の条件 (polymorphic 型カラム等)。subquery には
185
+ # 含まれないため、外側の WHERE に追加する。これにより、別の
186
+ # polymorphic 型に属する行まで削除してしまうのを防ぐ。
187
+ first_join.base_where_clauses.each do |where|
188
+ next unless where.is_a?(Exwiw::QueryAst::WhereClause)
189
+
190
+ sql += " AND #{compile_where_condition(where, select_query_ast.from_table_name)}"
191
+ end
192
+ sql += ";"
182
193
 
183
194
  sql
184
195
  end
@@ -201,6 +212,13 @@ module Exwiw
201
212
  compiled_where_condition = compile_where_condition(where, join.join_table_name)
202
213
  sql += " AND #{compiled_where_condition}"
203
214
  end
215
+
216
+ # base_where_clauses は結合元テーブル (base_table_name) に対して
217
+ # コンパイルする。polymorphic な結合元テーブルの型カラム絞り込み等。
218
+ join.base_where_clauses.each do |where|
219
+ compiled_where_condition = compile_where_condition(where, join.base_table_name)
220
+ sql += " AND #{compiled_where_condition}"
221
+ end
204
222
  end
205
223
 
206
224
  if query_ast.where_clauses.any?
@@ -123,7 +123,18 @@ module Exwiw
123
123
 
124
124
  foreign_key = first_join.foreign_key
125
125
  subquery_sql = compile_ast(subquery_ast)
126
- sql += "\nWHERE #{select_query_ast.from_table_name}.#{foreign_key} IN (#{subquery_sql});"
126
+ sql += "\nWHERE #{select_query_ast.from_table_name}.#{foreign_key} IN (#{subquery_sql})"
127
+
128
+ # first_join.base_where_clauses は外側の削除対象テーブル
129
+ # (from_table_name) 上の条件 (polymorphic 型カラム等)。subquery には
130
+ # 含まれないため、外側の WHERE に追加する。これにより、別の
131
+ # polymorphic 型に属する行まで削除してしまうのを防ぐ。
132
+ first_join.base_where_clauses.each do |where|
133
+ next unless where.is_a?(Exwiw::QueryAst::WhereClause)
134
+
135
+ sql += " AND #{compile_where_condition(where, select_query_ast.from_table_name)}"
136
+ end
137
+ sql += ";"
127
138
 
128
139
  sql
129
140
  end
@@ -146,6 +157,13 @@ module Exwiw
146
157
  compiled_where_condition = compile_where_condition(where, join.join_table_name)
147
158
  sql += " AND #{compiled_where_condition}"
148
159
  end
160
+
161
+ # base_where_clauses は結合元テーブル (base_table_name) に対して
162
+ # コンパイルする。polymorphic な結合元テーブルの型カラム絞り込み等。
163
+ join.base_where_clauses.each do |where|
164
+ compiled_where_condition = compile_where_condition(where, join.base_table_name)
165
+ sql += " AND #{compiled_where_condition}"
166
+ end
149
167
  end
150
168
 
151
169
  if query_ast.where_clauses.any?
@@ -6,9 +6,22 @@ module Exwiw
6
6
 
7
7
  attribute :foreign_key, String
8
8
  attribute :table_name, String
9
+ # polymorphic 関連の場合のみ設定される。`foreign_type` は型を格納するカラム名
10
+ # (例: `reviewable_type`)、`type_value` はそのカラムに入る値 (例: `"Product"`)。
11
+ # 非 polymorphic の belongs_to では両方とも nil。
12
+ attribute :foreign_type, optional(String), skip_serializing_if_nil: true
13
+ attribute :type_value, optional(String), skip_serializing_if_nil: true
9
14
 
10
15
  def self.from_symbol_keys(hash)
11
16
  from(hash.transform_keys(&:to_s))
12
17
  end
18
+
19
+ def polymorphic?
20
+ !foreign_type.nil?
21
+ end
22
+
23
+ def to_hash
24
+ super.compact
25
+ end
13
26
  end
14
27
  end
@@ -3,14 +3,20 @@
3
3
  module Exwiw
4
4
  module QueryAst
5
5
  class JoinClause
6
- attr_reader :base_table_name, :foreign_key, :join_table_name, :primary_key, :where_clauses
6
+ # `where_clauses` はこの join の join_table_name (= 結合先テーブル) に対して
7
+ # コンパイルされる。一方 `base_where_clauses` は base_table_name (= 結合元
8
+ # テーブル) に対してコンパイルされる。後者は、結合元テーブルが結合先へ
9
+ # polymorphic belongs_to していて型カラム (foreign_type) が結合元テーブル
10
+ # 側に存在するケースのために使う。
11
+ attr_reader :base_table_name, :foreign_key, :join_table_name, :primary_key, :where_clauses, :base_where_clauses
7
12
 
8
- def initialize(base_table_name:, foreign_key:, join_table_name:, primary_key:, where_clauses: [])
13
+ def initialize(base_table_name:, foreign_key:, join_table_name:, primary_key:, where_clauses: [], base_where_clauses: [])
9
14
  @base_table_name = base_table_name
10
15
  @foreign_key = foreign_key
11
16
  @join_table_name = join_table_name
12
17
  @primary_key = primary_key
13
18
  @where_clauses = where_clauses
19
+ @base_where_clauses = base_where_clauses
14
20
  end
15
21
 
16
22
  def to_h
@@ -23,6 +29,9 @@ module Exwiw
23
29
  if where_clauses.size.positive?
24
30
  hash[:where_clauses] = where_clauses.map { |wc| wc.is_a?(String) ? wc : wc.to_h }
25
31
  end
32
+ if base_where_clauses.size.positive?
33
+ hash[:base_where_clauses] = base_where_clauses.map { |wc| wc.is_a?(String) ? wc : wc.to_h }
34
+ end
26
35
  hash
27
36
  end
28
37
  end
@@ -54,8 +54,23 @@ module Exwiw
54
54
  foreign_key: relation.foreign_key,
55
55
  join_table_name: to_table.name,
56
56
  primary_key: to_table.primary_key,
57
- where_clauses: []
57
+ where_clauses: [],
58
+ base_where_clauses: []
58
59
  )
60
+
61
+ # この hop 自体が polymorphic belongs_to の場合 (例: comments が
62
+ # commentable として posts へ polymorphic belongs_to)、型カラム
63
+ # (foreign_type) は結合元テーブル (from_table = base_table_name) 側に
64
+ # 存在する。外部キーだけでは reviewable_id=1 のような値が別モデルの
65
+ # 行と衝突しうるため、base_where_clauses に型条件を追加して結合元
66
+ # テーブルを絞り込む。
67
+ if relation.polymorphic?
68
+ join_clause.base_where_clauses.push QueryAst::WhereClause.new(
69
+ column_name: relation.foreign_type,
70
+ operator: :eq,
71
+ value: [relation.type_value]
72
+ )
73
+ end
59
74
  relation_to_dump_target = to_table.belongs_to(dump_target.table_name)
60
75
  if relation_to_dump_target
61
76
  join_clause.where_clauses.push QueryAst::WhereClause.new(
@@ -63,6 +78,18 @@ module Exwiw
63
78
  operator: :eq,
64
79
  value: dump_target.ids
65
80
  )
81
+
82
+ # 中間テーブルが dump target へ polymorphic belongs_to している場合は、
83
+ # 型カラム (foreign_type) も join 条件に追加する。型カラムは to_table
84
+ # (= join_table_name) 上に存在するため、JoinClause の where_clauses が
85
+ # join_table_name に対してコンパイルされる仕組みにそのまま乗せられる。
86
+ if relation_to_dump_target.polymorphic?
87
+ join_clause.where_clauses.push QueryAst::WhereClause.new(
88
+ column_name: relation_to_dump_target.foreign_type,
89
+ operator: :eq,
90
+ value: [relation_to_dump_target.type_value]
91
+ )
92
+ end
66
93
  end
67
94
 
68
95
  # Add filter from intermediate table to join clause
@@ -98,6 +125,17 @@ module Exwiw
98
125
  value: dump_target.ids
99
126
  )
100
127
 
128
+ # polymorphic belongs_to の場合は外部キーだけでは型を区別できないため
129
+ # (例: reviewable_id=1 が Product なのか別モデルなのか判別できない)、
130
+ # 型カラム (foreign_type) を type_value で絞り込む条件を追加する。
131
+ if belongs_to.polymorphic?
132
+ clauses.push Exwiw::QueryAst::WhereClause.new(
133
+ column_name: belongs_to.foreign_type,
134
+ operator: :eq,
135
+ value: [belongs_to.type_value]
136
+ )
137
+ end
138
+
101
139
  if table.filter
102
140
  clauses.push table.filter
103
141
  end
@@ -145,15 +145,44 @@ module Exwiw
145
145
  end
146
146
 
147
147
  private def aggregate_belongs_tos(models)
148
- pairs = models
149
- .flat_map { |m| m.reflect_on_all_associations(:belongs_to) }
150
- .reject(&:polymorphic?) # XXX: Support polymorphic
151
- .map { |assoc| [assoc.table_name, assoc.foreign_key] }
152
- .uniq
153
-
154
- pairs.map do |table_name, foreign_key|
155
- { table_name: table_name, foreign_key: foreign_key }
156
- end
148
+ belongs_to_assocs = models.flat_map { |m| m.reflect_on_all_associations(:belongs_to) }
149
+
150
+ non_polymorphic = belongs_to_assocs
151
+ .reject(&:polymorphic?)
152
+ .map { |assoc| { table_name: assoc.table_name, foreign_key: assoc.foreign_key } }
153
+
154
+ # polymorphic な belongs_to (`belongs_to :reviewable, polymorphic: true`) は
155
+ # 単一の対象テーブルを持たない。対象になりうるテーブルは、他モデルで
156
+ # `has_many/has_one ..., as: <association_name>` と宣言されている側から逆引き
157
+ # する。各候補テーブルごとに、型カラム (`foreign_type`) と格納される型の値
158
+ # (`type_value`) を添えた belongs_to を 1 件ずつ展開する。
159
+ polymorphic = belongs_to_assocs
160
+ .select(&:polymorphic?)
161
+ .flat_map do |assoc|
162
+ polymorphic_target_models(assoc.name).map do |target_model|
163
+ {
164
+ table_name: target_model.table_name,
165
+ foreign_key: assoc.foreign_key,
166
+ foreign_type: assoc.foreign_type,
167
+ type_value: target_model.polymorphic_name,
168
+ }
169
+ end
170
+ end
171
+
172
+ (non_polymorphic + polymorphic).uniq
173
+ end
174
+
175
+ # polymorphic 関連 `association_name` の対象となりうる具象モデルを、全モデルの
176
+ # `has_many` / `has_one` の `as:` オプションから逆引きして列挙する。
177
+ # `concrete_models` の並びは `ActiveRecord::Base.descendants` の順に依存し、
178
+ # Ruby バージョンによって変わりうるため、生成される belongs_to の並びが安定する
179
+ # よう `table_name` でソートして決定的に返す。
180
+ private def polymorphic_target_models(association_name)
181
+ concrete_models.select do |model|
182
+ (model.reflect_on_all_associations(:has_many) +
183
+ model.reflect_on_all_associations(:has_one))
184
+ .any? { |reflection| reflection.options[:as] == association_name }
185
+ end.sort_by(&:table_name)
157
186
  end
158
187
 
159
188
  # Identifies which database a model belongs to. With Rails multi-DB
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.7"
4
+ VERSION = "0.2.8"
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.7
4
+ version: 0.2.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shia