exwiw 0.8.5 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3934deb015b3ede7c6a8caa25b857d1f7c8957b4efa3284cd26f21ff2620904d
4
- data.tar.gz: e44fef95c3c273aa96b1dde37736212687da88d8d9b98abb5c44fb305d45b7a3
3
+ metadata.gz: 577420a290ea24adc64ef6ba21914ac71b1c98f86163f9d1d8a88643cc89e15b
4
+ data.tar.gz: eaddb0073169543aa1bea944cb1b075fbd8258a034f4cffb9cf0011338ba260a
5
5
  SHA512:
6
- metadata.gz: 1bf410fb503b270aca3f96191f34cb8ee67730ac49ea6a57d34550d2b603fed7404206d405c61f6dd3b67808f8865d73c80121d303c628dda513ebcf9c418b17
7
- data.tar.gz: d1cbb8bd0ed7bd10f53d3d3169985844d4e5e509041c95a18d083dff458a06f26c09e325fea44bc5c1e14bf65b53fdc79e4dbe351f3e4e5f916e6c9135cfa71e
6
+ metadata.gz: c21735b0dcda7b30b0519944c37f24610564e91801a29e2c0f7cc3632cc0f24c532d10c078127bccffd0a2f258a5695cdabe25094edf228b5e3203a629ddf175
7
+ data.tar.gz: 7e9ce9682b281c097485da409befc466dd06fc655c1612278b9e98f6bccdea575aa443ff4292777be1e4e68f88d826554702eecd6b94bddc31b1e4edc01c923b
data/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.9.1] - 2026-06-30
6
+
7
+ ### Fixed
8
+
9
+ - **The `reverse_scope` satellite cascade now fires in single-target (`--target-table`) mode, not only scope-column mode.** A table that `belongs_to` a `reverse_scope`'d hub (a "satellite") is scoped by constraining it to the hub's in-scope ids — the multi-hop forward (`via_scoped_parent`) cascade. That cascade ran only in scope-column mode: in single-target / PK-anchor mode the hub itself was scoped via `reverse_scope`, but its `belongs_to` children fell through to a full dump — a silent cross-tenant export in a multi-tenant schema, despite the README promising the cascade works in "both single-target and scope-column mode." The single-target build now runs the same `build_belongs_to_scoped_clause` cascade for a non-target table that has no `belongs_to` path to the dump target, so satellites tighten to the kept ids (multi-hop, keeping the single-unambiguous-parent rule, polymorphic skip, and forward-path cycle guard); it can only narrow such a table's output, never widen it. Scope-column mode is unchanged — the cascade logic is reused as-is. Because single-target mode has no `validate_scope!` pre-flight, a satellite the cascade cannot resolve to a single scopable parent (e.g. it `belongs_to` two scopable hubs) is still dumped in full but now logs a warning. The README also documents that `referenced_by` scoping takes precedence over the hub cascade (which can under-scope) and how to force the hub cascade. SQL adapters only.
10
+
11
+ ## [0.9.0] - 2026-06-30
12
+
13
+ ### Added
14
+
15
+ - **`exwiw explain` now supports the mongodb adapter.** It runs the server's [explain command](https://www.mongodb.com/docs/manual/reference/command/explain/) for the `find` each collection would be dumped with, and prints the explain document as JSON alongside the find description. Verbosity is configurable via the `EXWIW_MONGODB_EXPLAIN_VERBOSITY` environment variable or the `explain_verbosity:` config key (the env var wins): `queryPlanner` (default), `executionStats`, or `allPlansExecution`. The default `queryPlanner` only **plans** the query — it does not execute it or scan any documents — so it is safe to point at a production source; `executionStats`/`allPlansExecution` run the real extraction query to gather runtime stats and should be used deliberately. Because a scoped collection's `belongs_to` ids are captured at runtime (which `explain` never does), `explain` fills each scoped collection's real foreign-key filter (e.g. `users` → `{ shop_id: { $in: [...] } }`) with a placeholder id instead of the match-nothing `{_id: {$in: []}}` fallback — so the plan reports the real `IXSCAN`/`COLLSCAN` (queryPlanner selects an index by field, not value; only the bound values are fake). `ExplainRunner` now prints the adapter-agnostic query description (the compiled SELECT for SQL, the find description for mongodb), so the SQL adapters' output is unchanged.
16
+ - **The MongoDB fork-parallel dump (`--parallel-workers`) logs a per-collection extracted-record summary on completion.** The serial path already logs each collection's row count inline, but the parallel path previously emitted only an aggregate (`genuine`/`leaves`/`ref_bt` counts). The leaf and `ref_bt` collections are extracted in forked workers whose counts never reached the parent, so the per-collection numbers were unavailable. Each worker now hands its counts back through the same Marshal-sidecar IPC the consumed-leaf `@state` already uses; the parent merges them with the genuine counts it holds and logs one `name: count` line per collection in processing order (matching the `insert-NNN-` file numbering), plus a `total_records` field on the returned stats. This is purely additive metadata — it does not touch the output files, so the byte-identity guarantee is unchanged.
17
+
5
18
  ## [0.8.5] - 2026-06-25
6
19
 
7
20
  - **`--parallel-workers=N` parallelizes the MongoDB dump across forked processes (opt-in, byte-identical).** When set with `N≥2` on the mongodb adapter's `export`, exwiw runs an inter-collection fork schedule that decodes whole collections in parallel while preserving each collection's natural row order, so the output files are byte-identical to a serial run (same filenames, same content). Collections are classified into three dependency groups — reference data dumped in full (no `belongs_to`), the scoped DAG reachable to the dump target, and non-reachable reference data — which lets the heavy full-dump collections run concurrently with the scoped pass; only a handful of small `@state` hand-offs cross process boundaries. The win needs real cores: it clears ~2× from 4 workers and saturates there. Requires a dump target and a `fork`-capable runtime (CRuby on POSIX); it falls back to the serial path on JRuby/TruffleRuby/Windows or when no target is given. Also settable as `parallel_workers:` in the config file. The default remains the serial dump.
data/README.md CHANGED
@@ -59,7 +59,7 @@ driver implements that auth handshake itself and sidesteps the issue.
59
59
  exwiw has two subcommands:
60
60
 
61
61
  - `export` (default) — generate INSERT/COPY SQL files. If the subcommand is omitted, `export` is assumed.
62
- - `explain` — print the compiled SQL and its `EXPLAIN` output for each query that `export` would run, without executing the SELECTs.
62
+ - `explain` — print each query `export` would run together with its `EXPLAIN` output. SQL adapters compile the SELECT without executing it; mongodb runs the server's explain (defaulting to the execution-free `queryPlanner`).
63
63
 
64
64
  ### `exwiw export`
65
65
 
@@ -115,7 +115,7 @@ idx meaning is the same as insert sql.
115
115
 
116
116
  ### `exwiw explain`
117
117
 
118
- Print the compiled SQL and its `EXPLAIN` output (estimate-only; `EXPLAIN QUERY PLAN` on SQLite) for each query that `export` would run, to stdout. No SELECT is executed. Supported for `mysql`, `postgresql`, and `sqlite`. The `mongodb` adapter is not yet supported.
118
+ Print the query each `export` would run together with its `EXPLAIN` output, to stdout. For the SQL adapters (`mysql`, `postgresql`, `sqlite`) this is the compiled SELECT plus its `EXPLAIN` (estimate-only; `EXPLAIN QUERY PLAN` on SQLite) no SELECT is executed. For `mongodb` it is the `find` description plus the server's explain document as JSON.
119
119
 
120
120
  ```bash
121
121
  # preview the queries exwiw would run, without executing the SELECTs
@@ -129,6 +129,29 @@ exwiw explain \
129
129
 
130
130
  The `--output-dir`, `--output-format`, `--insert-only`, and `--after-insert-hook` options are dump-specific and rejected when used with `explain`.
131
131
 
132
+ #### MongoDB explain verbosity
133
+
134
+ The mongodb explain runs the server's [explain command](https://www.mongodb.com/docs/manual/reference/command/explain/) at a configurable verbosity. The default, **`queryPlanner`, only plans the query and does not execute it**, so it is safe to point at a production source. Set it with the `EXWIW_MONGODB_EXPLAIN_VERBOSITY` environment variable or the `explain_verbosity:` config key (the env var wins):
135
+
136
+ | verbosity | behaviour |
137
+ |---|---|
138
+ | `queryPlanner` (default) | Plans the query only. **The query is not executed** — no documents are scanned. |
139
+ | `executionStats` | **Runs the query** and reports runtime statistics (docs examined, time, etc.). |
140
+ | `allPlansExecution` | Runs the winning plan **and the rejected candidate plans** to gather their stats. |
141
+
142
+ ```bash
143
+ # inspect index usage of the real extraction query (executes it)
144
+ EXWIW_MONGODB_EXPLAIN_VERBOSITY=executionStats exwiw explain \
145
+ --adapter=mongodb \
146
+ --uri="mongodb+srv://reader@cluster/app_production" \
147
+ --schema-dir=exwiw/schema \
148
+ --target-collection=shops --ids=...
149
+ ```
150
+
151
+ `executionStats` and `allPlansExecution` execute the extraction query against the source, so use them deliberately on large/production collections.
152
+
153
+ > **Scoped collections use a placeholder id.** A non-target collection is scoped by its parents' ids, which a real dump captures while running each parent query — but `explain` runs nothing. So for scoped collections, `explain` fills the real foreign-key filter (e.g. `users` → `{ shop_id: { $in: [...] } }`) with a placeholder id rather than real values. The plan still reflects the real dump: `queryPlanner` chooses an index by the queried *field*, not its value, so whether a scoped extraction does an `IXSCAN` or a `COLLSCAN` is reported correctly. Only the bound *values* are fake. (The target collection uses the real `--ids`; reference collections with no `belongs_to` use the real `{}` full scan.)
154
+
132
155
  ### Scope-column mode
133
156
 
134
157
  The default `--target-table` extraction assumes the schema converges on a single
@@ -197,6 +220,13 @@ Each table is resolved as follows:
197
220
  path, set `ignore: true` to skip it, or mark it `scope_exempt: true` (below) to
198
221
  export it in full.
199
222
 
223
+ > **Note — referenced-by is preferred over the hub cascade.** A table that is
224
+ > *both* `belongs_to` a scoped hub *and* referenced-by a constrained child is
225
+ > scoped to the (narrower) referenced-by id-set, not the hub cascade, so the hub's
226
+ > other children the child does not reference are dropped (under-scoping). To force
227
+ > the broader hub cascade, set `ignore: true` on the child's `belongs_to` edge that
228
+ > points at this table.
229
+
200
230
  Scope-column mode is SQL-only (mysql / postgresql / sqlite). It works with `exwiw
201
231
  explain` too, which is the recommended way to preview the queries before exporting.
202
232
 
@@ -286,6 +316,7 @@ Notes:
286
316
  - **Relative paths in the config (`schema_dir`, `output_dir`, `after_insert_hook`) are resolved relative to the config file's own directory**, not the current working directory. So with the config at the project root, `schema_dir: exwiw/schema` reads naturally, and an absolute `--config=/path/to/exwiw.yml` works no matter where you run from. (CLI path flags remain relative to the current directory — each source resolves relative to where it is written.) Absolute paths are used as-is.
287
317
  - Unknown keys are rejected so a typo surfaces immediately.
288
318
  - Export-only keys (`output_dir`, `output_format`, `insert_only`, `after_insert_hook`) are ignored when running `explain`, so a single config file can be shared by both subcommands.
319
+ - `explain_verbosity` sets the mongodb `explain` verbosity (`queryPlanner` | `executionStats` | `allPlansExecution`, default `queryPlanner`); the `EXWIW_MONGODB_EXPLAIN_VERBOSITY` env var overrides it. Ignored by the SQL adapters and by `export`. See [`exwiw explain`](#mongodb-explain-verbosity).
289
320
 
290
321
  ### Generator
291
322
 
@@ -634,7 +665,7 @@ Notes:
634
665
  - **Only scoped referencers belong in `via`.** Each arm's query must come out constrained; an unconstrained referencer (e.g. a `scope_exempt` table, or one with no path to a scope) would project *every* id and union the whole table back — so such an arm is **skipped with a warning** rather than silently widening the dump. An unknown table is likewise skipped with a warning. If no arm survives, the table stays unscopable and (in [scope-column mode](#scope-column-mode)) the run aborts via `validate_scope!`.
635
666
  - **NULLs are excluded** per arm (`IS NOT NULL`).
636
667
  - **Satellites need no config.** A table that `belongs_to` the reverse-scoped table (e.g. `end_users.id → users.id`, or `identities.user_id → users.id`) tightens to the kept ids automatically through the normal cascade — only the reverse-scoped table itself declares `reverse_scope`. The cascade is **multi-hop**, so a table several `belongs_to` hops below the reverse-scoped table (e.g. `end_user_profiles → end_users → users`) also tightens automatically, with no config of its own.
637
- - Works in both single-target and scope-column mode. Polymorphic foreign keys are not eligible as anchors (the named `column` is always a concrete column).
668
+ - Works in both single-target and scope-column mode. In single-target mode there is no scope-column pre-flight (`validate_scope!`), so a satellite the cascade cannot resolve to a single scopable parent (e.g. it `belongs_to` two scopable hubs) is dumped in full with a warning rather than aborting. Polymorphic foreign keys are not eligible as anchors (the named `column` is always a concrete column).
638
669
 
639
670
  ### Why a JOIN, not `IN (subquery)`
640
671
 
@@ -68,6 +68,28 @@ module Exwiw
68
68
  def initialize(connection_config, logger)
69
69
  super
70
70
  @state = {}
71
+ @explain_placeholder = false
72
+ end
73
+
74
+ # A recognizably-fake ObjectId substituted for captured parent ids when
75
+ # building scope filters in `explain` placeholder mode (see
76
+ # #explain_scope_with_placeholders! and #parent_state_for).
77
+ EXPLAIN_PLACEHOLDER_OID_HEX = "ffffffffffffffffffffffff"
78
+
79
+ # Switch #build_query into placeholder-scope mode for `explain`. The
80
+ # mongodb scope of a non-target collection is its parents' captured ids,
81
+ # which the serial dump harvests at runtime in #execute. `explain` never
82
+ # executes a query, so that @state stays empty and every scoped child would
83
+ # otherwise fall back to the match-nothing `{_id: {$in: []}}` filter —
84
+ # hiding which field (e.g. a foreign key) the real dump would actually
85
+ # filter on, and thus whether it is indexed. In this mode #parent_state_for
86
+ # returns a placeholder id for each dumped parent instead of reading
87
+ # @state, so build_query emits the real foreign-key filter shape with a
88
+ # dummy value. queryPlanner picks an index by the queried FIELD, not the
89
+ # value, so index selection (IXSCAN vs COLLSCAN) is reported correctly even
90
+ # though the bound value is fake.
91
+ def explain_scope_with_placeholders!
92
+ @explain_placeholder = true
71
93
  end
72
94
 
73
95
  # Propagation @state accessor, used ONLY by MongodbParallelDumper to seed a
@@ -193,8 +215,30 @@ module Exwiw
193
215
  raise NotImplementedError, "MongodbAdapter does not support bulk delete"
194
216
  end
195
217
 
196
- def explain(_query)
197
- raise NotImplementedError, "MongodbAdapter does not support explain yet"
218
+ # Default explain verbosity. `queryPlanner` asks the server to PLAN the
219
+ # query without executing it, so it is safe to run against a production
220
+ # source — no documents are scanned or returned. `executionStats` and
221
+ # `allPlansExecution` actually run the query to collect runtime stats (the
222
+ # latter also runs the rejected candidate plans), so they carry the cost of
223
+ # the real extraction query.
224
+ DEFAULT_EXPLAIN_VERBOSITY = "queryPlanner"
225
+
226
+ # Server-side explain for the find this dump would issue, returned as
227
+ # pretty-printed relaxed extended JSON (same value semantics as the dumped
228
+ # documents). `verbosity` is one of queryPlanner / executionStats /
229
+ # allPlansExecution; see DEFAULT_EXPLAIN_VERBOSITY for the safety
230
+ # implications. A String verbosity is passed through to the driver as-is.
231
+ def explain(query, verbosity: nil)
232
+ verbosity ||= DEFAULT_EXPLAIN_VERBOSITY
233
+ @logger.debug(" Running explain (verbosity=#{verbosity}) on '#{query.collection}': filter=#{query.filter.inspect}")
234
+
235
+ result = db[query.collection]
236
+ .find(query.filter)
237
+ .projection(query.projection)
238
+ .comment(query_comment_text("collection=#{query.collection}"))
239
+ .explain(verbosity: verbosity)
240
+
241
+ JSON.pretty_generate(result.as_extended_json(mode: :relaxed))
198
242
  end
199
243
 
200
244
  def describe_query(query)
@@ -478,7 +522,21 @@ module Exwiw
478
522
  # constrained by: the values of the parent field the FK references
479
523
  # (`relation.references`, default the parent primary_key). nil when the
480
524
  # parent has not been executed yet.
525
+ #
526
+ # In `explain` placeholder mode (#explain_scope_with_placeholders!) there is
527
+ # no captured state, so return a one-element placeholder id for any dumped
528
+ # (non-embedded) parent — enough to make the child's foreign-key clause
529
+ # appear with its real field, so the plan reflects the real dump. An
530
+ # embedded parent is not dumped on its own and yields nil, exactly as a real
531
+ # run's empty @state would.
481
532
  private def parent_state_for(relation, config_by_name)
533
+ if @explain_placeholder
534
+ parent = config_by_name[relation.table_name]
535
+ return nil if parent.nil? || parent.embedded?
536
+
537
+ return [explain_placeholder_id]
538
+ end
539
+
482
540
  parent_fields = @state[relation.table_name]
483
541
  return nil if parent_fields.nil?
484
542
 
@@ -487,6 +545,17 @@ module Exwiw
487
545
  parent_fields[reference_field]
488
546
  end
489
547
 
548
+ # The placeholder id used in explain placeholder mode. Built lazily because
549
+ # build_query can run before any db access loads bson; falls back to the
550
+ # hex String if bson is genuinely unavailable (the filter shape is what
551
+ # matters, not the value's type).
552
+ private def explain_placeholder_id
553
+ require 'bson' unless defined?(::BSON::ObjectId)
554
+ @explain_placeholder_id ||= ::BSON::ObjectId.from_string(EXPLAIN_PLACEHOLDER_OID_HEX)
555
+ rescue LoadError
556
+ EXPLAIN_PLACEHOLDER_OID_HEX
557
+ end
558
+
490
559
  # A masking plan compiled once per collection config and reused for every
491
560
  # document of that collection. `masked_fields` is `[field_name,
492
561
  # template_segments]` for each field carrying a `replace_with`;
@@ -68,7 +68,7 @@ module Exwiw
68
68
  StreamingResult.new(client: connection, data_sql: data_sql, count_sql: count_sql)
69
69
  end
70
70
 
71
- def explain(query_ast)
71
+ def explain(query_ast, verbosity: nil)
72
72
  sql = commented_sql(query_ast)
73
73
 
74
74
  @logger.debug(" Executing EXPLAIN: \n#{sql}")
@@ -90,7 +90,7 @@ module Exwiw
90
90
  StreamingResult.new(connection: connection, data_sql: data_sql, count_sql: count_sql)
91
91
  end
92
92
 
93
- def explain(query_ast)
93
+ def explain(query_ast, verbosity: nil)
94
94
  sql = commented_sql(query_ast)
95
95
 
96
96
  @logger.debug(" Executing EXPLAIN: \n#{sql}")
@@ -71,7 +71,7 @@ module Exwiw
71
71
  StreamingResult.new(connection: connection, data_sql: data_sql, count_sql: count_sql)
72
72
  end
73
73
 
74
- def explain(query_ast)
74
+ def explain(query_ast, verbosity: nil)
75
75
  sql = commented_sql(query_ast)
76
76
 
77
77
  @logger.debug(" Executing EXPLAIN QUERY PLAN: \n#{sql}")
data/lib/exwiw/adapter.rb CHANGED
@@ -182,11 +182,23 @@ module Exwiw
182
182
 
183
183
  # Run the database-specific EXPLAIN for the given query and return the
184
184
  # output as a single string for `explain` subcommand to print.
185
- # SQL adapters override; MongodbAdapter currently raises.
186
- def explain(_query_ast)
185
+ # `verbosity` selects the explain detail level; it is mongodb-only (the
186
+ # MongoDB explain command's queryPlanner / executionStats /
187
+ # allPlansExecution) and the SQL adapters accept it only to keep the
188
+ # contract uniform — they ignore it. SQL adapters override; MongodbAdapter
189
+ # implements verbosity.
190
+ def explain(_query_ast, verbosity: nil)
187
191
  raise NotImplementedError, "#{self.class.name} does not implement #explain"
188
192
  end
189
193
 
194
+ # Hook letting `explain` tell an adapter whose scope is resolved at runtime
195
+ # (mongodb captures parent ids during execution) to build placeholder scope
196
+ # filters instead, since explain executes nothing. No-op by default: the
197
+ # SQL adapters embed scoping in the query itself, so their `explain` already
198
+ # reflects the real query without any captured state.
199
+ def explain_scope_with_placeholders!
200
+ end
201
+
190
202
  # Identifier text prepended to every query exwiw sends to the (often
191
203
  # production) source DB, so the statement is recognizable in the
192
204
  # processlist / slow-query log / db.currentOp() and can be killed if it
data/lib/exwiw/cli.rb CHANGED
@@ -36,8 +36,15 @@ module Exwiw
36
36
  ids_field
37
37
  scope_column
38
38
  parallel_workers
39
+ explain_verbosity
39
40
  ].freeze
40
41
 
42
+ # MongoDB explain verbosity levels (passed through to the server's explain
43
+ # command). `queryPlanner` only plans the query and is the safe default —
44
+ # the query is not executed; the other two run it to gather runtime stats.
45
+ EXPLAIN_VERBOSITIES = %w[queryPlanner executionStats allPlansExecution].freeze
46
+ DEFAULT_EXPLAIN_VERBOSITY = "queryPlanner"
47
+
41
48
  # Database connection settings are environment-specific (and sometimes
42
49
  # secret-adjacent), so they must be passed via CLI/env, never the committed
43
50
  # config file. `adapter` is the one connection-ish key allowed in config.
@@ -82,6 +89,7 @@ module Exwiw
82
89
  @insert_only = nil
83
90
  @after_insert_hook_path = nil
84
91
  @parallel_workers = nil
92
+ @explain_verbosity = nil
85
93
  # nil (not :info) so we can tell "user passed --log-level" from the default,
86
94
  # letting a config-file value fill in; the :info default is applied later.
87
95
  @log_level = nil
@@ -138,6 +146,7 @@ module Exwiw
138
146
  dump_target: dump_target,
139
147
  logger: logger,
140
148
  io: $stdout,
149
+ explain_verbosity: @explain_verbosity,
141
150
  ).run
142
151
  end
143
152
  end
@@ -172,6 +181,7 @@ module Exwiw
172
181
 
173
182
  if @subcommand == "explain"
174
183
  validate_explain_only!
184
+ resolve_explain_verbosity!
175
185
  end
176
186
 
177
187
  if @database_adapter != "sqlite"
@@ -321,6 +331,7 @@ module Exwiw
321
331
  @ids_field ||= config["ids_field"]
322
332
  @scope_column ||= config["scope_column"]
323
333
  @parallel_workers ||= parse_parallel_workers(config["parallel_workers"]) if config.key?("parallel_workers")
334
+ @explain_verbosity ||= config["explain_verbosity"]
324
335
  end
325
336
 
326
337
  # Strip a trailing slash (like the CLI's dir options) and expand relative to
@@ -447,11 +458,6 @@ module Exwiw
447
458
  end
448
459
 
449
460
  private def validate_explain_only!
450
- if @database_adapter == "mongodb"
451
- $stderr.puts "mongodb adapter is not yet supported by 'explain' subcommand"
452
- exit 1
453
- end
454
-
455
461
  rejected = []
456
462
  rejected << "--output-dir" unless @output_dir.nil?
457
463
  rejected << "--output-format" unless @output_format.nil?
@@ -465,6 +471,24 @@ module Exwiw
465
471
  end
466
472
  end
467
473
 
474
+ # Resolve the MongoDB explain verbosity for an `explain` run. It is
475
+ # mongodb-only, so this no-ops (and skips validation) for the SQL adapters,
476
+ # which ignore verbosity. The env var wins over the config-file value (it is
477
+ # the more run-specific override, like a CLI flag would be); when neither is
478
+ # set the safe, execution-free `queryPlanner` default is used.
479
+ private def resolve_explain_verbosity!
480
+ return unless @database_adapter == "mongodb"
481
+
482
+ env = ENV["EXWIW_MONGODB_EXPLAIN_VERBOSITY"]
483
+ @explain_verbosity = env unless env.nil? || env.empty?
484
+ @explain_verbosity ||= DEFAULT_EXPLAIN_VERBOSITY
485
+
486
+ unless EXPLAIN_VERBOSITIES.include?(@explain_verbosity)
487
+ $stderr.puts "Invalid explain verbosity '#{@explain_verbosity}'. Available options are: #{EXPLAIN_VERBOSITIES.join(', ')}"
488
+ exit 1
489
+ end
490
+ end
491
+
468
492
  # The export clears @output_dir before writing (see Runner#clean_output_dir!).
469
493
  # That is destructive, so when running interactively (stdin is a tty) ask for
470
494
  # confirmation first. In non-interactive contexts (CI, pipes) we proceed
@@ -533,7 +557,9 @@ module Exwiw
533
557
  Subcommands:
534
558
  export Generate INSERT/COPY SQL files (default when omitted).
535
559
  explain Print EXPLAIN output for each extraction query to stdout.
536
- (not yet supported for the mongodb adapter)
560
+ For mongodb, set verbosity via EXWIW_MONGODB_EXPLAIN_VERBOSITY
561
+ or `explain_verbosity:` in config (queryPlanner (default,
562
+ no query is executed) | executionStats | allPlansExecution).
537
563
  BANNER
538
564
  opts.version = Exwiw::VERSION
539
565
 
@@ -7,17 +7,24 @@ module Exwiw
7
7
  schema_dir:,
8
8
  dump_target:,
9
9
  logger:,
10
- io: $stdout
10
+ io: $stdout,
11
+ explain_verbosity: nil
11
12
  )
12
13
  @connection_config = connection_config
13
14
  @schema_dir = schema_dir
14
15
  @dump_target = dump_target
15
16
  @logger = logger
16
17
  @io = io
18
+ # mongodb-only; nil lets the adapter pick its safe default (queryPlanner).
19
+ @explain_verbosity = explain_verbosity
17
20
  end
18
21
 
19
22
  def run
20
23
  adapter = Adapter.build(@connection_config, @logger)
24
+ # explain executes nothing, so an adapter whose non-target scope is captured
25
+ # at runtime (mongodb) has no parent ids to filter children by; ask it to
26
+ # build placeholder scope filters so each query reflects the real dump.
27
+ adapter.explain_scope_with_placeholders!
21
28
  configs = load_table_config(adapter.class.table_config_class)
22
29
  validate_ignored(configs)
23
30
 
@@ -44,11 +51,13 @@ module Exwiw
44
51
  @logger.debug("Explaining '#{table_name}'... (#{idx + 1}/#{total_size})")
45
52
 
46
53
  query_ast = adapter.build_query(table, @dump_target, table_by_name)
47
- sql = adapter.commented_sql(query_ast)
48
- explain_text = adapter.explain(query_ast)
54
+ # describe_query is the adapter-agnostic "what query is this": the
55
+ # commented SELECT for SQL adapters, the find description for mongodb.
56
+ query_text = adapter.describe_query(query_ast)
57
+ explain_text = adapter.explain(query_ast, verbosity: @explain_verbosity)
49
58
 
50
59
  @io.puts "-- [#{idx + 1}/#{total_size}] #{table_name}"
51
- @io.puts sql
60
+ @io.puts query_text
52
61
  @io.puts
53
62
  @io.puts "-- EXPLAIN:"
54
63
  @io.puts explain_text
@@ -41,6 +41,12 @@ module Exwiw
41
41
  # fork is required; callers must check {.available?} and fall back to the serial
42
42
  # Runner on JRuby/TruffleRuby/Windows.
43
43
  class MongodbParallelDumper
44
+ # Filename prefix for the per-worker record-count sidecars. Distinct from the
45
+ # consumed-leaf @state sidecars (`<name>.marshal`) so the two never collide in
46
+ # the shared sidecar dir and the counts can be globbed back independently.
47
+ COUNTS_SIDECAR_PREFIX = "__exwiw_counts__"
48
+ private_constant :COUNTS_SIDECAR_PREFIX
49
+
44
50
  # True when the runtime can `fork` (CRuby on a POSIX OS). On JRuby/TruffleRuby
45
51
  # and Windows it cannot — the caller must run the serial Runner instead.
46
52
  def self.available?
@@ -99,19 +105,24 @@ module Exwiw
99
105
 
100
106
  FileUtils.mkdir_p(@output_dir)
101
107
  parent = build_adapter
108
+ @counts = {}
102
109
 
103
110
  Dir.mktmpdir("exwiw-mongo-parallel-") do |sidecar_dir|
104
111
  phase1_leaf_and_genuine(parent, sidecar_dir)
105
112
  phase2_cascade(parent, sidecar_dir)
106
113
  phase3_ref_components(parent, sidecar_dir)
114
+ collect_counts(sidecar_dir)
107
115
  end
108
116
 
117
+ log_count_summary
118
+
109
119
  {
110
120
  workers: @workers,
111
121
  genuine: @plan.genuine.size,
112
122
  leaves: @plan.leaves.size,
113
123
  ref_bt: @plan.ref_bt.size,
114
124
  components: @plan.reference_components.map(&:size).sort.reverse,
125
+ total_records: @counts.values.sum,
115
126
  }
116
127
  end
117
128
 
@@ -175,7 +186,7 @@ module Exwiw
175
186
  pids = groups.reject(&:empty?).map do |group|
176
187
  members = group.flatten
177
188
  seed = leaf_state.slice(*parents_of(members))
178
- fork { run_component_worker(group, seed) }
189
+ fork { run_component_worker(group, seed, sidecar_dir) }
179
190
  end
180
191
  ok = pids.map { |pid| Process.wait(pid); $?.exitstatus&.zero? }.all?
181
192
  raise "exwiw parallel ref_bt pool failed" unless ok
@@ -204,25 +215,29 @@ module Exwiw
204
215
 
205
216
  def run_leaf_worker(group, sidecar_dir)
206
217
  adapter = build_adapter
207
- group.each { |name| process_collection(adapter, name) }
218
+ counts = {}
219
+ group.each { |name| counts[name] = process_collection(adapter, name) }
208
220
  group.each do |name|
209
221
  next unless @plan.consumed_leaves.include?(name)
210
222
  next unless adapter.state.key?(name)
211
223
 
212
224
  File.binwrite(File.join(sidecar_dir, "#{name}.marshal"), Marshal.dump(adapter.state[name]))
213
225
  end
226
+ write_counts_sidecar(sidecar_dir, group.first, counts)
214
227
  exit!(0)
215
228
  rescue StandardError => e
216
229
  @logger.error("exwiw parallel leaf worker error (#{group.first}..): #{e.class}: #{e.message}")
217
230
  exit!(1)
218
231
  end
219
232
 
220
- def run_component_worker(group, seed)
233
+ def run_component_worker(group, seed, sidecar_dir)
221
234
  adapter = build_adapter
222
235
  adapter.state = seed unless seed.empty?
223
236
  # Each component is already topologically ordered (parent before child) and
224
237
  # dependency-closed over intra-ref_bt edges, so a plain serial walk suffices.
225
- group.each { |component| component.each { |name| process_collection(adapter, name) } }
238
+ counts = {}
239
+ group.each { |component| component.each { |name| counts[name] = process_collection(adapter, name) } }
240
+ write_counts_sidecar(sidecar_dir, group.first.first, counts)
226
241
  exit!(0)
227
242
  rescue StandardError => e
228
243
  @logger.error("exwiw parallel ref_bt worker error (#{group.first&.first}..): #{e.class}: #{e.message}")
@@ -268,6 +283,34 @@ module Exwiw
268
283
  end
269
284
  end
270
285
 
286
+ # Gather every collection's extracted record count back into the parent for the
287
+ # completion summary. genuine counts are already in @row_counts (parent-local,
288
+ # post-cascade); the leaf and ref_bt collections were extracted in forked
289
+ # workers, so each worker wrote a small Marshal counts sidecar (the same IPC
290
+ # pattern the consumed-leaf @state uses) that we merge here before the tmpdir is
291
+ # removed.
292
+ def collect_counts(sidecar_dir)
293
+ @counts.merge!(@row_counts)
294
+ Dir.glob(File.join(sidecar_dir, "#{COUNTS_SIDECAR_PREFIX}*.marshal")).each do |path|
295
+ @counts.merge!(Marshal.load(File.binread(path)))
296
+ end
297
+ end
298
+
299
+ def write_counts_sidecar(sidecar_dir, tag, counts)
300
+ File.binwrite(File.join(sidecar_dir, "#{COUNTS_SIDECAR_PREFIX}#{tag}.marshal"), Marshal.dump(counts))
301
+ end
302
+
303
+ # Log one line per extractable collection in the dump's processing order (the
304
+ # same order the insert-NNN- files are numbered), so the counts cross-reference
305
+ # directly with the output files. A collection that matched no rows shows 0
306
+ # (its output file was deleted).
307
+ def log_count_summary
308
+ total = @counts.values.sum
309
+ nonzero = @counts.count { |_, count| count.positive? }
310
+ @logger.info("Per-collection extracted record counts (#{total} record(s) across #{nonzero} collection(s)):")
311
+ @plan.extractable.each { |name| @logger.info(" #{name}: #{@counts.fetch(name, 0)}") }
312
+ end
313
+
271
314
  # The distinct belongs_to parent names of `names`, used to slice the leaf @state
272
315
  # a worker is seeded with down to only the keys its collections reference.
273
316
  def parents_of(names)
@@ -86,6 +86,28 @@ module Exwiw
86
86
  where_clauses.push(reverse_clause) if reverse_clause
87
87
  end
88
88
 
89
+ # Forward cascade. A satellite of a reverse_scope'd (or referenced-by-scoped)
90
+ # hub has no belongs_to path to the dump target, so the clauses above stay
91
+ # empty and it would dump every row. When its belongs_to parent is itself
92
+ # scoped, constrain this table to the parent's in-scope ids — the same
93
+ # multi-hop cascade scope-column mode performs in build_scoped.
94
+ if table.name != dump_target.table_name &&
95
+ where_clauses.empty? && join_clauses.empty? &&
96
+ forward_scope_allowed?(table)
97
+ parent_clause = build_belongs_to_scoped_clause(table)
98
+ if parent_clause
99
+ where_clauses.push(parent_clause)
100
+ elsif @allow_reverse && @forward_path.empty? && !scope_exempt?(table) &&
101
+ scopable_parent_candidates(table).size > 1
102
+ @logger.warn(
103
+ " #{table.name} belongs_to multiple scopable parents; the cascade cannot " \
104
+ "pick one unambiguously, so it is dumped in full. If this is intended, set " \
105
+ "`scope_exempt: true`. Otherwise, scope it through a single parent (e.g. ignore one belongs_to edge), " \
106
+ "or switch to scope-column mode to scope it directly."
107
+ )
108
+ end
109
+ end
110
+
89
111
  QueryAst::Select.new.tap do |ast|
90
112
  ast.from(table.name)
91
113
  if table.rails_managed?
@@ -290,48 +312,18 @@ module Exwiw
290
312
  )
291
313
  end
292
314
 
293
- # Scope-column mode. Builds a `fk IN (SELECT parent.pk FROM <parent
294
- # extraction query>)` clause for a table whose belongs_to parent is itself
295
- # scopable but carries no scope column of its own — so find_path_to_scoped
296
- # cannot terminate on it (via_path fails) and nothing references this table
297
- # (referenced_by fails). The classic shape is a hub scoped only via
298
- # referenced_by (e.g. CDP `customer_accounts`, scoped by the `customers` that
299
- # reference it) with sibling detail tables (`customer_account_details`, ...)
300
- # hanging off it. Constraining those siblings to the hub's in-scope ids keeps
301
- # them out of a full dump. Returns nil when there is no single, unambiguous
302
- # scopable parent, leaving the caller on the unscopable path.
315
+ # Builds a `fk IN (SELECT parent.pk FROM <parent extraction query>)` clause
316
+ # for a table whose belongs_to parent is itself scopable but carries no scope
317
+ # column of its own — so find_path_to_scoped cannot terminate on it (via_path
318
+ # fails) and nothing references this table (referenced_by fails). The classic
319
+ # shape is a hub scoped only via referenced_by (e.g. CDP `customer_accounts`,
320
+ # scoped by the `customers` that reference it) with sibling detail tables
321
+ # (`customer_account_details`, ...) hanging off it. Constraining those
322
+ # siblings to the hub's in-scope ids keeps them out of a full dump. Returns
323
+ # nil when there is no single, unambiguous scopable parent, leaving the caller
324
+ # on the unscopable path. Used by both scope-column and single-target mode.
303
325
  private def build_belongs_to_scoped_clause(table)
304
- # This table plus every ancestor currently being forward-resolved. A
305
- # candidate parent already on this path would close a belongs_to cycle, so
306
- # it is skipped; threading the grown path into the parent build lets the
307
- # cascade recurse N hops (users -> end_users -> end_user_profiles -> ...)
308
- # and terminate only when a table reappears.
309
- forward_path = @forward_path + [table.name]
310
-
311
- candidates = table.belongs_tos.filter_map do |relation|
312
- # A polymorphic belongs_to points at several parent tables through one
313
- # column, so it cannot project to a single parent id set; skip it.
314
- next if relation.polymorphic?
315
-
316
- parent = table_by_name[relation.table_name]
317
- next if parent.nil?
318
-
319
- # Cycle guard: descending into a parent already on the forward path would
320
- # loop (a -> b -> a). Stop, leaving this table on the :unscopable path.
321
- next if forward_path.include?(parent.name)
322
-
323
- # Build the parent's own scoped query. allow_reverse stays true so the
324
- # parent may be scoped via referenced_by, and forward scoping stays
325
- # enabled so a parent that is itself scoped via *its* parent resolves
326
- # too — this is what makes the cascade multi-hop.
327
- parent_query = self.class.run(parent.name, table_by_name, dump_target, @logger, allow_reverse: true, forward_path: forward_path)
328
-
329
- # Only a constrained parent narrows anything; an unconstrained parent
330
- # would select every pk (i.e. dump all) and not help.
331
- next unless parent_query.where_clauses.any? || parent_query.join_clauses.any?
332
-
333
- [relation, parent, parent_query]
334
- end
326
+ candidates = scopable_parent_candidates(table)
335
327
 
336
328
  # Only the unambiguous single-parent case. Multiple scopable parents would
337
329
  # need their subqueries combined (not supported); fall back to unscopable.
@@ -360,6 +352,45 @@ module Exwiw
360
352
  )
361
353
  end
362
354
 
355
+ # The scopable belongs_to parents of `table`: each non-polymorphic parent
356
+ # whose own extraction query comes out constrained, paired with the relation
357
+ # and that query. Shared by build_belongs_to_scoped_clause (which requires
358
+ # exactly one) and the single-target full-dump warning (which flags two or
359
+ # more, since the cascade then cannot disambiguate).
360
+ private def scopable_parent_candidates(table)
361
+ # Memoized: #run can resolve this twice for the same table (once via
362
+ # build_belongs_to_scoped_clause, once for the ambiguous-parent warning),
363
+ # and each pass recursively builds every parent's query.
364
+ (@scopable_parent_candidates ||= {})[table.name] ||= begin
365
+ # This table plus every ancestor currently being forward-resolved; a
366
+ # candidate parent already on this path would close a belongs_to cycle, so
367
+ # it is skipped. Threading the grown path into the parent build lets the
368
+ # cascade recurse N hops and terminate only when a table reappears.
369
+ forward_path = @forward_path + [table.name]
370
+
371
+ table.belongs_tos.filter_map do |relation|
372
+ # A polymorphic belongs_to points at several parent tables through one
373
+ # column, so it cannot project to a single parent id set.
374
+ next if relation.polymorphic?
375
+
376
+ parent = table_by_name[relation.table_name]
377
+ next if parent.nil?
378
+ next if forward_path.include?(parent.name)
379
+
380
+ # allow_reverse and forward scoping stay enabled so the parent may itself
381
+ # be scoped via referenced_by or via *its* parent — this is what makes the
382
+ # cascade multi-hop.
383
+ parent_query = self.class.run(parent.name, table_by_name, dump_target, @logger, allow_reverse: true, forward_path: forward_path)
384
+
385
+ # Only a constrained parent narrows anything; an unconstrained parent
386
+ # would select every pk (i.e. dump all) and not help.
387
+ next unless parent_query.where_clauses.any? || parent_query.join_clauses.any?
388
+
389
+ [relation, parent, parent_query]
390
+ end
391
+ end
392
+ end
393
+
363
394
  private def build_where_clauses(table, dump_target)
364
395
  clauses = []
365
396
 
data/lib/exwiw/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Exwiw
4
- VERSION = "0.8.5"
4
+ VERSION = "0.9.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: exwiw
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.5
4
+ version: 0.9.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shia