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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2df915162dc88ac29ccc262c436d5d7ab1797a7e026398ced3e66d1e5b73a7b7
4
- data.tar.gz: 3144680abf12865a3db9d4173e4338101fa318da9f5e61e3367c5924fb24f9c6
3
+ metadata.gz: 345a459c251b837b718e00ed92b011c8975016af2a92149868fcd24744356041
4
+ data.tar.gz: c79d6c2525ae322bab05956102c424eecc6fa40864e0bc7d581912a68d20928e
5
5
  SHA512:
6
- metadata.gz: 2726b1b47c6584d6a85ab6f1342fdfb8ac760d237c09854f81a34e36d403a3720efbe25ee47185d3f381b4384e6a3f75f47ff6002fcbaa09d4613f937b39cc1f
7
- data.tar.gz: 75bb6c28ecd32adaa2aeb3488d319784811ad4ab08104560dea54982e94de51c7a7ff606b81815f6d73a41608f5f6dae49c57774a35ad837a8b6b5dab994c45d
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 name any scoped table as `--target-table` and pass the scope values as `--ids`:
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
- --target-table=shops --ids=42,43 \
203
+ --ids=42,43 \
197
204
  --output-dir=dump
198
205
  ```
199
206
 
200
- Because `shops` declares a `scope_column`, exwiw switches to scope-column mode: the
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` itself is scoped by `business_entity_id IN (42,43)` like every other scoped
203
- table — it is *not* used as a primary-key anchor. (A table that declares a
204
- `scope_column` therefore can no longer be single-extracted by primary key.)
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). It works with `exwiw
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 the value space
284
+ #### Per-table `scope_column` and ID spaces
270
285
 
271
- Scope-column mode assumes a single shared **value** space — the same `--ids` apply
272
- to every scoped table. Each table names its own column, so a table that stores that
273
- same value under a differently named column simply declares that name:
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
- Both `scope_exempt` and `scope_column` are user-maintained and preserved across
285
- `schema:generate` regeneration (the generators never emit them).
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 running with `--target-table`. A per-table
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.ids } }
167
+ { dump_target.ids_field => { "$in" => dump_target.default_ids } }
168
168
  else
169
- { config.primary_key => { "$in" => coerce_ids(dump_target.ids) } }
169
+ { config.primary_key => { "$in" => coerce_ids(dump_target.default_ids) } }
170
170
  end
171
171
  else
172
172
  related_collection_filter(config, config_by_name, dump_target)
@@ -4,25 +4,26 @@ require 'erb'
4
4
 
5
5
  module Exwiw
6
6
  class AfterInsertHook
7
- def self.run(path:, cli_options:, output_dir:, next_idx:, output_extension:, logger:)
7
+ def self.run(path:, cli_options:, dump_target:, output_dir:, next_idx:, output_extension:, logger:)
8
8
  ext = File.extname(path)
9
9
 
10
10
  if ext == '.rb'
11
11
  run_ruby(
12
12
  path: path,
13
13
  cli_options: cli_options,
14
+ dump_target: dump_target,
14
15
  output_dir: output_dir,
15
16
  next_idx: next_idx,
16
17
  output_extension: output_extension,
17
18
  logger: logger,
18
19
  )
19
20
  else
20
- run_shell(path: path, cli_options: cli_options, output_dir: output_dir, logger: logger)
21
+ run_shell(path: path, cli_options: cli_options, dump_target: dump_target, output_dir: output_dir, logger: logger)
21
22
  end
22
23
  end
23
24
 
24
- def self.run_ruby(path:, cli_options:, output_dir:, next_idx:, output_extension:, logger:)
25
- ctx = Context.new(cli_options, output_extension: output_extension)
25
+ def self.run_ruby(path:, cli_options:, dump_target:, output_dir:, next_idx:, output_extension:, logger:)
26
+ ctx = Context.new(cli_options, dump_target: dump_target, output_extension: output_extension)
26
27
  ctx.instance_eval(File.read(path), path)
27
28
 
28
29
  # One output file per buffer, numbered sequentially from next_idx: first
@@ -53,7 +54,7 @@ module Exwiw
53
54
  end
54
55
  end
55
56
 
56
- def self.run_shell(path:, cli_options:, output_dir:, logger:)
57
+ def self.run_shell(path:, cli_options:, dump_target:, output_dir:, logger:)
57
58
  env = {
58
59
  'EXWIW_OUTPUT_DIR' => output_dir,
59
60
  'EXWIW_SCHEMA_DIR' => cli_options[:schema_dir].to_s,
@@ -64,9 +65,12 @@ module Exwiw
64
65
  'EXWIW_DATABASE_NAME' => cli_options[:database_name].to_s,
65
66
  'EXWIW_TARGET_TABLE' => cli_options[:target_table].to_s,
66
67
  'EXWIW_SCOPE_COLUMN' => cli_options[:scope_column].to_s,
67
- 'EXWIW_IDS' => Array(cli_options[:ids]).join(','),
68
+ 'EXWIW_IDS' => dump_target.default_ids.join(','),
68
69
  'EXWIW_OUTPUT_FORMAT' => cli_options[:output_format].to_s,
69
70
  }
71
+ dump_target.ids.each do |space, values|
72
+ env["EXWIW_IDS_#{space.upcase}"] = Array(values).join(',')
73
+ end
70
74
  logger.info("Running after-insert shell hook: #{path}")
71
75
  ok = system(env, path)
72
76
  raise "after-insert shell hook failed: #{path}" unless ok
@@ -79,13 +83,19 @@ module Exwiw
79
83
 
80
84
  attr_reader :cli_options, :collected, :collected_by_collection
81
85
 
82
- def initialize(cli_options, output_extension: nil)
86
+ def initialize(cli_options, dump_target:, output_extension: nil)
83
87
  @cli_options = cli_options
88
+ @dump_target = dump_target
84
89
  @output_extension = output_extension
85
90
  @collected = []
86
91
  @collected_by_collection = {}
87
92
  end
88
93
 
94
+ # The values the run was scoped by for the given ID space (`default` when omitted).
95
+ def ids_for(id_space = DEFAULT_ID_SPACE)
96
+ @dump_target.ids_for(id_space.to_s)
97
+ end
98
+
89
99
  def insert_sql(template)
90
100
  @collected << render(template)
91
101
  end
data/lib/exwiw/cli.rb CHANGED
@@ -111,7 +111,8 @@ module Exwiw
111
111
  @database_name = nil
112
112
  @target_table_name = nil
113
113
  @target_collection_name = nil
114
- @ids = []
114
+ @ids_by_id_space = {}
115
+ @ids_errors = []
115
116
  @ids_field = nil
116
117
  @scope_column = nil
117
118
  @output_format = nil
@@ -151,7 +152,7 @@ module Exwiw
151
152
 
152
153
  dump_target = DumpTarget.new(
153
154
  table_name: @target_table_name,
154
- ids: @ids,
155
+ ids: @ids_by_id_space,
155
156
  ids_field: @ids_field,
156
157
  scope_column: @scope_column,
157
158
  )
@@ -297,6 +298,9 @@ module Exwiw
297
298
  # Fill in any options not given on the CLI from the config file. Done first
298
299
  # so a config-provided `adapter` is in place before normalization below.
299
300
  # CLI values always win (the merge only fills nil/empty ivars).
301
+ @ids_errors.each { |message| $stderr.puts message }
302
+ exit 1 if @ids_errors.any?
303
+
300
304
  apply_config_file!
301
305
 
302
306
  # Default log level once CLI and config have both had their say.
@@ -316,6 +320,7 @@ module Exwiw
316
320
  end
317
321
 
318
322
  resolve_target_collection_alias!
323
+ resolve_named_ids!
319
324
  resolve_scope_column!
320
325
  resolve_ids_field!
321
326
  resolve_uri_option!
@@ -391,18 +396,22 @@ module Exwiw
391
396
  exit 1
392
397
  end
393
398
 
394
- if @target_table_name && @ids.empty?
399
+ if @target_table_name && !ids_given?
395
400
  $stderr.puts "--ids is required when --target-table is specified"
396
401
  exit 1
397
402
  end
398
403
 
399
- if @scope_column && @ids.empty?
404
+ if @scope_column && !ids_given?
400
405
  $stderr.puts "--ids is required when --scope-column is specified"
401
406
  exit 1
402
407
  end
403
408
 
404
- if !@target_table_name && !@scope_column && @ids.any?
405
- $stderr.puts "--target-table or --scope-column is required when --ids is specified"
409
+ # For the SQL adapters, --ids without a target is scope-column mode; whether
410
+ # the schema supports it is checked once it is loaded
411
+ # (QueryAstBuilder.validate_scope!). MongoDB has no scope-column mode.
412
+ if @database_adapter == "mongodb" && !@target_table_name && default_ids.any?
413
+ $stderr.puts "--target-collection (or --target-table) is required when --ids is specified " \
414
+ "with the mongodb adapter"
406
415
  exit 1
407
416
  end
408
417
 
@@ -479,11 +488,23 @@ module Exwiw
479
488
  @log_level ||= config["log_level"]&.to_sym
480
489
  @target_table_name ||= config["target_table"]
481
490
  @target_collection_name ||= config["target_collection"]
482
- if @ids.empty? && config.key?("ids")
491
+ # Like the other keys, `ids` is taken from the config only when the CLI
492
+ # passed no --ids at all; the two are never merged per ID space.
493
+ if !ids_given? && config.key?("ids")
483
494
  raw = config["ids"]
484
- # Accept either a YAML list or a "1,2" string; coerce to strings to match
485
- # the CLI's `--ids=1,2` -> ["1", "2"] shape.
486
- @ids = (raw.is_a?(String) ? raw.split(",") : Array(raw)).map(&:to_s)
495
+ if raw.is_a?(Hash)
496
+ raw.each do |space, values|
497
+ space = space.to_s
498
+ unless space.match?(ID_SPACE_NAME_PATTERN)
499
+ $stderr.puts "config 'ids' keys must be ID space names (a lowercase letter followed by " \
500
+ "lowercase letters, digits or underscores; got #{space.inspect}) in #{path}"
501
+ exit 1
502
+ end
503
+ @ids_by_id_space[space] = parse_config_ids(values)
504
+ end
505
+ else
506
+ @ids_by_id_space[DEFAULT_ID_SPACE] = parse_config_ids(raw)
507
+ end
487
508
  end
488
509
  @ids_field ||= config["ids_field"]
489
510
  @scope_column ||= config["scope_column"]
@@ -492,6 +513,56 @@ module Exwiw
492
513
  @explain_verbosity ||= config["explain_verbosity"]
493
514
  end
494
515
 
516
+ # Accept either a YAML list or a "1,2" string; coerce to strings to match
517
+ # the CLI's `--ids=1,2` -> ["1", "2"] shape.
518
+ private def parse_config_ids(raw)
519
+ (raw.is_a?(String) ? raw.split(",") : Array(raw)).map(&:to_s)
520
+ end
521
+
522
+ # `--ids=<id_space>=1,2` gives the values of a named ID space; without the
523
+ # prefix the values are the default space's. The prefix is recognized only
524
+ # when it is shaped like an ID space name, so an id containing `=` is passed
525
+ # as `--ids=default=<ids>`. Each ID space may be given once: a second --ids
526
+ # for it would otherwise replace the first without notice.
527
+ private def add_ids_option(value)
528
+ prefix, rest = value.to_s.split("=", 2)
529
+ space, list =
530
+ if rest && prefix.match?(ID_SPACE_NAME_PATTERN)
531
+ [prefix, rest]
532
+ else
533
+ [DEFAULT_ID_SPACE, value.to_s]
534
+ end
535
+
536
+ if @ids_by_id_space.key?(space)
537
+ @ids_errors << "--ids was given more than once for the ID space '#{space}'; " \
538
+ "pass all of its ids in one comma-separated --ids."
539
+ end
540
+ @ids_by_id_space[space] = list.split(",")
541
+ end
542
+
543
+ private def ids_given?
544
+ @ids_by_id_space.each_value.any?(&:any?)
545
+ end
546
+
547
+ private def default_ids
548
+ @ids_by_id_space.fetch(DEFAULT_ID_SPACE, [])
549
+ end
550
+
551
+ # Named ID spaces exist only for scope-column mode, which the mongodb
552
+ # adapter does not have. Whether a SQL run is in scope-column mode depends on
553
+ # the schema, so that is checked once it is loaded (QueryAstBuilder.validate_scope!).
554
+ private def resolve_named_ids!
555
+ named = @ids_by_id_space.keys - [DEFAULT_ID_SPACE]
556
+ return if named.empty?
557
+
558
+ if @database_adapter == "mongodb"
559
+ $stderr.puts "--ids for a named ID space (#{named.map { |space| "'#{space}'" }.join(', ')}) " \
560
+ "is not supported by the mongodb adapter, which uses only the default ID space " \
561
+ "(--ids without an `<id_space>=` prefix)."
562
+ exit 1
563
+ end
564
+ end
565
+
495
566
  # Strip a trailing slash (like the CLI's dir options) and expand relative to
496
567
  # `base` (the config file's directory). Returns nil for a nil value.
497
568
  private def expand_dir(value, base)
@@ -542,8 +613,8 @@ module Exwiw
542
613
 
543
614
  # `--scope-column` is **deprecated**: it selected scope-column mode with a
544
615
  # single global column for every table. The preferred way is to declare a
545
- # per-table `scope_column:` in the schema config and pass `--target-table`
546
- # (the target is then scoped like any other table). The flag still works as
616
+ # per-table `scope_column:` in the schema config (`--target-table` is then
617
+ # optional). The flag still works as
547
618
  # before — SQL-only and mutually exclusive with `--target-table` — but emits a
548
619
  # deprecation warning. Runs after resolve_target_collection_alias! so
549
620
  # --target-collection is already folded into @target_table_name.
@@ -567,7 +638,7 @@ module Exwiw
567
638
  end
568
639
 
569
640
  $stderr.puts "warning: --scope-column is deprecated; declare a per-table `scope_column:` " \
570
- "in the schema config and run with --target-table instead."
641
+ "in the schema config and drop the flag instead."
571
642
  end
572
643
 
573
644
  # `--uri` supplies a full connection string (e.g. `mongodb+srv://...`) and is
@@ -780,7 +851,7 @@ module Exwiw
780
851
  database_adapter: @database_adapter,
781
852
  database_name: @database_name,
782
853
  target_table: @target_table_name,
783
- ids: @ids.dup.freeze,
854
+ ids: default_ids.dup.freeze,
784
855
  ids_field: @ids_field,
785
856
  scope_column: @scope_column,
786
857
  output_format: @output_format,
@@ -849,11 +920,11 @@ module Exwiw
849
920
  opts.on("-a", "--adapter=ADAPTER", "Database adapter: mysql, sqlite, postgresql, mongodb (aliases: mysql2, sqlite3)") { |v| @database_adapter = v }
850
921
  opts.on("--uri=URI", "Full MongoDB connection URI (mongodb:// or mongodb+srv://). mongodb adapter only; takes precedence over --host/--port/--user. TLS, replicaSet, authSource and credentials are read from the URI.") { |v| @connection_uri = v }
851
922
  opts.on("--database=DATABASE", "Target database name") { |v| @database_name = v }
852
- opts.on("--target-table=[TABLE]", "Target table for extraction. If omitted, dump all tables.") { |v| @target_table_name = v }
923
+ opts.on("--target-table=[TABLE]", "Target table for extraction. If omitted with --ids, run in scope-column mode (SQL adapters; the schema must declare `scope_column:`). If omitted without --ids, dump all tables.") { |v| @target_table_name = v }
853
924
  opts.on("--target-collection=[COLLECTION]", "Alias of --target-table for the mongodb adapter.") { |v| @target_collection_name = v }
854
- opts.on("--ids=[IDS]", "Comma-separated list of identifiers. Required when --target-table is given.") { |v| @ids = v.split(',') }
925
+ opts.on("--ids=[IDS]", "Comma-separated list of identifiers. Required when --target-table is given. Prefix with `<id_space>=` (e.g. --ids=org=a,b) to give the values of a named ID space; may be repeated, once per ID space. An id containing `=` is passed as --ids=default=<ids>.") { |v| add_ids_option(v) }
855
926
  opts.on("--ids-field=[FIELD]", "Field on the target collection that --ids is matched against. Defaults to the primary key. (mongodb adapter only)") { |v| @ids_field = v }
856
- opts.on("--scope-column=[COLUMN]", "DEPRECATED. Filter every table by this shared global column (--ids are its values) instead of a single --target-table. SQL adapters only; mutually exclusive with --target-table. Prefer declaring a per-table `scope_column:` in the schema config and running with --target-table.") { |v| @scope_column = v }
927
+ opts.on("--scope-column=[COLUMN]", "DEPRECATED. Filter every table by this shared global column (--ids are its values) instead of a single --target-table. SQL adapters only; mutually exclusive with --target-table. Prefer declaring a per-table `scope_column:` in the schema config.") { |v| @scope_column = v }
857
928
  opts.on("--output-format=[FORMAT]", "Output format: insert (default) or copy (PostgreSQL only, export subcommand only)") { |v| @output_format = v }
858
929
  opts.on("--insert-only", "DEPRECATED: ignored. exwiw no longer generates delete-*.sql files, so every export is insert-only.") do
859
930
  $stderr.puts "warning: --insert-only is obsolete and ignored (exwiw no longer generates delete-*.sql files); remove the flag"
@@ -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 EITHER the named `--target-table` declares a
16
- # per-table `scope_column` (the preferred trigger: the target is then scoped
17
- # like any other table — its `--ids` are scope-column values, not primary
18
- # keys), OR the deprecated `--scope-column` flag is set (a global column with no
19
- # target). In both cases every table is filtered by a shared column instead of
20
- # being anchored on one named target's primary key.
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
- return false if dump_target.table_name.nil?
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.ids
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.ids
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.ids
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
- self.class.scope_mode?(table_by_name, dump_target)
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::WhereClause.new(
931
+ Exwiw::QueryAst::ScopeWhereClause.new(
799
932
  column_name: resolved_scope_column(table),
800
933
  operator: :eq,
801
- value: dump_target.ids
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
@@ -203,6 +203,7 @@ module Exwiw
203
203
  AfterInsertHook.run(
204
204
  path: @after_insert_hook_path,
205
205
  cli_options: @cli_options,
206
+ dump_target: @dump_target,
206
207
  output_dir: @output_dir,
207
208
  next_idx: total_size + 1,
208
209
  output_extension: adapter.output_extension,
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Exwiw
4
- VERSION = "1.1.5"
4
+ VERSION = "1.1.6"
5
5
  end
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
- DumpTarget = Struct.new(:table_name, :ids, :ids_field, :scope_column, keyword_init: true)
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,
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: 1.1.5
4
+ version: 1.1.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shia