exwiw 1.1.4 → 1.1.5
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/AGENTS.md +24 -0
- data/CHANGELOG.md +8 -1
- data/README.md +28 -8
- data/lib/exwiw/query_ast_builder.rb +193 -80
- data/lib/exwiw/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2df915162dc88ac29ccc262c436d5d7ab1797a7e026398ced3e66d1e5b73a7b7
|
|
4
|
+
data.tar.gz: 3144680abf12865a3db9d4173e4338101fa318da9f5e61e3367c5924fb24f9c6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2726b1b47c6584d6a85ab6f1342fdfb8ac760d237c09854f81a34e36d403a3720efbe25ee47185d3f381b4384e6a3f75f47ff6002fcbaa09d4613f937b39cc1f
|
|
7
|
+
data.tar.gz: 75bb6c28ecd32adaa2aeb3488d319784811ad4ab08104560dea54982e94de51c7a7ff606b81815f6d73a41608f5f6dae49c57774a35ad837a8b6b5dab994c45d
|
data/AGENTS.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# AGENTS.md
|
|
2
|
+
|
|
3
|
+
## This repository is public
|
|
4
|
+
|
|
5
|
+
Everything here is world-readable: code, comments, fixtures, commit messages,
|
|
6
|
+
PR and issue text, review comments, and their edit history. Deleting or editing
|
|
7
|
+
text after the fact does not unpublish it.
|
|
8
|
+
|
|
9
|
+
Do not include anything that comes from a private codebase:
|
|
10
|
+
|
|
11
|
+
- table, column, model, or system names from a private application
|
|
12
|
+
- references to private repositories or their PR / issue numbers
|
|
13
|
+
- internal terminology, internal links (chat, wiki, tickets), or any customer or production data
|
|
14
|
+
|
|
15
|
+
Describe motivating cases with invented, generic names, and keep planning and
|
|
16
|
+
tracking in the private repository that needs the change. Re-read the title,
|
|
17
|
+
body, and commit messages against this list before committing or opening a PR.
|
|
18
|
+
|
|
19
|
+
## Development
|
|
20
|
+
|
|
21
|
+
See [README.md](README.md) for usage and [CHANGELOG.md](CHANGELOG.md) for the
|
|
22
|
+
release history. `bundle exec rake` compiles the native extension and runs the
|
|
23
|
+
specs; most scripts under `e2e/` need the databases from `compose.yml`
|
|
24
|
+
(the sqlite ones run without it).
|
data/CHANGELOG.md
CHANGED
|
@@ -2,11 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [1.1.5] - 2026-09-25
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **Single `--target-table` mode extracts every polymorphic arm, not just one.** The multi-arm union was only built in scope-column mode. In single-target mode a table reached through a polymorphic `belongs_to` followed one arm: the arm pointing at the dump target if there was one, otherwise the arm on the BFS's shortest path. Every other type's rows were dropped without a warning. Dumping `shops`, a `reviews` table with `Shop` and `Product` arms came out with the shop's own reviews but none of the reviews of its products. The arms are now resolved as in scope-column mode (an arm pointing at the dump target compares its foreign key with `--ids`, the others join or probe their target's ids) and the table is constrained to their `UNION`. One single-target rule keeps existing dumps from widening: an arm target that is constrained only by the automatic referenced-by detection does not count, because such a parent is extracted just to keep a child's foreign key valid. Dumping `products` therefore still yields only `reviewable_type = 'Product'` reviews. A declared `reverse_scope` does count. Tables that reach the target through a single arm, or through a non-polymorphic `belongs_to`, emit the same SQL as before.
|
|
10
|
+
- **A table whose only route is one polymorphic arm to a table scoped by other means is no longer unscopable.** The parent cascade skipped polymorphic `belongs_to`s outright, so a table like `pictures` with a lone `imageable_type = 'Shop'` arm, pointing at a `shops` table narrowed by `reverse_scope`, was rejected in scope-column mode and dumped in full in single-target mode. The cascade now falls back to the polymorphic arms, pinned by the type column, when no plain `belongs_to` parent is scopable. A table that already had a scopable plain parent is scoped as before. A newly scoped table does, however, count as a constrained child in the automatic referenced-by detection of the tables it points at. A parent that had exactly one constrained child can now have two, so the detection steps aside and the parent falls back to the parent cascade, or is left unscopable when nothing else narrows it. Declare `reverse_scope` on the parent to keep the referencer it used before. When two independent polymorphic associations are both scopable, the table stays unscopable, and single-target mode now warns about the full dump as it does for two plain parents.
|
|
11
|
+
|
|
5
12
|
## [1.1.4] - 2026-09-18
|
|
6
13
|
|
|
7
14
|
### Fixed
|
|
8
15
|
|
|
9
|
-
- **Polymorphic arms are grouped by the type column, so a config that gives each arm its own foreign key no longer loses rows.** The multi-arm walk keyed the group on `(foreign_key, foreign_type)`, which fits what the generators emit for Rails (`<name>_id` + `<name>_type`) but not a hand-written config where one type column selects between several foreign key columns — `
|
|
16
|
+
- **Polymorphic arms are grouped by the type column, so a config that gives each arm its own foreign key no longer loses rows.** The multi-arm walk keyed the group on `(foreign_key, foreign_type)`, which fits what the generators emit for Rails (`<name>_id` + `<name>_type`) but not a hand-written config where one type column selects between several foreign key columns — `owner_type` choosing between `user_id` and `team_id`, say. Such arms were not recognised as siblings, so the table fell back to the single-path behavior: only the arm the BFS happened to settle on was extracted and every other arm's rows were dropped without a warning. Which arm survived depended on path length, so a table could even lose the arm it used to keep once a shorter one appeared. The group is now keyed on `foreign_type` alone and each arm joins on its own foreign key; two *independent* polymorphic associations have distinct type columns and stay in distinct groups, as before. Two consequences for such a table, both already true of the single-foreign-key case: its SQL becomes the `UNION` id-set form instead of a single JOIN, and `batch_scope` on it is now rejected in pre-flight (a batch of one terminus's ids does not constrain the other arms).
|
|
10
17
|
|
|
11
18
|
## [1.1.3] - 2026-09-08
|
|
12
19
|
|
data/README.md
CHANGED
|
@@ -153,8 +153,8 @@ Only the dump target itself is filtered by `--ids` directly. Every *other* table
|
|
|
153
153
|
How the paths behave and interact:
|
|
154
154
|
|
|
155
155
|
1. **Direct filter.** In the default single-target mode the target is anchored on its primary key (or a custom field via the mongodb-only `--ids-field`). In [scope-column mode](#scope-column-mode) there is no single anchor: every table that declares a `scope_column` is filtered on that column directly.
|
|
156
|
-
2. **`belongs_to` join walk** — the "normal join" path. exwiw BFS-walks `belongs_to` edges to the nearest terminus (the target table, or a directly scoped table in scope-column mode) and compiles the shortest path into `INNER JOIN`s. A [polymorphic `belongs_to`](#polymorphic-belongs_to) hop additionally pins the type column
|
|
157
|
-
3. **Referenced-by** handles a table with no outgoing path that is pointed *at* by a constrained child — `active_storage_blobs`, referenced by `active_storage_attachments.blob_id`, is the canonical case (see [ActiveStorage](#activestorage-has_one_attached--has_many_attached)). It is automatic but deliberately narrow: it requires a single, non-polymorphic referencer. With two or more referencers it steps aside
|
|
156
|
+
2. **`belongs_to` join walk** — the "normal join" path. exwiw BFS-walks `belongs_to` edges to the nearest terminus (the target table, or a directly scoped table in scope-column mode) and compiles the shortest path into `INNER JOIN`s. A [polymorphic `belongs_to`](#polymorphic-belongs_to) hop additionally pins the type column, and is resolved for **every** concrete arm with the arms `UNION`ed (see [Every arm is extracted](#every-arm-is-extracted)).
|
|
157
|
+
3. **Referenced-by** handles a table with no outgoing path that is pointed *at* by a constrained child — `active_storage_blobs`, referenced by `active_storage_attachments.blob_id`, is the canonical case (see [ActiveStorage](#activestorage-has_one_attached--has_many_attached)). It is automatic but deliberately narrow: it requires a single, non-polymorphic referencer. With two or more referencers it steps aside to path 5, then 6, unless you declare `reverse_scope`.
|
|
158
158
|
4. **[`reverse_scope`](#reverse-scope-for-multi-referencer-tables-reverse_scope)** is the declared, multi-referencer form of path 3: the config enumerates which referencers' (already scoped) queries feed the id set. Unscoped arms are skipped with a warning rather than widening the dump.
|
|
159
159
|
5. **Scoped-parent cascade** rescues satellites: a table whose only link is a `belongs_to` toward a hub that is itself scoped (e.g. via referenced-by or `reverse_scope`) is constrained to that parent's in-scope ids. The cascade recurses hop by hop (each level requires a single unambiguous scopable parent) and stops on `belongs_to` cycles.
|
|
160
160
|
6. **Full dump** is the fallback for a genuinely unrelated table — intended for reference/master data. Single-target mode dumps it in full (with a warning when an ambiguous cascade was the reason); scope-column mode refuses to run instead, unless the table is explicitly marked [`scope_exempt: true`](#scope_exempt-intentional-full-dump) (Rails-managed tables are exempt automatically).
|
|
@@ -783,9 +783,9 @@ WHERE reviews.reviewable_id IN (/* products subquery */)
|
|
|
783
783
|
|
|
784
784
|
The same type filter is applied on the join path when the polymorphic table is an intermediate hop rather than the directly-dumped table.
|
|
785
785
|
|
|
786
|
-
#### Every arm is extracted
|
|
786
|
+
#### Every arm is extracted
|
|
787
787
|
|
|
788
|
-
A polymorphic `belongs_to` is several `belongs_to` entries — one per concrete target — that a row selects between via its type column. A single JOIN can only follow **one** of them, so a join table reached through such a hop would come out holding only the rows of that one `type_value`.
|
|
788
|
+
A polymorphic `belongs_to` is several `belongs_to` entries — one per concrete target — that a row selects between via its type column. A single JOIN can only follow **one** of them, so a join table reached through such a hop would come out holding only the rows of that one `type_value`. exwiw therefore resolves **every** arm of the group and constrains the table to the union of the ids the arms keep. In [scope-column mode](#scope-column-mode):
|
|
789
789
|
|
|
790
790
|
```sql
|
|
791
791
|
SELECT comments.* FROM comments
|
|
@@ -811,15 +811,35 @@ Notes:
|
|
|
811
811
|
- **Only arms that reach the scope are included.** An arm whose target has no scope of its own is dropped, never widened — an unscoped arm would pull in every tenant's rows. An arm marked `"ignore": true` is dropped as usual, before any of this.
|
|
812
812
|
- An arm's target does not need a `belongs_to` path to a scoped table: if it is scoped by other means (referenced-by, [`reverse_scope`](#reverse-scope-for-multi-referencer-tables-reverse_scope), or the parent cascade) the arm probes that query's ids instead, still pinned by the type column.
|
|
813
813
|
- An arm whose target is scoped **through this same table** (e.g. `active_storage_blobs`, narrowed by referenced-by from `active_storage_attachments`, appearing as an `ActiveStorage::Blob` arm of those same attachments) is dropped: adopting it would make the two tables scope each other and leave the referenced table short of rows the join table kept — a dangling foreign key on import.
|
|
814
|
-
- **Arms are grouped by the type column (`foreign_type`), not by the foreign key.** Rails keeps every arm's id in one `<name>_id` column, which is what the generators emit, but a hand-written config may give each arm its own foreign key while one type column still selects between them — e.g. `
|
|
815
|
-
-
|
|
816
|
-
-
|
|
814
|
+
- **Arms are grouped by the type column (`foreign_type`), not by the foreign key.** Rails keeps every arm's id in one `<name>_id` column, which is what the generators emit, but a hand-written config may give each arm its own foreign key while one type column still selects between them — e.g. `owner_type` choosing between `user_id` and `team_id`. Those are arms of the same discriminator, so each is resolved and joined on its own key. Two *independent* polymorphic associations on one table have distinct type columns and therefore stay in distinct groups.
|
|
815
|
+
- **The route the walk picks decides whether the arms are unioned.** The route is the shortest one; in single-target mode a `belongs_to` pointing at the dump target itself is taken first. When the route leaves through a polymorphic arm, every arm of that type column is unioned. When it leaves through a non-polymorphic `belongs_to`, the table is joined along that route alone and the polymorphic arms are not unioned in, so a row reachable only through an arm is not extracted. A plain `belongs_to` is not preferred over a shorter polymorphic route. A single arm is emitted as a plain JOIN.
|
|
816
|
+
- A table with no route of its own falls back to the parent cascade, which does prefer plain parents. A scopable plain parent is used when there is exactly one. Only when there is none are the polymorphic `belongs_to`s consulted: the arms of one type column that point at tables scoped by other means (referenced-by, `reverse_scope`, or the cascade) are unioned, each pinned by the type column. Multiple independent polymorphic associations that are all scopable are as ambiguous as multiple plain parents, so the table is left unscopable (dumped in full with a warning in single-target mode). A table scoped this way also counts as a constrained child in the [referenced-by](#how-each-table-is-narrowed--the-six-scoping-paths) detection of the tables it points at, so a parent that had a single constrained referencer may now have two and be narrowed by the next path instead.
|
|
817
|
+
|
|
818
|
+
In single `--target-table` mode the same union is built. An arm that points at the dump target itself compares its foreign key with `--ids`, and the other arms join or probe their way to the target as above. One difference: an arm target that is constrained **only** by the automatic referenced-by detection does not count. Such a parent is extracted just to keep a child's foreign key valid; it does not own the rows that point at it. So dumping `products` still pulls only `reviewable_type = 'Product'` reviews, even though the product's shop is extracted too, while dumping `shops` pulls the shop's own reviews **and** the reviews of its products:
|
|
819
|
+
|
|
820
|
+
```sql
|
|
821
|
+
SELECT reviews.* FROM reviews
|
|
822
|
+
JOIN (
|
|
823
|
+
SELECT DISTINCT exwiw_scope_src_0.id AS exwiw_scope_id
|
|
824
|
+
FROM (
|
|
825
|
+
SELECT reviews.id FROM reviews
|
|
826
|
+
JOIN products ON reviews.reviewable_id = products.id
|
|
827
|
+
AND products.shop_id = 1 AND reviews.reviewable_type = 'Product'
|
|
828
|
+
UNION
|
|
829
|
+
SELECT reviews.id FROM reviews
|
|
830
|
+
WHERE reviews.reviewable_id = 1 AND reviews.reviewable_type = 'Shop'
|
|
831
|
+
) AS exwiw_scope_src_0
|
|
832
|
+
) AS exwiw_scope_ids_0
|
|
833
|
+
ON reviews.id = exwiw_scope_ids_0.exwiw_scope_id
|
|
834
|
+
```
|
|
835
|
+
|
|
836
|
+
A declared [`reverse_scope`](#reverse-scope-for-multi-referencer-tables-reverse_scope) does count, since declaring it states that the referencers own the rows.
|
|
817
837
|
|
|
818
838
|
### ActiveStorage (`has_one_attached` / `has_many_attached`)
|
|
819
839
|
|
|
820
840
|
ActiveStorage is handled automatically — no ActiveStorage-specific configuration is required. The `has_one_attached` / `has_many_attached` macros don't add a column to the owning model; they generate ordinary associations that exwiw already understands:
|
|
821
841
|
|
|
822
|
-
- **`active_storage_attachments`** is the polymorphic join row (`belongs_to :record, polymorphic: true` + `belongs_to :blob`). `exwiw:schema:generate` expands the polymorphic `record` into one `belongs_to` per model that declared `has_*_attached` (found via the generated `has_* ..., as: :record` reflections), exactly like any other [polymorphic `belongs_to`](#polymorphic-belongs_to). So only the attachments whose owner is among the dumped rows are extracted.
|
|
842
|
+
- **`active_storage_attachments`** is the polymorphic join row (`belongs_to :record, polymorphic: true` + `belongs_to :blob`). `exwiw:schema:generate` expands the polymorphic `record` into one `belongs_to` per model that declared `has_*_attached` (found via the generated `has_* ..., as: :record` reflections), exactly like any other [polymorphic `belongs_to`](#polymorphic-belongs_to). So only the attachments whose owner is among the dumped rows are extracted. Every owner type that reaches the scope is extracted (see [Every arm is extracted](#every-arm-is-extracted)); before that, only the single owner type the walk happened to settle on came out.
|
|
823
843
|
- **`active_storage_blobs`** has no `belongs_to` of its own (attachments point *at* it), so it has no path to the dump target. exwiw narrows it via **reverse / "referenced_by" extraction**: a parent table referenced by exactly one constrained, non-polymorphic child is constrained to just the referenced ids instead of dumping every row. The id set is materialized once and joined back (see [Why a JOIN, not `IN (subquery)`](#why-a-join-not-in-subquery)):
|
|
824
844
|
|
|
825
845
|
```sql
|
|
@@ -72,6 +72,8 @@ module Exwiw
|
|
|
72
72
|
# automatic reverse detection step aside, when it did.
|
|
73
73
|
attr_reader :ambiguous_referencers
|
|
74
74
|
|
|
75
|
+
attr_reader :narrowed_by_automatic_reverse
|
|
76
|
+
|
|
75
77
|
def initialize(table_name, table_by_name, dump_target, logger, allow_reverse: true, allow_declared_reverse: true, forward_path: [], reverse_path: [], deep_chain_warned: nil, batch_ids: nil)
|
|
76
78
|
@table_name = table_name
|
|
77
79
|
@table_by_name = table_by_name
|
|
@@ -103,6 +105,7 @@ module Exwiw
|
|
|
103
105
|
# Deep-chain warnings already emitted, keyed by chain path and shared (the
|
|
104
106
|
# same Hash) across every recursive build under one top-level run.
|
|
105
107
|
@deep_chain_warned = deep_chain_warned || {}
|
|
108
|
+
@narrowed_by_automatic_reverse = false
|
|
106
109
|
end
|
|
107
110
|
|
|
108
111
|
def run
|
|
@@ -110,8 +113,15 @@ module Exwiw
|
|
|
110
113
|
|
|
111
114
|
return build_scoped(table) if scope_mode?
|
|
112
115
|
|
|
113
|
-
|
|
114
|
-
|
|
116
|
+
arm_queries = target_polymorphic_arm_queries(table)
|
|
117
|
+
if arm_queries
|
|
118
|
+
where_clauses = [pk_union_clause(table, arm_queries)]
|
|
119
|
+
where_clauses.push(table.filter) if table.filter
|
|
120
|
+
join_clauses = []
|
|
121
|
+
else
|
|
122
|
+
where_clauses = build_where_clauses(table, dump_target)
|
|
123
|
+
join_clauses = build_join_clauses(table, table_by_name, dump_target)
|
|
124
|
+
end
|
|
115
125
|
|
|
116
126
|
# Reverse / "referenced_by" extraction. A table with no belongs_to path to
|
|
117
127
|
# the dump target produces no where/join clauses and would otherwise dump
|
|
@@ -126,7 +136,10 @@ module Exwiw
|
|
|
126
136
|
table.name != dump_target.table_name &&
|
|
127
137
|
where_clauses.empty? && join_clauses.empty?
|
|
128
138
|
reverse_clause = build_referenced_by_clause(table)
|
|
129
|
-
|
|
139
|
+
if reverse_clause
|
|
140
|
+
where_clauses.push(reverse_clause)
|
|
141
|
+
@narrowed_by_automatic_reverse = !explicit_reverse_scope?(table)
|
|
142
|
+
end
|
|
130
143
|
end
|
|
131
144
|
|
|
132
145
|
# Forward cascade. A satellite of a reverse_scope'd (or referenced-by-scoped)
|
|
@@ -141,7 +154,7 @@ module Exwiw
|
|
|
141
154
|
if parent_clause
|
|
142
155
|
where_clauses.push(parent_clause)
|
|
143
156
|
elsif @allow_reverse && @forward_path.empty? && !scope_exempt?(table) &&
|
|
144
|
-
scopable_parent_candidates(table).size > 1
|
|
157
|
+
(scopable_parent_candidates(table).size > 1 || polymorphic_parent_groups(table).size > 1)
|
|
145
158
|
@logger.warn(
|
|
146
159
|
" #{table.name} belongs_to multiple scopable parents; the cascade cannot " \
|
|
147
160
|
"pick one unambiguously, so it is dumped in full. If this is intended, set " \
|
|
@@ -184,63 +197,60 @@ module Exwiw
|
|
|
184
197
|
# the path is 1, it's impossible case
|
|
185
198
|
return [] if path_tables.size < 2
|
|
186
199
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
path_tables.each_cons(2) do |from_table_name, to_table_name|
|
|
200
|
+
path_tables.each_cons(2).map do |from_table_name, to_table_name|
|
|
190
201
|
from_table = table_by_name[from_table_name]
|
|
191
202
|
to_table = table_by_name[to_table_name]
|
|
203
|
+
build_join_clause(from_table, to_table, from_table.belongs_to(to_table_name))
|
|
204
|
+
end
|
|
205
|
+
end
|
|
192
206
|
|
|
193
|
-
|
|
207
|
+
private def build_join_clause(from_table, to_table, relation)
|
|
208
|
+
join_clause = QueryAst::JoinClause.new(
|
|
209
|
+
base_table_name: from_table.name,
|
|
210
|
+
foreign_key: relation.foreign_key,
|
|
211
|
+
join_table_name: to_table.name,
|
|
212
|
+
primary_key: to_table.primary_key,
|
|
213
|
+
where_clauses: [],
|
|
214
|
+
base_where_clauses: []
|
|
215
|
+
)
|
|
194
216
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
217
|
+
# When this hop itself is a polymorphic belongs_to (e.g. comments
|
|
218
|
+
# polymorphically belongs_to posts as commentable), the type column
|
|
219
|
+
# (foreign_type) lives on the source table (from_table = base_table_name).
|
|
220
|
+
# The foreign key alone is not enough — a value like reviewable_id=1 can
|
|
221
|
+
# collide with rows of another model — so add the type condition to
|
|
222
|
+
# base_where_clauses to narrow down the source table.
|
|
223
|
+
if relation.polymorphic?
|
|
224
|
+
join_clause.base_where_clauses.push QueryAst::WhereClause.new(
|
|
225
|
+
column_name: relation.foreign_type,
|
|
226
|
+
operator: :eq,
|
|
227
|
+
value: [relation.type_value]
|
|
202
228
|
)
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
#
|
|
209
|
-
#
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
229
|
+
end
|
|
230
|
+
relation_to_dump_target = to_table.belongs_to(dump_target.table_name)
|
|
231
|
+
if relation_to_dump_target
|
|
232
|
+
join_clause.where_clauses.push dump_target_fk_clause(relation_to_dump_target.foreign_key)
|
|
233
|
+
|
|
234
|
+
# When the intermediate table polymorphically belongs_to the dump
|
|
235
|
+
# target, also add the type column (foreign_type) to the join
|
|
236
|
+
# condition. The type column lives on to_table (= join_table_name), so
|
|
237
|
+
# it rides on the existing mechanism where a JoinClause's where_clauses
|
|
238
|
+
# are compiled against join_table_name.
|
|
239
|
+
if relation_to_dump_target.polymorphic?
|
|
240
|
+
join_clause.where_clauses.push QueryAst::WhereClause.new(
|
|
241
|
+
column_name: relation_to_dump_target.foreign_type,
|
|
213
242
|
operator: :eq,
|
|
214
|
-
value: [
|
|
243
|
+
value: [relation_to_dump_target.type_value]
|
|
215
244
|
)
|
|
216
245
|
end
|
|
217
|
-
|
|
218
|
-
if relation_to_dump_target
|
|
219
|
-
join_clause.where_clauses.push dump_target_fk_clause(relation_to_dump_target.foreign_key)
|
|
220
|
-
|
|
221
|
-
# When the intermediate table polymorphically belongs_to the dump
|
|
222
|
-
# target, also add the type column (foreign_type) to the join
|
|
223
|
-
# condition. The type column lives on to_table (= join_table_name), so
|
|
224
|
-
# it rides on the existing mechanism where a JoinClause's where_clauses
|
|
225
|
-
# are compiled against join_table_name.
|
|
226
|
-
if relation_to_dump_target.polymorphic?
|
|
227
|
-
join_clause.where_clauses.push QueryAst::WhereClause.new(
|
|
228
|
-
column_name: relation_to_dump_target.foreign_type,
|
|
229
|
-
operator: :eq,
|
|
230
|
-
value: [relation_to_dump_target.type_value]
|
|
231
|
-
)
|
|
232
|
-
end
|
|
233
|
-
end
|
|
234
|
-
|
|
235
|
-
# Add filter from intermediate table to join clause
|
|
236
|
-
if to_table.filter
|
|
237
|
-
join_clause.where_clauses.push to_table.filter
|
|
238
|
-
end
|
|
246
|
+
end
|
|
239
247
|
|
|
240
|
-
|
|
248
|
+
# Add filter from intermediate table to join clause
|
|
249
|
+
if to_table.filter
|
|
250
|
+
join_clause.where_clauses.push to_table.filter
|
|
241
251
|
end
|
|
242
252
|
|
|
243
|
-
|
|
253
|
+
join_clause
|
|
244
254
|
end
|
|
245
255
|
|
|
246
256
|
# Builds a `pk IN (SELECT child.fk FROM <child extraction query>)` clause
|
|
@@ -419,6 +429,14 @@ module Exwiw
|
|
|
419
429
|
private def build_belongs_to_scoped_clause(table)
|
|
420
430
|
candidates = scopable_parent_candidates(table)
|
|
421
431
|
|
|
432
|
+
# Plain parents take precedence over polymorphic ones: polymorphic parents
|
|
433
|
+
# are consulted only when no plain parent is scopable. Multiple independent
|
|
434
|
+
# polymorphic associations are as ambiguous as multiple plain parents.
|
|
435
|
+
if candidates.empty?
|
|
436
|
+
groups = polymorphic_parent_groups(table)
|
|
437
|
+
return groups.size == 1 ? pk_union_clause(table, groups.first) : nil
|
|
438
|
+
end
|
|
439
|
+
|
|
422
440
|
# Only the unambiguous single-parent case. Multiple scopable parents would
|
|
423
441
|
# need their subqueries combined (not supported); fall back to unscopable.
|
|
424
442
|
if candidates.size != 1
|
|
@@ -446,6 +464,21 @@ module Exwiw
|
|
|
446
464
|
)
|
|
447
465
|
end
|
|
448
466
|
|
|
467
|
+
private def polymorphic_parent_groups(table)
|
|
468
|
+
@polymorphic_parent_groups ||= {}
|
|
469
|
+
return @polymorphic_parent_groups[table.name] if @polymorphic_parent_groups.key?(table.name)
|
|
470
|
+
|
|
471
|
+
@polymorphic_parent_groups[table.name] =
|
|
472
|
+
if table.primary_key.nil?
|
|
473
|
+
[]
|
|
474
|
+
else
|
|
475
|
+
table.belongs_tos.select(&:polymorphic?).group_by(&:foreign_type).filter_map do |_, relations|
|
|
476
|
+
arm_queries = relations.filter_map { |relation| scoped_target_arm_query(table, relation) }
|
|
477
|
+
arm_queries unless arm_queries.empty?
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
end
|
|
481
|
+
|
|
449
482
|
# The scopable belongs_to parents of `table`: each non-polymorphic parent
|
|
450
483
|
# whose own extraction query comes out constrained, paired with the relation
|
|
451
484
|
# and that query. Shared by build_belongs_to_scoped_clause (which requires
|
|
@@ -556,6 +589,53 @@ module Exwiw
|
|
|
556
589
|
)
|
|
557
590
|
end
|
|
558
591
|
|
|
592
|
+
# Single-target counterpart of #scoped_arms.
|
|
593
|
+
private def target_polymorphic_arm_queries(table)
|
|
594
|
+
return nil if table.name == dump_target.table_name
|
|
595
|
+
|
|
596
|
+
first_hop =
|
|
597
|
+
if table.belongs_to(dump_target.table_name)
|
|
598
|
+
dump_target.table_name
|
|
599
|
+
else
|
|
600
|
+
find_path_to_dump_target(table, table_by_name, dump_target)[1]
|
|
601
|
+
end
|
|
602
|
+
return nil if first_hop.nil?
|
|
603
|
+
|
|
604
|
+
sibling_arms = polymorphic_sibling_arms(table, first_hop)
|
|
605
|
+
return nil if sibling_arms.nil?
|
|
606
|
+
|
|
607
|
+
arm_queries = sibling_arms.filter_map { |relation| target_arm_query(table, relation) }
|
|
608
|
+
@logger.debug(
|
|
609
|
+
" #{table.name} reaches the dump target through #{arm_queries.size} of " \
|
|
610
|
+
"#{sibling_arms.size} polymorphic '#{sibling_arms.first.foreign_type}' arm(s)."
|
|
611
|
+
)
|
|
612
|
+
# A lone arm is always the one the shortest path leaves through, which the
|
|
613
|
+
# single-path build already emits as a plain JOIN.
|
|
614
|
+
arm_queries.size >= 2 ? arm_queries : nil
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
private def target_arm_query(table, relation)
|
|
618
|
+
if relation.table_name == dump_target.table_name
|
|
619
|
+
return arm_pk_query(table).tap do |query|
|
|
620
|
+
query.where dump_target_fk_clause(relation.foreign_key)
|
|
621
|
+
query.where polymorphic_type_clause(relation)
|
|
622
|
+
end
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
target = table_by_name[relation.table_name]
|
|
626
|
+
return nil if target.nil?
|
|
627
|
+
|
|
628
|
+
path = find_path_to_dump_target(target, table_by_name, dump_target)
|
|
629
|
+
if path.any? && !path.include?(table.name)
|
|
630
|
+
return arm_pk_query(table).tap do |query|
|
|
631
|
+
query.join(build_join_clause(table, target, relation))
|
|
632
|
+
build_join_clauses(target, table_by_name, dump_target).each { |join_clause| query.join(join_clause) }
|
|
633
|
+
end
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
scoped_target_arm_query(table, relation)
|
|
637
|
+
end
|
|
638
|
+
|
|
559
639
|
private def find_path_to_dump_target(table, table_by_name, dump_target)
|
|
560
640
|
return [] if table.name == dump_target.table_name
|
|
561
641
|
|
|
@@ -935,15 +1015,33 @@ module Exwiw
|
|
|
935
1015
|
path = find_path_to_scoped(table, first_relation: relation)
|
|
936
1016
|
return ScopedArm.new(relation: relation, path: path) if path.size >= 2
|
|
937
1017
|
|
|
1018
|
+
target_query = scoped_target_query(table, relation, allow_automatic_reverse: true)
|
|
1019
|
+
target_query && ScopedArm.new(relation: relation, target_query: target_query)
|
|
1020
|
+
end
|
|
1021
|
+
|
|
1022
|
+
private def scoped_target_arm_query(table, relation)
|
|
1023
|
+
# A parent kept only by the automatic referenced_by exists to keep a child's
|
|
1024
|
+
# foreign key valid. Single-target mode extracts only what sits under the
|
|
1025
|
+
# dump target, so such a parent must not pull in the rows that point at it.
|
|
1026
|
+
target_query = scoped_target_query(table, relation, allow_automatic_reverse: scope_mode?)
|
|
1027
|
+
target_query && target_ids_arm_query(table, relation, target_query)
|
|
1028
|
+
end
|
|
1029
|
+
|
|
1030
|
+
private def scoped_target_query(table, relation, allow_automatic_reverse:)
|
|
938
1031
|
target = table_by_name[relation.table_name]
|
|
939
1032
|
return nil if target.nil? || target.primary_key.nil?
|
|
940
1033
|
# Descending into a table already being resolved would close a cycle.
|
|
941
1034
|
return nil if target.name == table.name || @forward_path.include?(target.name)
|
|
942
1035
|
|
|
943
|
-
|
|
1036
|
+
# Built the same way as the target's own extraction, so the arm never keeps
|
|
1037
|
+
# a row pointing at a target row the dump leaves out.
|
|
1038
|
+
builder = self.class.new(
|
|
944
1039
|
target.name, table_by_name, dump_target, @logger,
|
|
945
|
-
|
|
1040
|
+
forward_path: @forward_path + [table.name], reverse_path: @reverse_path, deep_chain_warned: @deep_chain_warned
|
|
946
1041
|
)
|
|
1042
|
+
target_query = builder.run
|
|
1043
|
+
return nil if !allow_automatic_reverse && builder.narrowed_by_automatic_reverse
|
|
1044
|
+
|
|
947
1045
|
# An unconstrained target selects every id, i.e. does not scope the arm at
|
|
948
1046
|
# all; dropping the arm is the safe outcome.
|
|
949
1047
|
return nil unless target_query.where_clauses.any? || target_query.join_clauses.any?
|
|
@@ -964,7 +1062,7 @@ module Exwiw
|
|
|
964
1062
|
return nil
|
|
965
1063
|
end
|
|
966
1064
|
|
|
967
|
-
|
|
1065
|
+
target_query
|
|
968
1066
|
end
|
|
969
1067
|
|
|
970
1068
|
# The polymorphic belongs_to arms sharing the `foreign_type` of the relation
|
|
@@ -1019,42 +1117,57 @@ module Exwiw
|
|
|
1019
1117
|
# (`replace_with` / `raw_sql`) configured on it cannot corrupt the id
|
|
1020
1118
|
# comparison — the same guard the reverse-scope projections use.
|
|
1021
1119
|
private def polymorphic_arms_clause(table, arms)
|
|
1022
|
-
pk_column = TableColumn.from_symbol_keys(name: table.primary_key)
|
|
1023
|
-
|
|
1024
1120
|
queries = arms.map do |arm|
|
|
1025
|
-
query = QueryAst::Select.new
|
|
1026
|
-
query.from(table.name)
|
|
1027
|
-
query.select([pk_column])
|
|
1028
|
-
|
|
1029
1121
|
if arm.path
|
|
1030
|
-
|
|
1122
|
+
arm_pk_query(table).tap do |query|
|
|
1123
|
+
build_scoped_join_clauses(arm.path, first_relation: arm.relation).each { |jc| query.join(jc) }
|
|
1124
|
+
end
|
|
1031
1125
|
else
|
|
1032
|
-
|
|
1033
|
-
# its id set instead of joining up to a scoped ancestor. The type
|
|
1034
|
-
# column still has to be constrained: the foreign key alone cannot tell
|
|
1035
|
-
# this arm's rows from another arm's (record_id=1 may be any type).
|
|
1036
|
-
target = table_by_name.fetch(arm.relation.table_name)
|
|
1037
|
-
query.where QueryAst::WhereClause.new(
|
|
1038
|
-
column_name: arm.relation.foreign_key,
|
|
1039
|
-
operator: :in_subquery,
|
|
1040
|
-
value: QueryAst::SelectSubquery.new(
|
|
1041
|
-
query: project_query_to(arm.target_query, target.primary_key)
|
|
1042
|
-
)
|
|
1043
|
-
)
|
|
1044
|
-
query.where QueryAst::WhereClause.new(
|
|
1045
|
-
column_name: arm.relation.foreign_type,
|
|
1046
|
-
operator: :eq,
|
|
1047
|
-
value: [arm.relation.type_value]
|
|
1048
|
-
)
|
|
1126
|
+
target_ids_arm_query(table, arm.relation, arm.target_query)
|
|
1049
1127
|
end
|
|
1050
|
-
|
|
1051
|
-
query
|
|
1052
1128
|
end
|
|
1053
1129
|
|
|
1130
|
+
pk_union_clause(table, queries)
|
|
1131
|
+
end
|
|
1132
|
+
|
|
1133
|
+
private def pk_union_clause(table, arm_queries)
|
|
1054
1134
|
QueryAst::WhereClause.new(
|
|
1055
1135
|
column_name: table.primary_key,
|
|
1056
1136
|
operator: :in_subquery,
|
|
1057
|
-
value: QueryAst::UnionSubquery.new(queries:
|
|
1137
|
+
value: QueryAst::UnionSubquery.new(queries: arm_queries)
|
|
1138
|
+
)
|
|
1139
|
+
end
|
|
1140
|
+
|
|
1141
|
+
private def arm_pk_query(table)
|
|
1142
|
+
QueryAst::Select.new.tap do |query|
|
|
1143
|
+
query.from(table.name)
|
|
1144
|
+
query.select([TableColumn.from_symbol_keys(name: table.primary_key)])
|
|
1145
|
+
end
|
|
1146
|
+
end
|
|
1147
|
+
|
|
1148
|
+
# The arm's target is scoped without a join path of its own, so probe its id
|
|
1149
|
+
# set instead of joining up to a scoped ancestor. The type column still has
|
|
1150
|
+
# to be constrained: the foreign key alone cannot tell this arm's rows from
|
|
1151
|
+
# another arm's (record_id=1 may be any type).
|
|
1152
|
+
private def target_ids_arm_query(table, relation, target_query)
|
|
1153
|
+
target = table_by_name.fetch(relation.table_name)
|
|
1154
|
+
arm_pk_query(table).tap do |query|
|
|
1155
|
+
query.where QueryAst::WhereClause.new(
|
|
1156
|
+
column_name: relation.foreign_key,
|
|
1157
|
+
operator: :in_subquery,
|
|
1158
|
+
value: QueryAst::SelectSubquery.new(
|
|
1159
|
+
query: project_query_to(target_query, target.primary_key)
|
|
1160
|
+
)
|
|
1161
|
+
)
|
|
1162
|
+
query.where polymorphic_type_clause(relation)
|
|
1163
|
+
end
|
|
1164
|
+
end
|
|
1165
|
+
|
|
1166
|
+
private def polymorphic_type_clause(relation)
|
|
1167
|
+
QueryAst::WhereClause.new(
|
|
1168
|
+
column_name: relation.foreign_type,
|
|
1169
|
+
operator: :eq,
|
|
1170
|
+
value: [relation.type_value]
|
|
1058
1171
|
)
|
|
1059
1172
|
end
|
|
1060
1173
|
|
data/lib/exwiw/version.rb
CHANGED
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.
|
|
4
|
+
version: 1.1.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Shia
|
|
@@ -47,6 +47,7 @@ extensions:
|
|
|
47
47
|
- ext/exwiw/ext_json/extconf.rb
|
|
48
48
|
extra_rdoc_files: []
|
|
49
49
|
files:
|
|
50
|
+
- AGENTS.md
|
|
50
51
|
- CHANGELOG.md
|
|
51
52
|
- LICENSE.txt
|
|
52
53
|
- README.md
|