exwiw 0.9.18 → 0.9.19

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: 329bc5d4ceae914d8ba650d64b00a9d83916b1aa2f753cab6310ea61d3c6989a
4
- data.tar.gz: 5f404952e90a272b7b4818257901f8e3dec7cbcdb0e4ae63310c728374790dad
3
+ metadata.gz: 9f565edc88b4eba108ef4d9accf7bbaa7d48f0e1700007cc9d280b0033e357b6
4
+ data.tar.gz: 822e5cd0b9d33c81d0b396cc641fa960cf80bffbebd6bd078322460a140b37cf
5
5
  SHA512:
6
- metadata.gz: 77b947ce4141e5ed17e6faf4a1f6e4f910e8f0a70d96274fff3e7d33be11a1adeed2fa2ddbe59787ce6f9b4572ab969432a50bcccd2efb758e1c1c423dedb8cd
7
- data.tar.gz: de8c14e52a77e610d950381af8f0028b4e78c0f572e0e7f1bcbeda37d9c824c1d04ddf6683fe9e80150631dc56df9aac340598b30c53eba25d207c0bad8057f4
6
+ metadata.gz: 20da3c9ee09865762f40a04b6c4bf6cf24582545ba7c51945a69aed69798906a19a214c9bd2ad588e0b6740f29a3ebfa08a122845f9a28fe64b654ba723088a0
7
+ data.tar.gz: db91db3a7e1774d386f45d3582301098a23df86f48fe790fae3631895b87fe686dcce3d048a17827f69ce530f948ca423bb9028f8a4b2495d167e820edd6f3fb
data/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.9.19] - 2026-08-04
6
+
7
+ ### Added
8
+
9
+ - **`batch_scope`: extract a table as one query per slice of the scope's id set, so a very large table stays index-driven instead of degrading into a full scan.** A scoped table reached through a `belongs_to` hop compiles to `SELECT t.* FROM t JOIN parent ON t.parent_id = parent.id AND parent.tenant_id IN (...)`, which is index-driven while the scope keeps few parent rows. Past some number of them the planner's estimate of "probe the foreign-key index once per parent row" exceeds its estimate of "scan the table once" and it switches to a sequential scan of the whole table — for a result set that is a small fraction of it. On a table of hundreds of millions of rows that scan exceeds the server's `statement_timeout` (or runs for hours), and a `filter` on the extracted table does not help: a predicate that reduces the *output* does not reduce the *work* once the plan is a scan. `batch_scope: { "table": "<scoped table>", "size": 1000 }` names the scoped table this one reaches, resolves that table's in-scope primary keys once (from its own extraction query, so it is narrowed by exactly the filter it would carry anyway), and extracts one `size`-sized slice of those ids at a time, each batch carrying `<batch table>.<pk> IN (<ids>)` in place of the scope filter. A literal id list of that size is exactly estimated and selective, so the foreign-key index is unambiguously the cheapest plan for every batch, and total work becomes proportional to the rows the table keeps rather than to the table's size. **The dumped rows are the same as the unbatched query's**, in batch-by-batch order: the slices partition the id set, so no row is dropped or emitted twice, and the ids are sorted in exwiw before slicing (not via `ORDER BY`, which would push a sort onto the source DB), so the batched output is reproducible run to run. The batch table may be any number of hops up the path (a table two hops below it names the same batch table, and the ids are applied where the path meets the scope, bounding the whole join chain), or the table itself when it carries the scope column. Because a batch key only splits an extraction correctly when *every* row the table keeps is selected through the batch table's scope filter, the supported shapes are deliberately narrow — scope-column mode, and either a directly scoped table naming itself or a single `belongs_to` join path terminating at the named table. Polymorphic arm `UNION`s, `reverse_scope`, referenced-by, the parent cascade, `scope_exempt` (on either side — an exempt batch table's id set would not be scoped, so its batches would reach outside the scope) and single `--target-table` mode are rejected with an explanation rather than silently mis-sliced (they keep rows by routes a batch of ids does not constrain, so every batch would re-emit them), and the rejection happens in the pre-flight validation, before any output is written. `exwiw explain` additionally prints the id-set query and its `EXPLAIN` for a batched table; it cannot show a batch's literal ids, since it executes no extraction SELECT. `delete-*.sql` is generated from the unbatched query as before, `bulk_insert_chunk_size` is independent (batches are query boundaries, chunks are statement boundaries), and output for every table without `batch_scope` is byte-identical.
10
+
5
11
  ## [0.9.18] - 2026-08-03
6
12
 
7
13
  ### Fixed
data/README.md CHANGED
@@ -738,6 +738,60 @@ Unlike rails-managed entries, `columns` and `belongs_tos` are retained so the en
738
738
 
739
739
  If omitted, the adapter default applies: 10,000 rows per statement for the SQL adapters (1,000 documents per chunk for MongoDB). Tables at or below the chunk size still produce a single `INSERT` statement. To force a single statement regardless of table size, set a value larger than the table's row count.
740
740
 
741
+ ### Batched extraction (`batch_scope`)
742
+
743
+ A scoped table is normally extracted with one query, whose scope filter sits on the table it joins up to:
744
+
745
+ ```sql
746
+ SELECT activities.* FROM activities
747
+ JOIN customers ON activities.customer_id = customers.id
748
+ AND customers.tenant_id IN ('t1')
749
+ ```
750
+
751
+ That is index-driven while the scope keeps few `customers`. Past some number of them the planner's estimate of "probe the foreign-key index once per customer" exceeds its estimate of "scan the table once", and it switches to a **sequential scan of the whole table** — for a result set that is a small fraction of it. On a table of hundreds of millions of rows the scan then exceeds the server's `statement_timeout`, or simply runs for hours. Note that no `filter` on the extracted table fixes this: a predicate that reduces the *output* does not reduce the *work* once the plan is a scan (it may not even change the plan).
752
+
753
+ `batch_scope` removes the choice instead of arguing with the estimate. It names the scoped table this one reaches — the **batch table** — and exwiw resolves that table's in-scope primary keys once, then extracts one `size`-sized slice of those ids at a time:
754
+
755
+ ```json
756
+ {
757
+ "name": "activities",
758
+ "primary_key": "id",
759
+ "batch_scope": { "table": "customers", "size": 1000 },
760
+ "belongs_tos": [{ "table_name": "customers", "foreign_key": "customer_id" }],
761
+ "columns": [{ "name": "id" }, { "name": "customer_id" }]
762
+ }
763
+ ```
764
+
765
+ Each batch runs with that slice's ids in place of the scope filter:
766
+
767
+ ```sql
768
+ SELECT activities.* FROM activities
769
+ JOIN customers ON activities.customer_id = customers.id
770
+ AND customers.id IN (/* 1000 ids */)
771
+ ```
772
+
773
+ An explicit id list of that size is exactly estimated and selective, so the foreign-key index is unambiguously the cheapest plan for every batch, and total work is proportional to the rows the table actually keeps rather than to the table's size.
774
+
775
+ - **The dumped rows are the same as the unbatched query's** (in batch-by-batch order). The slices partition the id set — every id is in exactly one batch — so no row is dropped or emitted twice. The ids are sorted (in exwiw, not with `ORDER BY` — the id-set query stays cheap on the source DB) before slicing, so batch composition, and the dump, is reproducible run to run.
776
+ - **`size` defaults to 1000** ids per batch.
777
+ - The batch table's ids come from **its own extraction query**, so it is narrowed by exactly the filter it would carry in the unbatched query. They are held in memory for the extraction: one scope's worth of primary keys, orders of magnitude smaller than the table being batched.
778
+ - The batch table may be **any number of hops up** the path — a table two hops below it (`activity_orders → activities → customers`) names `customers` too, and the batch ids are applied where the path meets the scope, bounding the whole join chain.
779
+ - A table that **carries the scope column itself** batches by naming itself; each batch then filters `WHERE <pk> IN (<ids>)` directly. Note that the id-set query is then the same scope predicate over the same table, so this shape only avoids the scan when the scope column is indexed (ideally index-only) — the join shape above is the one that genuinely removes the planner's choice.
780
+ - `delete-*.sql` is unaffected (it is generated from the unbatched query).
781
+ - `bulk_insert_chunk_size` is independent: batches are query boundaries, chunks are `INSERT` statement boundaries.
782
+ - With `--output-format=copy`, batching bounds each query's cost but not memory: COPY builds the whole table's body in memory, so all batches' rows are resident at once. Use the default INSERT format (which streams) when the kept rows themselves are huge.
783
+
784
+ **Supported shapes.** A batch key only splits an extraction correctly when *every* row the table keeps is selected through the batch table's scope filter — otherwise a route the batch key does not constrain would keep the same rows in every batch, and the dump would repeat them (a primary-key conflict on import). So `batch_scope` requires [scope-column mode](#scope-column-mode) and one of:
785
+
786
+ - the table is **directly scoped** (`scope_column`) and names itself, or
787
+ - the table reaches the scope through a **single `belongs_to` join path** (path 2 in [the six scoping paths](#how-each-table-is-narrowed--the-six-scoping-paths)) whose scoped terminus is the named table.
788
+
789
+ Every other shape — polymorphic arm `UNION`s, `reverse_scope`, referenced-by, the parent cascade, `scope_exempt` (on the batched table *or* the batch table, whose id set would then not be scoped), and single `--target-table` mode — is **rejected with an explanation** rather than silently mis-sliced, before any output is written. (In single-target mode the extraction is already anchored on a caller-supplied id list, so batching it means running exwiw once per slice of `--ids`.)
790
+
791
+ `exwiw explain` prints the id-set query and its `EXPLAIN` after a batched table's own query, since that query is the part of a batched export the table's query does not show. It cannot show a batch's literal id list — `explain` resolves no ids, because it executes no extraction SELECT.
792
+
793
+ Like `scope_column` / `scope_exempt` / `reverse_scope`, `batch_scope` is user-maintained: never emitted by `schema:generate`, and preserved across regeneration.
794
+
741
795
  ### Filter
742
796
 
743
797
  Some case, you don't need full records related to target. e.g. dump user access logs only for the last year.
@@ -745,6 +799,7 @@ Some case, you don't need full records related to target. e.g. dump user access
745
799
 
746
800
  - injected as it is in table condition(e.g. WHERE on mysql), so you are recommended to clearify table name of column to avoid ambiguity.
747
801
  - injected to every where / join clause, so it affects to all tables depends on filterted target-table. it results to data inconsistency.
802
+ - a way to reduce the rows returned, which is **not** necessarily a way to reduce the work: on a large table the engine may keep (or switch to) a full scan and evaluate the filter per row. See [batched extraction](#batched-extraction-batch_scope) when the goal is to bound how much of the table is read.
748
803
 
749
804
  ### Masking
750
805
 
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exwiw
4
+ # Opt-in config for batched extraction (see {BatchedExtraction} and the
5
+ # `batch_scope` section of README.md): `table` is the scoped table whose
6
+ # in-scope primary keys slice this table's extraction, `size` the ids per batch.
7
+ class BatchScope
8
+ include Serdes
9
+
10
+ DEFAULT_SIZE = 1_000
11
+
12
+ attribute :table, String
13
+ attribute :size, optional(Integer), skip_serializing_if_nil: true
14
+ attribute :comment, optional(String), skip_serializing_if_nil: true
15
+
16
+ def batch_size
17
+ size || DEFAULT_SIZE
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exwiw
4
+ # Extracts a table configured with `batch_scope` as one query per slice of the
5
+ # scope's id set, so each query stays index-driven instead of degrading into a
6
+ # full scan. Rows are the unbatched query's, in batch order; the slices
7
+ # partition the id set, so none is dropped or repeated. See README.md.
8
+ class BatchedExtraction
9
+ include Enumerable
10
+
11
+ attr_reader :terminus
12
+
13
+ def self.build(adapter:, table:, dump_target:, table_by_name:, logger:)
14
+ return nil unless table.respond_to?(:batch_scope) && table.batch_scope
15
+
16
+ new(
17
+ adapter: adapter,
18
+ table: table,
19
+ dump_target: dump_target,
20
+ table_by_name: table_by_name,
21
+ logger: logger,
22
+ )
23
+ end
24
+
25
+ def initialize(adapter:, table:, dump_target:, table_by_name:, logger:)
26
+ @adapter = adapter
27
+ @table = table
28
+ @dump_target = dump_target
29
+ @table_by_name = table_by_name
30
+ @logger = logger
31
+ @terminus = QueryAstBuilder
32
+ .new(table.name, table_by_name, dump_target, logger)
33
+ .batch_scope_terminus!
34
+ end
35
+
36
+ def batch_size
37
+ @table.batch_scope.batch_size
38
+ end
39
+
40
+ # The batch table's own extraction query projected to its primary key, so the
41
+ # ids are narrowed by exactly the filter the unbatched query would carry. The
42
+ # key is a plain column so masking configured on it cannot corrupt the ids.
43
+ def key_query_ast
44
+ @key_query_ast ||= begin
45
+ scoped = QueryAstBuilder.run(@terminus.name, @table_by_name, @dump_target, @logger)
46
+
47
+ QueryAst::Select.new.tap do |ast|
48
+ ast.from(scoped.from_table_name)
49
+ ast.select([TableColumn.from_symbol_keys(name: @terminus.primary_key)])
50
+ scoped.join_clauses.each { |join_clause| ast.join(join_clause) }
51
+ scoped.where_clauses.each { |where_clause| ast.where(where_clause) }
52
+ end
53
+ end
54
+ end
55
+
56
+ def batch_query_ast(ids)
57
+ QueryAstBuilder.run(@table.name, @table_by_name, @dump_target, @logger, batch_ids: ids)
58
+ end
59
+
60
+ # Resolve the id set and log the plan before extraction starts, so its cost
61
+ # (and an empty id set) is reported where it happens rather than mid-stream.
62
+ def prepare!
63
+ if key_ids.empty?
64
+ @logger.info(" No in-scope #{@terminus.name} ids to batch by; extracting nothing.")
65
+ else
66
+ @logger.info(
67
+ " Extracting in #{batch_count} batch(es) of up to #{batch_size} " \
68
+ "#{@terminus.name}.#{@terminus.primary_key} value(s) (#{key_ids.size} in scope)."
69
+ )
70
+ end
71
+ self
72
+ end
73
+
74
+ # Drained in full (the connection must be free for the batch queries) and
75
+ # sorted here rather than via ORDER BY, which would push a sort onto the
76
+ # source DB. Any total order makes the batches reproducible run to run.
77
+ def key_ids
78
+ @key_ids ||= @adapter.execute(key_query_ast).map(&:first).sort
79
+ end
80
+
81
+ def batch_count
82
+ (key_ids.size + batch_size - 1) / batch_size
83
+ end
84
+
85
+ def each
86
+ return enum_for(:each) unless block_given?
87
+
88
+ extracted = 0
89
+ key_ids.each_slice(batch_size).with_index do |ids, idx|
90
+ rows = 0
91
+ @adapter.execute(batch_query_ast(ids)).each do |row|
92
+ rows += 1
93
+ yield row
94
+ end
95
+ extracted += rows
96
+ @logger.info(" Batch #{idx + 1}/#{batch_count}: #{rows} record(s), #{extracted} so far.")
97
+ end
98
+
99
+ self
100
+ end
101
+
102
+ # Only the COPY output format needs the count up front, and each batch answers
103
+ # it with its own count query, so this stays lazy.
104
+ def size
105
+ @size ||= key_ids.each_slice(batch_size).sum { |ids| @adapter.execute(batch_query_ast(ids)).size }
106
+ end
107
+ alias length size
108
+
109
+ def describe_plan
110
+ "-- batch_scope: extracted in batches of up to #{batch_size} #{@terminus.name}." \
111
+ "#{@terminus.primary_key} value(s). Each batch runs the query above with " \
112
+ "`#{@terminus.name}.#{@terminus.primary_key} IN (<batch ids>)` in place of the scope filter, " \
113
+ "over the ids of:"
114
+ end
115
+ end
116
+ end
@@ -68,9 +68,34 @@ module Exwiw
68
68
  @io.puts "-- EXPLAIN:"
69
69
  @io.puts explain_text
70
70
  @io.puts
71
+
72
+ explain_batch_scope(adapter, table, table_by_name)
71
73
  end
72
74
  end
73
75
 
76
+ # A batched export also runs the query resolving the ids it is sliced by,
77
+ # which the table's own block above does not show. The per-batch query cannot
78
+ # be rendered faithfully here: explain resolves no ids, so #describe_plan
79
+ # explains the substitution instead.
80
+ private def explain_batch_scope(adapter, table, table_by_name)
81
+ batched = BatchedExtraction.build(
82
+ adapter: adapter,
83
+ table: table,
84
+ dump_target: @dump_target,
85
+ table_by_name: table_by_name,
86
+ logger: @logger,
87
+ )
88
+ return if batched.nil?
89
+
90
+ key_query_ast = batched.key_query_ast
91
+ @io.puts batched.describe_plan
92
+ @io.puts adapter.describe_query(key_query_ast)
93
+ @io.puts
94
+ @io.puts "-- EXPLAIN (batch_scope id set):"
95
+ @io.puts adapter.explain(key_query_ast, verbosity: @explain_verbosity)
96
+ @io.puts
97
+ end
98
+
74
99
  private def load_table_config(klass)
75
100
  Dir[File.join(@schema_dir, "*.json")].map do |file|
76
101
  json = JSON.parse(File.read(file))
@@ -2,8 +2,8 @@
2
2
 
3
3
  module Exwiw
4
4
  class QueryAstBuilder
5
- def self.run(table_name, table_by_name, dump_target, logger, allow_reverse: true, forward_path: [])
6
- new(table_name, table_by_name, dump_target, logger, allow_reverse: allow_reverse, forward_path: forward_path).run
5
+ def self.run(table_name, table_by_name, dump_target, logger, allow_reverse: true, forward_path: [], batch_ids: nil)
6
+ new(table_name, table_by_name, dump_target, logger, allow_reverse: allow_reverse, forward_path: forward_path, batch_ids: batch_ids).run
7
7
  end
8
8
 
9
9
  # Scope-column mode classification for a single table. One of
@@ -26,35 +26,48 @@ module Exwiw
26
26
  !!(target && target.respond_to?(:scope_column) && target.scope_column)
27
27
  end
28
28
 
29
- # Strict pre-flight for scope-column mode: abort if any extractable table
30
- # cannot be scoped, so an unscoped (potentially sensitive) table is never
31
- # silently dumped in full. No-op outside scope mode. `tables` is the set of
29
+ # Strict pre-flight: abort if any extractable table cannot be scoped (scope
30
+ # mode), or declares a `batch_scope` its scoping shape cannot be sliced by
31
+ # (both modes) before any output is written. `tables` is the set of
32
32
  # dumpable configs (ignore:true tables are skipped — they are not extracted).
33
33
  def self.validate_scope!(tables, table_by_name, dump_target, logger)
34
- return unless scope_mode?(table_by_name, dump_target)
34
+ # Unscopable is reported before a bad batch_scope shape — it is the more
35
+ # fundamental problem.
36
+ if scope_mode?(table_by_name, dump_target)
37
+ unscopable =
38
+ tables.reject(&:ignore).select do |table|
39
+ scope_category(table.name, table_by_name, dump_target, logger) == :unscopable
40
+ end
35
41
 
36
- unscopable =
37
- tables.reject(&:ignore).select do |table|
38
- scope_category(table.name, table_by_name, dump_target, logger) == :unscopable
42
+ if unscopable.any?
43
+ names = unscopable.map(&:name).sort.join(", ")
44
+ raise ArgumentError,
45
+ "scope-column mode: #{unscopable.size} table(s) cannot be scoped: #{names}. " \
46
+ "For each, declare `scope_column: <column>` on the table to filter it directly, " \
47
+ "add a belongs_to path to a table that carries the scope column, mark it " \
48
+ "`scope_exempt: true` to export it in full, or set `ignore: true` to skip it."
39
49
  end
40
- return if unscopable.empty?
50
+ end
51
+
52
+ tables.reject(&:ignore).each do |table|
53
+ next unless table.respond_to?(:batch_scope) && table.batch_scope
41
54
 
42
- names = unscopable.map(&:name).sort.join(", ")
43
- raise ArgumentError,
44
- "scope-column mode: #{unscopable.size} table(s) cannot be scoped: #{names}. " \
45
- "For each, declare `scope_column: <column>` on the table to filter it directly, " \
46
- "add a belongs_to path to a table that carries the scope column, mark it " \
47
- "`scope_exempt: true` to export it in full, or set `ignore: true` to skip it."
55
+ new(table.name, table_by_name, dump_target, logger).batch_scope_terminus!
56
+ end
48
57
  end
49
58
 
50
59
  attr_reader :table_name, :table_by_name, :dump_target
51
60
 
52
- def initialize(table_name, table_by_name, dump_target, logger, allow_reverse: true, forward_path: [])
61
+ def initialize(table_name, table_by_name, dump_target, logger, allow_reverse: true, forward_path: [], batch_ids: nil)
53
62
  @table_name = table_name
54
63
  @table_by_name = table_by_name
55
64
  @dump_target = dump_target
56
65
  @logger = logger
57
66
  @allow_reverse = allow_reverse
67
+ # One batch's slice of the batch table's in-scope primary keys, set only by
68
+ # BatchedExtraction. Deliberately not threaded into the recursive builds
69
+ # below, which compile other tables' queries.
70
+ @batch_ids = batch_ids
58
71
  # @forward_path is the chain of tables currently being forward-resolved by
59
72
  # the "scope via an indirectly-scoped belongs_to parent" rescue
60
73
  # (build_belongs_to_scoped_clause). Each forward hop appends the table it is
@@ -612,6 +625,9 @@ module Exwiw
612
625
  end
613
626
 
614
627
  private def scope_where_clause(table)
628
+ batch_clause = batch_ids_clause(table)
629
+ return batch_clause if batch_clause
630
+
615
631
  Exwiw::QueryAst::WhereClause.new(
616
632
  column_name: resolved_scope_column(table),
617
633
  operator: :eq,
@@ -619,6 +635,95 @@ module Exwiw
619
635
  )
620
636
  end
621
637
 
638
+ # This batch's ids, in place of the batch table's scope filter. nil when the
639
+ # build is not batched or `table` is not the batch table.
640
+ private def batch_ids_clause(table)
641
+ return nil if @batch_ids.nil?
642
+
643
+ batch_scope = table_by_name.fetch(table_name).batch_scope
644
+ return nil if batch_scope.nil? || batch_scope.table != table.name
645
+
646
+ Exwiw::QueryAst::WhereClause.new(
647
+ column_name: table.primary_key,
648
+ operator: :eq,
649
+ value: @batch_ids
650
+ )
651
+ end
652
+
653
+ # The scoped table whose in-scope primary keys slice this table's extraction,
654
+ # or nil when it declares no `batch_scope`. Shapes are accepted only when
655
+ # every row the table keeps is selected through that table's scope filter —
656
+ # otherwise the unconstrained route would re-emit the same rows in every
657
+ # batch — so each rejection below explains itself to the config author.
658
+ def batch_scope_terminus!
659
+ table = table_by_name.fetch(table_name)
660
+ batch_scope = table.batch_scope
661
+ return nil if batch_scope.nil?
662
+
663
+ prefix = "Table '#{table.name}': batch_scope"
664
+
665
+ unless scope_mode?
666
+ raise ArgumentError,
667
+ "#{prefix} is supported in scope-column mode only. In single `--target-table` mode the " \
668
+ "extraction is already anchored on a caller-supplied id list, which can be batched by " \
669
+ "running exwiw once per slice of `--ids`."
670
+ end
671
+
672
+ if scope_exempt?(table)
673
+ raise ArgumentError,
674
+ "#{prefix} cannot apply: the table is exported in full (scope_exempt / rails-managed), " \
675
+ "so there is no scope filter to slice."
676
+ end
677
+
678
+ terminus = table_by_name[batch_scope.table]
679
+ if terminus.nil?
680
+ raise ArgumentError, "#{prefix} names table '#{batch_scope.table}', which is not in the schema."
681
+ end
682
+ if terminus.primary_key.nil?
683
+ raise ArgumentError, "#{prefix} table '#{terminus.name}' has no primary_key to slice the extraction by."
684
+ end
685
+ unless directly_scoped?(terminus)
686
+ raise ArgumentError,
687
+ "#{prefix} table '#{terminus.name}' does not carry the scope column " \
688
+ "(#{resolved_scope_column(terminus) || 'none declared'}), so its in-scope ids cannot be " \
689
+ "resolved. Name the scoped table this table joins up to."
690
+ end
691
+ # A scope_exempt terminus carries the column but its own extraction query is
692
+ # unfiltered, so the batches would substitute every tenant's ids for the
693
+ # scope filter the unbatched join still applies.
694
+ if scope_exempt?(terminus)
695
+ raise ArgumentError,
696
+ "#{prefix} table '#{terminus.name}' is exported in full (scope_exempt / rails-managed), " \
697
+ "so its id set is not scoped and every batch would reach outside the scope."
698
+ end
699
+
700
+ if directly_scoped?(table)
701
+ return terminus if terminus.name == table.name
702
+
703
+ raise ArgumentError,
704
+ "#{prefix} must name '#{table.name}' itself, which carries the scope column and is " \
705
+ "therefore filtered directly rather than through '#{terminus.name}'."
706
+ end
707
+
708
+ arms = scoped_arms(table)
709
+ unless arms.size == 1 && arms.first.path
710
+ raise ArgumentError,
711
+ "#{prefix} needs a single belongs_to join path from '#{table.name}' to the scope, but it is " \
712
+ "scoped another way (polymorphic arms / reverse_scope / referenced-by / the parent cascade), " \
713
+ "or not scoped at all. Those other id sets keep rows by routes a batch of '#{terminus.name}' " \
714
+ "ids does not constrain, so every batch would re-emit them."
715
+ end
716
+
717
+ path = arms.first.path
718
+ unless path.last == terminus.name
719
+ raise ArgumentError,
720
+ "#{prefix} table '#{terminus.name}' is not where '#{table.name}' reaches the scope " \
721
+ "(#{path.join(' -> ')}); name that path's scoped table, '#{path.last}'."
722
+ end
723
+
724
+ terminus
725
+ end
726
+
622
727
  # BFS over belongs_tos to the nearest *directly scoped* ancestor. Unlike the
623
728
  # target-mode walk, the returned path INCLUDES that ancestor: the scope column
624
729
  # lives on the ancestor itself (not on a foreign key of the child), so the
data/lib/exwiw/runner.rb CHANGED
@@ -103,8 +103,22 @@ module Exwiw
103
103
  # both the INSERT and COPY branches below.
104
104
  row_transformer = RowTransformer.build(table)
105
105
 
106
+ # `batch_scope` splits the extraction into one query per slice of the
107
+ # scope's id set, streaming rows like any adapter result. `query_ast`
108
+ # stays the unbatched query — the DELETE file and the error message
109
+ # below describe that one.
110
+ phase = "resolving the batch_scope id set"
111
+ batched = BatchedExtraction.build(
112
+ adapter: adapter,
113
+ table: table,
114
+ dump_target: @dump_target,
115
+ table_by_name: table_by_name,
116
+ logger: @logger,
117
+ )
118
+ batched&.prepare!
119
+
106
120
  phase = "executing extraction query"
107
- results = adapter.execute(query_ast)
121
+ results = batched || adapter.execute(query_ast)
108
122
  results = row_transformer.wrap(results) if row_transformer
109
123
  insert_idx = (idx + 1).to_s.rjust(3, '0')
110
124
 
@@ -47,6 +47,10 @@ module Exwiw
47
47
  # schema generators.
48
48
  attribute :reverse_scope, Serdes::OptionalType.new(ReverseScope), skip_serializing_if_nil: true
49
49
 
50
+ # `batch_scope` splits this table's extraction into one query per slice of the
51
+ # scope's id set (see Exwiw::BatchScope). User-configured, never generated.
52
+ attribute :batch_scope, Serdes::OptionalType.new(BatchScope), skip_serializing_if_nil: true
53
+
50
54
  def self.from(hash)
51
55
  # Reject unknown keys before deserializing: Serdes silently drops them,
52
56
  # which would turn a typo'd or unsupported key into a silent no-op (see
@@ -76,6 +80,7 @@ module Exwiw
76
80
  hash.delete("belongs_tos")
77
81
  hash.delete("columns")
78
82
  hash.delete("reverse_scope")
83
+ hash.delete("batch_scope")
79
84
  end
80
85
  hash
81
86
  end
@@ -171,6 +176,7 @@ module Exwiw
171
176
  merged_table.scope_exempt = scope_exempt
172
177
  merged_table.scope_column = scope_column
173
178
  merged_table.reverse_scope = reverse_scope
179
+ merged_table.batch_scope = batch_scope
174
180
 
175
181
  # Structural facts of each belongs_to come from the freshly generated
176
182
  # config, but the user-owned `comment`/`ignore`/`ignore_type`/`references`
@@ -222,6 +228,10 @@ module Exwiw
222
228
  raise ArgumentError,
223
229
  "Table '#{name}' has type=#{type}; reverse_scope must not be defined."
224
230
  end
231
+ if batch_scope
232
+ raise ArgumentError,
233
+ "Table '#{name}' has type=#{type}; batch_scope must not be defined."
234
+ end
225
235
  else
226
236
  # An ignore:true table is not extracted, so primary_key is not required
227
237
  # (e.g. a composite-primary-key table that exwiw does not support).
@@ -229,6 +239,12 @@ module Exwiw
229
239
  raise ArgumentError, "Table '#{name}' requires primary_key."
230
240
  end
231
241
 
242
+ if batch_scope && batch_scope.size && batch_scope.size < 1
243
+ raise ArgumentError,
244
+ "Table '#{name}': batch_scope size must be a positive number of ids per batch " \
245
+ "(got #{batch_scope.size})."
246
+ end
247
+
232
248
  columns.each { |column| validate_ruby_side_masking!(column) }
233
249
  end
234
250
  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 = "0.9.18"
4
+ VERSION = "0.9.19"
5
5
  end
data/lib/exwiw.rb CHANGED
@@ -12,6 +12,7 @@ require_relative "exwiw/belongs_to"
12
12
  require_relative "exwiw/fake_data"
13
13
  require_relative "exwiw/table_column"
14
14
  require_relative "exwiw/reverse_scope"
15
+ require_relative "exwiw/batch_scope"
15
16
  require_relative "exwiw/table_config"
16
17
  require_relative "exwiw/embedded_in"
17
18
  require_relative "exwiw/mongodb_field"
@@ -32,6 +33,7 @@ require_relative "exwiw/mongo_query"
32
33
  require_relative "exwiw/query_ast"
33
34
  require_relative "exwiw/query_ast_builder"
34
35
  require_relative "exwiw/row_transformer"
36
+ require_relative "exwiw/batched_extraction"
35
37
  require_relative "exwiw/after_insert_hook"
36
38
  require_relative "exwiw/runner"
37
39
  require_relative "exwiw/explain_runner"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: exwiw
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.18
4
+ version: 0.9.19
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shia
@@ -64,6 +64,8 @@ files:
64
64
  - lib/exwiw/adapter/sql_bulk_insert.rb
65
65
  - lib/exwiw/adapter/sqlite_adapter.rb
66
66
  - lib/exwiw/after_insert_hook.rb
67
+ - lib/exwiw/batch_scope.rb
68
+ - lib/exwiw/batched_extraction.rb
67
69
  - lib/exwiw/belongs_to.rb
68
70
  - lib/exwiw/cli.rb
69
71
  - lib/exwiw/config_file.rb