Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
15818e9
Add RFC
robacourt Jan 27, 2026
0e61e43
Fix bug caused by typo
robacourt Jan 29, 2026
9fa207e
Add SqlGenerator module to convert parsed AST back to SQL
robacourt Feb 16, 2026
da81f42
Update SqlGenerator to only use brackets when needed
robacourt Feb 17, 2026
d1d3437
Add Ilia's Decomposer
robacourt Feb 16, 2026
f457955
Update Decomposer to use AST
robacourt Feb 17, 2026
89d782a
Add more decomposer tests
robacourt Feb 17, 2026
fc38917
Follow plan for Decomposer
robacourt Feb 17, 2026
ed60de2
Add @max_disjuncts complexity guard to Decomposer
robacourt Feb 17, 2026
6a883c2
Format decomposer
robacourt Feb 17, 2026
0c1870b
Phase 3: Add DnfContext module for DNF decomposition state
robacourt Feb 17, 2026
92ab8c1
Phase 4: Shape tag_structure + fill_move_tags for multi-disjunct DNF
robacourt Feb 17, 2026
17a472e
Phase 5: Consumer State holds DnfContext, removes invalidation flags
robacourt Feb 17, 2026
f80d1f9
Phase 6: Active Conditions Computation
robacourt Feb 17, 2026
d69c1f4
Phase 7: Move-in/Move-out Message Format + DNF-Aware Exclusion Clauses
robacourt Feb 17, 2026
dbb0ef1
Phase 8: Log Items Format — wire tags and active_conditions
robacourt Feb 17, 2026
c25085b
Phase 9: Change Handling — compute active_conditions via DnfContext
robacourt Feb 17, 2026
f716838
Phase 10: Querying Updates — active_conditions SQL generation
robacourt Feb 17, 2026
d6ec5aa
Phase 11: Move Handling for Multiple Positions
robacourt Feb 17, 2026
f73da63
Phase 12: Position-aware condition_hashes filtering
robacourt Feb 17, 2026
3621e02
Phase 13: Remove Shape Invalidation
robacourt Feb 17, 2026
e42240e
Phase 14: Elixir Client Updates — position-based tag tracking and DNF…
robacourt Feb 17, 2026
f74b12a
Include active_conditions in snapshot and move-in query JSON headers
robacourt Feb 17, 2026
974d0ce
Add protocol version validation for complex subquery shapes
robacourt Feb 17, 2026
4be9ac3
Add postgres oracle tests
robacourt Jan 29, 2026
cc98de5
Fix DNF position-aware move handling bugs in oracle property tests
robacourt Feb 18, 2026
e3f5a32
REMOVE - review
robacourt Feb 18, 2026
c44d151
Bug 1: Materializer crashes on list-format tags (causes 409s)
robacourt Feb 18, 2026
821d136
Remove legacy (no-DnfContext) path from move_in_where_clause
robacourt Feb 23, 2026
d672174
Unify tag format to slash-delimited strings everywhere
robacourt Feb 23, 2026
90eecae
Update OR/NOT subquery tests for protocol v2 and proper move handling
robacourt Feb 23, 2026
d84fd02
Update tag expectations to slash-delimited multi-position format
robacourt Feb 23, 2026
51a5b44
Update two-subqueries test to expect successful move-in
robacourt Feb 23, 2026
8baba32
Make invariant assertion message clearxer
robacourt Feb 24, 2026
b4868a9
Add updated plan
robacourt Feb 24, 2026
c2cdcee
Impliment point-in-time subqueries
robacourt Feb 24, 2026
d9f0e5f
REMOVE - Make oracle property test deterministic with --seed
robacourt Feb 23, 2026
1322f32
Fix orphaned tag_to_keys entries causing phantom synthetic deletes
robacourt Feb 25, 2026
d7017b0
REMOVE - add summary
robacourt Feb 25, 2026
4600dfe
REMOVE: fix oracle view tests
robacourt Feb 25, 2026
27408fb
Lift disjunct_positions out of per-key storage into shared shape-leve…
robacourt Feb 25, 2026
d922401
Add DNF/active_conditions unit tests ported from TanStack/db#1270
robacourt Feb 25, 2026
8ab6105
Update plan for tx_offset based view reads
robacourt Feb 25, 2026
4e2b0b7
Replace prev_value_counts with LSN-keyed snapshots to fix phantom del…
robacourt Feb 25, 2026
b77531a
Always generate exclusion clauses for non-containing disjuncts in mov…
robacourt Feb 25, 2026
8c9ec36
REMOVE - failing test mix test --include oracle test/integration/orac…
robacourt Feb 25, 2026
ed7ecc8
ADD TO PLAN - Fix exclusion clause vs change_will_be_covered_by_move_…
robacourt Feb 26, 2026
2ddbb89
REMOVE - added raise for snapshot behind replication stream
robacourt Mar 1, 2026
d55af83
REMOVE - update oracle tests to do multiple txns per batch
robacourt Mar 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/arbitrary-boolean-expressions-with-subqueries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@core/sync-service": minor
"@core/elixir-client": minor
---

Add support for arbitrary boolean expressions with subqueries.

Previously, WHERE clauses with OR or NOT combined with subqueries would cause shape invalidation. This update implements RFC "Arbitrary Boolean Expressions with Subqueries" which enables:

- OR with multiple subqueries: `WHERE project_id IN (SELECT ...) OR assigned_to IN (SELECT ...)`
- NOT with subqueries: `WHERE project_id NOT IN (SELECT ...)`
- Complex expressions: `WHERE (a IN sq1 AND b='x') OR c NOT IN sq2`

Key features:
- DNF (Disjunctive Normal Form) decomposition for WHERE clause analysis
- `active_conditions` array in row messages indicating which atomic conditions are satisfied
- Position-based move-in/move-out handling that correctly inverts behavior for negated positions
- Deduplication logic to prevent duplicate inserts when rows match multiple disjuncts
- Updated elixir-client to parse `active_conditions` field in message headers
627 changes: 627 additions & 0 deletions docs/rfcs/arbitrary-boolean-expressions-with-subqueries.md

Large diffs are not rendered by default.

16 changes: 10 additions & 6 deletions packages/elixir-client/lib/electric/client/message.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ defmodule Electric.Client.Message do
txids: [],
op_position: 0,
tags: [],
removed_tags: []
removed_tags: [],
active_conditions: []
]

@type operation :: :insert | :update | :delete
Expand All @@ -29,7 +30,8 @@ defmodule Electric.Client.Message do
txids: txids(),
op_position: non_neg_integer(),
tags: [tag()],
removed_tags: [tag()]
removed_tags: [tag()],
active_conditions: [boolean()]
}

@doc false
Expand All @@ -44,7 +46,8 @@ defmodule Electric.Client.Message do
lsn: Map.get(msg, "lsn", nil),
op_position: Map.get(msg, "op_position", 0),
tags: Map.get(msg, "tags", []),
removed_tags: Map.get(msg, "removed_tags", [])
removed_tags: Map.get(msg, "removed_tags", []),
active_conditions: Map.get(msg, "active_conditions", [])
}
end

Expand Down Expand Up @@ -187,14 +190,15 @@ defmodule Electric.Client.Message do

@enforce_keys [:shape_handle, :offset, :schema]

defstruct [:shape_handle, :offset, :schema, tag_to_keys: %{}, key_data: %{}]
defstruct [:shape_handle, :offset, :schema, tag_to_keys: %{}, key_data: %{}, disjunct_positions: nil]

@type t :: %__MODULE__{
shape_handle: Client.shape_handle(),
offset: Offset.t(),
schema: Client.schema(),
tag_to_keys: %{String.t() => MapSet.t(String.t())},
key_data: %{String.t() => %{tags: MapSet.t(String.t()), msg: ChangeMessage.t()}}
tag_to_keys: %{optional(term()) => MapSet.t(String.t())},
key_data: %{optional(String.t()) => map()},
disjunct_positions: [[non_neg_integer()]] | nil
}
end

Expand Down
8 changes: 5 additions & 3 deletions packages/elixir-client/lib/electric/client/poll.ex
Original file line number Diff line number Diff line change
Expand Up @@ -235,10 +235,11 @@ defmodule Electric.Client.Poll do
end

defp handle_message(%Message.ChangeMessage{} = msg, state) do
{tag_to_keys, key_data} =
TagTracker.update_tag_index(state.tag_to_keys, state.key_data, msg)
{tag_to_keys, key_data, disjunct_positions} =
TagTracker.update_tag_index(state.tag_to_keys, state.key_data, state.disjunct_positions, msg)

{:message, msg, %{state | tag_to_keys: tag_to_keys, key_data: key_data}}
{:message, msg,
%{state | tag_to_keys: tag_to_keys, key_data: key_data, disjunct_positions: disjunct_positions}}
end

defp handle_message(
Expand All @@ -249,6 +250,7 @@ defmodule Electric.Client.Poll do
TagTracker.generate_synthetic_deletes(
state.tag_to_keys,
state.key_data,
state.disjunct_positions,
patterns,
request_timestamp
)
Expand Down
11 changes: 8 additions & 3 deletions packages/elixir-client/lib/electric/client/shape_state.ex
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ defmodule Electric.Client.ShapeState do
up_to_date?: false,
tag_to_keys: %{},
key_data: %{},
disjunct_positions: nil,
stale_cache_retry_count: 0
]

Expand All @@ -46,6 +47,7 @@ defmodule Electric.Client.ShapeState do
up_to_date?: boolean(),
tag_to_keys: %{optional(term()) => MapSet.t()},
key_data: %{optional(term()) => %{tags: MapSet.t(), msg: term()}},
disjunct_positions: [[non_neg_integer()]] | nil,
stale_cache_buster: String.t() | nil,
stale_cache_retry_count: non_neg_integer()
}
Expand Down Expand Up @@ -80,7 +82,8 @@ defmodule Electric.Client.ShapeState do
schema: resume.schema,
up_to_date?: true,
tag_to_keys: Map.get(resume, :tag_to_keys, %{}),
key_data: Map.get(resume, :key_data, %{})
key_data: Map.get(resume, :key_data, %{}),
disjunct_positions: Map.get(resume, :disjunct_positions)
}
end

Expand All @@ -99,7 +102,8 @@ defmodule Electric.Client.ShapeState do
up_to_date?: false,
next_cursor: nil,
tag_to_keys: %{},
key_data: %{}
key_data: %{},
disjunct_positions: nil
}
end

Expand All @@ -113,7 +117,8 @@ defmodule Electric.Client.ShapeState do
offset: state.offset,
schema: state.schema,
tag_to_keys: state.tag_to_keys,
key_data: state.key_data
key_data: state.key_data,
disjunct_positions: state.disjunct_positions
}
end

Expand Down
Loading