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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: eb6879b8efb94ef9a9a0bd37df32962257106df9eec4c3fc9de844a12c6360cd
4
- data.tar.gz: b43b0bfde090111d7ce23083db7471f15974292ddb5886da682de9bc443febfa
3
+ metadata.gz: 90d949cb54565ed644b599102cfabc16236fe2d83523594cd7b14f269118a9b2
4
+ data.tar.gz: 7236bb6ee6b9dda38aec93491f0266e334890e3c271b4f7d5d072d46a1e0f88b
5
5
  SHA512:
6
- metadata.gz: e8cca59b376a369b9253d1482f3ee64ae8c542b00c1c3eddb192d273efe0e3c969e3aec1770f087977efe6b75fa52f375538e286d31a87d7f87a197739a4eeaf
7
- data.tar.gz: 78a3ece09cd6ec9ecb6fd7b0a2f166bc41d51720ea36935ca4cd974bf766008f11688f0e0dc7bbd8a63ce29f4719806e1a4705b0390850a857a192fcfaa56976
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. Limited to
187
- a single forward hop and a single unambiguous scopable parent.
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, allow_forward: true)
6
- new(table_name, table_by_name, dump_target, logger, allow_reverse: allow_reverse, allow_forward: allow_forward).run
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, allow_forward: 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
- # @allow_forward gates the "scope via an indirectly-scoped belongs_to
59
- # parent" rescue (build_belongs_to_scoped_clause). Disabled while building a
60
- # parent/child subquery so a single forward hop never recurses into another
61
- # (which could loop on a belongs_to cycle).
62
- @allow_forward = allow_forward
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
- # allow_forward:false stops the child from forward-scoping back through
192
- # this very table (which would loop).
193
- child_query = self.class.run(other.name, table_by_name, dump_target, @logger, allow_reverse: false, allow_forward: false)
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
- # 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)
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; allow_forward:false bounds this
310
- # to a single forward hop so a belongs_to cycle cannot loop.
311
- parent_query = self.class.run(parent.name, table_by_name, dump_target, @logger, allow_reverse: true, allow_forward: false)
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 @allow_forward && build_belongs_to_scoped_clause(table)
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 @allow_forward
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. Constrain this table to that parent's in-scope
509
- # ids so its rows ride along instead of being dumped in full.
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 (no rescue disabled) is allowed to fail
518
- # hard. The Runner/ExplainRunner pre-flight (validate_scope!) rejects
519
- # unscopable tables before extraction, so a top-level build never
520
- # legitimately lands here; if it does, raise rather than emit an unfiltered
521
- # (potential full PII) dump.
522
- if @allow_reverse && @allow_forward
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Exwiw
4
- VERSION = "0.8.2"
4
+ VERSION = "0.8.3"
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.8.2
4
+ version: 0.8.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shia