exwiw 0.3.7 → 0.3.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 +4 -4
- data/CHANGELOG.md +6 -0
- data/README.md +19 -0
- data/lib/exwiw/adapter/mysql_adapter.rb +4 -0
- data/lib/exwiw/adapter/postgresql_adapter.rb +4 -0
- data/lib/exwiw/adapter/sqlite_adapter.rb +4 -0
- data/lib/exwiw/query_ast.rb +28 -1
- data/lib/exwiw/query_ast_builder.rb +72 -3
- data/lib/exwiw/schema_generator.rb +34 -0
- data/lib/exwiw/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 90a440a039497562ccc9a4ab3045342953ba23faf0fdff48cfd633771a4688a4
|
|
4
|
+
data.tar.gz: 5f8ee4853cbbe39dedba4176e5db61d91572ee3766d1d76e3eda6977ea373e6b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: aa34168f0c3b6137eca95bf8ebeea625ea106559a779bd8739c09e5ab8ffeae0a9a84f9ca188809e579ab3d1c3f8c77b68d57d006b156de30b1d01e100435ecb
|
|
7
|
+
data.tar.gz: c7ad4547852032fcb4c356883232c8a34d6418baf498686ed6f25a80279d1cf61379b566ee29267f989d9de80f4b2490022d03695e686dedf5c506b2c01871f8
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.3.8] - 2026-06-02
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- ActiveStorage support is now complete. `schema:generate` follows `has_one_attached` / `has_many_attached` macros so `active_storage_attachments` is extracted correctly via its polymorphic `belongs_to`. `active_storage_blobs` is no longer dumped wholesale: a reverse "referenced_by" extraction (a `SELECT` subquery) narrows it to only the blobs referenced by the extracted attachments. `active_storage_variant_records` (derivative, regenerable variant-tracking rows) is now emitted with `ignore: true` instead of being dumped in full — it has no path to a dump target and its `blob_id` could otherwise reference blobs outside the narrowed set, causing a foreign-key violation on import. It is also dropped from the attachments `record` polymorphic expansion so the non-ignored attachments table carries no dangling belongs_to to it. ([#69](https://github.com/heyinc/exwiw/pull/69))
|
|
10
|
+
|
|
5
11
|
## [0.3.7] - 2026-06-01
|
|
6
12
|
|
|
7
13
|
- bug fix https://github.com/heyinc/exwiw/pull/67
|
data/README.md
CHANGED
|
@@ -341,6 +341,25 @@ WHERE reviews.reviewable_id IN (/* products subquery */)
|
|
|
341
341
|
|
|
342
342
|
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.
|
|
343
343
|
|
|
344
|
+
### ActiveStorage (`has_one_attached` / `has_many_attached`)
|
|
345
|
+
|
|
346
|
+
ActiveStorage is handled automatically — no ActiveStorage-specific configuration is required. The `has_one_attached` / `has_many_attached` macros don't add a column to the owning model; they generate ordinary associations that exwiw already understands:
|
|
347
|
+
|
|
348
|
+
- **`active_storage_attachments`** is the polymorphic join row (`belongs_to :record, polymorphic: true` + `belongs_to :blob`). `exwiw:schema:generate` expands the polymorphic `record` into one `belongs_to` per model that declared `has_*_attached` (found via the generated `has_* ..., as: :record` reflections), exactly like any other [polymorphic `belongs_to`](#polymorphic-belongs_to). So only the attachments whose owner is among the dumped rows are extracted.
|
|
349
|
+
- **`active_storage_blobs`** has no `belongs_to` of its own (attachments point *at* it), so it has no path to the dump target. exwiw narrows it via **reverse / "referenced_by" extraction**: a parent table referenced by exactly one constrained, non-polymorphic child is constrained to just the referenced ids instead of dumping every row:
|
|
350
|
+
|
|
351
|
+
```sql
|
|
352
|
+
SELECT active_storage_blobs.* FROM active_storage_blobs
|
|
353
|
+
WHERE active_storage_blobs.id IN (
|
|
354
|
+
SELECT active_storage_attachments.blob_id FROM active_storage_attachments
|
|
355
|
+
WHERE active_storage_attachments.record_id IN (/* owner subquery */)
|
|
356
|
+
AND active_storage_attachments.record_type = '...'
|
|
357
|
+
)
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
`active_storage_variant_records` also references blobs, but since it has no path of its own to the dump target it doesn't constrain anything and is ignored as a referencer — blobs stays narrowed to the attachment-referenced ids. (A parent referenced by *multiple* constrained children currently falls back to dumping all of its rows.)
|
|
361
|
+
- **`active_storage_variant_records`** holds derivative variant-tracking rows that ActiveStorage regenerates lazily, and it too has no path to the dump target — left alone it would land in the "no relation → dump all" branch and, worse, its `blob_id` could point at blobs outside the narrowed set above (a foreign-key violation on import). `exwiw:schema:generate` therefore emits it with **`ignore: true`** (and drops it from the attachments `record` polymorphic expansion so nothing carries a dangling reference to it), so its data is skipped while the DDL is still written. Remove `ignore` from the generated config if you really need to export it.
|
|
362
|
+
|
|
344
363
|
### Rails-managed tables (special `type` values)
|
|
345
364
|
|
|
346
365
|
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:
|
|
@@ -226,6 +226,10 @@ module Exwiw
|
|
|
226
226
|
end
|
|
227
227
|
|
|
228
228
|
private def compile_subquery(subquery)
|
|
229
|
+
# A SelectSubquery wraps a full Select (the referencing table's
|
|
230
|
+
# extraction query, projected to a foreign key); compile it as-is.
|
|
231
|
+
return compile_ast(subquery.query) if subquery.is_a?(Exwiw::QueryAst::SelectSubquery)
|
|
232
|
+
|
|
229
233
|
inner_values = subquery.where_values.map { |v| escape_value(v) }
|
|
230
234
|
"SELECT #{subquery.table_name}.#{subquery.select_column} " \
|
|
231
235
|
"FROM #{subquery.table_name} " \
|
|
@@ -253,6 +253,10 @@ module Exwiw
|
|
|
253
253
|
end
|
|
254
254
|
|
|
255
255
|
private def compile_subquery(subquery)
|
|
256
|
+
# A SelectSubquery wraps a full Select (the referencing table's
|
|
257
|
+
# extraction query, projected to a foreign key); compile it as-is.
|
|
258
|
+
return compile_ast(subquery.query) if subquery.is_a?(Exwiw::QueryAst::SelectSubquery)
|
|
259
|
+
|
|
256
260
|
inner_values = subquery.where_values.map { |v| escape_value(v) }
|
|
257
261
|
"SELECT #{subquery.table_name}.#{subquery.select_column} " \
|
|
258
262
|
"FROM #{subquery.table_name} " \
|
|
@@ -198,6 +198,10 @@ module Exwiw
|
|
|
198
198
|
end
|
|
199
199
|
|
|
200
200
|
private def compile_subquery(subquery)
|
|
201
|
+
# A SelectSubquery wraps a full Select (the referencing table's
|
|
202
|
+
# extraction query, projected to a foreign key); compile it as-is.
|
|
203
|
+
return compile_ast(subquery.query) if subquery.is_a?(Exwiw::QueryAst::SelectSubquery)
|
|
204
|
+
|
|
201
205
|
inner_values = subquery.where_values.map { |v| escape_value(v) }
|
|
202
206
|
"SELECT #{subquery.table_name}.#{subquery.select_column} " \
|
|
203
207
|
"FROM #{subquery.table_name} " \
|
data/lib/exwiw/query_ast.rb
CHANGED
|
@@ -41,7 +41,7 @@ module Exwiw
|
|
|
41
41
|
{
|
|
42
42
|
column_name: column_name,
|
|
43
43
|
operator: operator,
|
|
44
|
-
value: value.is_a?(Subquery) ? value.to_h : value,
|
|
44
|
+
value: value.is_a?(Subquery) || value.is_a?(SelectSubquery) ? value.to_h : value,
|
|
45
45
|
}
|
|
46
46
|
end
|
|
47
47
|
end
|
|
@@ -65,6 +65,24 @@ module Exwiw
|
|
|
65
65
|
end
|
|
66
66
|
end
|
|
67
67
|
|
|
68
|
+
# A subquery whose body is a full `Select`, projected down to a single
|
|
69
|
+
# column. Unlike the flat `Subquery` above (one column = one IN-list), this
|
|
70
|
+
# carries the referencing table's complete extraction query — joins,
|
|
71
|
+
# multiple where conditions, polymorphic type filters and all. Used by the
|
|
72
|
+
# reverse / "referenced_by" extraction so a parent table with no belongs_to
|
|
73
|
+
# path to the dump target (e.g. active_storage_blobs) is constrained to only
|
|
74
|
+
# the rows referenced by an extractable child table:
|
|
75
|
+
#
|
|
76
|
+
# <parent>.<pk> IN (SELECT <child>.<fk> FROM <child> WHERE <child filters>)
|
|
77
|
+
#
|
|
78
|
+
# `query` is the child's `Select` already projected to the foreign-key
|
|
79
|
+
# column that points at the parent.
|
|
80
|
+
SelectSubquery = Struct.new(:query, keyword_init: true) do
|
|
81
|
+
def to_h
|
|
82
|
+
{ query: query.to_h }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
68
86
|
module ColumnValue
|
|
69
87
|
Base = Struct.new(:name, :value, keyword_init: true)
|
|
70
88
|
Plain = Class.new(Base)
|
|
@@ -103,6 +121,15 @@ module Exwiw
|
|
|
103
121
|
@join_clauses << join_clause
|
|
104
122
|
end
|
|
105
123
|
|
|
124
|
+
def to_h
|
|
125
|
+
{
|
|
126
|
+
from: from_table_name,
|
|
127
|
+
columns: select_all ? "*" : columns.map { |c| { name: c.name, value: c.value } },
|
|
128
|
+
joins: join_clauses.map(&:to_h),
|
|
129
|
+
where: where_clauses.map { |w| w.is_a?(String) ? w : w.to_h },
|
|
130
|
+
}
|
|
131
|
+
end
|
|
132
|
+
|
|
106
133
|
private def map_column_value(columns)
|
|
107
134
|
columns.map do |c|
|
|
108
135
|
if c.raw_sql
|
|
@@ -2,17 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
module Exwiw
|
|
4
4
|
class QueryAstBuilder
|
|
5
|
-
def self.run(table_name, table_by_name, dump_target, logger)
|
|
6
|
-
new(table_name, table_by_name, dump_target, logger).run
|
|
5
|
+
def self.run(table_name, table_by_name, dump_target, logger, allow_reverse: true)
|
|
6
|
+
new(table_name, table_by_name, dump_target, logger, allow_reverse: allow_reverse).run
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
attr_reader :table_name, :table_by_name, :dump_target
|
|
10
10
|
|
|
11
|
-
def initialize(table_name, table_by_name, dump_target, logger)
|
|
11
|
+
def initialize(table_name, table_by_name, dump_target, logger, allow_reverse: true)
|
|
12
12
|
@table_name = table_name
|
|
13
13
|
@table_by_name = table_by_name
|
|
14
14
|
@dump_target = dump_target
|
|
15
15
|
@logger = logger
|
|
16
|
+
@allow_reverse = allow_reverse
|
|
16
17
|
end
|
|
17
18
|
|
|
18
19
|
def run
|
|
@@ -21,6 +22,19 @@ module Exwiw
|
|
|
21
22
|
where_clauses = build_where_clauses(table, dump_target)
|
|
22
23
|
join_clauses = build_join_clauses(table, table_by_name, dump_target)
|
|
23
24
|
|
|
25
|
+
# Reverse / "referenced_by" extraction. A table with no belongs_to path to
|
|
26
|
+
# the dump target produces no where/join clauses and would otherwise dump
|
|
27
|
+
# every row (see the "no relation -> dump all" case). If an extractable
|
|
28
|
+
# child table references it via a foreign key (e.g. active_storage_blobs is
|
|
29
|
+
# referenced by active_storage_attachments.blob_id), constrain it to just
|
|
30
|
+
# the referenced ids instead. Disabled (@allow_reverse=false) while building
|
|
31
|
+
# a child's subquery, so this never recurses.
|
|
32
|
+
if @allow_reverse && table.name != dump_target.table_name &&
|
|
33
|
+
where_clauses.empty? && join_clauses.empty?
|
|
34
|
+
reverse_clause = build_referenced_by_clause(table)
|
|
35
|
+
where_clauses.push(reverse_clause) if reverse_clause
|
|
36
|
+
end
|
|
37
|
+
|
|
24
38
|
QueryAst::Select.new.tap do |ast|
|
|
25
39
|
ast.from(table.name)
|
|
26
40
|
if table.rails_managed?
|
|
@@ -100,6 +114,61 @@ module Exwiw
|
|
|
100
114
|
join_clauses
|
|
101
115
|
end
|
|
102
116
|
|
|
117
|
+
# Builds a `pk IN (SELECT child.fk FROM <child extraction query>)` clause
|
|
118
|
+
# for a table that is referenced by an extractable child table but has no
|
|
119
|
+
# belongs_to of its own toward the dump target. Returns nil when there is no
|
|
120
|
+
# such (single, unambiguous) referencer, leaving the caller to fall back to
|
|
121
|
+
# the dump-all behavior.
|
|
122
|
+
private def build_referenced_by_clause(table)
|
|
123
|
+
candidates = table_by_name.each_value.filter_map do |other|
|
|
124
|
+
next if other.name == table.name
|
|
125
|
+
|
|
126
|
+
relation = other.belongs_to(table.name)
|
|
127
|
+
# A polymorphic foreign key stores ids of several models in one column,
|
|
128
|
+
# so projecting it would pull in unrelated parents. Skip it here; the
|
|
129
|
+
# non-polymorphic blob_id on active_storage_attachments is what we want.
|
|
130
|
+
next if relation.nil? || relation.polymorphic?
|
|
131
|
+
|
|
132
|
+
# Build the child's own extraction query. allow_reverse:false stops a
|
|
133
|
+
# chain of FK-less tables from recursing back into each other.
|
|
134
|
+
child_query = self.class.run(other.name, table_by_name, dump_target, @logger, allow_reverse: false)
|
|
135
|
+
|
|
136
|
+
# Only an *already constrained* child narrows anything; an unconstrained
|
|
137
|
+
# child would select every fk value (i.e. dump all) and not help.
|
|
138
|
+
next unless child_query.where_clauses.any? || child_query.join_clauses.any?
|
|
139
|
+
|
|
140
|
+
[relation, child_query]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Scope: only the unambiguous single-referencer case. Multiple referencers
|
|
144
|
+
# would need their subqueries OR'd together (not yet supported); falling
|
|
145
|
+
# back to dump-all preserves today's behavior for those.
|
|
146
|
+
if candidates.size != 1
|
|
147
|
+
if candidates.size > 1
|
|
148
|
+
@logger.debug(" #{table.name} has multiple referencing tables; skipping reverse extraction (dump all).")
|
|
149
|
+
end
|
|
150
|
+
return nil
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
relation, child_query = candidates.first
|
|
154
|
+
|
|
155
|
+
# Project the child's extraction query down to just the foreign key that
|
|
156
|
+
# points at `table`. Force a plain column so any masking/raw_sql configured
|
|
157
|
+
# on that column does not corrupt the id comparison.
|
|
158
|
+
fk_column = TableColumn.from_symbol_keys(name: relation.foreign_key)
|
|
159
|
+
projected = QueryAst::Select.new
|
|
160
|
+
projected.from(child_query.from_table_name)
|
|
161
|
+
projected.select([fk_column])
|
|
162
|
+
child_query.join_clauses.each { |j| projected.join(j) }
|
|
163
|
+
child_query.where_clauses.each { |w| projected.where(w) }
|
|
164
|
+
|
|
165
|
+
QueryAst::WhereClause.new(
|
|
166
|
+
column_name: table.primary_key,
|
|
167
|
+
operator: :in_subquery,
|
|
168
|
+
value: QueryAst::SelectSubquery.new(query: projected)
|
|
169
|
+
)
|
|
170
|
+
end
|
|
171
|
+
|
|
103
172
|
private def build_where_clauses(table, dump_target)
|
|
104
173
|
clauses = []
|
|
105
174
|
|
|
@@ -5,6 +5,20 @@ require "json"
|
|
|
5
5
|
|
|
6
6
|
module Exwiw
|
|
7
7
|
class SchemaGenerator
|
|
8
|
+
# ActiveStorage tracks generated image variants in this table. Its rows are
|
|
9
|
+
# derivative and regenerable — ActiveStorage lazily (re)creates a variant the
|
|
10
|
+
# next time it is requested — so there is little value in exporting them. More
|
|
11
|
+
# importantly, the table has no belongs_to path to any dump target, which
|
|
12
|
+
# would land it in QueryAstBuilder's "no relation -> dump all" branch, while
|
|
13
|
+
# its `blob_id` references active_storage_blobs, which the reverse
|
|
14
|
+
# "referenced_by" extraction narrows to only the attachment-referenced blobs.
|
|
15
|
+
# A full variant_records dump can therefore reference blobs that were never
|
|
16
|
+
# exported (a foreign-key violation on import). So the table is emitted with
|
|
17
|
+
# `ignore: true` (data extraction skipped) and excluded as a polymorphic
|
|
18
|
+
# `record` target so the non-ignored attachments table carries no dangling
|
|
19
|
+
# belongs_to to it.
|
|
20
|
+
ACTIVE_STORAGE_VARIANT_RECORDS_TABLE = "active_storage_variant_records"
|
|
21
|
+
|
|
8
22
|
def self.from_rails_application(output_dir:)
|
|
9
23
|
Rails.application.eager_load!
|
|
10
24
|
new(models: ActiveRecord::Base.descendants, output_dir: output_dir)
|
|
@@ -94,6 +108,20 @@ module Exwiw
|
|
|
94
108
|
belongs_tos: aggregate_belongs_tos(model_group),
|
|
95
109
|
columns: representative.column_names.map { |name| { name: name } },
|
|
96
110
|
)
|
|
111
|
+
elsif table_name == ACTIVE_STORAGE_VARIANT_RECORDS_TABLE
|
|
112
|
+
# See ACTIVE_STORAGE_VARIANT_RECORDS_TABLE. Emitted with ignore:true so
|
|
113
|
+
# the derivative variant rows are not dumped; primary_key/columns are
|
|
114
|
+
# kept so a user can remove `ignore` to opt back in if they really want
|
|
115
|
+
# to export them.
|
|
116
|
+
TableConfig.from_symbol_keys(
|
|
117
|
+
name: table_name,
|
|
118
|
+
primary_key: primary_key,
|
|
119
|
+
ignore: true,
|
|
120
|
+
comment: "ActiveStorage variant tracking records are derivative and " \
|
|
121
|
+
"regenerable; data extraction is skipped. Remove `ignore` to export them.",
|
|
122
|
+
belongs_tos: aggregate_belongs_tos(model_group),
|
|
123
|
+
columns: representative.column_names.map { |name| { name: name } },
|
|
124
|
+
)
|
|
97
125
|
else
|
|
98
126
|
TableConfig.from_symbol_keys(
|
|
99
127
|
name: table_name,
|
|
@@ -203,6 +231,12 @@ module Exwiw
|
|
|
203
231
|
# belongs_to ordering stable.
|
|
204
232
|
private def polymorphic_target_models(association_name)
|
|
205
233
|
concrete_models.select do |model|
|
|
234
|
+
# active_storage_variant_records is ignored (see the constant), so it must
|
|
235
|
+
# not be expanded as a polymorphic target — otherwise the non-ignored
|
|
236
|
+
# attachments table would carry a dangling belongs_to to an ignored table,
|
|
237
|
+
# which is rejected at load time.
|
|
238
|
+
next false if model.table_name == ACTIVE_STORAGE_VARIANT_RECORDS_TABLE
|
|
239
|
+
|
|
206
240
|
(model.reflect_on_all_associations(:has_many) +
|
|
207
241
|
model.reflect_on_all_associations(:has_one))
|
|
208
242
|
.any? { |reflection| reflection.options[:as] == association_name }
|
data/lib/exwiw/version.rb
CHANGED