exwiw 1.1.5 → 1.1.6
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 +72 -18
- data/lib/exwiw/adapter/mongodb_adapter.rb +2 -2
- data/lib/exwiw/after_insert_hook.rb +17 -7
- data/lib/exwiw/cli.rb +88 -17
- data/lib/exwiw/query_ast.rb +25 -0
- data/lib/exwiw/query_ast_builder.rb +171 -14
- data/lib/exwiw/runner.rb +1 -0
- data/lib/exwiw/table_config.rb +17 -0
- data/lib/exwiw/version.rb +1 -1
- data/lib/exwiw.rb +40 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 345a459c251b837b718e00ed92b011c8975016af2a92149868fcd24744356041
|
|
4
|
+
data.tar.gz: c79d6c2525ae322bab05956102c424eecc6fa40864e0bc7d581912a68d20928e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 881bc0c87eb1121c9670075e059eafd77a3c551f444442575b4545a5c9b78387dd90e2726c0745618b7486e4b1f907d5cb49ce414aefd772c7fd10cd8c80060d
|
|
7
|
+
data.tar.gz: c6a2ee871b7f8ff89e1ddd048c4494c71b7b8776e9d26a1d677b24b11614d1ff4a82c418e5169935514d17404ca18d30626dcfdf105469f6a01e5b0d5664782e
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [1.1.6] - 2026-09-29
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`--target-table` can be omitted in scope-column mode.** Scope-column mode starts from every table that declares a `scope_column`, so the target only had to name one of them and was otherwise unused. `--ids` alone now runs in scope-column mode when any table in the schema declares a `scope_column` (tables marked `ignore: true` do not count), for both `export` and `explain`. When none does, the run stops before any output with an error saying to either name the table the ids belong to with `--target-table` (single-target mode) or declare a `scope_column` (scope-column mode). This check moved from the CLI to the schema pre-flight (`QueryAstBuilder.validate_scope!`), since it needs the schema. Runs that pass `--target-table`, and the deprecated `--scope-column` flag, behave as before. The mongodb adapter has no scope-column mode, so `--ids` there still requires `--target-collection` and is rejected by the CLI without it.
|
|
10
|
+
- **Scope-column mode can filter unrelated groups of tables by different kinds of id in one run.** A table can declare `id_space` next to its `scope_column` to say which ID space the column's values belong to, and `--ids` takes one value list per ID space: `--ids=1,2 --ids=org=<uuid>,<uuid>`. Tables without `id_space`, and `--ids` without a prefix, are the `default` ID space, so existing schemas, commands and configs keep their meaning. A table without a scope column is filtered by the ID space of the table it is scoped through. The config file's `ids:` also accepts a mapping (`ids: { default: [1, 2], org: [...] }`), and a shell after-insert hook receives each space given values as `EXWIW_IDS_<NAME>` (`EXWIW_IDS` stays the `default` space and is always set; Ruby hooks get `ids_for("<name>")`). The run aborts before extracting anything when a table's ID space was given no values, when a given ID space is used by no table, or when a table reaches scoped tables of more than one ID space, which the `belongs_to` walk would otherwise settle silently by picking the nearest. Single `--target-table` mode and the MongoDB adapter use only the `default` space and abort when a named one is given. See [Per-table `scope_column` and ID spaces](README.md#per-table-scope_column-and-id-spaces).
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **`--ids` is read as `<id_space>=<ids>` when the part before its first `=` looks like an ID space name** (a lowercase letter followed by lowercase letters, digits or underscores). An id list whose first id contains such a prefix, like `--ids=key=1`, now names the ID space `key`; pass `--ids=default=key=1` to keep it as an id. Giving `--ids` more than once for the same ID space is now an error instead of the last one silently winning.
|
|
15
|
+
- **`Exwiw::DumpTarget#ids` is a Hash from ID space name to values.** An Array passed to `DumpTarget.new(ids:)` is still accepted as the `default` space's values; read them with `#default_ids`.
|
|
16
|
+
|
|
5
17
|
## [1.1.5] - 2026-09-25
|
|
6
18
|
|
|
7
19
|
### Fixed
|
data/README.md
CHANGED
|
@@ -80,7 +80,13 @@ exwiw \
|
|
|
80
80
|
--log-level=info
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
-
By default `--ids` are matched against the target table's primary key. If the target table declares a per-table `scope_column`, exwiw runs in [scope-column mode](#scope-column-mode) instead — `--ids` are then values of that shared column, and the table is scoped like any other rather than anchored by primary key.
|
|
83
|
+
By default `--ids` are matched against the target table's primary key. If the target table declares a per-table `scope_column`, exwiw runs in [scope-column mode](#scope-column-mode) instead — `--ids` are then values of that shared column, and the table is scoped like any other rather than anchored by primary key. In scope-column mode `--target-table` can be omitted: passing only `--ids` runs in scope-column mode as long as some table in the schema declares a `scope_column`.
|
|
84
|
+
|
|
85
|
+
| `--target-table` | `--ids` | Mode |
|
|
86
|
+
|---|---|---|
|
|
87
|
+
| given | given | Scope-column mode if the table declares a `scope_column`, single-target mode otherwise |
|
|
88
|
+
| omitted | given | Scope-column mode if any table declares a `scope_column`, an error otherwise (SQL adapters only) |
|
|
89
|
+
| omitted | omitted | Every table is dumped in full |
|
|
84
90
|
|
|
85
91
|
When `--target-table` and `--ids` are omitted, exwiw dumps all tables defined in `--schema-dir`:
|
|
86
92
|
|
|
@@ -173,7 +179,8 @@ all. Choosing one table as `--target-table` would leave the others unrelated to
|
|
|
173
179
|
and an unrelated table is dumped in full — a problem if it holds personal data.
|
|
174
180
|
|
|
175
181
|
Scope-column mode handles this shape: instead of anchoring on one table's primary
|
|
176
|
-
key, **every table is filtered by a shared column** whose values are `--ids
|
|
182
|
+
key, **every table is filtered by a shared column** whose values are `--ids` (tables
|
|
183
|
+
keyed by an unrelated kind of id can use their own [ID space](#per-table-scope_column-and-id-spaces)).
|
|
177
184
|
Declare that column per table in the schema config with `scope_column:`:
|
|
178
185
|
|
|
179
186
|
```json
|
|
@@ -185,7 +192,7 @@ Declare that column per table in the schema config with `scope_column:`:
|
|
|
185
192
|
}
|
|
186
193
|
```
|
|
187
194
|
|
|
188
|
-
Then
|
|
195
|
+
Then pass the scope values as `--ids`, without `--target-table`:
|
|
189
196
|
|
|
190
197
|
```bash
|
|
191
198
|
exwiw \
|
|
@@ -193,15 +200,22 @@ exwiw \
|
|
|
193
200
|
--host=localhost --port=5432 --user=reader \
|
|
194
201
|
--database=app_production \
|
|
195
202
|
--schema-dir=exwiw/schema \
|
|
196
|
-
--
|
|
203
|
+
--ids=42,43 \
|
|
197
204
|
--output-dir=dump
|
|
198
205
|
```
|
|
199
206
|
|
|
200
|
-
Because
|
|
207
|
+
Because the schema declares a `scope_column`, exwiw runs in scope-column mode: the
|
|
201
208
|
`--ids` (`42,43`) are **`business_entity_id` values, not shop primary keys**, and
|
|
202
|
-
`shops`
|
|
203
|
-
|
|
204
|
-
`scope_column`
|
|
209
|
+
`shops` is scoped by `business_entity_id IN (42,43)` like every other scoped table.
|
|
210
|
+
Where extraction starts is decided by the schema, so `--target-table` is not
|
|
211
|
+
needed. If no table declares a `scope_column`, `--ids` alone is an error: name the
|
|
212
|
+
table the ids belong to with `--target-table` (single-target mode), or declare a
|
|
213
|
+
`scope_column`. Tables marked `ignore: true` do not count.
|
|
214
|
+
|
|
215
|
+
Naming a scoped table as `--target-table` (`--target-table=shops --ids=42,43`)
|
|
216
|
+
selects the same mode and extracts the same rows; the target is *not* used as a
|
|
217
|
+
primary-key anchor. (A table that declares a `scope_column` therefore can no longer
|
|
218
|
+
be single-extracted by primary key.)
|
|
205
219
|
|
|
206
220
|
Each table is resolved as follows:
|
|
207
221
|
|
|
@@ -236,7 +250,8 @@ Each table is resolved as follows:
|
|
|
236
250
|
> the broader hub cascade, set `ignore: true` on the child's `belongs_to` edge that
|
|
237
251
|
> points at this table.
|
|
238
252
|
|
|
239
|
-
Scope-column mode is SQL-only (mysql / postgresql / sqlite)
|
|
253
|
+
Scope-column mode is SQL-only (mysql / postgresql / sqlite); with the mongodb
|
|
254
|
+
adapter, `--ids` still requires `--target-collection`. It works with `exwiw
|
|
240
255
|
explain` too, which is the recommended way to preview the queries before exporting.
|
|
241
256
|
|
|
242
257
|
#### Cross-database foreign keys
|
|
@@ -266,11 +281,13 @@ opt out of the strict check and be exported in full:
|
|
|
266
281
|
Rails-managed tables (`schema_migrations`, `ar_internal_metadata`) are treated as
|
|
267
282
|
exempt automatically.
|
|
268
283
|
|
|
269
|
-
#### Per-table `scope_column` and
|
|
284
|
+
#### Per-table `scope_column` and ID spaces
|
|
270
285
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
286
|
+
The values of every `scope_column` belong to an **ID space**, and `--ids` gives the
|
|
287
|
+
values of each space. A table that does not declare `id_space` is in the `default`
|
|
288
|
+
space, which a plain `--ids=1,2` fills, so without any `id_space` every scoped table
|
|
289
|
+
is filtered by the same `--ids`. Each table names its own column, so a table that
|
|
290
|
+
stores that same value under a differently named column simply declares that name:
|
|
274
291
|
|
|
275
292
|
```json
|
|
276
293
|
{
|
|
@@ -281,8 +298,42 @@ same value under a differently named column simply declares that name:
|
|
|
281
298
|
}
|
|
282
299
|
```
|
|
283
300
|
|
|
284
|
-
|
|
285
|
-
|
|
301
|
+
When one database holds two groups of tables that no foreign key connects, each
|
|
302
|
+
keyed by a different kind of id, put one group in a named ID space. Here `tenants`
|
|
303
|
+
are identified by integer ids and `organizations` by UUIDs (each object is its own
|
|
304
|
+
schema file):
|
|
305
|
+
|
|
306
|
+
```json
|
|
307
|
+
{ "name": "tenants", "primary_key": "id", "scope_column": "id", "columns": [{ "name": "id" }] }
|
|
308
|
+
{ "name": "organizations", "primary_key": "id", "scope_column": "id", "id_space": "org", "columns": [{ "name": "id" }] }
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
```bash
|
|
312
|
+
exwiw ... --target-table=tenants \
|
|
313
|
+
--ids=1,2 \
|
|
314
|
+
--ids=org=0b6f4c1e-0000-4000-8000-000000000001
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
`--ids=1,2` is the same as `--ids=default=1,2`. The part before the first `=` is
|
|
318
|
+
read as an ID space name only when it is shaped like one (a lowercase letter
|
|
319
|
+
followed by lowercase letters, digits or underscores), so an id that contains `=`
|
|
320
|
+
itself is passed with an explicit `default=`. Each ID space may be given once. A
|
|
321
|
+
table without a scope column is filtered by the ID space of the table it is scoped
|
|
322
|
+
through (a `belongs_to` join, `reverse_scope`, referenced-by or the parent cascade).
|
|
323
|
+
|
|
324
|
+
The run aborts before extracting anything when a table is filtered by an ID space
|
|
325
|
+
that was given no values, when values are given for an ID space no table uses, or
|
|
326
|
+
when a table reaches scoped tables of more than one ID space (otherwise the
|
|
327
|
+
`belongs_to` walk would silently settle on the nearest one).
|
|
328
|
+
|
|
329
|
+
Single `--target-table` mode and the MongoDB adapter use only the `default` space
|
|
330
|
+
and abort when a named one is given. In the config file, `ids:` takes either a list
|
|
331
|
+
(the `default` space) or a mapping from ID space name to values, such as
|
|
332
|
+
`ids: { default: [1, 2], org: [0b6f4c1e-0000-4000-8000-000000000001] }`; as with
|
|
333
|
+
the other keys, it is used only when `--ids` is not passed at all.
|
|
334
|
+
|
|
335
|
+
`scope_exempt`, `scope_column` and `id_space` are user-maintained and preserved
|
|
336
|
+
across `schema:generate` regeneration (the generators never emit them).
|
|
286
337
|
|
|
287
338
|
#### Deprecated: the `--scope-column` flag
|
|
288
339
|
|
|
@@ -290,7 +341,7 @@ Before per-table declarations, scope-column mode was selected with a global
|
|
|
290
341
|
`--scope-column=COLUMN` flag (every table filtered by that one column, `--ids` its
|
|
291
342
|
values, no `--target-table`). The flag still works — SQL-only and mutually
|
|
292
343
|
exclusive with `--target-table` — but is **deprecated** and emits a warning; prefer
|
|
293
|
-
declaring a per-table `scope_column` and
|
|
344
|
+
declaring a per-table `scope_column` and dropping the flag. A per-table
|
|
294
345
|
`scope_column` takes precedence over the flag for any table that sets both.
|
|
295
346
|
|
|
296
347
|
### Config file (`exwiw.yml`)
|
|
@@ -308,6 +359,7 @@ output_format: insert # insert | copy
|
|
|
308
359
|
after_insert_hook: hooks/seed.rb
|
|
309
360
|
log_level: info # debug | info
|
|
310
361
|
# target_table / ids / ids_field / scope_column may also be set here
|
|
362
|
+
# (ids may map ID spaces to values; see "Per-table scope_column and ID spaces")
|
|
311
363
|
# mongodb_query_timeout_ms: 30000 # global query timeout (mongodb only)
|
|
312
364
|
```
|
|
313
365
|
|
|
@@ -637,7 +689,8 @@ psql -d app_dev -f dump/insert-001-shops.sql
|
|
|
637
689
|
|
|
638
690
|
**Ruby hook (`.rb`)**: provides a tiny DSL with these builtins:
|
|
639
691
|
|
|
640
|
-
- `cli_options` — Hash of all parsed CLI options (e.g. `cli_options.fetch(:ids)` returns the `--ids` array)
|
|
692
|
+
- `cli_options` — Hash of all parsed CLI options (e.g. `cli_options.fetch(:ids)` returns the `--ids` array of the `default` ID space)
|
|
693
|
+
- `ids_for(id_space = "default")` — the values the run was scoped by for that ID space (e.g. `ids_for("org")` for `--ids=org=...`)
|
|
641
694
|
- `insert_sql(template)` — appends an ERB-rendered string to a buffer. After the hook finishes, the buffer is concatenated and written to `insert-{N+1}-after_insert.{ext}` where `{N+1}` is one past the last per-table insert file. For the MongoDB adapter the equivalent alias `insert_jsonl(template)` is available; output goes to `insert-{N+1}-after_insert.jsonl`. Multiple `insert_sql` calls in a single hook are joined with `"\n"` into the same file. If no `insert_sql` call is made, no file is created.
|
|
642
695
|
- `insert_jsonl(collection, template)` — **MongoDB adapter only**. SQL statements name their table in-band, but JSONL documents do not — the import convention derives the target collection from the filename — so the two-argument form writes the ERB-rendered extended-JSON lines to the named collection's own `insert-NNN-<collection>.jsonl` file, importable with the same `mongoimport --collection <collection>` convention as the per-collection dump files. Multiple calls targeting the same collection are appended (joined with `"\n"`) into that collection's file; distinct collections get one file each, numbered sequentially after the last per-collection dump file (the collection-less `after_insert` buffer, when also used, keeps `{N+1}` and the collection files follow it). Calling this form with a SQL adapter raises an error.
|
|
643
696
|
|
|
@@ -667,7 +720,8 @@ insert_jsonl 'posts', '{"title":"welcome"}'
|
|
|
667
720
|
|
|
668
721
|
- `EXWIW_OUTPUT_DIR`, `EXWIW_SCHEMA_DIR`
|
|
669
722
|
- `EXWIW_DATABASE_ADAPTER`, `EXWIW_DATABASE_HOST`, `EXWIW_DATABASE_PORT`, `EXWIW_DATABASE_USER`, `EXWIW_DATABASE_NAME`
|
|
670
|
-
- `EXWIW_TARGET_TABLE`, `EXWIW_IDS` (comma-separated), `EXWIW_OUTPUT_FORMAT`
|
|
723
|
+
- `EXWIW_TARGET_TABLE`, `EXWIW_IDS` (comma-separated, the `default` ID space), `EXWIW_OUTPUT_FORMAT`
|
|
724
|
+
- `EXWIW_IDS_<ID_SPACE>` for each ID space given values, with the name uppercased (`--ids=org=...` becomes `EXWIW_IDS_ORG`). `EXWIW_IDS_DEFAULT` is set only when the `default` space is given, while `EXWIW_IDS` is always set
|
|
671
725
|
|
|
672
726
|
A non-zero exit code from the shell hook aborts exwiw.
|
|
673
727
|
|
|
@@ -164,9 +164,9 @@ 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.
|
|
167
|
+
{ dump_target.ids_field => { "$in" => dump_target.default_ids } }
|
|
168
168
|
else
|
|
169
|
-
{ config.primary_key => { "$in" => coerce_ids(dump_target.
|
|
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)
|
|
@@ -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' =>
|
|
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
|
-
@
|
|
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: @
|
|
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 &&
|
|
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 &&
|
|
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
|
-
|
|
405
|
-
|
|
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
|
-
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
|
546
|
-
#
|
|
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
|
|
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:
|
|
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|
|
|
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
|
|
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"
|
data/lib/exwiw/query_ast.rb
CHANGED
|
@@ -26,6 +26,25 @@ module Exwiw
|
|
|
26
26
|
end
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
+
# The ID spaces whose values `query` filters by, anywhere in it.
|
|
30
|
+
def self.id_spaces(query)
|
|
31
|
+
where_clauses = query.where_clauses +
|
|
32
|
+
query.join_clauses.flat_map { |j| j.where_clauses + j.base_where_clauses }
|
|
33
|
+
|
|
34
|
+
where_clauses.flat_map do |where_clause|
|
|
35
|
+
next [] unless where_clause.is_a?(WhereClause)
|
|
36
|
+
|
|
37
|
+
own = where_clause.is_a?(ScopeWhereClause) ? [where_clause.id_space] : []
|
|
38
|
+
nested =
|
|
39
|
+
case where_clause.value
|
|
40
|
+
when SelectSubquery then id_spaces(where_clause.value.query)
|
|
41
|
+
when UnionSubquery then where_clause.value.queries.flat_map { |q| id_spaces(q) }
|
|
42
|
+
else []
|
|
43
|
+
end
|
|
44
|
+
own + nested
|
|
45
|
+
end.uniq
|
|
46
|
+
end
|
|
47
|
+
|
|
29
48
|
class JoinClause
|
|
30
49
|
# `where_clauses` is compiled against this join's join_table_name (the
|
|
31
50
|
# joined-to table). `base_where_clauses`, on the other hand, is compiled
|
|
@@ -70,6 +89,12 @@ module Exwiw
|
|
|
70
89
|
end
|
|
71
90
|
end
|
|
72
91
|
|
|
92
|
+
# A scope-column filter. It compiles like any WhereClause; `id_space` records
|
|
93
|
+
# which ID space its values come from, so a query mixing spaces can be found.
|
|
94
|
+
ScopeWhereClause = Class.new(WhereClause) do
|
|
95
|
+
attr_accessor :id_space
|
|
96
|
+
end
|
|
97
|
+
|
|
73
98
|
# Resolves a set of values on `where_column` to the rows' `select_column`
|
|
74
99
|
# via a nested SELECT. Used as the `value` of a WhereClause whose operator
|
|
75
100
|
# is `:in_subquery`, so a non primary-key `ids_field` can filter related
|
|
@@ -12,25 +12,57 @@ module Exwiw
|
|
|
12
12
|
new(table_name, table_by_name, dump_target, logger).scope_category
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
# Scope-column mode is active when
|
|
16
|
-
# per-table `scope_column` (the
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
15
|
+
# Scope-column mode is active when the named `--target-table` declares a
|
|
16
|
+
# per-table `scope_column` (the target is then scoped like any other table,
|
|
17
|
+
# and its `--ids` are scope-column values, not primary keys); when no target is
|
|
18
|
+
# named but `--ids` are given and some table declares a `scope_column`; or when
|
|
19
|
+
# the deprecated `--scope-column` flag is set (a global column with no target).
|
|
20
|
+
# In each case every table is filtered by a shared column instead of being
|
|
21
|
+
# anchored on one named target's primary key.
|
|
21
22
|
def self.scope_mode?(table_by_name, dump_target)
|
|
22
23
|
return true unless dump_target.scope_column.nil?
|
|
23
|
-
|
|
24
|
+
if dump_target.table_name.nil?
|
|
25
|
+
return ids_given?(dump_target) && scope_column_declared?(table_by_name)
|
|
26
|
+
end
|
|
24
27
|
|
|
25
28
|
target = table_by_name[dump_target.table_name]
|
|
26
29
|
!!(target && target.respond_to?(:scope_column) && target.scope_column)
|
|
27
30
|
end
|
|
28
31
|
|
|
32
|
+
def self.ids_given?(dump_target)
|
|
33
|
+
dump_target.ids.each_value.any?(&:any?)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# An ignore:true table is not extracted, so its declaration cannot anchor the
|
|
37
|
+
# scope.
|
|
38
|
+
def self.scope_column_declared?(table_by_name)
|
|
39
|
+
table_by_name.each_value.any? do |table|
|
|
40
|
+
!table.ignore && table.respond_to?(:scope_column) && !table.scope_column.nil?
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# `--ids` without `--target-table` (and without the deprecated flag) selects
|
|
45
|
+
# scope-column mode, which needs a declared `scope_column` to start from.
|
|
46
|
+
def self.validate_ids_anchor!(table_by_name, dump_target)
|
|
47
|
+
return unless dump_target.table_name.nil? && dump_target.scope_column.nil?
|
|
48
|
+
return unless ids_given?(dump_target)
|
|
49
|
+
return if scope_column_declared?(table_by_name)
|
|
50
|
+
|
|
51
|
+
raise ArgumentError,
|
|
52
|
+
"--ids was given without --target-table, but no table in the schema declares " \
|
|
53
|
+
"`scope_column` (tables marked `ignore: true` are not counted). Either name the table " \
|
|
54
|
+
"the ids belong to with --target-table (single-target mode), or declare " \
|
|
55
|
+
"`scope_column: <column>` on the tables to filter by it (scope-column mode)."
|
|
56
|
+
end
|
|
57
|
+
|
|
29
58
|
# Strict pre-flight: abort if any extractable table cannot be scoped (scope
|
|
30
59
|
# mode), or declares a `batch_scope` its scoping shape cannot be sliced by
|
|
31
60
|
# (both modes) — before any output is written. `tables` is the set of
|
|
32
61
|
# dumpable configs (ignore:true tables are skipped — they are not extracted).
|
|
33
62
|
def self.validate_scope!(tables, table_by_name, dump_target, logger)
|
|
63
|
+
validate_ids_anchor!(table_by_name, dump_target)
|
|
64
|
+
validate_named_id_spaces_allowed!(tables, table_by_name, dump_target)
|
|
65
|
+
|
|
34
66
|
# Unscopable is reported before a bad batch_scope shape — it is the more
|
|
35
67
|
# fundamental problem.
|
|
36
68
|
if scope_mode?(table_by_name, dump_target)
|
|
@@ -57,6 +89,9 @@ module Exwiw
|
|
|
57
89
|
"add a belongs_to path to a table that carries the scope column, mark it " \
|
|
58
90
|
"`scope_exempt: true` to export it in full, or set `ignore: true` to skip it."
|
|
59
91
|
end
|
|
92
|
+
|
|
93
|
+
validate_id_space_values!(tables, table_by_name, dump_target, logger)
|
|
94
|
+
validate_single_id_space_per_table!(tables, table_by_name, dump_target, logger)
|
|
60
95
|
end
|
|
61
96
|
|
|
62
97
|
tables.reject(&:ignore).each do |table|
|
|
@@ -66,6 +101,77 @@ module Exwiw
|
|
|
66
101
|
end
|
|
67
102
|
end
|
|
68
103
|
|
|
104
|
+
# Only scope-column mode on the SQL adapters can filter by a named ID space.
|
|
105
|
+
def self.validate_named_id_spaces_allowed!(tables, table_by_name, dump_target)
|
|
106
|
+
named = dump_target.named_id_spaces
|
|
107
|
+
return if named.empty?
|
|
108
|
+
|
|
109
|
+
given = named.map { |space| "'#{space}'" }.join(', ')
|
|
110
|
+
# The CLI rejects this up front; this covers callers that build a Runner directly.
|
|
111
|
+
if tables.any? { |table| table.is_a?(MongodbCollectionConfig) }
|
|
112
|
+
raise ArgumentError,
|
|
113
|
+
"--ids were given for the ID space(s) #{given}, but the mongodb adapter supports " \
|
|
114
|
+
"only the default ID space (--ids without a `<id_space>=` prefix)."
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
return if scope_mode?(table_by_name, dump_target)
|
|
118
|
+
|
|
119
|
+
raise ArgumentError,
|
|
120
|
+
"--ids were given for the ID space(s) #{given}, but --target-table " \
|
|
121
|
+
"'#{dump_target.table_name}' declares no scope_column, so the run is single-target mode, " \
|
|
122
|
+
"which uses only the default ID space. Name a table that declares a scope_column as " \
|
|
123
|
+
"--target-table, or drop the named --ids."
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Every ID space a scope_column belongs to must be given values,
|
|
127
|
+
# and every ID space given values must be used by some table.
|
|
128
|
+
def self.validate_id_space_values!(tables, table_by_name, dump_target, logger)
|
|
129
|
+
tables_by_space =
|
|
130
|
+
tables.reject(&:ignore).each_with_object({}) do |table, hash|
|
|
131
|
+
space = new(table.name, table_by_name, dump_target, logger).declared_id_space
|
|
132
|
+
(hash[space] ||= []) << table.name if space
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
missing = tables_by_space.keys.select { |space| dump_target.ids_for(space).empty? }.sort
|
|
136
|
+
if missing.any?
|
|
137
|
+
details = missing.map do |space|
|
|
138
|
+
flag = space == DEFAULT_ID_SPACE ? "--ids=<ids>" : "--ids=#{space}=<ids>"
|
|
139
|
+
"'#{space}' (#{tables_by_space[space].sort.join(', ')}; pass #{flag})"
|
|
140
|
+
end
|
|
141
|
+
raise ArgumentError,
|
|
142
|
+
"scope-column mode: no ids were given for the ID space(s) that these tables are " \
|
|
143
|
+
"filtered by: #{details.join('; ')}."
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
unused = dump_target.ids.keys.reject { |space| tables_by_space.key?(space) }.sort
|
|
147
|
+
if unused.any?
|
|
148
|
+
raise ArgumentError,
|
|
149
|
+
"scope-column mode: ids were given for the ID space(s) #{unused.map { |space| "'#{space}'" }.join(', ')}, " \
|
|
150
|
+
"but no table declares a scope_column in them (a table without `id_space` is in " \
|
|
151
|
+
"'#{DEFAULT_ID_SPACE}'). Check the ID space name, or declare `id_space` on the tables it scopes."
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# A table is filtered by the values of one ID space only. Without this check
|
|
156
|
+
# the belongs_to walk would silently settle on the nearest scoped table and
|
|
157
|
+
# ignore a route into another space.
|
|
158
|
+
def self.validate_single_id_space_per_table!(tables, table_by_name, dump_target, logger)
|
|
159
|
+
return if dump_target.ids.size < 2
|
|
160
|
+
|
|
161
|
+
mixed =
|
|
162
|
+
tables.reject(&:ignore).filter_map do |table|
|
|
163
|
+
spaces = new(table.name, table_by_name, dump_target, logger).reached_id_spaces
|
|
164
|
+
"#{table.name} (#{spaces.join(', ')})" if spaces.size > 1
|
|
165
|
+
end
|
|
166
|
+
return if mixed.empty?
|
|
167
|
+
|
|
168
|
+
raise ArgumentError,
|
|
169
|
+
"scope-column mode: #{mixed.size} table(s) reach more than one ID space: #{mixed.sort.join(', ')}. " \
|
|
170
|
+
"A table must be scoped by the values of a single ID space; set `ignore: true` on the " \
|
|
171
|
+
"belongs_to (or remove the reverse_scope arm) that leads into the other space, or declare " \
|
|
172
|
+
"`scope_column:` on the table to filter it directly."
|
|
173
|
+
end
|
|
174
|
+
|
|
69
175
|
attr_reader :table_name, :table_by_name, :dump_target
|
|
70
176
|
|
|
71
177
|
# The multiple constrained referencers (comma-joined) that made the
|
|
@@ -530,7 +636,7 @@ module Exwiw
|
|
|
530
636
|
clauses.push Exwiw::QueryAst::WhereClause.new(
|
|
531
637
|
column_name: dump_target.ids_field || table.primary_key,
|
|
532
638
|
operator: :eq,
|
|
533
|
-
value: dump_target.
|
|
639
|
+
value: dump_target.default_ids
|
|
534
640
|
)
|
|
535
641
|
|
|
536
642
|
return clauses
|
|
@@ -572,7 +678,7 @@ module Exwiw
|
|
|
572
678
|
return Exwiw::QueryAst::WhereClause.new(
|
|
573
679
|
column_name: foreign_key,
|
|
574
680
|
operator: :eq,
|
|
575
|
-
value: dump_target.
|
|
681
|
+
value: dump_target.default_ids
|
|
576
682
|
)
|
|
577
683
|
end
|
|
578
684
|
|
|
@@ -584,7 +690,7 @@ module Exwiw
|
|
|
584
690
|
table_name: target.name,
|
|
585
691
|
select_column: target.primary_key,
|
|
586
692
|
where_column: dump_target.ids_field,
|
|
587
|
-
where_values: dump_target.
|
|
693
|
+
where_values: dump_target.default_ids
|
|
588
694
|
)
|
|
589
695
|
)
|
|
590
696
|
end
|
|
@@ -672,7 +778,9 @@ module Exwiw
|
|
|
672
778
|
# ------------------------------------------------------------------
|
|
673
779
|
|
|
674
780
|
private def scope_mode?
|
|
675
|
-
|
|
781
|
+
return @scope_mode if defined?(@scope_mode)
|
|
782
|
+
|
|
783
|
+
@scope_mode = self.class.scope_mode?(table_by_name, dump_target)
|
|
676
784
|
end
|
|
677
785
|
|
|
678
786
|
# Classifier used by validate_scope! and mirrored by build_scoped below.
|
|
@@ -687,6 +795,27 @@ module Exwiw
|
|
|
687
795
|
:unscopable
|
|
688
796
|
end
|
|
689
797
|
|
|
798
|
+
# The ID space this table's scope column belongs to, or nil when it has none.
|
|
799
|
+
# A scope_exempt table is dumped in full, but tables reaching it through
|
|
800
|
+
# belongs_to are still filtered by its scope column, so its ID space counts.
|
|
801
|
+
def declared_id_space
|
|
802
|
+
table = table_by_name.fetch(table_name)
|
|
803
|
+
return nil unless directly_scoped?(table)
|
|
804
|
+
|
|
805
|
+
id_space_of(table)
|
|
806
|
+
end
|
|
807
|
+
|
|
808
|
+
# The ID spaces a table that is not directly scoped can be scoped through:
|
|
809
|
+
# those of the scoped tables its belongs_to walk can stop at, and those its
|
|
810
|
+
# built query filters by (polymorphic arms, reverse_scope, referenced-by and
|
|
811
|
+
# the parent cascade).
|
|
812
|
+
def reached_id_spaces
|
|
813
|
+
table = table_by_name.fetch(table_name)
|
|
814
|
+
return [] if scope_exempt?(table) || directly_scoped?(table)
|
|
815
|
+
|
|
816
|
+
(belongs_to_reachable_id_spaces(table) | QueryAst.id_spaces(run)).sort
|
|
817
|
+
end
|
|
818
|
+
|
|
690
819
|
# True when this table may still attempt the forward "scope via a scoped
|
|
691
820
|
# belongs_to parent" rescue: it is not already on the forward-resolution
|
|
692
821
|
# path, so descending into its parent cannot revisit it (a belongs_to cycle).
|
|
@@ -791,15 +920,43 @@ module Exwiw
|
|
|
791
920
|
table.columns.any? { |c| c.name == column }
|
|
792
921
|
end
|
|
793
922
|
|
|
923
|
+
private def id_space_of(table)
|
|
924
|
+
table.id_space || DEFAULT_ID_SPACE
|
|
925
|
+
end
|
|
926
|
+
|
|
794
927
|
private def scope_where_clause(table)
|
|
795
928
|
batch_clause = batch_ids_clause(table)
|
|
796
929
|
return batch_clause if batch_clause
|
|
797
930
|
|
|
798
|
-
Exwiw::QueryAst::
|
|
931
|
+
Exwiw::QueryAst::ScopeWhereClause.new(
|
|
799
932
|
column_name: resolved_scope_column(table),
|
|
800
933
|
operator: :eq,
|
|
801
|
-
value: dump_target.
|
|
802
|
-
)
|
|
934
|
+
value: dump_target.ids_for(id_space_of(table))
|
|
935
|
+
).tap { |clause| clause.id_space = id_space_of(table) }
|
|
936
|
+
end
|
|
937
|
+
|
|
938
|
+
# The ID spaces of every directly scoped table the belongs_to walk of
|
|
939
|
+
# #find_path_to_scoped could stop at, not only the nearest one.
|
|
940
|
+
private def belongs_to_reachable_id_spaces(table)
|
|
941
|
+
visited = Set[table.name]
|
|
942
|
+
queue = [table]
|
|
943
|
+
spaces = Set.new
|
|
944
|
+
|
|
945
|
+
until queue.empty?
|
|
946
|
+
current = queue.shift
|
|
947
|
+
current.belongs_tos.each do |relation|
|
|
948
|
+
next_table = table_by_name[relation.table_name]
|
|
949
|
+
next if next_table.nil? || !visited.add?(next_table.name)
|
|
950
|
+
|
|
951
|
+
if directly_scoped?(next_table)
|
|
952
|
+
spaces << id_space_of(next_table)
|
|
953
|
+
else
|
|
954
|
+
queue.push(next_table)
|
|
955
|
+
end
|
|
956
|
+
end
|
|
957
|
+
end
|
|
958
|
+
|
|
959
|
+
spaces
|
|
803
960
|
end
|
|
804
961
|
|
|
805
962
|
# This batch's ids, in place of the batch table's scope filter. nil when the
|
data/lib/exwiw/runner.rb
CHANGED
data/lib/exwiw/table_config.rb
CHANGED
|
@@ -38,6 +38,9 @@ module Exwiw
|
|
|
38
38
|
# different column name on this table).
|
|
39
39
|
attribute :scope_exempt, Serdes::OptionalType.new(Serdes::ConcreteType.new(Boolean)), skip_serializing_if_nil: true
|
|
40
40
|
attribute :scope_column, optional(String), skip_serializing_if_nil: true
|
|
41
|
+
# The ID space (Exwiw::DumpTarget#ids) the `scope_column` values belong to.
|
|
42
|
+
# nil means `default`. User-configured, never generated.
|
|
43
|
+
attribute :id_space, optional(String), skip_serializing_if_nil: true
|
|
41
44
|
|
|
42
45
|
# `reverse_scope` opts a table into multi-referencer reverse scoping (see
|
|
43
46
|
# Exwiw::ReverseScope and QueryAstBuilder#build_referenced_by_clause): a
|
|
@@ -175,6 +178,7 @@ module Exwiw
|
|
|
175
178
|
merged_table.bulk_insert_chunk_size = bulk_insert_chunk_size
|
|
176
179
|
merged_table.scope_exempt = scope_exempt
|
|
177
180
|
merged_table.scope_column = scope_column
|
|
181
|
+
merged_table.id_space = id_space
|
|
178
182
|
merged_table.reverse_scope = reverse_scope
|
|
179
183
|
merged_table.batch_scope = batch_scope
|
|
180
184
|
|
|
@@ -250,6 +254,19 @@ module Exwiw
|
|
|
250
254
|
"Table '#{name}': reverse_scope.column '#{reverse_scope.column}' is not a declared column."
|
|
251
255
|
end
|
|
252
256
|
|
|
257
|
+
if id_space
|
|
258
|
+
unless id_space.match?(ID_SPACE_NAME_PATTERN)
|
|
259
|
+
raise ArgumentError,
|
|
260
|
+
"Table '#{name}': id_space '#{id_space}' must start with a lowercase letter and " \
|
|
261
|
+
"contain only lowercase letters, digits and underscores."
|
|
262
|
+
end
|
|
263
|
+
if scope_column.nil?
|
|
264
|
+
raise ArgumentError,
|
|
265
|
+
"Table '#{name}': id_space names the ID space of the scope_column values, " \
|
|
266
|
+
"so it requires scope_column."
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
253
270
|
columns.each { |column| validate_ruby_side_masking!(column) }
|
|
254
271
|
end
|
|
255
272
|
end
|
data/lib/exwiw/version.rb
CHANGED
data/lib/exwiw.rb
CHANGED
|
@@ -64,7 +64,46 @@ module Exwiw
|
|
|
64
64
|
# (`scope_column IN ids`) and tables lacking it are reached by walking
|
|
65
65
|
# belongs_to up to the nearest table that has it. When set, `table_name` is
|
|
66
66
|
# nil. SQL adapters only.
|
|
67
|
-
|
|
67
|
+
#
|
|
68
|
+
# `ids` maps an ID space name to its values. A table's `scope_column` values
|
|
69
|
+
# belong to the ID space it declares (`id_space`, `default` when omitted), so
|
|
70
|
+
# groups of tables keyed by unrelated kinds of id can be scoped in one run. An
|
|
71
|
+
# Array is taken as the values of the `default` space. Single-target mode and
|
|
72
|
+
# the mongodb adapter use the `default` space only.
|
|
73
|
+
DEFAULT_ID_SPACE = "default"
|
|
74
|
+
ID_SPACE_NAME_PATTERN = /\A[a-z][a-z0-9_]*\z/
|
|
75
|
+
|
|
76
|
+
DumpTarget = Struct.new(:table_name, :ids, :ids_field, :scope_column, keyword_init: true) do
|
|
77
|
+
def self.normalize_ids(ids)
|
|
78
|
+
case ids
|
|
79
|
+
when nil then {}
|
|
80
|
+
when Hash then ids.to_h { |space, values| [space.to_s, Array(values)] }
|
|
81
|
+
else { DEFAULT_ID_SPACE => Array(ids) }
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def initialize(**kwargs)
|
|
86
|
+
super
|
|
87
|
+
self.ids = ids
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def ids=(value)
|
|
91
|
+
self[:ids] = self.class.normalize_ids(value)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def ids_for(id_space)
|
|
95
|
+
ids.fetch(id_space, [])
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def default_ids
|
|
99
|
+
ids_for(DEFAULT_ID_SPACE)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# ID spaces other than `default` that were given, with or without values.
|
|
103
|
+
def named_id_spaces
|
|
104
|
+
ids.keys - [DEFAULT_ID_SPACE]
|
|
105
|
+
end
|
|
106
|
+
end
|
|
68
107
|
# `uri` is an optional full connection string (currently only honored by the
|
|
69
108
|
# mongodb adapter, e.g. `mongodb+srv://...`). When present it is the source of
|
|
70
109
|
# truth for the connection — host/port/user/password are ignored — so TLS,
|