exwiw 0.8.0 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +12 -0
- data/README.md +43 -0
- data/lib/exwiw/adapter/mysql_adapter.rb +8 -0
- data/lib/exwiw/adapter/postgresql_adapter.rb +33 -0
- data/lib/exwiw/adapter/sqlite_adapter.rb +8 -0
- data/lib/exwiw/query_ast.rb +20 -1
- data/lib/exwiw/query_ast_builder.rb +67 -0
- data/lib/exwiw/reverse_scope.rb +47 -0
- data/lib/exwiw/schema_generator.rb +28 -1
- data/lib/exwiw/table_config.rb +14 -0
- data/lib/exwiw/version.rb +1 -1
- data/lib/exwiw.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: eb6879b8efb94ef9a9a0bd37df32962257106df9eec4c3fc9de844a12c6360cd
|
|
4
|
+
data.tar.gz: b43b0bfde090111d7ce23083db7471f15974292ddb5886da682de9bc443febfa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e8cca59b376a369b9253d1482f3ee64ae8c542b00c1c3eddb192d273efe0e3c969e3aec1770f087977efe6b75fa52f375538e286d31a87d7f87a197739a4eeaf
|
|
7
|
+
data.tar.gz: 78a3ece09cd6ec9ecb6fd7b0a2f166bc41d51720ea36935ca4cd974bf766008f11688f0e0dc7bbd8a63ce29f4719806e1a4705b0390850a857a192fcfaa56976
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.8.2] - 2026-06-24
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Multi-referencer reverse scoping (`reverse_scope`).** Reverse / "referenced_by" extraction previously narrowed only a table referenced by *exactly one* constrained child; a table referenced by two or more (most importantly a **global-identity table** like `users`, which carries no scope/tenant column and has no `belongs_to` of its own, yet many scoped tables point *at* it) fell back to dumping every row — dragging in every tenant's identities. A table can now opt into **multi-referencer** reverse scoping with a user-owned `reverse_scope: { via: [{ table, column }, …] }` key listing the referencers whose own (already scoped) extraction queries are `UNION`'d into the id set it is constrained to (`pk IN (SELECT ref1.col1 FROM ref1 <scope> UNION SELECT ref2.col2 FROM ref2 <scope> …)`). Each arm reuses that referencer's own scope, so a per-tenant run keeps only that tenant's ids; the named `column` is explicit, so a non-default foreign key (or a column with no declared `belongs_to`) is honored; NULLs are excluded per arm. Only **scoped** referencers belong in `via` — an unconstrained arm (e.g. a `scope_exempt` referencer) would union every row back, so it is skipped with a warning rather than silently widening the dump, and an unknown table is likewise skipped; if no arm survives, the table stays unscopable (so scope-column mode's `validate_scope!` still aborts rather than dumping it in full). Tables that `belongs_to` the reverse-scoped table tighten automatically through the existing cascade and need no config. `reverse_scope` is never emitted by `schema:generate` and is preserved across regeneration like `scope_exempt`/`scope_column`. SQL adapters only. See the [README](README.md#reverse-scope-for-multi-referencer-tables-reverse_scope).
|
|
10
|
+
|
|
11
|
+
## [0.8.1] - 2026-06-24
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- **`schema:generate` skips a `belongs_to` whose target is not an ActiveRecord model instead of crashing.** A `belongs_to` can point at a non-ActiveRecord class — most commonly an ActiveHash/ActiveYaml master (`belongs_to :equipment, class_name: "SomeActiveYamlModel"`). active_hash registers these as ordinary `belongs_to` reflections, but the target class has no database table, so resolving its `table_name` raised and aborted generation. Such a relation is not a DB edge exwiw can join or extract across, so it is now dropped from the generated belongs_tos; the underlying foreign-key column is still emitted as a plain column. A bare `belongs_to` to a plain non-AR class — which makes ActiveRecord raise while resolving the target — is treated the same way. Polymorphic associations are unaffected.
|
|
16
|
+
|
|
5
17
|
## [0.8.0] - 2026-06-24
|
|
6
18
|
|
|
7
19
|
### Added
|
data/README.md
CHANGED
|
@@ -579,6 +579,49 @@ ActiveStorage is handled automatically — no ActiveStorage-specific configurati
|
|
|
579
579
|
`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.)
|
|
580
580
|
- **`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.
|
|
581
581
|
|
|
582
|
+
### Reverse scope for multi-referencer tables (`reverse_scope`)
|
|
583
|
+
|
|
584
|
+
The automatic reverse extraction above narrows a table referenced by **exactly one** constrained child. A table referenced by **two or more** constrained children falls back to dumping every row — fine for `active_storage_blobs`, but a problem for a **global-identity table** such as `users`: it carries no scope/tenant column and has no `belongs_to` of its own, yet dozens of scoped tables point *at* it. Dumping it (and everything that hangs off it) in full pulls in every tenant's identities.
|
|
585
|
+
|
|
586
|
+
`reverse_scope` opts such a table into **multi-referencer** reverse scoping: you enumerate the referencers whose own (already scoped) extraction queries should be `UNION`'d into the id set the table is constrained to. It is a user-owned key (never emitted by `schema:generate`, preserved across regeneration like `scope_exempt`/`scope_column`):
|
|
587
|
+
|
|
588
|
+
```json
|
|
589
|
+
{
|
|
590
|
+
"name": "users",
|
|
591
|
+
"primary_key": "id",
|
|
592
|
+
"reverse_scope": {
|
|
593
|
+
"via": [
|
|
594
|
+
{ "table": "customers", "column": "user_id" },
|
|
595
|
+
{ "table": "staff", "column": "user_id" },
|
|
596
|
+
{ "table": "business_entity_customers", "column": "kantan_yoyaku_user_id" }
|
|
597
|
+
]
|
|
598
|
+
},
|
|
599
|
+
"columns": [{ "name": "id" }, { "name": "name" }]
|
|
600
|
+
}
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
produces (each arm reuses that referencer's own scope, so a per-tenant run keeps only that tenant's ids):
|
|
604
|
+
|
|
605
|
+
```sql
|
|
606
|
+
SELECT users.* FROM users
|
|
607
|
+
WHERE users.id IN (
|
|
608
|
+
SELECT customers.user_id FROM customers WHERE <customers' scope> AND customers.user_id IS NOT NULL
|
|
609
|
+
UNION
|
|
610
|
+
SELECT staff.user_id FROM staff WHERE <staff' scope> AND staff.user_id IS NOT NULL
|
|
611
|
+
UNION
|
|
612
|
+
SELECT business_entity_customers.kantan_yoyaku_user_id FROM business_entity_customers
|
|
613
|
+
WHERE <…' scope> AND business_entity_customers.kantan_yoyaku_user_id IS NOT NULL
|
|
614
|
+
)
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
Notes:
|
|
618
|
+
|
|
619
|
+
- **`column` is explicit**, so a *non-default* foreign key (e.g. `kantan_yoyaku_user_id`, or `organization_admins.id` which itself references `users.id`) is honored, and even a column with no declared `belongs_to` edge can be enumerated.
|
|
620
|
+
- **Only scoped referencers belong in `via`.** Each arm's query must come out constrained; an unconstrained referencer (e.g. a `scope_exempt` table, or one with no path to a scope) would project *every* id and union the whole table back — so such an arm is **skipped with a warning** rather than silently widening the dump. An unknown table is likewise skipped with a warning. If no arm survives, the table stays unscopable and (in [scope-column mode](#scope-column-mode)) the run aborts via `validate_scope!`.
|
|
621
|
+
- **NULLs are excluded** per arm (`IS NOT NULL`).
|
|
622
|
+
- **Satellites need no config.** A table that `belongs_to` the reverse-scoped table (e.g. `end_users.id → users.id`, or `identities.user_id → users.id`) tightens to the kept ids automatically through the normal cascade — only the reverse-scoped table itself declares `reverse_scope`.
|
|
623
|
+
- Works in both single-target and scope-column mode. Polymorphic foreign keys are not eligible as anchors (the named `column` is always a concrete column).
|
|
624
|
+
|
|
582
625
|
### Rails-managed tables (special `type` values)
|
|
583
626
|
|
|
584
627
|
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:
|
|
@@ -280,6 +280,8 @@ module Exwiw
|
|
|
280
280
|
end
|
|
281
281
|
elsif where_clause.operator == :in_subquery
|
|
282
282
|
"#{key} IN (#{compile_subquery(where_clause.value)})"
|
|
283
|
+
elsif where_clause.operator == :not_null
|
|
284
|
+
"#{key} IS NOT NULL"
|
|
283
285
|
else
|
|
284
286
|
raise "Unsupported operator: #{where_clause.operator}"
|
|
285
287
|
end
|
|
@@ -290,6 +292,12 @@ module Exwiw
|
|
|
290
292
|
# extraction query, projected to a foreign key); compile it as-is.
|
|
291
293
|
return compile_ast(subquery.query) if subquery.is_a?(Exwiw::QueryAst::SelectSubquery)
|
|
292
294
|
|
|
295
|
+
# A UnionSubquery wraps several such Selects; UNION their compiled forms
|
|
296
|
+
# into a single id set.
|
|
297
|
+
if subquery.is_a?(Exwiw::QueryAst::UnionSubquery)
|
|
298
|
+
return subquery.queries.map { |q| compile_ast(q) }.join(' UNION ')
|
|
299
|
+
end
|
|
300
|
+
|
|
293
301
|
inner_values = subquery.where_values.map { |v| escape_value(v) }
|
|
294
302
|
"SELECT #{subquery.table_name}.#{subquery.select_column} " \
|
|
295
303
|
"FROM #{subquery.table_name} " \
|
|
@@ -364,6 +364,8 @@ module Exwiw
|
|
|
364
364
|
cast_to = subquery_cast_to(where_clause.value, table_name, where_clause.column_name)
|
|
365
365
|
outer_key = cast_to ? "#{key}::#{cast_to}" : key
|
|
366
366
|
"#{outer_key} IN (#{subquery_sql})"
|
|
367
|
+
elsif where_clause.operator == :not_null
|
|
368
|
+
"#{key} IS NOT NULL"
|
|
367
369
|
else
|
|
368
370
|
raise "Unsupported operator: #{where_clause.operator}"
|
|
369
371
|
end
|
|
@@ -376,6 +378,15 @@ module Exwiw
|
|
|
376
378
|
return compile_ast(subquery.query, select_cast_to: cast_to)
|
|
377
379
|
end
|
|
378
380
|
|
|
381
|
+
# A UnionSubquery wraps several projected Selects; UNION their compiled
|
|
382
|
+
# forms. cast_to is the union-wide decision (see union_cast_to): when any
|
|
383
|
+
# arm's column type would clash with the outer column or another arm,
|
|
384
|
+
# every arm's projected column and the outer key are cast to text so the
|
|
385
|
+
# UNION and the enclosing IN comparison resolve to one type.
|
|
386
|
+
if subquery.is_a?(Exwiw::QueryAst::UnionSubquery)
|
|
387
|
+
return subquery.queries.map { |q| compile_ast(q, select_cast_to: cast_to) }.join(' UNION ')
|
|
388
|
+
end
|
|
389
|
+
|
|
379
390
|
inner_values = subquery.where_values.map { |v| escape_value(v) }
|
|
380
391
|
select_expr = "#{subquery.table_name}.#{subquery.select_column}"
|
|
381
392
|
select_expr = "#{select_expr}::#{cast_to}" if cast_to
|
|
@@ -400,6 +411,11 @@ module Exwiw
|
|
|
400
411
|
private def subquery_cast_to(subquery, outer_table, outer_column)
|
|
401
412
|
return nil if outer_table.nil? || outer_column.nil?
|
|
402
413
|
|
|
414
|
+
# A UNION's arms (and the enclosing IN comparison) must all resolve to
|
|
415
|
+
# one type, so the cast decision must weigh every arm — not just one, as
|
|
416
|
+
# a flat Subquery would.
|
|
417
|
+
return union_cast_to(subquery, outer_table, outer_column) if subquery.is_a?(Exwiw::QueryAst::UnionSubquery)
|
|
418
|
+
|
|
403
419
|
inner_table, inner_column = subquery_select_target(subquery)
|
|
404
420
|
return nil if inner_table.nil?
|
|
405
421
|
|
|
@@ -408,6 +424,23 @@ module Exwiw
|
|
|
408
424
|
types_need_cast?(outer_type, inner_type) ? 'text' : nil
|
|
409
425
|
end
|
|
410
426
|
|
|
427
|
+
# Postgres rejects a UNION (or an `IN`) that mixes incompatible types
|
|
428
|
+
# (e.g. uuid and varchar). Examining only the first arm is not enough: a
|
|
429
|
+
# heterogeneous later arm would go uncast and break at execution. So
|
|
430
|
+
# consider the outer column together with every arm's projected column and,
|
|
431
|
+
# if ANY pair needs reconciliation, cast them all to text.
|
|
432
|
+
private def union_cast_to(union, outer_table, outer_column)
|
|
433
|
+
types = [column_pg_type(outer_table, outer_column)]
|
|
434
|
+
union.queries.each do |q|
|
|
435
|
+
col = q.columns.first
|
|
436
|
+
types << column_pg_type(q.from_table_name, col.name) if col
|
|
437
|
+
end
|
|
438
|
+
types.compact!
|
|
439
|
+
|
|
440
|
+
needs_cast = types.combination(2).any? { |a, b| types_need_cast?(a, b) }
|
|
441
|
+
needs_cast ? 'text' : nil
|
|
442
|
+
end
|
|
443
|
+
|
|
411
444
|
private def escape_value(value)
|
|
412
445
|
case value
|
|
413
446
|
when nil
|
|
@@ -249,6 +249,8 @@ module Exwiw
|
|
|
249
249
|
end
|
|
250
250
|
elsif where_clause.operator == :in_subquery
|
|
251
251
|
"#{key} IN (#{compile_subquery(where_clause.value)})"
|
|
252
|
+
elsif where_clause.operator == :not_null
|
|
253
|
+
"#{key} IS NOT NULL"
|
|
252
254
|
else
|
|
253
255
|
raise "Unsupported operator: #{where_clause.operator}"
|
|
254
256
|
end
|
|
@@ -259,6 +261,12 @@ module Exwiw
|
|
|
259
261
|
# extraction query, projected to a foreign key); compile it as-is.
|
|
260
262
|
return compile_ast(subquery.query) if subquery.is_a?(Exwiw::QueryAst::SelectSubquery)
|
|
261
263
|
|
|
264
|
+
# A UnionSubquery wraps several such Selects; UNION their compiled forms
|
|
265
|
+
# into a single id set.
|
|
266
|
+
if subquery.is_a?(Exwiw::QueryAst::UnionSubquery)
|
|
267
|
+
return subquery.queries.map { |q| compile_ast(q) }.join(' UNION ')
|
|
268
|
+
end
|
|
269
|
+
|
|
262
270
|
inner_values = subquery.where_values.map { |v| escape_value(v) }
|
|
263
271
|
"SELECT #{subquery.table_name}.#{subquery.select_column} " \
|
|
264
272
|
"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.is_a?(SelectSubquery) ? value.to_h : value,
|
|
44
|
+
value: value.is_a?(Subquery) || value.is_a?(SelectSubquery) || value.is_a?(UnionSubquery) ? value.to_h : value,
|
|
45
45
|
}
|
|
46
46
|
end
|
|
47
47
|
end
|
|
@@ -83,6 +83,25 @@ module Exwiw
|
|
|
83
83
|
end
|
|
84
84
|
end
|
|
85
85
|
|
|
86
|
+
# A subquery that UNIONs several single-column `Select`s into one id set.
|
|
87
|
+
# Used by *multi-referencer* reverse / "referenced_by" extraction
|
|
88
|
+
# (TableConfig#reverse_scope): a parent table referenced by many scoped
|
|
89
|
+
# tables is constrained to the union of every referencer's projected foreign
|
|
90
|
+
# key, rather than falling back to dumping every row:
|
|
91
|
+
#
|
|
92
|
+
# <parent>.<pk> IN (
|
|
93
|
+
# SELECT <ref1>.<col1> FROM <ref1> WHERE <ref1 scope> AND <col1> IS NOT NULL
|
|
94
|
+
# UNION SELECT <ref2>.<col2> FROM <ref2> WHERE <ref2 scope> AND <col2> IS NOT NULL
|
|
95
|
+
# )
|
|
96
|
+
#
|
|
97
|
+
# Each `queries` element is a `Select` already projected to the foreign-key
|
|
98
|
+
# column that points at the parent (with a NULL-excluding filter).
|
|
99
|
+
UnionSubquery = Struct.new(:queries, keyword_init: true) do
|
|
100
|
+
def to_h
|
|
101
|
+
{ union: queries.map(&:to_h) }
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
86
105
|
module ColumnValue
|
|
87
106
|
Base = Struct.new(:name, :value, keyword_init: true)
|
|
88
107
|
Plain = Class.new(Base)
|
|
@@ -168,6 +168,15 @@ module Exwiw
|
|
|
168
168
|
# such (single, unambiguous) referencer, leaving the caller to fall back to
|
|
169
169
|
# the dump-all behavior.
|
|
170
170
|
private def build_referenced_by_clause(table)
|
|
171
|
+
# Opt-in multi-referencer reverse scope (TableConfig#reverse_scope): when
|
|
172
|
+
# the schema author has enumerated the referencers explicitly, constrain
|
|
173
|
+
# the table to the UNION of those referencers' scoped queries instead of
|
|
174
|
+
# the single-referencer auto-detection below (which bails to a full dump
|
|
175
|
+
# once two or more tables reference the table).
|
|
176
|
+
if table.reverse_scope && table.reverse_scope.via.any?
|
|
177
|
+
return build_reverse_scope_via_clause(table)
|
|
178
|
+
end
|
|
179
|
+
|
|
171
180
|
candidates = table_by_name.each_value.filter_map do |other|
|
|
172
181
|
next if other.name == table.name
|
|
173
182
|
|
|
@@ -219,6 +228,64 @@ module Exwiw
|
|
|
219
228
|
)
|
|
220
229
|
end
|
|
221
230
|
|
|
231
|
+
# Multi-referencer reverse scope (TableConfig#reverse_scope). Builds a
|
|
232
|
+
# `pk IN (SELECT ref1.col1 FROM ref1 <scope> UNION SELECT ref2.col2 ...)`
|
|
233
|
+
# clause for a global-identity table referenced by many scoped tables. Each
|
|
234
|
+
# `via` arm reuses the referencer's own (already-scoped) extraction query —
|
|
235
|
+
# so a per-tenant run keeps only that tenant's ids — projected down to the
|
|
236
|
+
# foreign-key column that points at this table, with NULLs excluded.
|
|
237
|
+
#
|
|
238
|
+
# An arm whose referencer is unknown or comes out unconstrained is skipped
|
|
239
|
+
# with a warning rather than included: an unconstrained arm would project
|
|
240
|
+
# every row's id and union the whole table back, silently defeating the
|
|
241
|
+
# prune. Returns nil when no arm survives, leaving the caller to fall back to
|
|
242
|
+
# the dump-all behavior (which validate_scope! then rejects in scope mode).
|
|
243
|
+
private def build_reverse_scope_via_clause(table)
|
|
244
|
+
arms = table.reverse_scope.via.filter_map do |via|
|
|
245
|
+
referencer = table_by_name[via.table]
|
|
246
|
+
if referencer.nil?
|
|
247
|
+
@logger.warn(" #{table.name}.reverse_scope references unknown table '#{via.table}'; skipping arm.")
|
|
248
|
+
next
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Build the referencer's own scoped extraction query. allow_reverse /
|
|
252
|
+
# allow_forward are disabled to bound recursion exactly as the
|
|
253
|
+
# single-referencer path does (a referencer scopable only via its own
|
|
254
|
+
# reverse/forward rescue would loop or, worse, recurse back into this
|
|
255
|
+
# table).
|
|
256
|
+
ref_query = self.class.run(referencer.name, table_by_name, dump_target, @logger, allow_reverse: false, allow_forward: false)
|
|
257
|
+
|
|
258
|
+
unless ref_query.where_clauses.any? || ref_query.join_clauses.any?
|
|
259
|
+
@logger.warn(
|
|
260
|
+
" #{table.name}.reverse_scope arm '#{via.table}.#{via.column}' is not scoped; " \
|
|
261
|
+
"skipping it (an unconstrained arm would union every row back). " \
|
|
262
|
+
"Make '#{via.table}' scopable or remove it from reverse_scope.via."
|
|
263
|
+
)
|
|
264
|
+
next
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Project the referencer's query to the foreign-key column that points
|
|
268
|
+
# at this table, excluding NULLs. Force a plain column so any masking /
|
|
269
|
+
# raw_sql configured on that column does not corrupt the id comparison.
|
|
270
|
+
fk_column = TableColumn.from_symbol_keys(name: via.column)
|
|
271
|
+
projected = QueryAst::Select.new
|
|
272
|
+
projected.from(ref_query.from_table_name)
|
|
273
|
+
projected.select([fk_column])
|
|
274
|
+
ref_query.join_clauses.each { |j| projected.join(j) }
|
|
275
|
+
ref_query.where_clauses.each { |w| projected.where(w) }
|
|
276
|
+
projected.where(QueryAst::WhereClause.new(column_name: via.column, operator: :not_null))
|
|
277
|
+
projected
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
return nil if arms.empty?
|
|
281
|
+
|
|
282
|
+
QueryAst::WhereClause.new(
|
|
283
|
+
column_name: table.primary_key,
|
|
284
|
+
operator: :in_subquery,
|
|
285
|
+
value: QueryAst::UnionSubquery.new(queries: arms)
|
|
286
|
+
)
|
|
287
|
+
end
|
|
288
|
+
|
|
222
289
|
# Scope-column mode. Builds a `fk IN (SELECT parent.pk FROM <parent
|
|
223
290
|
# extraction query>)` clause for a table whose belongs_to parent is itself
|
|
224
291
|
# scopable but carries no scope column of its own — so find_path_to_scoped
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Exwiw
|
|
4
|
+
# One referencer arm of a {ReverseScope}: the referencing table and the column
|
|
5
|
+
# on it that points at the reverse-scoped table's primary key.
|
|
6
|
+
#
|
|
7
|
+
# `column` is given explicitly so a *non-default* foreign key (e.g.
|
|
8
|
+
# `business_entity_customers.kantan_yoyaku_user_id`, or `organization_admins.id`
|
|
9
|
+
# which itself references `users.id`) can be projected — and even a column with
|
|
10
|
+
# no declared `belongs_to` edge can be enumerated.
|
|
11
|
+
class ReverseScopeVia
|
|
12
|
+
include Serdes
|
|
13
|
+
|
|
14
|
+
attribute :table, String
|
|
15
|
+
attribute :column, String
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Opt-in configuration for *multi-referencer* reverse scoping
|
|
19
|
+
# (see {QueryAstBuilder#build_referenced_by_clause}).
|
|
20
|
+
#
|
|
21
|
+
# A global-identity table such as `users` carries no scope/tenant column and
|
|
22
|
+
# has no `belongs_to` path of its own to the dump target; many tenant-owned
|
|
23
|
+
# tables instead point *at* it. The automatic single-referencer reverse
|
|
24
|
+
# extraction only narrows a table referenced by exactly one constrained child
|
|
25
|
+
# — with two or more referencers it falls back to a full dump. `reverse_scope`
|
|
26
|
+
# lets the schema author enumerate the referencers whose own (already-scoped)
|
|
27
|
+
# extraction queries should be UNION'd into the id set this table is
|
|
28
|
+
# constrained to:
|
|
29
|
+
#
|
|
30
|
+
# <table>.<pk> IN (
|
|
31
|
+
# SELECT <ref1>.<col1> FROM <ref1> <ref1 scope> WHERE <col1> IS NOT NULL
|
|
32
|
+
# UNION SELECT <ref2>.<col2> FROM <ref2> <ref2 scope> WHERE <col2> IS NOT NULL
|
|
33
|
+
# UNION ...
|
|
34
|
+
# )
|
|
35
|
+
#
|
|
36
|
+
# It is deliberately explicit — never inferred or emitted by the schema
|
|
37
|
+
# generators, and preserved across regeneration like the other user-owned
|
|
38
|
+
# config (see {TableConfig#merge}). Only referencers that are themselves scoped
|
|
39
|
+
# belong in `via`: an unconstrained referencer would project every row's id and
|
|
40
|
+
# union the whole table back, defeating the prune (such an arm is skipped with
|
|
41
|
+
# a warning rather than silently widening the dump).
|
|
42
|
+
class ReverseScope
|
|
43
|
+
include Serdes
|
|
44
|
+
|
|
45
|
+
attribute :via, array(ReverseScopeVia), default: []
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -291,7 +291,9 @@ module Exwiw
|
|
|
291
291
|
end
|
|
292
292
|
|
|
293
293
|
private def aggregate_belongs_tos(models)
|
|
294
|
-
belongs_to_assocs = models
|
|
294
|
+
belongs_to_assocs = models
|
|
295
|
+
.flat_map { |m| belongs_to_associations_for(m) }
|
|
296
|
+
.select { |assoc| assoc.polymorphic? || active_record_target?(assoc) }
|
|
295
297
|
owner_db = database_name_for(models.first)
|
|
296
298
|
|
|
297
299
|
non_polymorphic = belongs_to_assocs
|
|
@@ -376,6 +378,31 @@ module Exwiw
|
|
|
376
378
|
assocs.reject { |assoc| assoc.equal?(left) }
|
|
377
379
|
end
|
|
378
380
|
|
|
381
|
+
# Whether a (non-polymorphic) belongs_to points at an ActiveRecord model.
|
|
382
|
+
#
|
|
383
|
+
# A belongs_to can target a non-ActiveRecord class — most commonly an
|
|
384
|
+
# ActiveHash/ActiveYaml master (`belongs_to :equipment, class_name:
|
|
385
|
+
# "SomeActiveYamlModel"`). active_hash registers these as ordinary
|
|
386
|
+
# `belongs_to` reflections, yet the target class has no database table, so
|
|
387
|
+
# `assoc.table_name` (which delegates to `klass.table_name`) raises. Such a
|
|
388
|
+
# relation is not a DB edge exwiw can join or extract across, so it is
|
|
389
|
+
# dropped from the generated belongs_tos; the underlying foreign-key column
|
|
390
|
+
# is still emitted as a plain column. Polymorphic associations cannot be
|
|
391
|
+
# `klass`-resolved, so callers must screen those out before calling this.
|
|
392
|
+
#
|
|
393
|
+
# Resolving the target class behaves differently per non-AR shape: an
|
|
394
|
+
# ActiveHash reflection returns the class fine (the crash is later, at
|
|
395
|
+
# `table_name`), while a bare `belongs_to` to a plain class makes AR raise
|
|
396
|
+
# ArgumentError ("... is not an ActiveRecord::Base subclass") right here when
|
|
397
|
+
# the klass is computed. Both mean "not a DB relation", so rescue the lookup
|
|
398
|
+
# and treat either as a non-AR target to skip.
|
|
399
|
+
private def active_record_target?(assoc)
|
|
400
|
+
klass = assoc.klass
|
|
401
|
+
klass.is_a?(Class) && klass < ActiveRecord::Base ? true : false
|
|
402
|
+
rescue StandardError
|
|
403
|
+
false
|
|
404
|
+
end
|
|
405
|
+
|
|
379
406
|
# Enumerate the concrete models that can be targets of the polymorphic
|
|
380
407
|
# association `association_name`, by looking them up from every model's
|
|
381
408
|
# `has_many` / `has_one` `as:` option. The order of `concrete_models` depends
|
data/lib/exwiw/table_config.rb
CHANGED
|
@@ -39,6 +39,14 @@ module Exwiw
|
|
|
39
39
|
attribute :scope_exempt, Serdes::OptionalType.new(Serdes::ConcreteType.new(Boolean)), skip_serializing_if_nil: true
|
|
40
40
|
attribute :scope_column, optional(String), skip_serializing_if_nil: true
|
|
41
41
|
|
|
42
|
+
# `reverse_scope` opts a table into multi-referencer reverse scoping (see
|
|
43
|
+
# Exwiw::ReverseScope and QueryAstBuilder#build_referenced_by_clause): a
|
|
44
|
+
# global-identity table (e.g. `users`) referenced by many scoped tables is
|
|
45
|
+
# constrained to the UNION of those referencers' projected foreign keys
|
|
46
|
+
# instead of being dumped in full. User-configured and never emitted by the
|
|
47
|
+
# schema generators.
|
|
48
|
+
attribute :reverse_scope, Serdes::OptionalType.new(ReverseScope), skip_serializing_if_nil: true
|
|
49
|
+
|
|
42
50
|
def self.from(hash)
|
|
43
51
|
config = super
|
|
44
52
|
config.send(:validate_after_load!)
|
|
@@ -58,6 +66,7 @@ module Exwiw
|
|
|
58
66
|
if rails_managed?
|
|
59
67
|
hash.delete("belongs_tos")
|
|
60
68
|
hash.delete("columns")
|
|
69
|
+
hash.delete("reverse_scope")
|
|
61
70
|
end
|
|
62
71
|
hash
|
|
63
72
|
end
|
|
@@ -152,6 +161,7 @@ module Exwiw
|
|
|
152
161
|
# User-owned, never regenerated: carry over from the existing config.
|
|
153
162
|
merged_table.scope_exempt = scope_exempt
|
|
154
163
|
merged_table.scope_column = scope_column
|
|
164
|
+
merged_table.reverse_scope = reverse_scope
|
|
155
165
|
|
|
156
166
|
# Structural facts of each belongs_to come from the freshly generated
|
|
157
167
|
# config, but the user-owned `comment`/`ignore`/`ignore_type`/`references`
|
|
@@ -199,6 +209,10 @@ module Exwiw
|
|
|
199
209
|
raise ArgumentError,
|
|
200
210
|
"Table '#{name}' has type=#{type}; columns must not be defined."
|
|
201
211
|
end
|
|
212
|
+
if reverse_scope
|
|
213
|
+
raise ArgumentError,
|
|
214
|
+
"Table '#{name}' has type=#{type}; reverse_scope must not be defined."
|
|
215
|
+
end
|
|
202
216
|
else
|
|
203
217
|
# An ignore:true table is not extracted, so primary_key is not required
|
|
204
218
|
# (e.g. a composite-primary-key table that exwiw does not support).
|
data/lib/exwiw/version.rb
CHANGED
data/lib/exwiw.rb
CHANGED
|
@@ -9,6 +9,7 @@ require_relative "exwiw/ext_json"
|
|
|
9
9
|
require_relative "exwiw/config_file"
|
|
10
10
|
require_relative "exwiw/belongs_to"
|
|
11
11
|
require_relative "exwiw/table_column"
|
|
12
|
+
require_relative "exwiw/reverse_scope"
|
|
12
13
|
require_relative "exwiw/table_config"
|
|
13
14
|
require_relative "exwiw/embedded_in"
|
|
14
15
|
require_relative "exwiw/mongodb_field"
|
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.8.
|
|
4
|
+
version: 0.8.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Shia
|
|
@@ -76,6 +76,7 @@ files:
|
|
|
76
76
|
- lib/exwiw/query_ast.rb
|
|
77
77
|
- lib/exwiw/query_ast_builder.rb
|
|
78
78
|
- lib/exwiw/railtie.rb
|
|
79
|
+
- lib/exwiw/reverse_scope.rb
|
|
79
80
|
- lib/exwiw/runner.rb
|
|
80
81
|
- lib/exwiw/schema_generator.rb
|
|
81
82
|
- lib/exwiw/table_column.rb
|