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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c9644b63d339ba01e55e57dd12274ea89af28eef67fda6bdb1201202d52e0f61
4
- data.tar.gz: 40ce93cb0342d35d684119fb8f0c520f05b46fbcb8f486ceed2f1c8b9c8c354c
3
+ metadata.gz: eb6879b8efb94ef9a9a0bd37df32962257106df9eec4c3fc9de844a12c6360cd
4
+ data.tar.gz: b43b0bfde090111d7ce23083db7471f15974292ddb5886da682de9bc443febfa
5
5
  SHA512:
6
- metadata.gz: c5cb48eb2c6757768621aaab607cf9bbe90de06f169b672be9f188a12d21f104a6c34e77c101bd0b06b50c4448422906772c56c205bb9f30e4dce14baafb664c
7
- data.tar.gz: 45ec7c49d36c2316238d42ff13055d0183f11151579e20d7f15c4eab05ed40a34d2d3144737d1eaa38c4e2cc2c2abecf45c9eb77276f38c5a4aa38355f5ed68b
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} " \
@@ -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.flat_map { |m| belongs_to_associations_for(m) }
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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Exwiw
4
- VERSION = "0.8.0"
4
+ VERSION = "0.8.2"
5
5
  end
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.0
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