exwiw 1.1.6 → 1.1.8

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
 
@@ -103,6 +103,7 @@ module Exwiw
103
103
  # MariaDB's mysqldump does not recognise the MySQL-specific
104
104
  # --set-gtid-purged flag and exits with code 7.
105
105
  gtid_flags = mariadb_mysqldump?(mysqldump_bin) ? [] : ['--set-gtid-purged=OFF']
106
+ ssl_flags = mysqldump_ssl_flags(mariadb: mariadb_mysqldump?(mysqldump_bin))
106
107
 
107
108
  cmd = [
108
109
  mysqldump_bin,
@@ -115,6 +116,7 @@ module Exwiw
115
116
  '--skip-set-charset',
116
117
  '--no-tablespaces', # skip tablespace query that requires PROCESS privilege (unavailable on managed MySQL like RDS)
117
118
  *gtid_flags,
119
+ *ssl_flags,
118
120
  '--compact',
119
121
  @connection_config.database_name,
120
122
  ]
@@ -353,24 +355,27 @@ module Exwiw
353
355
  end
354
356
 
355
357
  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
358
+ case subquery
359
+ in Exwiw::QueryAst::RecursiveAncestorSubquery
360
+ compile_ancestor_closure(subquery)
361
+ in Exwiw::QueryAst::SelectSubquery
362
+ # A SelectSubquery wraps a full Select (the referencing table's
363
+ # extraction query, projected to a foreign key); compile it as-is.
364
+ compile_ast(subquery.query)
365
+ in Exwiw::QueryAst::UnionSubquery
366
+ # A UnionSubquery wraps several such Selects; UNION their compiled forms
367
+ # into a single id set. Sibling arms can carry the same nested scope
368
+ # id-set, and MySQL cannot reference one TEMPORARY table twice in a
369
+ # statement (ER_CANT_REOPEN_TABLE), so nested scopes compile inline here.
370
+ without_scope_materialization do
366
371
  subquery.queries.map { |q| compile_ast(q) }.join(' UNION ')
367
372
  end
373
+ in Exwiw::QueryAst::Subquery
374
+ inner_values = subquery.where_values.map { |v| escape_value(v) }
375
+ "SELECT #{qualified_name(subquery.table_name, subquery.select_column)} " \
376
+ "FROM #{quote_table_name(subquery.table_name)} " \
377
+ "WHERE #{qualified_name(subquery.table_name, subquery.where_column)} IN (#{inner_values.join(', ')})"
368
378
  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
379
  end
375
380
 
376
381
  # Backslash and control-character escapes, matching mysqldump. Escaping
@@ -434,6 +439,23 @@ module Exwiw
434
439
  end
435
440
  end
436
441
 
442
+ private def mysqldump_ssl_flags(mariadb:)
443
+ mode = @connection_config.ssl_mode
444
+ ca_flags = @connection_config.ssl_ca ? ["--ssl-ca=#{@connection_config.ssl_ca}"] : []
445
+ return ca_flags + (mode ? ["--ssl-mode=#{mode.upcase}"] : []) unless mariadb
446
+
447
+ # MariaDB's client has no --ssl-mode, and from 11.4 verifies the server
448
+ # certificate by default, so `required` must opt out to stay unverified.
449
+ mode_flags =
450
+ case mode
451
+ when nil, "preferred" then []
452
+ when "disabled" then ["--skip-ssl"]
453
+ when "required" then ["--ssl", "--skip-ssl-verify-server-cert"]
454
+ else ["--ssl", "--ssl-verify-server-cert"]
455
+ end
456
+ ca_flags + mode_flags
457
+ end
458
+
437
459
  private def connection
438
460
  @connection ||= MysqlClient.new(@connection_config)
439
461
  end
@@ -20,6 +20,16 @@ module Exwiw
20
20
 
21
21
  DRIVERS = [:mysql2, :trilogy].freeze
22
22
 
23
+ SSL_MODES = %w[disabled preferred required verify_ca verify_identity].freeze
24
+
25
+ TRILOGY_SSL_MODE_CONSTANTS = {
26
+ "disabled" => :SSL_DISABLED,
27
+ "preferred" => :SSL_PREFERRED_NOVERIFY,
28
+ "required" => :SSL_REQUIRED_NOVERIFY,
29
+ "verify_ca" => :SSL_VERIFY_CA,
30
+ "verify_identity" => :SSL_VERIFY_IDENTITY,
31
+ }.freeze
32
+
23
33
  # Pick the available driver, preferring mysql2 (exwiw's historical default).
24
34
  # require returns false when already loaded, so this is safe to call repeatedly.
25
35
  #
@@ -188,10 +198,31 @@ module Exwiw
188
198
  }.compact
189
199
 
190
200
  case @driver
191
- when :mysql2 then Mysql2::Client.new(options)
192
- when :trilogy then Trilogy.new(options)
201
+ when :mysql2 then Mysql2::Client.new(options.merge(mysql2_ssl_options))
202
+ when :trilogy then Trilogy.new(options.merge(trilogy_ssl_options))
193
203
  end
194
204
  end
205
+
206
+ private def mysql2_ssl_options
207
+ { ssl_mode: mysql2_ssl_mode, sslca: @connection_config.ssl_ca }.compact
208
+ end
209
+
210
+ # mysql2 linked against MariaDB Connector/C defines unsupported modes as 0 and
211
+ # ignores them with a warning, so fall back to the nearest mode it supports.
212
+ private def mysql2_ssl_mode
213
+ mode = @connection_config.ssl_mode
214
+ return mode if mode.nil? || !Mysql2::Client.const_get("SSL_MODE_#{mode.upcase}").zero?
215
+
216
+ "verify_identity" if mode == "verify_ca"
217
+ end
218
+
219
+ private def trilogy_ssl_options
220
+ mode = @connection_config.ssl_mode
221
+ {
222
+ ssl_mode: mode && Trilogy.const_get(TRILOGY_SSL_MODE_CONSTANTS.fetch(mode)),
223
+ ssl_ca: @connection_config.ssl_ca,
224
+ }.compact
225
+ end
195
226
  end
196
227
  end
197
228
  end
@@ -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
data/lib/exwiw/cli.rb CHANGED
@@ -65,7 +65,7 @@ module Exwiw
65
65
  # Database connection settings are environment-specific (and sometimes
66
66
  # secret-adjacent), so they must be passed via CLI/env, never the committed
67
67
  # config file. `adapter` is the one connection-ish key allowed in config.
68
- REJECTED_CONNECTION_KEYS = %w[host port user database uri password].freeze
68
+ REJECTED_CONNECTION_KEYS = %w[host port user database uri password ssl_mode ssl_ca].freeze
69
69
 
70
70
  # Keys that only make sense for `export`. They are skipped when merging config
71
71
  # for `explain` so a shared config file does not trip validate_explain_only!,
@@ -103,6 +103,8 @@ module Exwiw
103
103
  @database_user = nil
104
104
  @database_password = ENV["DATABASE_PASSWORD"]
105
105
  @connection_uri = nil
106
+ @ssl_mode = nil
107
+ @ssl_ca = nil
106
108
  @output_dir = nil
107
109
  @schema_dir = nil
108
110
  @from_db = false
@@ -148,6 +150,8 @@ module Exwiw
148
150
  database_name: @database_name,
149
151
  uri: @connection_uri,
150
152
  mongodb_query_timeout_ms: @mongodb_query_timeout_ms,
153
+ ssl_mode: @ssl_mode,
154
+ ssl_ca: @ssl_ca,
151
155
  )
152
156
 
153
157
  dump_target = DumpTarget.new(
@@ -324,6 +328,7 @@ module Exwiw
324
328
  resolve_scope_column!
325
329
  resolve_ids_field!
326
330
  resolve_uri_option!
331
+ resolve_ssl_options!
327
332
  resolve_parallel_workers!
328
333
  resolve_mongodb_query_timeout_ms!
329
334
 
@@ -654,6 +659,31 @@ module Exwiw
654
659
  end
655
660
  end
656
661
 
662
+ private def resolve_ssl_options!
663
+ return if @ssl_mode.nil? && @ssl_ca.nil?
664
+
665
+ if @database_adapter != "mysql"
666
+ $stderr.puts "--ssl-mode / --ssl-ca are only supported by the mysql adapter"
667
+ exit 1
668
+ end
669
+
670
+ if @ssl_ca&.empty?
671
+ $stderr.puts "--ssl-ca must be a path to a CA certificate file"
672
+ exit 1
673
+ end
674
+
675
+ if @ssl_mode.nil?
676
+ $stderr.puts "--ssl-ca requires --ssl-mode (verify_ca or verify_identity to verify the server)"
677
+ exit 1
678
+ end
679
+
680
+ @ssl_mode = @ssl_mode.downcase
681
+ unless Adapter::MysqlClient::SSL_MODES.include?(@ssl_mode)
682
+ $stderr.puts "Invalid --ssl-mode. Available options are: #{Adapter::MysqlClient::SSL_MODES.join(', ')}"
683
+ exit 1
684
+ end
685
+ end
686
+
657
687
  # `--parallel-workers` opts into the MongoDB fork-parallel dump schedule
658
688
  # (docs/mongodb-dump-parallelism-2x-notes.md). It is mongodb-only (the SQL
659
689
  # adapters shell out to their own dumpers) and must be a positive integer;
@@ -919,6 +949,8 @@ module Exwiw
919
949
  end
920
950
  opts.on("-a", "--adapter=ADAPTER", "Database adapter: mysql, sqlite, postgresql, mongodb (aliases: mysql2, sqlite3)") { |v| @database_adapter = v }
921
951
  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 }
952
+ opts.on("--ssl-mode=MODE", "TLS mode for the connection and mysqldump: #{Adapter::MysqlClient::SSL_MODES.join(', ')}. mysql adapter only; defaults to the client library's behavior.") { |v| @ssl_mode = v }
953
+ opts.on("--ssl-ca=PATH", "CA certificate file used to verify the server. mysql adapter only.") { |v| @ssl_ca = v }
922
954
  opts.on("--database=DATABASE", "Target database name") { |v| @database_name = v }
923
955
  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 }
924
956
  opts.on("--target-collection=[COLLECTION]", "Alias of --target-table for the mongodb adapter.") { |v| @target_collection_name = v }
@@ -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)