Repository navigation
fix: compile parent(rel.id) or-filters as EXISTS instead of joining b… - #265
Conversation
|
The issue with matching on |
|
That makes sense, matching on only = handles the one all_tags shape. While other parent filters can still run into the join issue. I'll look into rewriting the whole related filter to EXISTS when it would join multiple parent paths. |
|
I rewrote it so it doesn't match on '=' anymore. Now, if a related filter would join two or more parent to-many paths, it skips those joins and rewrites that filter to 'exists', keeping the 'or' / 'and'. So '== id', '== title', and '>' all go through the same path. I re-ran the all_tags case in ash_postgres (three unique tags). I also added a title union and a > filter there, both passed. Those tests aren't in this PR because they are in ash_postgres. |
|
👋 sorry it took me a while to get back to this, been busy! I think we need to move this much higher up in the stack where we build a join. We need to ideally make the decision once, at the point where join.ex currently calls
|
|
I moved it up higher in the stack to about where you mentioned. If parent( ) would hit a many-to-many relationship, it doesn't join the paths together onto the source. The whole relationship filter is wrapped in one correlated EXISIT over the source now. The parent join lives inside the subquery, and parent( )also binds there. The tests in ash_postgres related to all_tags still pass. |
|
Pulled this down and ran it against ash_postgres. The lateral load case is fixed, but:
I just pushed some tests to a branch in |
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
|
I went through it again. Now the EXISTS wrap is only the cartesian parent( ) part. The load filters and dest exists/2 stay on the destination. authour.first_name and relates_to_actor_via do not resolve against the source. Now a single m2m hop is not treated as a cartiesian product anymore. This makes parent(credited_posts.role) stays correlated. Load, exists(rel) / rel.field, and aggregates now all go through maybe_join_parent_expr. I dropped the typed_binary_operator_expr extract and merged main. The correlate_source_pk is a real field on Ash.Query.Exists instead of Map.put. That was a one line change that i have locally and it isnt in the pr. The tests were ASH_VERSION=local ASH_SQL_VERSION=local mix test against test/parent-union-relationships. |
…Exists Statements that reference parent() are grouped into one correlated EXISTS over the source, compiled by swapping bindings instead of rewriting the expression, so destination refs (including relationship paths) stay bound to the destination. Drops `correlate_source_pk`, so no ash core change is needed. Also hydrates relationship filters before the cartesian check, so the lateral aggregate and exists-through-relationship paths stop joining the parent paths onto the outer query, and resolves the source resource at nested paths.
|
👋 this was very close! I wrapped it up by pushing a commit on my end.
The previous version skipped the parent joins for the whole filter but only wrapped some subexpressions, so these broke:
|
Contributor checklist
Leave anything that you believe does not apply unchecked.
What was wrong
Some of the relationships are a union of two lists. The example from the original issue is 'all_tags' it should be "this post's genre tags, or this post's mood tags."
It would be written like this:
no_attributes? true
sort :id
filter expr(parent(genre_tags.id) == id or parent(mood_tags.id) == id)
'no_attributes?' means there is no direct foreign key. Ash needs to use the 'parent( )' filter to make a decision on what rows belong.
The old SQL joined both parent lists. if a post had 2 genre tags and 1 mood tag the join could produce extra combinations of those rows. It would then 'sort :id' added a 'row_number( )' column called 'order'. 'SELECT DISTINCT' also looked at 'order' so because of this, two copies of the same tag could survive due to them looking different. You would end up with 4 tags instead of 3.
The main issue boiled down to a Cartesian product. This shouldn't be a possibility. The fix that I implemented was to turn this into EXISTS, and that is the fix that I used.
The changes
it was done in two separate pieces both in ash_sql:
lib/join.ex
If the filter is "parent(this.id) == id or parent(that.id) == id", we do not join both parent lists. When you would join them is when extra rows would be created.
lib/expr.ex
For 'parent(rel.id)== id' it compiles EXISTS instead of a normal ==. Now EXISTS looks at whether the parent has this related id. This makes it more of a true-or-false per row so the same tag cannot show up twice from two join combinations.
I did testing through local ash_postgres due to there being no Postgres test suite. I added a small 'all_tags' relationship on Post and a test that created three tags that had ids in order of genre, mood, genre. It used to duplicate the 2nd tag, but now the test returns 3 unique rows, so it passed.