exwiw 0.8.2 → 0.8.3
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 +6 -3
- data/lib/exwiw/query_ast_builder.rb +57 -31
- 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: 90d949cb54565ed644b599102cfabc16236fe2d83523594cd7b14f269118a9b2
|
|
4
|
+
data.tar.gz: 7236bb6ee6b9dda38aec93491f0266e334890e3c271b4f7d5d072d46a1e0f88b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bcfac0aaf220b55dfa3172f94d2ad0a6e828f53253e367aa9203db7061695fa6a9188e22a92c18d51bd9a55b6a9bf840cf780a3647d4aca2a0cccff1a785a347
|
|
7
|
+
data.tar.gz: d891fc7101fea30d674b933213715a8a4534c459e191b9bf4fce31b3bda8a9be037bf09627419f43ef914d2a9288aa40e1cd4836a4d8e1e0901d73e320218cd3
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.8.3] - 2026-06-24
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **Forward scope (`via_scoped_parent`) now cascades across multiple `belongs_to` hops instead of dying after one.** A table with no scope column of its own is scoped by constraining it to its `belongs_to` parent's in-scope ids (`fk IN (SELECT parent.pk FROM <parent's scoped query>)`). Previously the parent was rebuilt with forward scoping turned off, so if the parent was *itself* scoped only through *its* parent (e.g. an identity-family table two or more hops below a `reverse_scope`/`referenced_by` table — `users ← end_users ← end_user_profiles`), the rebuilt parent came back unconstrained and the child was classified `:unscopable` — forcing a `scope_exempt` full dump and re-introducing the bloat the prune removes. The boolean single-hop bound is replaced by a forward-path guard: the rescue keeps forward scoping enabled while rebuilding the parent (appending the current table to the path), so the cascade recurses N levels and produces a correspondingly nested `IN (subquery)`; it terminates only on a genuine `belongs_to` cycle (a table already on the path is not revisited, falling through to `:unscopable`). The single-unambiguous-parent rule and the polymorphic-skip are unchanged, and the reverse arms still cannot loop back through the table being reverse-scoped. SQL adapters only.
|
|
10
|
+
|
|
5
11
|
## [0.8.2] - 2026-06-24
|
|
6
12
|
|
|
7
13
|
### Added
|
data/README.md
CHANGED
|
@@ -183,8 +183,11 @@ Each table is resolved as follows:
|
|
|
183
183
|
(`fk IN (SELECT parent.pk FROM <parent's scoped query>)`). This covers a *hub*
|
|
184
184
|
table that has no scope column and is scoped only because an extractable child
|
|
185
185
|
references it (see referenced-by below): the hub's other `belongs_to` children
|
|
186
|
-
ride along to just the in-scope rows instead of being dumped in full.
|
|
187
|
-
|
|
186
|
+
ride along to just the in-scope rows instead of being dumped in full. The parent
|
|
187
|
+
itself may be scoped the same way, so this **cascades across multiple hops**
|
|
188
|
+
(each a single unambiguous scopable parent) and the subquery nests
|
|
189
|
+
correspondingly; the recursion terminates on a genuine `belongs_to` cycle (a
|
|
190
|
+
table already on the path is left `:unscopable` rather than looped on).
|
|
188
191
|
- **Cannot be scoped at all** (no scope column and no path to one) → exwiw
|
|
189
192
|
**aborts** and lists the offending tables, so an unscoped table is never silently
|
|
190
193
|
dumped in full. For each, either declare a `scope_column`, add a `belongs_to`
|
|
@@ -619,7 +622,7 @@ Notes:
|
|
|
619
622
|
- **`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
623
|
- **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
624
|
- **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`.
|
|
625
|
+
- **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`. The cascade is **multi-hop**, so a table several `belongs_to` hops below the reverse-scoped table (e.g. `end_user_profiles → end_users → users`) also tightens automatically, with no config of its own.
|
|
623
626
|
- 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
627
|
|
|
625
628
|
### Rails-managed tables (special `type` values)
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module Exwiw
|
|
4
4
|
class QueryAstBuilder
|
|
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,
|
|
5
|
+
def self.run(table_name, table_by_name, dump_target, logger, allow_reverse: true, forward_path: [])
|
|
6
|
+
new(table_name, table_by_name, dump_target, logger, allow_reverse: allow_reverse, forward_path: forward_path).run
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
# Scope-column mode classification for a single table. One of
|
|
@@ -49,17 +49,20 @@ module Exwiw
|
|
|
49
49
|
|
|
50
50
|
attr_reader :table_name, :table_by_name, :dump_target
|
|
51
51
|
|
|
52
|
-
def initialize(table_name, table_by_name, dump_target, logger, allow_reverse: true,
|
|
52
|
+
def initialize(table_name, table_by_name, dump_target, logger, allow_reverse: true, forward_path: [])
|
|
53
53
|
@table_name = table_name
|
|
54
54
|
@table_by_name = table_by_name
|
|
55
55
|
@dump_target = dump_target
|
|
56
56
|
@logger = logger
|
|
57
57
|
@allow_reverse = allow_reverse
|
|
58
|
-
# @
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
#
|
|
62
|
-
|
|
58
|
+
# @forward_path is the chain of tables currently being forward-resolved by
|
|
59
|
+
# the "scope via an indirectly-scoped belongs_to parent" rescue
|
|
60
|
+
# (build_belongs_to_scoped_clause). Each forward hop appends the table it is
|
|
61
|
+
# descending from, so the rescue recurses N levels (users -> end_users ->
|
|
62
|
+
# end_user_profiles -> ...) and stops only on a real belongs_to cycle: a
|
|
63
|
+
# table already on the path is not re-resolved, falling through to
|
|
64
|
+
# :unscopable instead of looping forever.
|
|
65
|
+
@forward_path = forward_path
|
|
63
66
|
end
|
|
64
67
|
|
|
65
68
|
def run
|
|
@@ -187,10 +190,11 @@ module Exwiw
|
|
|
187
190
|
next if relation.nil? || relation.polymorphic?
|
|
188
191
|
|
|
189
192
|
# Build the child's own extraction query. allow_reverse:false stops a
|
|
190
|
-
# chain of FK-less tables from recursing back into each other;
|
|
191
|
-
#
|
|
192
|
-
#
|
|
193
|
-
|
|
193
|
+
# chain of FK-less tables from recursing back into each other; adding this
|
|
194
|
+
# table to forward_path stops the child from forward-scoping back through
|
|
195
|
+
# it (which would loop) while still letting the child forward-scope
|
|
196
|
+
# through other tables.
|
|
197
|
+
child_query = self.class.run(other.name, table_by_name, dump_target, @logger, allow_reverse: false, forward_path: @forward_path + [table.name])
|
|
194
198
|
|
|
195
199
|
# Only an *already constrained* child narrows anything; an unconstrained
|
|
196
200
|
# child would select every fk value (i.e. dump all) and not help.
|
|
@@ -248,12 +252,12 @@ module Exwiw
|
|
|
248
252
|
next
|
|
249
253
|
end
|
|
250
254
|
|
|
251
|
-
# Build the referencer's own scoped extraction query. allow_reverse
|
|
252
|
-
#
|
|
253
|
-
# single-referencer path does (a referencer
|
|
254
|
-
#
|
|
255
|
-
#
|
|
256
|
-
ref_query = self.class.run(referencer.name, table_by_name, dump_target, @logger, allow_reverse: false,
|
|
255
|
+
# Build the referencer's own scoped extraction query. allow_reverse is
|
|
256
|
+
# disabled and this table is added to forward_path to bound recursion
|
|
257
|
+
# exactly as the single-referencer path does (a referencer that could only
|
|
258
|
+
# be scoped by recursing back into this table would loop); the referencer
|
|
259
|
+
# may still forward-scope through other tables.
|
|
260
|
+
ref_query = self.class.run(referencer.name, table_by_name, dump_target, @logger, allow_reverse: false, forward_path: @forward_path + [table.name])
|
|
257
261
|
|
|
258
262
|
unless ref_query.where_clauses.any? || ref_query.join_clauses.any?
|
|
259
263
|
@logger.warn(
|
|
@@ -297,6 +301,13 @@ module Exwiw
|
|
|
297
301
|
# them out of a full dump. Returns nil when there is no single, unambiguous
|
|
298
302
|
# scopable parent, leaving the caller on the unscopable path.
|
|
299
303
|
private def build_belongs_to_scoped_clause(table)
|
|
304
|
+
# This table plus every ancestor currently being forward-resolved. A
|
|
305
|
+
# candidate parent already on this path would close a belongs_to cycle, so
|
|
306
|
+
# it is skipped; threading the grown path into the parent build lets the
|
|
307
|
+
# cascade recurse N hops (users -> end_users -> end_user_profiles -> ...)
|
|
308
|
+
# and terminate only when a table reappears.
|
|
309
|
+
forward_path = @forward_path + [table.name]
|
|
310
|
+
|
|
300
311
|
candidates = table.belongs_tos.filter_map do |relation|
|
|
301
312
|
# A polymorphic belongs_to points at several parent tables through one
|
|
302
313
|
# column, so it cannot project to a single parent id set; skip it.
|
|
@@ -305,10 +316,15 @@ module Exwiw
|
|
|
305
316
|
parent = table_by_name[relation.table_name]
|
|
306
317
|
next if parent.nil?
|
|
307
318
|
|
|
319
|
+
# Cycle guard: descending into a parent already on the forward path would
|
|
320
|
+
# loop (a -> b -> a). Stop, leaving this table on the :unscopable path.
|
|
321
|
+
next if forward_path.include?(parent.name)
|
|
322
|
+
|
|
308
323
|
# Build the parent's own scoped query. allow_reverse stays true so the
|
|
309
|
-
# parent may be scoped via referenced_by
|
|
310
|
-
#
|
|
311
|
-
|
|
324
|
+
# parent may be scoped via referenced_by, and forward scoping stays
|
|
325
|
+
# enabled so a parent that is itself scoped via *its* parent resolves
|
|
326
|
+
# too — this is what makes the cascade multi-hop.
|
|
327
|
+
parent_query = self.class.run(parent.name, table_by_name, dump_target, @logger, allow_reverse: true, forward_path: forward_path)
|
|
312
328
|
|
|
313
329
|
# Only a constrained parent narrows anything; an unconstrained parent
|
|
314
330
|
# would select every pk (i.e. dump all) and not help.
|
|
@@ -460,11 +476,18 @@ module Exwiw
|
|
|
460
476
|
return :direct if directly_scoped?(table)
|
|
461
477
|
return :via_path if build_join_clauses_scoped(table).any?
|
|
462
478
|
return :referenced_by if @allow_reverse && build_referenced_by_clause(table)
|
|
463
|
-
return :via_scoped_parent if
|
|
479
|
+
return :via_scoped_parent if forward_scope_allowed?(table) && build_belongs_to_scoped_clause(table)
|
|
464
480
|
|
|
465
481
|
:unscopable
|
|
466
482
|
end
|
|
467
483
|
|
|
484
|
+
# True when this table may still attempt the forward "scope via a scoped
|
|
485
|
+
# belongs_to parent" rescue: it is not already on the forward-resolution
|
|
486
|
+
# path, so descending into its parent cannot revisit it (a belongs_to cycle).
|
|
487
|
+
private def forward_scope_allowed?(table)
|
|
488
|
+
!@forward_path.include?(table.name)
|
|
489
|
+
end
|
|
490
|
+
|
|
468
491
|
private def build_scoped(table)
|
|
469
492
|
ast = QueryAst::Select.new
|
|
470
493
|
ast.from(table.name)
|
|
@@ -502,11 +525,13 @@ module Exwiw
|
|
|
502
525
|
end
|
|
503
526
|
end
|
|
504
527
|
|
|
505
|
-
if
|
|
528
|
+
if forward_scope_allowed?(table)
|
|
506
529
|
# Belongs_to a parent that is itself scoped but carries no scope column of
|
|
507
530
|
# its own (so via_path cannot terminate on it) — e.g. a hub table scoped
|
|
508
|
-
# only via referenced_by
|
|
509
|
-
#
|
|
531
|
+
# only via referenced_by, or a parent that is itself scoped through *its*
|
|
532
|
+
# parent. Constrain this table to that parent's in-scope ids so its rows
|
|
533
|
+
# ride along instead of being dumped in full; the parent build recurses
|
|
534
|
+
# the cascade further up.
|
|
510
535
|
parent_clause = build_belongs_to_scoped_clause(table)
|
|
511
536
|
if parent_clause
|
|
512
537
|
ast.where(parent_clause)
|
|
@@ -514,12 +539,13 @@ module Exwiw
|
|
|
514
539
|
end
|
|
515
540
|
end
|
|
516
541
|
|
|
517
|
-
# Only the genuine top-level build (
|
|
518
|
-
#
|
|
519
|
-
#
|
|
520
|
-
#
|
|
521
|
-
# (potential full
|
|
522
|
-
|
|
542
|
+
# Only the genuine top-level build (allow_reverse on, forward_path empty —
|
|
543
|
+
# i.e. no rescue subquery in progress) is allowed to fail hard. The
|
|
544
|
+
# Runner/ExplainRunner pre-flight (validate_scope!) rejects unscopable
|
|
545
|
+
# tables before extraction, so a top-level build never legitimately lands
|
|
546
|
+
# here; if it does, raise rather than emit an unfiltered (potential full
|
|
547
|
+
# PII) dump.
|
|
548
|
+
if @allow_reverse && @forward_path.empty?
|
|
523
549
|
raise ArgumentError, scope_unscopable_message(table)
|
|
524
550
|
end
|
|
525
551
|
|
data/lib/exwiw/version.rb
CHANGED