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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +12 -0
- data/README.md +318 -1017
- data/docs/mongodb.md +4 -0
- data/docs/scope-id-set-join-notes.md +12 -0
- data/lib/exwiw/adapter/mongodb_adapter.rb +12 -0
- data/lib/exwiw/adapter/mysql_adapter.rb +37 -15
- data/lib/exwiw/adapter/mysql_client.rb +33 -2
- data/lib/exwiw/adapter/postgresql_adapter.rb +26 -16
- data/lib/exwiw/adapter/sqlite_adapter.rb +16 -13
- data/lib/exwiw/adapter.rb +30 -1
- data/lib/exwiw/cli.rb +33 -1
- data/lib/exwiw/query_ast.rb +34 -1
- data/lib/exwiw/query_ast_builder.rb +177 -44
- data/lib/exwiw/version.rb +1 -1
- data/lib/exwiw.rb +3 -1
- metadata +2 -1
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
465
|
-
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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 }
|
data/lib/exwiw/query_ast.rb
CHANGED
|
@@ -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:
|
|
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)
|