exwiw 1.1.6 → 1.1.7

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.
data/docs/mongodb.md CHANGED
@@ -71,6 +71,10 @@ Where the SQL adapters emit a `UNION` subquery, MongoDB has no cross-collection
71
71
 
72
72
  Masking (`replace_with`) and `fields` behavior on a reverse-scoped collection are unchanged. Like the SQL key, `reverse_scope` is user-owned: `exwiw:mongoid:schema:generate` never emits it and regeneration preserves a hand-added value.
73
73
 
74
+ ## Self-referencing `belongs_to`
75
+
76
+ Unlike the SQL adapters, which keep the ancestors of a narrowed [tree table](../README.md#self-referencing-belongs_to-tree-tables), the MongoDB adapter extracts each collection with a single `find` and does not walk a self-reference. A narrowed collection with a `belongs_to` to itself logs a warning, since a kept document's parent may be missing from the dump.
77
+
74
78
  ## Embedded documents
75
79
 
76
80
  MongoDB models often store one-to-many relationships as embedded subdocument arrays (e.g. `users` documents with a `posts: [...]` field). To mask fields inside embedded subdocuments, declare a separate config with `embedded_in`:
@@ -0,0 +1,12 @@
1
+ # Why scope id sets are joined, not `IN (subquery)`
2
+
3
+ When a table is narrowed by a set of ids that another query produces (referenced-by, `reverse_scope`, the narrowed-parent cascade, polymorphic arms, tree ancestors), the SQL adapters emit a `JOIN` to a `SELECT DISTINCT` derived table instead of `<col> IN (<subquery>)`:
4
+
5
+ ```sql
6
+ … JOIN (SELECT DISTINCT src.<id> AS exwiw_scope_id FROM (<id-set subquery>) AS src) AS ids
7
+ ON <table>.<col> = ids.exwiw_scope_id
8
+ ```
9
+
10
+ Both forms return the same rows; the `DISTINCT` keeps the join from multiplying them. The plans differ on a large table. MySQL cannot turn `IN (… UNION …)` into a materialized semi-join, so it rewrites it into a correlated `EXISTS` that is evaluated once per outer row: a full scan of the outer table, with the union re-run for every row. The `DISTINCT` derived table cannot be merged into the outer query, so it is materialized once and the outer table is probed by its key. On a large `users` table this is the difference between a full scan and an index lookup. Nested cascades are materialized once per level for the same reason.
11
+
12
+ PostgreSQL casts both sides to `text` when the key types differ (`uuid` against `varchar`).
@@ -171,6 +171,7 @@ module Exwiw
171
171
  else
172
172
  related_collection_filter(config, config_by_name, dump_target)
173
173
  end
174
+ warn_missing_self_ancestors(config) unless filter.empty?
174
175
 
175
176
  Exwiw::MongoQuery::Find.new(
176
177
  collection: config.name,
@@ -181,6 +182,17 @@ module Exwiw
181
182
  )
182
183
  end
183
184
 
185
+ # The SQL adapters add the ancestors of a narrowed self-referencing table;
186
+ # this adapter extracts each collection with a single find, so it cannot.
187
+ private def warn_missing_self_ancestors(config)
188
+ return unless config.belongs_tos.any? { |relation| relation.table_name == config.name && !relation.polymorphic? }
189
+
190
+ @logger.warn(
191
+ " #{config.name} references itself, but the mongodb adapter does not add the ancestors " \
192
+ "of the documents it keeps; a kept document's parent may be missing."
193
+ )
194
+ end
195
+
184
196
  def execute(query)
185
197
  @logger.debug(" Executing Mongo find on '#{query.collection}': filter=#{query.filter.inspect} projection=#{query.projection.inspect}")
186
198
 
@@ -353,24 +353,27 @@ module Exwiw
353
353
  end
354
354
 
355
355
  private def compile_subquery(subquery)
356
- # A SelectSubquery wraps a full Select (the referencing table's
357
- # extraction query, projected to a foreign key); compile it as-is.
358
- return compile_ast(subquery.query) if subquery.is_a?(Exwiw::QueryAst::SelectSubquery)
359
-
360
- # A UnionSubquery wraps several such Selects; UNION their compiled forms
361
- # into a single id set. Sibling arms can carry the same nested scope
362
- # id-set, and MySQL cannot reference one TEMPORARY table twice in a
363
- # statement (ER_CANT_REOPEN_TABLE), so nested scopes compile inline here.
364
- if subquery.is_a?(Exwiw::QueryAst::UnionSubquery)
365
- return without_scope_materialization do
356
+ case subquery
357
+ in Exwiw::QueryAst::RecursiveAncestorSubquery
358
+ compile_ancestor_closure(subquery)
359
+ in Exwiw::QueryAst::SelectSubquery
360
+ # A SelectSubquery wraps a full Select (the referencing table's
361
+ # extraction query, projected to a foreign key); compile it as-is.
362
+ compile_ast(subquery.query)
363
+ in Exwiw::QueryAst::UnionSubquery
364
+ # A UnionSubquery wraps several such Selects; UNION their compiled forms
365
+ # into a single id set. Sibling arms can carry the same nested scope
366
+ # id-set, and MySQL cannot reference one TEMPORARY table twice in a
367
+ # statement (ER_CANT_REOPEN_TABLE), so nested scopes compile inline here.
368
+ without_scope_materialization do
366
369
  subquery.queries.map { |q| compile_ast(q) }.join(' UNION ')
367
370
  end
371
+ in Exwiw::QueryAst::Subquery
372
+ inner_values = subquery.where_values.map { |v| escape_value(v) }
373
+ "SELECT #{qualified_name(subquery.table_name, subquery.select_column)} " \
374
+ "FROM #{quote_table_name(subquery.table_name)} " \
375
+ "WHERE #{qualified_name(subquery.table_name, subquery.where_column)} IN (#{inner_values.join(', ')})"
368
376
  end
369
-
370
- inner_values = subquery.where_values.map { |v| escape_value(v) }
371
- "SELECT #{qualified_name(subquery.table_name, subquery.select_column)} " \
372
- "FROM #{quote_table_name(subquery.table_name)} " \
373
- "WHERE #{qualified_name(subquery.table_name, subquery.where_column)} IN (#{inner_values.join(', ')})"
374
377
  end
375
378
 
376
379
  # Backslash and control-character escapes, matching mysqldump. Escaping
@@ -461,25 +461,35 @@ module Exwiw
461
461
  private def compile_subquery(subquery, outer_table: nil, outer_column: nil)
462
462
  cast_to = subquery_cast_to(subquery, outer_table, outer_column)
463
463
 
464
- if subquery.is_a?(Exwiw::QueryAst::SelectSubquery)
465
- return compile_ast(subquery.query, select_cast_to: cast_to)
464
+ case subquery
465
+ in Exwiw::QueryAst::RecursiveAncestorSubquery
466
+ # The outer key is compared with this table's own primary keys, so it needs no cast.
467
+ compile_ancestor_closure(subquery)
468
+ in Exwiw::QueryAst::SelectSubquery
469
+ compile_ast(subquery.query, select_cast_to: cast_to)
470
+ in Exwiw::QueryAst::UnionSubquery
471
+ # A UnionSubquery wraps several projected Selects; UNION their compiled
472
+ # forms. cast_to is the union-wide decision (see union_cast_to): when any
473
+ # arm's column type would clash with the outer column or another arm,
474
+ # every arm's projected column and the outer key are cast to text so the
475
+ # UNION and the enclosing IN comparison resolve to one type.
476
+ subquery.queries.map { |q| compile_ast(q, select_cast_to: cast_to) }.join(' UNION ')
477
+ in Exwiw::QueryAst::Subquery
478
+ inner_values = subquery.where_values.map { |v| escape_value(v) }
479
+ select_expr = qualified_name(subquery.table_name, subquery.select_column)
480
+ select_expr = "#{select_expr}::#{cast_to}" if cast_to
481
+ "SELECT #{select_expr} " \
482
+ "FROM #{quote_table_name(subquery.table_name)} " \
483
+ "WHERE #{qualified_name(subquery.table_name, subquery.where_column)} IN (#{inner_values.join(', ')})"
466
484
  end
485
+ end
467
486
 
468
- # A UnionSubquery wraps several projected Selects; UNION their compiled
469
- # forms. cast_to is the union-wide decision (see union_cast_to): when any
470
- # arm's column type would clash with the outer column or another arm,
471
- # every arm's projected column and the outer key are cast to text so the
472
- # UNION and the enclosing IN comparison resolve to one type.
473
- if subquery.is_a?(Exwiw::QueryAst::UnionSubquery)
474
- return subquery.queries.map { |q| compile_ast(q, select_cast_to: cast_to) }.join(' UNION ')
475
- end
487
+ # A self-referencing foreign key may be stored as text while the key it
488
+ # points at is a uuid; reconcile them the same way a JOIN does.
489
+ private def ancestor_link_condition(table, referenced_column, foreign_key)
490
+ return super unless types_need_cast?(column_pg_type(table, referenced_column), column_pg_type(table, foreign_key))
476
491
 
477
- inner_values = subquery.where_values.map { |v| escape_value(v) }
478
- select_expr = qualified_name(subquery.table_name, subquery.select_column)
479
- select_expr = "#{select_expr}::#{cast_to}" if cast_to
480
- "SELECT #{select_expr} " \
481
- "FROM #{quote_table_name(subquery.table_name)} " \
482
- "WHERE #{qualified_name(subquery.table_name, subquery.where_column)} IN (#{inner_values.join(', ')})"
492
+ "#{qualified_name(table, referenced_column)}::text = #{ANCESTOR_SET_NAME}.#{quote_identifier(foreign_key)}::text"
483
493
  end
484
494
 
485
495
  private def subquery_select_target(subquery)
@@ -232,20 +232,23 @@ module Exwiw
232
232
  end
233
233
 
234
234
  private def compile_subquery(subquery)
235
- # A SelectSubquery wraps a full Select (the referencing table's
236
- # extraction query, projected to a foreign key); compile it as-is.
237
- return compile_ast(subquery.query) if subquery.is_a?(Exwiw::QueryAst::SelectSubquery)
238
-
239
- # A UnionSubquery wraps several such Selects; UNION their compiled forms
240
- # into a single id set.
241
- if subquery.is_a?(Exwiw::QueryAst::UnionSubquery)
242
- return subquery.queries.map { |q| compile_ast(q) }.join(' UNION ')
235
+ case subquery
236
+ in Exwiw::QueryAst::RecursiveAncestorSubquery
237
+ compile_ancestor_closure(subquery)
238
+ in Exwiw::QueryAst::SelectSubquery
239
+ # A SelectSubquery wraps a full Select (the referencing table's
240
+ # extraction query, projected to a foreign key); compile it as-is.
241
+ compile_ast(subquery.query)
242
+ in Exwiw::QueryAst::UnionSubquery
243
+ # A UnionSubquery wraps several such Selects; UNION their compiled forms
244
+ # into a single id set.
245
+ subquery.queries.map { |q| compile_ast(q) }.join(' UNION ')
246
+ in Exwiw::QueryAst::Subquery
247
+ inner_values = subquery.where_values.map { |v| escape_value(v) }
248
+ "SELECT #{qualified_name(subquery.table_name, subquery.select_column)} " \
249
+ "FROM #{quote_table_name(subquery.table_name)} " \
250
+ "WHERE #{qualified_name(subquery.table_name, subquery.where_column)} IN (#{inner_values.join(', ')})"
243
251
  end
244
-
245
- inner_values = subquery.where_values.map { |v| escape_value(v) }
246
- "SELECT #{qualified_name(subquery.table_name, subquery.select_column)} " \
247
- "FROM #{quote_table_name(subquery.table_name)} " \
248
- "WHERE #{qualified_name(subquery.table_name, subquery.where_column)} IN (#{inner_values.join(', ')})"
249
252
  end
250
253
 
251
254
  private def escape_value(value)
data/lib/exwiw/adapter.rb CHANGED
@@ -307,7 +307,8 @@ module Exwiw
307
307
  where_clause.is_a?(Exwiw::QueryAst::WhereClause) &&
308
308
  where_clause.operator == :in_subquery &&
309
309
  (where_clause.value.is_a?(Exwiw::QueryAst::SelectSubquery) ||
310
- where_clause.value.is_a?(Exwiw::QueryAst::UnionSubquery))
310
+ where_clause.value.is_a?(Exwiw::QueryAst::UnionSubquery) ||
311
+ where_clause.value.is_a?(Exwiw::QueryAst::RecursiveAncestorSubquery))
311
312
  end
312
313
 
313
314
  # The bare name of the single column a scope subquery projects, used to
@@ -319,8 +320,36 @@ module Exwiw
319
320
  subquery.query.columns.first.name
320
321
  when Exwiw::QueryAst::UnionSubquery
321
322
  subquery.queries.first.columns.first.name
323
+ when Exwiw::QueryAst::RecursiveAncestorSubquery
324
+ subquery.primary_key
322
325
  end
323
326
  end
327
+
328
+ ANCESTOR_SET_NAME = "exwiw_ancestors"
329
+
330
+ # The WITH clause sits inside the subquery, so the id set stays valid wherever
331
+ # it is embedded: a derived-table JOIN, a MySQL temporary table, a UNION arm.
332
+ private def compile_ancestor_closure(subquery)
333
+ table = subquery.table_name
334
+ ancestor_columns = [subquery.primary_key, *subquery.links.map(&:foreign_key)].uniq
335
+ column_list = ancestor_columns.map { |c| quote_identifier(c) }.join(', ')
336
+ recursive_select = ancestor_columns.map { |c| qualified_name(table, c) }.join(', ')
337
+ link_conditions = subquery.links.map do |link|
338
+ ancestor_link_condition(table, link.references || subquery.primary_key, link.foreign_key)
339
+ end
340
+
341
+ "WITH RECURSIVE #{ANCESTOR_SET_NAME} (#{column_list}) AS (" \
342
+ "#{compile_ast(subquery.base)} " \
343
+ "UNION " \
344
+ "SELECT #{recursive_select} FROM #{quote_table_name(table)} " \
345
+ "JOIN #{ANCESTOR_SET_NAME} ON #{link_conditions.join(' OR ')}" \
346
+ ") SELECT #{ANCESTOR_SET_NAME}.#{quote_identifier(subquery.primary_key)} FROM #{ANCESTOR_SET_NAME}"
347
+ end
348
+
349
+ # Overridden where a dialect compares the two columns differently.
350
+ private def ancestor_link_condition(table, referenced_column, foreign_key)
351
+ "#{qualified_name(table, referenced_column)} = #{ANCESTOR_SET_NAME}.#{quote_identifier(foreign_key)}"
352
+ end
324
353
  end
325
354
 
326
355
  # @params [Exwiw::QueryAst] query_ast
@@ -20,6 +20,8 @@ module Exwiw
20
20
  reads_table?(where_clause.value.query, table_name)
21
21
  when UnionSubquery
22
22
  where_clause.value.queries.any? { |q| reads_table?(q, table_name) }
23
+ when RecursiveAncestorSubquery
24
+ where_clause.value.table_name == table_name || reads_table?(where_clause.value.base, table_name)
23
25
  else
24
26
  false
25
27
  end
@@ -39,6 +41,7 @@ module Exwiw
39
41
  case where_clause.value
40
42
  when SelectSubquery then id_spaces(where_clause.value.query)
41
43
  when UnionSubquery then where_clause.value.queries.flat_map { |q| id_spaces(q) }
44
+ when RecursiveAncestorSubquery then id_spaces(where_clause.value.base)
42
45
  else []
43
46
  end
44
47
  own + nested
@@ -84,7 +87,7 @@ module Exwiw
84
87
  {
85
88
  column_name: column_name,
86
89
  operator: operator,
87
- value: value.is_a?(Subquery) || value.is_a?(SelectSubquery) || value.is_a?(UnionSubquery) ? value.to_h : value,
90
+ value: SUBQUERY_VALUE_TYPES.any? { |type| value.is_a?(type) } ? value.to_h : value,
88
91
  }
89
92
  end
90
93
  end
@@ -151,6 +154,36 @@ module Exwiw
151
154
  end
152
155
  end
153
156
 
157
+ # The primary keys of the rows `base` keeps plus every ancestor reachable
158
+ # through the table's own self-referencing foreign keys (`links`), so a tree
159
+ # table never keeps a row whose parent was left out:
160
+ #
161
+ # WITH RECURSIVE exwiw_ancestors (<pk>, <fk>...) AS (
162
+ # <base projected to pk and every link's fk>
163
+ # UNION
164
+ # SELECT <table>.<pk>, <table>.<fk>... FROM <table>
165
+ # JOIN exwiw_ancestors ON <table>.<ref> = exwiw_ancestors.<fk> OR ...
166
+ # ) SELECT <pk> FROM exwiw_ancestors
167
+ #
168
+ # UNION (not UNION ALL) stops the walk on cyclic data.
169
+ RecursiveAncestorSubquery = Struct.new(:base, :table_name, :primary_key, :links, keyword_init: true) do
170
+ def to_h
171
+ {
172
+ ancestors_of: base.to_h,
173
+ table_name: table_name,
174
+ primary_key: primary_key,
175
+ links: links.map(&:to_h),
176
+ }
177
+ end
178
+ end
179
+
180
+ # One self-referencing foreign key of a RecursiveAncestorSubquery.
181
+ # `references` is the column the foreign key points at (the primary key
182
+ # unless the belongs_to says otherwise).
183
+ AncestorLink = Struct.new(:foreign_key, :references, keyword_init: true)
184
+
185
+ SUBQUERY_VALUE_TYPES = [Subquery, SelectSubquery, UnionSubquery, RecursiveAncestorSubquery].freeze
186
+
154
187
  module ColumnValue
155
188
  Base = Struct.new(:name, :value, keyword_init: true)
156
189
  Plain = Class.new(Base)
@@ -6,6 +6,20 @@ module Exwiw
6
6
  new(table_name, table_by_name, dump_target, logger, allow_reverse: allow_reverse, allow_declared_reverse: allow_declared_reverse, forward_path: forward_path, reverse_path: reverse_path, deep_chain_warned: deep_chain_warned, batch_ids: batch_ids).run
7
7
  end
8
8
 
9
+ # The self-referencing foreign keys whose ancestors a narrowed `table` also
10
+ # keeps. A polymorphic one is not supported: following it would also need its
11
+ # type column checked at every step.
12
+ def self.self_ancestor_links(table)
13
+ return [] if table.primary_key.nil?
14
+
15
+ table.belongs_tos.filter_map do |relation|
16
+ next unless relation.table_name == table.name
17
+ next if relation.polymorphic?
18
+
19
+ QueryAst::AncestorLink.new(foreign_key: relation.foreign_key, references: relation.references)
20
+ end.uniq
21
+ end
22
+
9
23
  # Scope-column mode classification for a single table. One of
10
24
  # :exempt / :direct / :via_path / :referenced_by / :via_scoped_parent / :unscopable.
11
25
  def self.scope_category(table_name, table_by_name, dump_target, logger)
@@ -97,7 +111,9 @@ module Exwiw
97
111
  tables.reject(&:ignore).each do |table|
98
112
  next unless table.respond_to?(:batch_scope) && table.batch_scope
99
113
 
100
- new(table.name, table_by_name, dump_target, logger).batch_scope_terminus!
114
+ builder = new(table.name, table_by_name, dump_target, logger)
115
+ builder.batch_scope_terminus!
116
+ builder.warn_batch_scope_ancestor_gaps
101
117
  end
102
118
  end
103
119
 
@@ -216,14 +232,21 @@ module Exwiw
216
232
 
217
233
  def run
218
234
  table = table_by_name.fetch(table_name)
235
+ ast = scope_mode? ? build_scoped(table) : build_single_target(table)
236
+ with_self_ancestors(table, ast)
237
+ end
219
238
 
220
- return build_scoped(table) if scope_mode?
221
-
239
+ private def build_single_target(table)
222
240
  arm_queries = target_polymorphic_arm_queries(table)
241
+ tree_route_clauses = arm_queries ? nil : dump_target_tree_route_clauses(table)
223
242
  if arm_queries
224
243
  where_clauses = [pk_union_clause(table, arm_queries)]
225
244
  where_clauses.push(table.filter) if table.filter
226
245
  join_clauses = []
246
+ elsif tree_route_clauses
247
+ where_clauses = tree_route_clauses
248
+ where_clauses.push(table.filter) if table.filter
249
+ join_clauses = []
227
250
  else
228
251
  where_clauses = build_where_clauses(table, dump_target)
229
252
  join_clauses = build_join_clauses(table, table_by_name, dump_target)
@@ -283,6 +306,13 @@ module Exwiw
283
306
  )
284
307
  end
285
308
 
309
+ new_extraction_query(table).tap do |ast|
310
+ join_clauses.each { |join_clause| ast.join(join_clause) }
311
+ where_clauses.each { |where_clause| ast.where(where_clause) }
312
+ end
313
+ end
314
+
315
+ private def new_extraction_query(table)
286
316
  QueryAst::Select.new.tap do |ast|
287
317
  ast.from(table.name)
288
318
  if table.rails_managed?
@@ -290,8 +320,28 @@ module Exwiw
290
320
  else
291
321
  ast.select(table.columns)
292
322
  end
293
- join_clauses.each { |join_clause| ast.join(join_clause) }
294
- where_clauses.each { |where_clause| ast.where(where_clause) }
323
+ end
324
+ end
325
+
326
+ # Keeps a narrowed tree table's parent ids from dangling. The ancestors are
327
+ # not held to the table's filter or scope, since valid foreign keys win. A
328
+ # batched table is left alone: every batch would repeat the ancestors.
329
+ private def with_self_ancestors(table, ast)
330
+ return ast unless @batch_ids.nil?
331
+ return ast if ast.where_clauses.empty? && ast.join_clauses.empty?
332
+
333
+ links = self.class.self_ancestor_links(table)
334
+ return ast if links.empty?
335
+
336
+ closure = QueryAst::RecursiveAncestorSubquery.new(
337
+ base: project_query_to(ast, [table.primary_key, *links.map(&:foreign_key)].uniq),
338
+ table_name: table.name,
339
+ primary_key: table.primary_key,
340
+ links: links
341
+ )
342
+
343
+ new_extraction_query(table).tap do |widened|
344
+ widened.where(QueryAst::WhereClause.new(column_name: table.primary_key, operator: :in_subquery, value: closure))
295
345
  end
296
346
  end
297
347
 
@@ -552,22 +602,52 @@ module Exwiw
552
602
  return nil
553
603
  end
554
604
 
555
- relation, parent, parent_query = candidates.first
556
-
557
- # Project the parent's extraction query down to just its primary key — the
558
- # column this table's foreign key points at.
559
- pk_column = TableColumn.from_symbol_keys(name: parent.primary_key)
560
- projected = QueryAst::Select.new
561
- projected.from(parent_query.from_table_name)
562
- projected.select([pk_column])
563
- parent_query.join_clauses.each { |j| projected.join(j) }
564
- parent_query.where_clauses.each { |w| projected.where(w) }
605
+ relation, _parent, parent_query = candidates.first
606
+ parent_ids_clause(relation, parent_query)
607
+ end
565
608
 
609
+ # `<fk> IN (<the parent's extraction query, projected to its primary key>)`.
610
+ private def parent_ids_clause(relation, parent_query)
611
+ parent = table_by_name.fetch(relation.table_name)
566
612
  QueryAst::WhereClause.new(
567
613
  column_name: relation.foreign_key,
568
614
  operator: :in_subquery,
569
- value: QueryAst::SelectSubquery.new(query: projected)
615
+ value: QueryAst::SelectSubquery.new(query: project_query_to(parent_query, parent.primary_key))
616
+ )
617
+ end
618
+
619
+ # A JOIN through a tree table applies that table's own conditions, which its
620
+ # ancestors are exempt from, so such a route narrows by the first hop's
621
+ # extraction query instead. A batched table keeps the JOIN its batches slice.
622
+ private def route_through_tree?(table, route_tables)
623
+ return false if batched?(table)
624
+
625
+ !tree_tables_in(route_tables).empty?
626
+ end
627
+
628
+ private def tree_tables_in(table_names)
629
+ table_names.select { |name| (hop = table_by_name[name]) && self.class.self_ancestor_links(hop).any? }
630
+ end
631
+
632
+ # nil keeps the JOIN: on a cycle, or when the parent is unconstrained or
633
+ # scoped through this very table.
634
+ private def route_parent_query(table, relation)
635
+ parent = table_by_name[relation.table_name]
636
+ return nil if parent.nil? || parent.primary_key.nil?
637
+ return nil if parent.name == table.name || @forward_path.include?(parent.name)
638
+
639
+ parent_query = self.class.run(
640
+ parent.name, table_by_name, dump_target, @logger,
641
+ forward_path: @forward_path + [table.name], reverse_path: @reverse_path, deep_chain_warned: @deep_chain_warned
570
642
  )
643
+ return nil unless parent_query.where_clauses.any? || parent_query.join_clauses.any?
644
+ return nil if QueryAst.reads_table?(parent_query, table.name)
645
+
646
+ parent_query
647
+ end
648
+
649
+ private def batched?(table)
650
+ table.respond_to?(:batch_scope) && !table.batch_scope.nil?
571
651
  end
572
652
 
573
653
  private def polymorphic_parent_groups(table)
@@ -695,6 +775,28 @@ module Exwiw
695
775
  )
696
776
  end
697
777
 
778
+ # Single-target counterpart of #path_or_parent_query_arm; nil keeps the JOIN.
779
+ private def dump_target_tree_route_clauses(table)
780
+ return nil if table.name == dump_target.table_name
781
+
782
+ relation = table.belongs_to(dump_target.table_name)
783
+ route = relation ? [dump_target.table_name] : dump_target_route_after_first_hop(table)
784
+ return nil if route.empty? || !route_through_tree?(table, route)
785
+
786
+ relation ||= table.belongs_to(route.first)
787
+ parent_query = route_parent_query(table, relation)
788
+ return nil if parent_query.nil?
789
+
790
+ [parent_ids_clause(relation, parent_query)].tap do |clauses|
791
+ clauses.push(polymorphic_type_clause(relation)) if relation.polymorphic?
792
+ end
793
+ end
794
+
795
+ private def dump_target_route_after_first_hop(table)
796
+ path = find_path_to_dump_target(table, table_by_name, dump_target)
797
+ path.size < 2 ? [] : path.drop(1) + [dump_target.table_name]
798
+ end
799
+
698
800
  # Single-target counterpart of #scoped_arms.
699
801
  private def target_polymorphic_arm_queries(table)
700
802
  return nil if table.name == dump_target.table_name
@@ -721,18 +823,25 @@ module Exwiw
721
823
  end
722
824
 
723
825
  private def target_arm_query(table, relation)
724
- if relation.table_name == dump_target.table_name
826
+ target = table_by_name[relation.table_name]
827
+ return nil if target.nil?
828
+
829
+ on_target = relation.table_name == dump_target.table_name
830
+ path = on_target ? [] : find_path_to_dump_target(target, table_by_name, dump_target)
831
+ joinable = on_target || (path.any? && !path.include?(table.name))
832
+ route = on_target ? [target.name] : path + [dump_target.table_name]
833
+ if joinable && route_through_tree?(table, route) && (parent_query = route_parent_query(table, relation))
834
+ return target_ids_arm_query(table, relation, parent_query)
835
+ end
836
+
837
+ if on_target
725
838
  return arm_pk_query(table).tap do |query|
726
839
  query.where dump_target_fk_clause(relation.foreign_key)
727
840
  query.where polymorphic_type_clause(relation)
728
841
  end
729
842
  end
730
843
 
731
- target = table_by_name[relation.table_name]
732
- return nil if target.nil?
733
-
734
- path = find_path_to_dump_target(target, table_by_name, dump_target)
735
- if path.any? && !path.include?(table.name)
844
+ if joinable
736
845
  return arm_pk_query(table).tap do |query|
737
846
  query.join(build_join_clause(table, target, relation))
738
847
  build_join_clauses(target, table_by_name, dump_target).each { |join_clause| query.join(join_clause) }
@@ -824,13 +933,7 @@ module Exwiw
824
933
  end
825
934
 
826
935
  private def build_scoped(table)
827
- ast = QueryAst::Select.new
828
- ast.from(table.name)
829
- if table.rails_managed?
830
- ast.select_all!
831
- else
832
- ast.select(table.columns)
833
- end
936
+ ast = new_extraction_query(table)
834
937
 
835
938
  # Reference/master (or rails-managed) table: export every row.
836
939
  return ast if scope_exempt?(table)
@@ -852,6 +955,10 @@ module Exwiw
852
955
  build_scoped_join_clauses(arm.path, first_relation: arm.relation).each { |join_clause| ast.join(join_clause) }
853
956
  ast.where(table.filter) if table.filter
854
957
  return ast
958
+ elsif arms.size == 1 && !arms.first.relation.polymorphic?
959
+ ast.where(parent_ids_clause(arms.first.relation, arms.first.target_query))
960
+ ast.where(table.filter) if table.filter
961
+ return ast
855
962
  elsif arms.size >= 1
856
963
  # Several polymorphic arms: INNER JOINing one of them would drop the rows
857
964
  # of every other type, so constrain this table to the UNION of the ids the
@@ -1048,6 +1155,30 @@ module Exwiw
1048
1155
  terminus
1049
1156
  end
1050
1157
 
1158
+ # These gaps should reject the batch_scope like the shapes
1159
+ # #batch_scope_terminus! raises on, but only warn so that configs batching a
1160
+ # table that now keeps ancestors still run. Called once from the pre-flight,
1161
+ # after #batch_scope_terminus! accepts the shape; #batch_scope_terminus!
1162
+ # itself also runs mid-extraction.
1163
+ def warn_batch_scope_ancestor_gaps
1164
+ table = table_by_name.fetch(table_name)
1165
+
1166
+ if self.class.self_ancestor_links(table).any?
1167
+ @logger.warn(
1168
+ " #{table.name} references itself, but a batched table does not add the ancestors " \
1169
+ "of its rows (every batch would repeat them); a kept row's parent may be missing."
1170
+ )
1171
+ end
1172
+
1173
+ route = directly_scoped?(table) ? [] : scoped_arms(table).first.path.drop(1)
1174
+ tree_tables_in(route).each do |tree|
1175
+ @logger.warn(
1176
+ " #{table.name} is batched through #{tree}, which keeps the ancestors of its rows, but " \
1177
+ "the batches join #{tree} directly; #{table.name} rows of those ancestors are not extracted."
1178
+ )
1179
+ end
1180
+ end
1181
+
1051
1182
  # BFS over belongs_tos to the nearest *directly scoped* ancestor. Unlike the
1052
1183
  # target-mode walk, the returned path INCLUDES that ancestor: the scope column
1053
1184
  # lives on the ancestor itself (not on a foreign key of the child), so the
@@ -1145,7 +1276,7 @@ module Exwiw
1145
1276
  else
1146
1277
  sibling_arms = polymorphic_sibling_arms(table, shortest[1])
1147
1278
  if sibling_arms.nil?
1148
- [ScopedArm.new(relation: nil, path: shortest)]
1279
+ [path_or_parent_query_arm(table, table.belongs_to(shortest[1]), shortest, pinned: false)]
1149
1280
  else
1150
1281
  arms = sibling_arms.filter_map { |relation| resolve_scoped_arm(table, relation) }
1151
1282
  @logger.debug(
@@ -1170,12 +1301,21 @@ module Exwiw
1170
1301
  # up referenced_by / reverse_scope / the multi-hop cascade for free.
1171
1302
  private def resolve_scoped_arm(table, relation)
1172
1303
  path = find_path_to_scoped(table, first_relation: relation)
1173
- return ScopedArm.new(relation: relation, path: path) if path.size >= 2
1304
+ return path_or_parent_query_arm(table, relation, path, pinned: true) if path.size >= 2
1174
1305
 
1175
1306
  target_query = scoped_target_query(table, relation, allow_automatic_reverse: true)
1176
1307
  target_query && ScopedArm.new(relation: relation, target_query: target_query)
1177
1308
  end
1178
1309
 
1310
+ # `pinned` keeps the relation on a JOIN arm, which only a polymorphic arm needs.
1311
+ private def path_or_parent_query_arm(table, relation, path, pinned:)
1312
+ if route_through_tree?(table, path.drop(1)) && (parent_query = route_parent_query(table, relation))
1313
+ return ScopedArm.new(relation: relation, target_query: parent_query)
1314
+ end
1315
+
1316
+ ScopedArm.new(relation: pinned ? relation : nil, path: path)
1317
+ end
1318
+
1179
1319
  private def scoped_target_arm_query(table, relation)
1180
1320
  # A parent kept only by the automatic referenced_by exists to keep a child's
1181
1321
  # foreign key valid. Single-target mode extracts only what sits under the
@@ -1307,15 +1447,8 @@ module Exwiw
1307
1447
  # to be constrained: the foreign key alone cannot tell this arm's rows from
1308
1448
  # another arm's (record_id=1 may be any type).
1309
1449
  private def target_ids_arm_query(table, relation, target_query)
1310
- target = table_by_name.fetch(relation.table_name)
1311
1450
  arm_pk_query(table).tap do |query|
1312
- query.where QueryAst::WhereClause.new(
1313
- column_name: relation.foreign_key,
1314
- operator: :in_subquery,
1315
- value: QueryAst::SelectSubquery.new(
1316
- query: project_query_to(target_query, target.primary_key)
1317
- )
1318
- )
1451
+ query.where parent_ids_clause(relation, target_query)
1319
1452
  query.where polymorphic_type_clause(relation)
1320
1453
  end
1321
1454
  end
@@ -1328,14 +1461,14 @@ module Exwiw
1328
1461
  )
1329
1462
  end
1330
1463
 
1331
- # Reduce an extraction query to a single-column id projection, keeping its
1332
- # joins and filters. The column is forced to a plain TableColumn so any
1333
- # masking (`replace_with` / `raw_sql`) configured on it cannot corrupt the id
1464
+ # Reduce an extraction query to an id projection, keeping its joins and
1465
+ # filters. The columns are forced to plain TableColumns so any masking
1466
+ # (`replace_with` / `raw_sql`) configured on them cannot corrupt the id
1334
1467
  # comparison.
1335
- private def project_query_to(query, column_name)
1468
+ private def project_query_to(query, column_names)
1336
1469
  projected = QueryAst::Select.new
1337
1470
  projected.from(query.from_table_name)
1338
- projected.select([TableColumn.from_symbol_keys(name: column_name)])
1471
+ projected.select(Array(column_names).map { |name| TableColumn.from_symbol_keys(name: name) })
1339
1472
  query.join_clauses.each { |join_clause| projected.join(join_clause) }
1340
1473
  query.where_clauses.each { |where_clause| projected.where(where_clause) }
1341
1474
  projected
data/lib/exwiw/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Exwiw
4
- VERSION = "1.1.6"
4
+ VERSION = "1.1.7"
5
5
  end