exwiw 1.1.5 → 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`).
@@ -164,13 +164,14 @@ module Exwiw
164
164
  # left as Strings rather than guessed at — the caller passes values
165
165
  # matching the field's actual type.
166
166
  if dump_target.ids_field
167
- { dump_target.ids_field => { "$in" => dump_target.ids } }
167
+ { dump_target.ids_field => { "$in" => dump_target.default_ids } }
168
168
  else
169
- { config.primary_key => { "$in" => coerce_ids(dump_target.ids) } }
169
+ { config.primary_key => { "$in" => coerce_ids(dump_target.default_ids) } }
170
170
  end
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
@@ -4,25 +4,26 @@ require 'erb'
4
4
 
5
5
  module Exwiw
6
6
  class AfterInsertHook
7
- def self.run(path:, cli_options:, output_dir:, next_idx:, output_extension:, logger:)
7
+ def self.run(path:, cli_options:, dump_target:, output_dir:, next_idx:, output_extension:, logger:)
8
8
  ext = File.extname(path)
9
9
 
10
10
  if ext == '.rb'
11
11
  run_ruby(
12
12
  path: path,
13
13
  cli_options: cli_options,
14
+ dump_target: dump_target,
14
15
  output_dir: output_dir,
15
16
  next_idx: next_idx,
16
17
  output_extension: output_extension,
17
18
  logger: logger,
18
19
  )
19
20
  else
20
- run_shell(path: path, cli_options: cli_options, output_dir: output_dir, logger: logger)
21
+ run_shell(path: path, cli_options: cli_options, dump_target: dump_target, output_dir: output_dir, logger: logger)
21
22
  end
22
23
  end
23
24
 
24
- def self.run_ruby(path:, cli_options:, output_dir:, next_idx:, output_extension:, logger:)
25
- ctx = Context.new(cli_options, output_extension: output_extension)
25
+ def self.run_ruby(path:, cli_options:, dump_target:, output_dir:, next_idx:, output_extension:, logger:)
26
+ ctx = Context.new(cli_options, dump_target: dump_target, output_extension: output_extension)
26
27
  ctx.instance_eval(File.read(path), path)
27
28
 
28
29
  # One output file per buffer, numbered sequentially from next_idx: first
@@ -53,7 +54,7 @@ module Exwiw
53
54
  end
54
55
  end
55
56
 
56
- def self.run_shell(path:, cli_options:, output_dir:, logger:)
57
+ def self.run_shell(path:, cli_options:, dump_target:, output_dir:, logger:)
57
58
  env = {
58
59
  'EXWIW_OUTPUT_DIR' => output_dir,
59
60
  'EXWIW_SCHEMA_DIR' => cli_options[:schema_dir].to_s,
@@ -64,9 +65,12 @@ module Exwiw
64
65
  'EXWIW_DATABASE_NAME' => cli_options[:database_name].to_s,
65
66
  'EXWIW_TARGET_TABLE' => cli_options[:target_table].to_s,
66
67
  'EXWIW_SCOPE_COLUMN' => cli_options[:scope_column].to_s,
67
- 'EXWIW_IDS' => Array(cli_options[:ids]).join(','),
68
+ 'EXWIW_IDS' => dump_target.default_ids.join(','),
68
69
  'EXWIW_OUTPUT_FORMAT' => cli_options[:output_format].to_s,
69
70
  }
71
+ dump_target.ids.each do |space, values|
72
+ env["EXWIW_IDS_#{space.upcase}"] = Array(values).join(',')
73
+ end
70
74
  logger.info("Running after-insert shell hook: #{path}")
71
75
  ok = system(env, path)
72
76
  raise "after-insert shell hook failed: #{path}" unless ok
@@ -79,13 +83,19 @@ module Exwiw
79
83
 
80
84
  attr_reader :cli_options, :collected, :collected_by_collection
81
85
 
82
- def initialize(cli_options, output_extension: nil)
86
+ def initialize(cli_options, dump_target:, output_extension: nil)
83
87
  @cli_options = cli_options
88
+ @dump_target = dump_target
84
89
  @output_extension = output_extension
85
90
  @collected = []
86
91
  @collected_by_collection = {}
87
92
  end
88
93
 
94
+ # The values the run was scoped by for the given ID space (`default` when omitted).
95
+ def ids_for(id_space = DEFAULT_ID_SPACE)
96
+ @dump_target.ids_for(id_space.to_s)
97
+ end
98
+
89
99
  def insert_sql(template)
90
100
  @collected << render(template)
91
101
  end
data/lib/exwiw/cli.rb CHANGED
@@ -111,7 +111,8 @@ module Exwiw
111
111
  @database_name = nil
112
112
  @target_table_name = nil
113
113
  @target_collection_name = nil
114
- @ids = []
114
+ @ids_by_id_space = {}
115
+ @ids_errors = []
115
116
  @ids_field = nil
116
117
  @scope_column = nil
117
118
  @output_format = nil
@@ -151,7 +152,7 @@ module Exwiw
151
152
 
152
153
  dump_target = DumpTarget.new(
153
154
  table_name: @target_table_name,
154
- ids: @ids,
155
+ ids: @ids_by_id_space,
155
156
  ids_field: @ids_field,
156
157
  scope_column: @scope_column,
157
158
  )
@@ -297,6 +298,9 @@ module Exwiw
297
298
  # Fill in any options not given on the CLI from the config file. Done first
298
299
  # so a config-provided `adapter` is in place before normalization below.
299
300
  # CLI values always win (the merge only fills nil/empty ivars).
301
+ @ids_errors.each { |message| $stderr.puts message }
302
+ exit 1 if @ids_errors.any?
303
+
300
304
  apply_config_file!
301
305
 
302
306
  # Default log level once CLI and config have both had their say.
@@ -316,6 +320,7 @@ module Exwiw
316
320
  end
317
321
 
318
322
  resolve_target_collection_alias!
323
+ resolve_named_ids!
319
324
  resolve_scope_column!
320
325
  resolve_ids_field!
321
326
  resolve_uri_option!
@@ -391,18 +396,22 @@ module Exwiw
391
396
  exit 1
392
397
  end
393
398
 
394
- if @target_table_name && @ids.empty?
399
+ if @target_table_name && !ids_given?
395
400
  $stderr.puts "--ids is required when --target-table is specified"
396
401
  exit 1
397
402
  end
398
403
 
399
- if @scope_column && @ids.empty?
404
+ if @scope_column && !ids_given?
400
405
  $stderr.puts "--ids is required when --scope-column is specified"
401
406
  exit 1
402
407
  end
403
408
 
404
- if !@target_table_name && !@scope_column && @ids.any?
405
- $stderr.puts "--target-table or --scope-column is required when --ids is specified"
409
+ # For the SQL adapters, --ids without a target is scope-column mode; whether
410
+ # the schema supports it is checked once it is loaded
411
+ # (QueryAstBuilder.validate_scope!). MongoDB has no scope-column mode.
412
+ if @database_adapter == "mongodb" && !@target_table_name && default_ids.any?
413
+ $stderr.puts "--target-collection (or --target-table) is required when --ids is specified " \
414
+ "with the mongodb adapter"
406
415
  exit 1
407
416
  end
408
417
 
@@ -479,11 +488,23 @@ module Exwiw
479
488
  @log_level ||= config["log_level"]&.to_sym
480
489
  @target_table_name ||= config["target_table"]
481
490
  @target_collection_name ||= config["target_collection"]
482
- if @ids.empty? && config.key?("ids")
491
+ # Like the other keys, `ids` is taken from the config only when the CLI
492
+ # passed no --ids at all; the two are never merged per ID space.
493
+ if !ids_given? && config.key?("ids")
483
494
  raw = config["ids"]
484
- # Accept either a YAML list or a "1,2" string; coerce to strings to match
485
- # the CLI's `--ids=1,2` -> ["1", "2"] shape.
486
- @ids = (raw.is_a?(String) ? raw.split(",") : Array(raw)).map(&:to_s)
495
+ if raw.is_a?(Hash)
496
+ raw.each do |space, values|
497
+ space = space.to_s
498
+ unless space.match?(ID_SPACE_NAME_PATTERN)
499
+ $stderr.puts "config 'ids' keys must be ID space names (a lowercase letter followed by " \
500
+ "lowercase letters, digits or underscores; got #{space.inspect}) in #{path}"
501
+ exit 1
502
+ end
503
+ @ids_by_id_space[space] = parse_config_ids(values)
504
+ end
505
+ else
506
+ @ids_by_id_space[DEFAULT_ID_SPACE] = parse_config_ids(raw)
507
+ end
487
508
  end
488
509
  @ids_field ||= config["ids_field"]
489
510
  @scope_column ||= config["scope_column"]
@@ -492,6 +513,56 @@ module Exwiw
492
513
  @explain_verbosity ||= config["explain_verbosity"]
493
514
  end
494
515
 
516
+ # Accept either a YAML list or a "1,2" string; coerce to strings to match
517
+ # the CLI's `--ids=1,2` -> ["1", "2"] shape.
518
+ private def parse_config_ids(raw)
519
+ (raw.is_a?(String) ? raw.split(",") : Array(raw)).map(&:to_s)
520
+ end
521
+
522
+ # `--ids=<id_space>=1,2` gives the values of a named ID space; without the
523
+ # prefix the values are the default space's. The prefix is recognized only
524
+ # when it is shaped like an ID space name, so an id containing `=` is passed
525
+ # as `--ids=default=<ids>`. Each ID space may be given once: a second --ids
526
+ # for it would otherwise replace the first without notice.
527
+ private def add_ids_option(value)
528
+ prefix, rest = value.to_s.split("=", 2)
529
+ space, list =
530
+ if rest && prefix.match?(ID_SPACE_NAME_PATTERN)
531
+ [prefix, rest]
532
+ else
533
+ [DEFAULT_ID_SPACE, value.to_s]
534
+ end
535
+
536
+ if @ids_by_id_space.key?(space)
537
+ @ids_errors << "--ids was given more than once for the ID space '#{space}'; " \
538
+ "pass all of its ids in one comma-separated --ids."
539
+ end
540
+ @ids_by_id_space[space] = list.split(",")
541
+ end
542
+
543
+ private def ids_given?
544
+ @ids_by_id_space.each_value.any?(&:any?)
545
+ end
546
+
547
+ private def default_ids
548
+ @ids_by_id_space.fetch(DEFAULT_ID_SPACE, [])
549
+ end
550
+
551
+ # Named ID spaces exist only for scope-column mode, which the mongodb
552
+ # adapter does not have. Whether a SQL run is in scope-column mode depends on
553
+ # the schema, so that is checked once it is loaded (QueryAstBuilder.validate_scope!).
554
+ private def resolve_named_ids!
555
+ named = @ids_by_id_space.keys - [DEFAULT_ID_SPACE]
556
+ return if named.empty?
557
+
558
+ if @database_adapter == "mongodb"
559
+ $stderr.puts "--ids for a named ID space (#{named.map { |space| "'#{space}'" }.join(', ')}) " \
560
+ "is not supported by the mongodb adapter, which uses only the default ID space " \
561
+ "(--ids without an `<id_space>=` prefix)."
562
+ exit 1
563
+ end
564
+ end
565
+
495
566
  # Strip a trailing slash (like the CLI's dir options) and expand relative to
496
567
  # `base` (the config file's directory). Returns nil for a nil value.
497
568
  private def expand_dir(value, base)
@@ -542,8 +613,8 @@ module Exwiw
542
613
 
543
614
  # `--scope-column` is **deprecated**: it selected scope-column mode with a
544
615
  # single global column for every table. The preferred way is to declare a
545
- # per-table `scope_column:` in the schema config and pass `--target-table`
546
- # (the target is then scoped like any other table). The flag still works as
616
+ # per-table `scope_column:` in the schema config (`--target-table` is then
617
+ # optional). The flag still works as
547
618
  # before — SQL-only and mutually exclusive with `--target-table` — but emits a
548
619
  # deprecation warning. Runs after resolve_target_collection_alias! so
549
620
  # --target-collection is already folded into @target_table_name.
@@ -567,7 +638,7 @@ module Exwiw
567
638
  end
568
639
 
569
640
  $stderr.puts "warning: --scope-column is deprecated; declare a per-table `scope_column:` " \
570
- "in the schema config and run with --target-table instead."
641
+ "in the schema config and drop the flag instead."
571
642
  end
572
643
 
573
644
  # `--uri` supplies a full connection string (e.g. `mongodb+srv://...`) and is
@@ -780,7 +851,7 @@ module Exwiw
780
851
  database_adapter: @database_adapter,
781
852
  database_name: @database_name,
782
853
  target_table: @target_table_name,
783
- ids: @ids.dup.freeze,
854
+ ids: default_ids.dup.freeze,
784
855
  ids_field: @ids_field,
785
856
  scope_column: @scope_column,
786
857
  output_format: @output_format,
@@ -849,11 +920,11 @@ module Exwiw
849
920
  opts.on("-a", "--adapter=ADAPTER", "Database adapter: mysql, sqlite, postgresql, mongodb (aliases: mysql2, sqlite3)") { |v| @database_adapter = v }
850
921
  opts.on("--uri=URI", "Full MongoDB connection URI (mongodb:// or mongodb+srv://). mongodb adapter only; takes precedence over --host/--port/--user. TLS, replicaSet, authSource and credentials are read from the URI.") { |v| @connection_uri = v }
851
922
  opts.on("--database=DATABASE", "Target database name") { |v| @database_name = v }
852
- opts.on("--target-table=[TABLE]", "Target table for extraction. If omitted, dump all tables.") { |v| @target_table_name = v }
923
+ opts.on("--target-table=[TABLE]", "Target table for extraction. If omitted with --ids, run in scope-column mode (SQL adapters; the schema must declare `scope_column:`). If omitted without --ids, dump all tables.") { |v| @target_table_name = v }
853
924
  opts.on("--target-collection=[COLLECTION]", "Alias of --target-table for the mongodb adapter.") { |v| @target_collection_name = v }
854
- opts.on("--ids=[IDS]", "Comma-separated list of identifiers. Required when --target-table is given.") { |v| @ids = v.split(',') }
925
+ opts.on("--ids=[IDS]", "Comma-separated list of identifiers. Required when --target-table is given. Prefix with `<id_space>=` (e.g. --ids=org=a,b) to give the values of a named ID space; may be repeated, once per ID space. An id containing `=` is passed as --ids=default=<ids>.") { |v| add_ids_option(v) }
855
926
  opts.on("--ids-field=[FIELD]", "Field on the target collection that --ids is matched against. Defaults to the primary key. (mongodb adapter only)") { |v| @ids_field = v }
856
- opts.on("--scope-column=[COLUMN]", "DEPRECATED. Filter every table by this shared global column (--ids are its values) instead of a single --target-table. SQL adapters only; mutually exclusive with --target-table. Prefer declaring a per-table `scope_column:` in the schema config and running with --target-table.") { |v| @scope_column = v }
927
+ opts.on("--scope-column=[COLUMN]", "DEPRECATED. Filter every table by this shared global column (--ids are its values) instead of a single --target-table. SQL adapters only; mutually exclusive with --target-table. Prefer declaring a per-table `scope_column:` in the schema config.") { |v| @scope_column = v }
857
928
  opts.on("--output-format=[FORMAT]", "Output format: insert (default) or copy (PostgreSQL only, export subcommand only)") { |v| @output_format = v }
858
929
  opts.on("--insert-only", "DEPRECATED: ignored. exwiw no longer generates delete-*.sql files, so every export is insert-only.") do
859
930
  $stderr.puts "warning: --insert-only is obsolete and ignored (exwiw no longer generates delete-*.sql files); remove the flag"
@@ -20,12 +20,34 @@ 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
26
28
  end
27
29
  end
28
30
 
31
+ # The ID spaces whose values `query` filters by, anywhere in it.
32
+ def self.id_spaces(query)
33
+ where_clauses = query.where_clauses +
34
+ query.join_clauses.flat_map { |j| j.where_clauses + j.base_where_clauses }
35
+
36
+ where_clauses.flat_map do |where_clause|
37
+ next [] unless where_clause.is_a?(WhereClause)
38
+
39
+ own = where_clause.is_a?(ScopeWhereClause) ? [where_clause.id_space] : []
40
+ nested =
41
+ case where_clause.value
42
+ when SelectSubquery then id_spaces(where_clause.value.query)
43
+ when UnionSubquery then where_clause.value.queries.flat_map { |q| id_spaces(q) }
44
+ when RecursiveAncestorSubquery then id_spaces(where_clause.value.base)
45
+ else []
46
+ end
47
+ own + nested
48
+ end.uniq
49
+ end
50
+
29
51
  class JoinClause
30
52
  # `where_clauses` is compiled against this join's join_table_name (the
31
53
  # joined-to table). `base_where_clauses`, on the other hand, is compiled
@@ -65,11 +87,17 @@ module Exwiw
65
87
  {
66
88
  column_name: column_name,
67
89
  operator: operator,
68
- 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,
69
91
  }
70
92
  end
71
93
  end
72
94
 
95
+ # A scope-column filter. It compiles like any WhereClause; `id_space` records
96
+ # which ID space its values come from, so a query mixing spaces can be found.
97
+ ScopeWhereClause = Class.new(WhereClause) do
98
+ attr_accessor :id_space
99
+ end
100
+
73
101
  # Resolves a set of values on `where_column` to the rows' `select_column`
74
102
  # via a nested SELECT. Used as the `value` of a WhereClause whose operator
75
103
  # is `:in_subquery`, so a non primary-key `ids_field` can filter related
@@ -126,6 +154,36 @@ module Exwiw
126
154
  end
127
155
  end
128
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
+
129
187
  module ColumnValue
130
188
  Base = Struct.new(:name, :value, keyword_init: true)
131
189
  Plain = Class.new(Base)