exwiw 0.9.18 → 0.9.20
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 +62 -0
- data/lib/exwiw/adapter/postgresql_adapter.rb +79 -1
- data/lib/exwiw/batch_scope.rb +20 -0
- data/lib/exwiw/batched_extraction.rb +116 -0
- data/lib/exwiw/ddl_postprocessor.rb +44 -0
- data/lib/exwiw/explain_runner.rb +25 -0
- data/lib/exwiw/query_ast_builder.rb +122 -17
- data/lib/exwiw/runner.rb +15 -1
- data/lib/exwiw/table_config.rb +16 -0
- data/lib/exwiw/version.rb +1 -1
- data/lib/exwiw.rb +2 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 328fbb843d5c15da7ea9f764adda96a5c58b4d24de615b9cac38ba305550ed05
|
|
4
|
+
data.tar.gz: 691448e490f27475d1ae17be240f89af5ea7c3ac54ddedfa544ca1b71517f5c1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ee57c7925b2b912a3e9777947d251b8870f94572ec96b5d7fbd0a8d60dbb8aaefd33cde4e12e735b2ff2eb44b24e2f101b81e4bdfc78752f55788619a4ce5a6c
|
|
7
|
+
data.tar.gz: 9f3a2b95adb72322032b7ac4967d69e3c32a0df6fa75c64adc7f59be1f9de6b7958a8250c0abb2fd9857a5c063d8123f71c2d9678376757d1161ef34f484f368
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.9.20] - 2026-08-05
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- **PostgreSQL: the extensions a managed platform installs to run the source instance itself (Cloud SQL / AlloyDB) are now explicitly out of target and left out of `insert-000-schema.sql`.** A full-database `pg_dump` of a Cloud SQL instance emits `CREATE SCHEMA google_vacuum_mgmt` plus the `CREATE EXTENSION` / `COMMENT ON EXTENSION` for it; an AlloyDB one emits `google_columnar_engine` and `google_db_advisor`. exwiw already made these survivable — earlier releases wrapped both statements in `DO` blocks that warn-and-skip when the restore target cannot provide the extension — but they still occupied the dump, left an empty vendor schema behind on every restore, and logged a `WARNING` each time. They serve the *source* instance's operation (vacuum tuning, the in-memory columnar cache, index advice), carry no application data, are named by nothing in the application's own schema, and ship only with the managed platform, so no restore target outside it can ever create them. Both halves are now dropped: their schemas via `pg_dump --exclude-schema` (which also covers any object the platform adds inside one later), and the extension statements — not schema-qualified, so no `pg_dump` filter reaches them — via a new `DdlPostprocessor.strip_extensions` pass, with whatever was excluded named in the run's log rather than dropped silently. The set is an explicit list of names (`PostgresqlAdapter::PLATFORM_MANAGED_EXTENSIONS`), deliberately not a `google_*` prefix match: the prefixes are not reserved, so a prefix rule would also drop a schema an application legitimately owns — `google_calendar` for a Google Calendar integration — together with its tables. Adding a name costs a release, which is the right trade. Every other extension is still emitted wrapped in the warn-and-skip `DO` block, including two kinds that are also managed-platform-only: a third-party extension pulled in as a dependency of an excluded one (`google_db_advisor` requires `hypopg`, installable on any plain PostgreSQL), and an application-facing platform extension (`google_ml_integration`, `alloydb_scann`, `alloydb_ai_nl`), which application SQL and dumped DDL can name (a ScaNN index is `USING scann`) — removing its `CREATE` would strand whatever refers to it. Data extraction never touched any of these objects — it is driven by the schema config, which has no entry for a table without an application model — so no `insert-*` / `delete-*` file changes.
|
|
10
|
+
|
|
11
|
+
## [0.9.19] - 2026-08-04
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **`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.
|
|
16
|
+
|
|
5
17
|
## [0.9.18] - 2026-08-03
|
|
6
18
|
|
|
7
19
|
### Fixed
|
data/README.md
CHANGED
|
@@ -108,6 +108,13 @@ so you should import the dump in order.
|
|
|
108
108
|
|
|
109
109
|
`insert-000-schema.sql` is generated by shelling out to the database client tools (`mysqldump` for `mysql`, `pg_dump` for `postgresql`, and the sqlite3 driver for `sqlite`), so the corresponding client must be available on PATH when running exwiw. For `mysql`, set `EXWIW_MYSQLDUMP` to point at a specific `mysqldump` binary when the one on PATH is incompatible with the server (e.g. a MySQL 9.x `mysqldump` cannot load `mysql_native_password` against a server still using that auth plugin — `EXWIW_MYSQLDUMP=/path/to/mysql@8.0/bin/mysqldump`). The output is post-processed to make it idempotent: `CREATE TABLE IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS` (where the engine supports it), and PostgreSQL's `ALTER TABLE ... ADD CONSTRAINT` statements are wrapped in `DO $$ ... EXCEPTION WHEN duplicate_object`. For `mysql`, the source server's `DEFINER=user@host` stamp on views and triggers is stripped too, so restoring into a managed MySQL instance (which usually can't grant the privilege to recreate someone else's `DEFINER`) does not fail.
|
|
110
110
|
|
|
111
|
+
For `postgresql`, the extensions a managed platform installs to run the source instance itself are treated as out of target and left out of the dump entirely — currently `google_vacuum_mgmt` (Cloud SQL / AlloyDB adaptive autovacuum), `google_columnar_engine` and `google_db_advisor` (AlloyDB). They serve the source instance's operation (vacuum tuning, the in-memory columnar cache, index advice), hold no application data, are referenced by nothing in the application's own schema, and ship only with the managed platform, so a restore target outside it can never create them. Their schemas are dropped via `pg_dump --exclude-schema` and their `CREATE EXTENSION` / `COMMENT ON EXTENSION` statements — which are not schema-qualified, so no `pg_dump` filter reaches them — are removed from the output; whatever was excluded is named in the run's log.
|
|
112
|
+
|
|
113
|
+
The list is exact names, not a `google_*` prefix match: those prefixes are not reserved, so a prefix rule would also drop a schema an application legitimately owns (`google_calendar` for a Google Calendar integration) together with its tables. Every other extension is kept and wrapped in the usual warn-and-skip `DO` block, including two kinds that are also managed-platform-only:
|
|
114
|
+
|
|
115
|
+
- a third-party extension pulled in as a dependency of an excluded one (`google_db_advisor` requires `hypopg`), since that one *is* installable on a plain PostgreSQL, and
|
|
116
|
+
- an application-facing platform extension (`google_ml_integration`, `alloydb_scann`, `alloydb_ai_nl`), which the application's own SQL and DDL can name (a ScaNN index is `USING scann`) — removing its `CREATE` would strand whatever refers to it, so it warns and skips instead.
|
|
117
|
+
|
|
111
118
|
you need to delete the records before importing the dump,
|
|
112
119
|
`delete-{idx}-{table_name}.sql` will help you to do that.
|
|
113
120
|
This sql will delete "all" related records to the extract targets.
|
|
@@ -738,6 +745,60 @@ Unlike rails-managed entries, `columns` and `belongs_tos` are retained so the en
|
|
|
738
745
|
|
|
739
746
|
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
747
|
|
|
748
|
+
### Batched extraction (`batch_scope`)
|
|
749
|
+
|
|
750
|
+
A scoped table is normally extracted with one query, whose scope filter sits on the table it joins up to:
|
|
751
|
+
|
|
752
|
+
```sql
|
|
753
|
+
SELECT activities.* FROM activities
|
|
754
|
+
JOIN customers ON activities.customer_id = customers.id
|
|
755
|
+
AND customers.tenant_id IN ('t1')
|
|
756
|
+
```
|
|
757
|
+
|
|
758
|
+
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).
|
|
759
|
+
|
|
760
|
+
`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:
|
|
761
|
+
|
|
762
|
+
```json
|
|
763
|
+
{
|
|
764
|
+
"name": "activities",
|
|
765
|
+
"primary_key": "id",
|
|
766
|
+
"batch_scope": { "table": "customers", "size": 1000 },
|
|
767
|
+
"belongs_tos": [{ "table_name": "customers", "foreign_key": "customer_id" }],
|
|
768
|
+
"columns": [{ "name": "id" }, { "name": "customer_id" }]
|
|
769
|
+
}
|
|
770
|
+
```
|
|
771
|
+
|
|
772
|
+
Each batch runs with that slice's ids in place of the scope filter:
|
|
773
|
+
|
|
774
|
+
```sql
|
|
775
|
+
SELECT activities.* FROM activities
|
|
776
|
+
JOIN customers ON activities.customer_id = customers.id
|
|
777
|
+
AND customers.id IN (/* 1000 ids */)
|
|
778
|
+
```
|
|
779
|
+
|
|
780
|
+
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.
|
|
781
|
+
|
|
782
|
+
- **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.
|
|
783
|
+
- **`size` defaults to 1000** ids per batch.
|
|
784
|
+
- 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.
|
|
785
|
+
- 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.
|
|
786
|
+
- 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.
|
|
787
|
+
- `delete-*.sql` is unaffected (it is generated from the unbatched query).
|
|
788
|
+
- `bulk_insert_chunk_size` is independent: batches are query boundaries, chunks are `INSERT` statement boundaries.
|
|
789
|
+
- 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.
|
|
790
|
+
|
|
791
|
+
**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:
|
|
792
|
+
|
|
793
|
+
- the table is **directly scoped** (`scope_column`) and names itself, or
|
|
794
|
+
- 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.
|
|
795
|
+
|
|
796
|
+
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`.)
|
|
797
|
+
|
|
798
|
+
`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.
|
|
799
|
+
|
|
800
|
+
Like `scope_column` / `scope_exempt` / `reverse_scope`, `batch_scope` is user-maintained: never emitted by `schema:generate`, and preserved across regeneration.
|
|
801
|
+
|
|
741
802
|
### Filter
|
|
742
803
|
|
|
743
804
|
Some case, you don't need full records related to target. e.g. dump user access logs only for the last year.
|
|
@@ -745,6 +806,7 @@ Some case, you don't need full records related to target. e.g. dump user access
|
|
|
745
806
|
|
|
746
807
|
- 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
808
|
- injected to every where / join clause, so it affects to all tables depends on filterted target-table. it results to data inconsistency.
|
|
809
|
+
- 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
810
|
|
|
749
811
|
### Masking
|
|
750
812
|
|
|
@@ -98,6 +98,41 @@ module Exwiw
|
|
|
98
98
|
connection.exec("EXPLAIN #{sql}").values.map(&:first).join("\n")
|
|
99
99
|
end
|
|
100
100
|
|
|
101
|
+
# Extensions a managed PostgreSQL platform installs to run the *source*
|
|
102
|
+
# instance itself, which exwiw treats as out of target and leaves out of the
|
|
103
|
+
# dump entirely. Each one here satisfies both conditions:
|
|
104
|
+
#
|
|
105
|
+
# 1. it ships only with the managed platform, so no restore target outside
|
|
106
|
+
# that platform can create it, and
|
|
107
|
+
# 2. nothing in the application's own schema or queries references it — it
|
|
108
|
+
# is operational machinery (autovacuum tuning, the in-memory columnar
|
|
109
|
+
# cache, index advice), not a feature the app builds on.
|
|
110
|
+
#
|
|
111
|
+
# Condition 2 is what keeps this list short. AlloyDB's application-facing
|
|
112
|
+
# extensions — `google_ml_integration`, `alloydb_scann`, `alloydb_ai_nl` —
|
|
113
|
+
# meet condition 1 but are called from application SQL and can be named by
|
|
114
|
+
# dumped DDL (a ScaNN index is `USING scann`), so dropping their CREATE would
|
|
115
|
+
# silently strand whatever refers to them. They stay in the dump, wrapped in
|
|
116
|
+
# the usual warn-and-skip DO block, like `pglogical` and like a third-party
|
|
117
|
+
# extension pulled in as a dependency of one listed here (`google_db_advisor`
|
|
118
|
+
# requires `hypopg`, which any plain PostgreSQL can install).
|
|
119
|
+
#
|
|
120
|
+
# Deliberately an explicit list of names, not a `google_`/`alloydb_` prefix
|
|
121
|
+
# match (which is how the old `pg_extension` query filtered, before switching
|
|
122
|
+
# to a full-database pg_dump dropped that query and the filter with it): the
|
|
123
|
+
# prefixes are not reserved, so a schema an application legitimately owns —
|
|
124
|
+
# `google_calendar` for a Google Calendar integration — would be matched and
|
|
125
|
+
# dropped together with its tables. The vendor gains extensions faster than
|
|
126
|
+
# this list does; that costs a release, which is the right trade against
|
|
127
|
+
# deleting an application's own objects. Its `rds_%` / `aiven_%` arms are not
|
|
128
|
+
# carried over for the same reason: naming their members takes a dump to
|
|
129
|
+
# confirm, and adding one here is a one-line change.
|
|
130
|
+
PLATFORM_MANAGED_EXTENSIONS = %w[
|
|
131
|
+
google_columnar_engine
|
|
132
|
+
google_db_advisor
|
|
133
|
+
google_vacuum_mgmt
|
|
134
|
+
].freeze
|
|
135
|
+
|
|
101
136
|
def dump_schema(ordered_tables, output_path)
|
|
102
137
|
require 'open3'
|
|
103
138
|
|
|
@@ -117,10 +152,20 @@ module Exwiw
|
|
|
117
152
|
'--schema-only',
|
|
118
153
|
'--no-owner',
|
|
119
154
|
'--no-acl',
|
|
155
|
+
# An extension that needs a schema of its own puts it under its own name
|
|
156
|
+
# (Cloud SQL's google_vacuum_mgmt does; the AlloyDB two install into
|
|
157
|
+
# public), so excluding the same names covers the schema half — including
|
|
158
|
+
# any object the platform adds inside one later. The patterns are exact
|
|
159
|
+
# names: pg_dump reads them psql \d-style, where `_` is literal and only
|
|
160
|
+
# `*` globs. A pattern matching nothing is not an error (that needs
|
|
161
|
+
# --strict-names), so these are harmless against a self-hosted server.
|
|
162
|
+
*PLATFORM_MANAGED_EXTENSIONS.map { |name| "--exclude-schema=#{name}" },
|
|
120
163
|
@connection_config.database_name,
|
|
121
164
|
]
|
|
122
165
|
env = { 'PGPASSWORD' => @connection_config.password.to_s }
|
|
123
166
|
|
|
167
|
+
log_excluded_platform_managed_schemas
|
|
168
|
+
|
|
124
169
|
@logger.debug(" Running pg_dump for the whole database (#{@connection_config.database_name})...")
|
|
125
170
|
stdout, stderr, status = Open3.capture3(env, *cmd)
|
|
126
171
|
unless status.success?
|
|
@@ -141,7 +186,9 @@ module Exwiw
|
|
|
141
186
|
# EXTENSION that pg_dump emits alongside is likewise wrapped to swallow
|
|
142
187
|
# undefined_object, so a skipped extension's trailing comment does not
|
|
143
188
|
# abort the restore either.
|
|
144
|
-
|
|
189
|
+
# Platform-managed extensions are stripped first: the wrapping passes below
|
|
190
|
+
# rewrite the bare CREATE/COMMENT statements this removes.
|
|
191
|
+
idempotent = strip_platform_managed_extensions(stdout)
|
|
145
192
|
idempotent = DdlPostprocessor.wrap_create_type_enum_in_do_block(idempotent)
|
|
146
193
|
idempotent = DdlPostprocessor.wrap_create_extension_in_do_block(idempotent)
|
|
147
194
|
idempotent = DdlPostprocessor.wrap_comment_on_extension_in_do_block(idempotent)
|
|
@@ -159,6 +206,37 @@ module Exwiw
|
|
|
159
206
|
@logger.info(" Wrote full-database schema to #{output_path} (#{ordered_tables.size} table(s) in scope for data).")
|
|
160
207
|
end
|
|
161
208
|
|
|
209
|
+
# Drop the platform-managed extensions from a raw pg_dump, naming the ones
|
|
210
|
+
# actually removed so the run records what it left out rather than silently
|
|
211
|
+
# dropping it. See PLATFORM_MANAGED_EXTENSIONS.
|
|
212
|
+
private def strip_platform_managed_extensions(sql)
|
|
213
|
+
removed = DdlPostprocessor.extension_names(sql) & PLATFORM_MANAGED_EXTENSIONS
|
|
214
|
+
unless removed.empty?
|
|
215
|
+
@logger.info(" Excluded platform-managed extension(s) from the schema dump: #{removed.join(', ')}.")
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
DdlPostprocessor.strip_extensions(sql, PLATFORM_MANAGED_EXTENSIONS)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Name the --exclude-schema patterns the source instance actually has, so the
|
|
222
|
+
# run records the schema half of the exclusion the way
|
|
223
|
+
# #strip_platform_managed_extensions records the extension half. Only exact
|
|
224
|
+
# names are excluded, but nothing stops an application from owning one of
|
|
225
|
+
# them, and excluding a schema takes every object inside it along — that must
|
|
226
|
+
# not happen without the log saying so. See PLATFORM_MANAGED_EXTENSIONS.
|
|
227
|
+
private def log_excluded_platform_managed_schemas
|
|
228
|
+
# The names are bare identifiers from the constant, so they need no quoting
|
|
229
|
+
# inside the array literal ANY() takes.
|
|
230
|
+
result = connection.exec_params(
|
|
231
|
+
'SELECT nspname FROM pg_namespace WHERE nspname = ANY($1) ORDER BY nspname',
|
|
232
|
+
["{#{PLATFORM_MANAGED_EXTENSIONS.join(',')}}"],
|
|
233
|
+
)
|
|
234
|
+
excluded = result.values.map(&:first)
|
|
235
|
+
return if excluded.empty?
|
|
236
|
+
|
|
237
|
+
@logger.info(" Excluded platform-managed schema(s) from the schema dump: #{excluded.join(', ')}.")
|
|
238
|
+
end
|
|
239
|
+
|
|
162
240
|
# The INSERT header for this adapter. PostgreSQL uses bare identifiers,
|
|
163
241
|
# quoted only when required (reserved word / unsafe characters).
|
|
164
242
|
# #to_bulk_insert / #write_inserts (SqlBulkInsert) append the value tuples
|
|
@@ -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
|
|
@@ -170,6 +170,50 @@ module Exwiw
|
|
|
170
170
|
end
|
|
171
171
|
end
|
|
172
172
|
|
|
173
|
+
# Every extension name a dump installs, in the order the CREATE EXTENSION
|
|
174
|
+
# statements appear. Used to report which of them #strip_extensions is about
|
|
175
|
+
# to drop; run it on the raw dump, before any wrapping pass.
|
|
176
|
+
def extension_names(sql)
|
|
177
|
+
sql.scan(CREATE_EXTENSION_RE).map { |(name)| name.delete('"') }
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Drop every trace of the extensions named in `names` — the ones a managed
|
|
181
|
+
# PostgreSQL platform installs to run the source instance itself (see
|
|
182
|
+
# PostgresqlAdapter::PLATFORM_MANAGED_EXTENSIONS). Removed rather than merely
|
|
183
|
+
# wrapped like any other extension, because they cannot be created anywhere
|
|
184
|
+
# outside that platform, so keeping them only leaves a restore-time WARNING
|
|
185
|
+
# and objects the target will never have.
|
|
186
|
+
#
|
|
187
|
+
# Three things are emitted per extension and all three must go, or a leftover
|
|
188
|
+
# references a name that is no longer installed: pg_dump's `-- Name: <ext>;
|
|
189
|
+
# Type: EXTENSION` header block, the `CREATE EXTENSION`, and the
|
|
190
|
+
# `COMMENT ON EXTENSION` (whose own header reads `-- Name: EXTENSION <ext>;
|
|
191
|
+
# Type: COMMENT`). Run this on the raw dump, before the wrapping passes: they
|
|
192
|
+
# rewrite the bare statements this matches into DO blocks.
|
|
193
|
+
def strip_extensions(sql, names)
|
|
194
|
+
return sql if names.empty?
|
|
195
|
+
|
|
196
|
+
# Whole names only: each match is anchored by the whitespace/quote before it
|
|
197
|
+
# and a \b after, so `google_vacuum_mgmt` never matches an extension merely
|
|
198
|
+
# containing it (`not_google_vacuum_mgmt`, `google_vacuum_mgmt_v2`).
|
|
199
|
+
names_re = Regexp.union(names.map { |n| Regexp.escape(n) })
|
|
200
|
+
name = /(?:"#{names_re}"|#{names_re}\b)/
|
|
201
|
+
|
|
202
|
+
# Each removal takes the blank lines that trailed the object too, so the
|
|
203
|
+
# surrounding statements keep pg_dump's spacing instead of gaining a gap.
|
|
204
|
+
trailing_blank_lines = /(?:[ \t]*\r?\n(?:[ \t]*\r?\n)*)?/
|
|
205
|
+
|
|
206
|
+
sql = sql.gsub(
|
|
207
|
+
/^--\r?\n-- Name: (?:EXTENSION\s+)?#{name};[ \t]*Type:[ \t]*(?:EXTENSION|COMMENT);[^\n]*\n--(?:\r?\n)+/i,
|
|
208
|
+
"",
|
|
209
|
+
)
|
|
210
|
+
sql = sql.gsub(/^[ \t]*CREATE\s+EXTENSION\b(?:\s+IF\s+NOT\s+EXISTS)?\s+#{name}[^;]*;#{trailing_blank_lines}/i, "")
|
|
211
|
+
sql.gsub(
|
|
212
|
+
/^[ \t]*COMMENT\s+ON\s+EXTENSION\s+#{name}\s+IS\s+(?:'(?:[^']|'')*'|NULL)\s*;#{trailing_blank_lines}/i,
|
|
213
|
+
"",
|
|
214
|
+
)
|
|
215
|
+
end
|
|
216
|
+
|
|
173
217
|
# Generate idempotent CREATE TYPE ... AS ENUM statements.
|
|
174
218
|
# +enum_types+ is an Array of Hashes with keys :schema, :name, :labels.
|
|
175
219
|
def create_type_enum_statements(enum_types)
|
data/lib/exwiw/explain_runner.rb
CHANGED
|
@@ -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
|
|
30
|
-
#
|
|
31
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
tables.reject(&:ignore).each do |table|
|
|
53
|
+
next unless table.respond_to?(:batch_scope) && table.batch_scope
|
|
41
54
|
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
data/lib/exwiw/table_config.rb
CHANGED
|
@@ -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
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.
|
|
4
|
+
version: 0.9.20
|
|
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
|