Skip to content

feat: Support arbitrary boolean expressions with subqueries (OR, NOT)#3791

Draft
robacourt wants to merge 49 commits intomainfrom
rob/arbitrary-boolean-expressions-with-subqueries
Draft

feat: Support arbitrary boolean expressions with subqueries (OR, NOT)#3791
robacourt wants to merge 49 commits intomainfrom
rob/arbitrary-boolean-expressions-with-subqueries

Conversation

@robacourt
Copy link
Contributor

@robacourt robacourt commented Jan 28, 2026

Summary

This PR implements RFC "Arbitrary Boolean Expressions with Subqueries" which extends Electric's subquery support from single IN (SELECT ...) conditions to arbitrary boolean expressions with OR, NOT, and multiple subqueries.

What's Changed

  • 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 Implementation Details

  1. DNF Decomposer (lib/electric/replication/eval/decomposer.ex)

    • Converts WHERE clause AST to Disjunctive Normal Form
    • Applies De Morgan's laws for NOT handling
    • Assigns positions to atomic conditions for tracking
  2. Active Conditions (lib/electric/shapes/where_clause.ex)

    • Computes active_conditions array indicating which atomic conditions are satisfied
    • Evaluates DNF against active conditions to determine inclusion
    • Used for both replication stream changes and initial snapshots
  3. Move-in/Move-out Handling (lib/electric/shapes/consumer/move_handling.ex)

    • Position-based move-in/move-out with correct NOT inversion
    • Move-in to negated position triggers move-out
    • Move-out from negated position triggers move-in query
    • Deduplication logic prevents duplicate inserts for rows matching multiple disjuncts
  4. Message Format (lib/electric/log_items.ex, lib/electric/shapes/querying.ex)

    • active_conditions array included in row message headers
    • SQL generation for initial snapshots includes active_conditions
    • Updated elixir-client to parse active_conditions field

Test plan

  • All 1389 existing tests pass
  • 21 new integration tests for OR/NOT with subqueries (test/electric/plug/subquery_router_test.exs)
  • DNF decomposer tests (test/electric/replication/eval/decomposer_test.exs)
  • WhereClause tests for active conditions (test/electric/shapes/where_clause_test.exs)
  • Updated router tests no longer expect 409 for OR/NOT shapes

Test Coverage

  • Initial snapshot with OR subqueries
  • Move-in/move-out with OR subqueries
  • NOT IN shapes with correct inversion
  • Row matching both disjuncts (deduplication)
  • Row removed when all disjuncts become false
  • Complex De Morgan expressions

Related

  • RFC: docs/rfcs/arbitrary-boolean-expressions-with-subqueries.md
  • Implementation Plan: packages/sync-service/IMPLEMENTATION_PLAN.md

🤖 Generated with Claude Code

@coderabbitai
Copy link

coderabbitai bot commented Jan 28, 2026

Important

Review skipped

Auto reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

  • 🔍 Trigger a full review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov
Copy link

codecov bot commented Jan 28, 2026

❌ 7 Tests Failed:

Tests completed Failed Passed Skipped
2519 7 2512 11
View the top 3 failed test(s) by shortest run time
Elixir.Electric.Plug.RouterTest::test /v1/shapes - subqueries subquery combined with OR should return a 409 on move-out
Stack Traces | 0.102s run time
44) test /v1/shapes - subqueries subquery combined with OR should return a 409 on move-out (Electric.Plug.RouterTest)
     .../electric/plug/router_test.exs:2495
     match (=) failed
     code:  assert {req, 200, response} = shape_req(orig_req, opts)
     left:  {req, 200, response}
     right: {%{offset: "-1", table: "child", where: "parent_id in (SELECT id FROM parent WHERE include_parent = true) OR include_child = true", live: false}, 400, %{"errors" => %{"where" => ["WHERE clauses with OR or NOT combined with subqueries require protocol version 2. Please upgrade your client or set the Electric-Protocol-Version: 2 header."]}, "message" => "Invalid request"}}
     stacktrace:
       .../electric/plug/router_test.exs:2505: (test)
Elixir.Electric.Plug.RouterTest::test /v1/shapes - subqueries subquery combined with OR should return a 409 on move-in
Stack Traces | 0.114s run time
16) test /v1/shapes - subqueries subquery combined with OR should return a 409 on move-in (Electric.Plug.RouterTest)
     .../electric/plug/router_test.exs:2527
     match (=) failed
     code:  assert {req, 200, response} = shape_req(orig_req, opts)
     left:  {req, 200, response}
     right: {%{offset: "-1", table: "child", where: "parent_id in (SELECT id FROM parent WHERE include_parent = true) OR include_child = true", live: false}, 400, %{"errors" => %{"where" => ["WHERE clauses with OR or NOT combined with subqueries require protocol version 2. Please upgrade your client or set the Electric-Protocol-Version: 2 header."]}, "message" => "Invalid request"}}
     stacktrace:
       .../electric/plug/router_test.exs:2537: (test)
Elixir.Electric.Plug.RouterTest::test /v1/shapes - subqueries NOT IN subquery should return 409 on move-in to subquery
Stack Traces | 0.121s run time
12) test /v1/shapes - subqueries NOT IN subquery should return 409 on move-in to subquery (Electric.Plug.RouterTest)
     .../electric/plug/router_test.exs:2334
     match (=) failed
     code:  assert {req, 200, [data, snapshot_end]} = shape_req(req, opts)
     left:  {req, 200, [data, snapshot_end]}
     right: {%{offset: "-1", table: "child", where: "parent_id NOT IN (SELECT id FROM parent WHERE excluded = true)", live: false}, 400, %{"errors" => %{"where" => ["WHERE clauses with OR or NOT combined with subqueries require protocol version 2. Please upgrade your client or set the Electric-Protocol-Version: 2 header."]}, "message" => "Invalid request"}}
     stacktrace:
       .../electric/plug/router_test.exs:2346: (test)
Elixir.Electric.Plug.RouterTest::test /v1/shapes - subqueries subqueries work with quoted column names and are tagged correctly
Stack Traces | 0.151s run time
76) test /v1/shapes - subqueries subqueries work with quoted column names and are tagged correctly (Electric.Plug.RouterTest)
     .../electric/plug/router_test.exs:2793
     match (=) failed
     The following variables were pinned:
       tag = "edef6e0e7454bf0ad3adc4bf774e1fd0"
     code:  assert %{"value" => %{"id" => "1", "parentId" => "1", "Value" => "10"}, "headers" => %{"tags" => [^tag]}} =
              Enum.find(response, &Map.has_key?(&1, "key"))
     left:  %{"value" => %{"id" => "1", "parentId" => "1", "Value" => "10"}, "headers" => %{"tags" => [^tag]}}
     right: %{"headers" => %{"active_conditions" => [true, true], "operation" => "insert", "relation" => ["public", "Child"], "tags" => ["edef6e0e7454bf0ad3adc4bf774e1fd0/6de8df718bc42a1f77e75ddfed67be68"]}, "key" => "\"public\".\"Child\"/\"1\"", "value" => %{"Value" => "10", "id" => "1", "parentId" => "1"}}
     stacktrace:
       .../electric/plug/router_test.exs:2807: (test)
Elixir.Electric.Plug.RouterTest::test /v1/shapes - subqueries subqueries work with params
Stack Traces | 0.153s run time
33) test /v1/shapes - subqueries subqueries work with params (Electric.Plug.RouterTest)
     .../electric/plug/router_test.exs:2745
     match (=) failed
     The following variables were pinned:
       tag = "fdbe0699a0e90077a1104f673dc5fff7"
     code:  assert {_, 200,
             [
               %{"headers" => %{"tags" => [^tag]}, "value" => %{"id" => "3"}},
               %{"headers" => %{"control" => "snapshot-end"}},
               up_to_date_ctl()
             ]} = Task.await(task)
     left:  {_, 200,
             [
               %{"headers" => %{"tags" => [^tag]}, "value" => %{"id" => "3"}},
               %{"headers" => %{"control" => "snapshot-end"}},
               up_to_date_ctl()
             ]}
     right: {%{handle: "129707345-1771410743115937", offset: "814342736_2", table: "child", where: "value in (SELECT value FROM parent WHERE other_value >= $2) AND other_value >= $1", params: %{"1" => "10", "2" => "6"}, live: true}, 200, [%{"headers" => %{"active_conditions" => [true, true], "is_move_in" => true, "operation" => "insert", "relation" => ["public", "child"], "tags" => ["fdbe0699a0e90077a1104f673dc5fff7/fdbe0699a0e90077a1104f673dc5fff7"]}, "key" => "\"public\".\"child\"/\"3\"", "value" => %{"id" => "3", "other_value" => "20", "value" => "20"}}, %{"headers" => %{"control" => "snapshot-end", "xip_list" => [], "xmax" => "2948", "xmin" => "2948"}}, %{"headers" => %{"control" => "up-to-date", "global_last_seen_lsn" => "814342736"}}]}
     stacktrace:
       .../electric/plug/router_test.exs:2778: (test)
Elixir.Electric.Plug.RouterTest::test /v1/shapes - subqueries supports two subqueries at the same level but returns 409 on move-in
Stack Traces | 0.198s run time
15) test /v1/shapes - subqueries supports two subqueries at the same level but returns 409 on move-in (Electric.Plug.RouterTest)
     .../electric/plug/router_test.exs:2985
     match (=) failed
     code:  assert %{status: 409} = Task.await(task)
     left:  %{status: 409}
     right: %Plug.Conn{adapter: {Plug.Adapters.Test.Conn, :...}, assigns: %{request: %Electric.Shapes.Api.Request{chunk_end_offset: LogOffset.new(890861112, 0), handle: "16467413-1771410753254019", last_offset: LogOffset.new(890861112, 0), global_last_seen_lsn: 890861288, new_changes_ref: #Reference<0.504478028.72351746.167460>, new_changes_pid: #PID<0.32246.0>, api: %Electric.Shapes.Api{inspector: {Electric.Postgres.Inspector.EtsInspector, [stack_id: "Electric.Plug.RouterTest test /v1/shapes - subqueries supports two subqueries at the same level but returns 409 on move-in", server: {:via, Registry, {:"Electric.ProcessRegistry:Electric.Plug.RouterTest test /v1/shapes - subqueries supports two subqueries at the same level but returns 409 on move-in", {Electric.Postgres.Inspector.EtsInspector, nil}}}]}, shape: nil, stack_id: "Electric.Plug.RouterTest test /v1/shapes - subqueries supports two subqueries at the same level but returns 409 on move-in", feature_flags: ["allow_subqueries", "tagged_subqueries"], max_concurrent_requests: %{existing: 1000, initial: 300}, allow_shape_deletion: true, keepalive_interval: 21000, long_poll_timeout: 4000, sse_timeout: 60000, max_age: 60, stack_ready_timeout: 5000, stale_age: 300, send_cache_headers?: true, encoder: Electric.Shapes.Api.Encoder.JSON, sse_encoder: Electric.Shapes.Api.Encoder.SSE, configured: true}, params: %Electric.Shapes.Api.Params{table: "projects", offset: LogOffset.new(890861112, 0), handle: "16467413-1771410753254019", live: true, where: "workspace_id IN (SELECT workspace_id FROM workspace_members WHERE user_id = 100) AND id IN (SELECT project_id FROM project_members WHERE user_id = 100)", columns: nil, shape_definition: Shape.new!({22921, "public.projects"}, where: "workspace_id IN (SELECT workspace_id FROM public.workspace_members WHERE user_id = 100) AND id IN (SELECT project_id FROM public.project_members WHERE user_id = 100)", deps: [Shape.new!({22928, "public.workspace_members"}, where: "user_id = 100", columns: ["workspace_id"]), Shape.new!({22933, "public.project_members"}, where: "user_id = 100", columns: ["project_id"])]), replica: :default, params: %{}, experimental_compaction: false, live_sse: false, log: :full, electric_protocol_version: 1, subset: nil}, response: %Electric.Shapes.Api.Response{handle: "16467413-1771410753254019", offset: LogOffset.new(890861112, 0), shape_definition: Shape.new!({22921, "public.projects"}, where: "workspace_id IN (SELECT workspace_id FROM public.workspace_members WHERE user_id = 100) AND id IN (SELECT project_id FROM public.project_members WHERE user_id = 100)", deps: [Shape.new!({22928, "public.workspace_members"}, where: "user_id = 100", columns: ["workspace_id"]), Shape.new!({22933, "public.project_members"}, where: "user_id = 100", columns: ["project_id"])]), known_error: nil, retry_after: nil, api: %Electric.Shapes.Api{inspector: {Electric.Postgres.Inspector.EtsInspector, [stack_id: "Electric.Plug.RouterTest test /v1/shapes - subqueries supports two subqueries at the same level but returns 409 on move-in", server: {:via, Registry, {:"Electric.ProcessRegistry:Electric.Plug.RouterTest test /v1/shapes - subqueries supports two subqueries at the same level but returns 409 on move-in", {Electric.Postgres.Inspector.EtsInspector, nil}}}]}, shape: nil, stack_id: "Electric.Plug.RouterTest test /v1/shapes - subqueries supports two subqueries at the same level but returns 409 on move-in", feature_flags: ["allow_subqueries", "tagged_subqueries"], max_concurrent_requests: %{existing: 1000, initial: 300}, allow_shape_deletion: true, keepalive_interval: 21000, long_poll_timeout: 4000, sse_timeout: 60000, max_age: 60, stack_ready_timeout: 5000, stale_age: 300, send_cache_headers?: true, encoder: Electric.Shapes.Api.Encoder.JSON, sse_encoder: Electric.Shapes.Api.Encoder.SSE, configured: true}, chunked: false, up_to_date: true, no_changes: false, response_type: :normal_log, ...}}, ...}, ...}
     stacktrace:
       .../electric/plug/router_test.exs:3054: (test)
Elixir.Electric.Replication.PublicationManagerTest::test component restarts handles relation tracker restart
Stack Traces | 0.221s run time
1) test component restarts handles relation tracker restart (Electric.Replication.PublicationManagerTest)
     .../electric/replication/publication_manager_test.exs:503
     ** (exit) exited in: GenServer.call({:via, Registry, {:"Electric.ProcessRegistry:Electric.Replication.PublicationManagerTest test component restarts handles relation tracker restart", {Electric.Replication.PublicationManager.RelationTracker, nil}}}, {:remove_shape, "44497180-1771410662370176"}, 5000)
         ** (EXIT) no process: the process is not alive or there's no process currently associated with the given name, possibly because its application isn't started
     code: PublicationManager.remove_shape(ctx.stack_id, shape_handle)
     stacktrace:
       (elixir 1.19.1) lib/gen_server.ex:1135: GenServer.call/3
       (electric 1.4.4) .../replication/publication_manager/relation_tracker.ex:73: Electric.Replication.PublicationManager.RelationTracker.remove_shape/2
       .../electric/replication/publication_manager_test.exs:522: (test)
Elixir.Electric.ClientTest::test move-out handling multiple patterns matching same row generates single delete
Stack Traces | 0.528s run time
25) test move-out handling multiple patterns matching same row generates single delete (Electric.ClientTest)
     test/electric/client_test.exs:2009
     ** (Electric.Client.Error) 
     code: msgs = stream(ctx, 4)
     stacktrace:
       (electric_client 0.9.0) .../electric/client/stream.ex:218: Electric.Client.Stream.handle_error/2
       (electric_client 0.9.0) .../electric/client/stream.ex:265: Enumerable.Electric.Client.Stream.reduce/3
       (elixir 1.19.1) lib/enum.ex:3603: Enum.take/2
       test/electric/client_test.exs:2064: (test)

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

@netlify
Copy link

netlify bot commented Jan 28, 2026

Deploy Preview for electric-next ready!

Name Link
🔨 Latest commit 76452d9
🔍 Latest deploy log https://app.netlify.com/projects/electric-next/deploys/69a58579b70f100008934552
😎 Deploy Preview https://deploy-preview-3791--electric-next.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@robacourt
Copy link
Contributor Author

@coderabbitai review

@robacourt robacourt marked this pull request as draft January 28, 2026 17:35
@robacourt robacourt force-pushed the rob/arbitrary-boolean-expressions-with-subqueries branch from 5a3dbbb to 3105a9c Compare January 29, 2026 14:20
@blacksmith-sh
Copy link
Contributor

blacksmith-sh bot commented Jan 29, 2026

Found 26 test failures on Blacksmith runners:

Failures

Test View Logs
Elixir.Electric.ClientTest/
test move-out handling multiple patterns matching same row generates single delete
View Logs
Elixir.Electric.Plug.RouterTest/test /v1/
shapes - subqueries NOT IN subquery should return 409 on move-in to subquery
View Logs
Elixir.Electric.Plug.RouterTest/test /v1/
shapes - subqueries NOT IN subquery should return 409 on move-in to subquery
View Logs
Elixir.Electric.Plug.RouterTest/test /v1/
shapes - subqueries NOT IN subquery should return 409 on move-in to subquery
View Logs
Elixir.Electric.Plug.RouterTest/test /v1/
shapes - subqueries NOT IN subquery should return 409 on move-in to subquery
View Logs
Elixir.Electric.Plug.RouterTest/test /v1/
shapes - subqueries subqueries work with params
View Logs
Elixir.Electric.Plug.RouterTest/test /v1/
shapes - subqueries subqueries work with params
View Logs
Elixir.Electric.Plug.RouterTest/test /v1/
shapes - subqueries subqueries work with params
View Logs
Elixir.Electric.Plug.RouterTest/test /v1/
shapes - subqueries subqueries work with params
View Logs
Elixir.Electric.Plug.RouterTest/test /v1/
shapes - subqueries subqueries work with quoted column names and are tagged correctly
View Logs
Elixir.Electric.Plug.RouterTest/test /v1/
shapes - subqueries subqueries work with quoted column names and are tagged correctly
View Logs
Elixir.Electric.Plug.RouterTest/test /v1/
shapes - subqueries subqueries work with quoted column names and are tagged correctly
View Logs
Elixir.Electric.Plug.RouterTest/test /v1/
shapes - subqueries subqueries work with quoted column names and are tagged correctly
View Logs
Elixir.Electric.Plug.RouterTest/test /v1/
shapes - subqueries subquery combined with OR should return a 409 on move-in
View Logs
Elixir.Electric.Plug.RouterTest/test /v1/
shapes - subqueries subquery combined with OR should return a 409 on move-in
View Logs
Elixir.Electric.Plug.RouterTest/test /v1/
shapes - subqueries subquery combined with OR should return a 409 on move-in
View Logs
Elixir.Electric.Plug.RouterTest/test /v1/
shapes - subqueries subquery combined with OR should return a 409 on move-in
View Logs
Elixir.Electric.Plug.RouterTest/test /v1/
shapes - subqueries subquery combined with OR should return a 409 on move-out
View Logs
Elixir.Electric.Plug.RouterTest/test /v1/
shapes - subqueries subquery combined with OR should return a 409 on move-out
View Logs
Elixir.Electric.Plug.RouterTest/test /v1/
shapes - subqueries subquery combined with OR should return a 409 on move-out
View Logs
Elixir.Electric.Plug.RouterTest/test /v1/
shapes - subqueries subquery combined with OR should return a 409 on move-out
View Logs
Elixir.Electric.Plug.RouterTest/test /v1/
shapes - subqueries supports two subqueries at the same level but returns 409 on move-i
n
View Logs
Elixir.Electric.Plug.RouterTest/test /v1/
shapes - subqueries supports two subqueries at the same level but returns 409 on move-i
n
View Logs
Elixir.Electric.Plug.RouterTest/test /v1/
shapes - subqueries supports two subqueries at the same level but returns 409 on move-i
n
View Logs
Elixir.Electric.Plug.RouterTest/test /v1/
shapes - subqueries supports two subqueries at the same level but returns 409 on move-i
n
View Logs
Elixir.Electric.Replication.PublicationManagerTest/
test component restarts handles relation tracker restart
View Logs

Fix in Cursor

@robacourt robacourt force-pushed the rob/arbitrary-boolean-expressions-with-subqueries branch 2 times, most recently from 9b57934 to 905ee8d Compare February 10, 2026 16:43
@robacourt robacourt force-pushed the rob/arbitrary-boolean-expressions-with-subqueries branch from 905ee8d to e5ce334 Compare February 18, 2026 10:29
@kevin-dp
Copy link
Contributor

Hi @robacourt, while implementing the DNF/active_conditions support in the ts/db client, I noticed that tag_tracker.ex stores disjunct_positions per-key in key_data and re-derives it every time a message with new tags arrives:

disjunct_positions =
  case derive_disjunct_positions(normalized_new) do
    [] -> current_data && current_data.disjunct_positions
    positions -> positions
  end

This is redundant work — disjunct_positions is determined solely by the shape's WHERE clause (the DNF decomposition structure), which is fixed for the lifetime of a shape. Every row has the same tag structure; the hash values differ per row but which positions are nil vs participating is always identical. So there's no need to store it per-key or recompute it on each tagged message.

In the TS client we derive it once from the first tagged message and store it as a single variable shared across all rows. Might be worth doing the same in the Elixir client — just a single disjunct_positions field alongside tag_to_keys/key_data instead of inside each key's data, derived once on the first insert.

kevin-dp added a commit to TanStack/db that referenced this pull request Feb 19, 2026
…rbitrary boolean WHERE clauses

Support the new Electric server wire protocol (PR electric-sql/electric#3791):
- Change tag delimiter from `|` to `/`, replace `_` wildcards with empty
  segments (NON_PARTICIPATING positions)
- Add `active_conditions` header support for DNF visibility evaluation
- Shapes with subquery dependencies use DNF: a row is visible if ANY
  disjunct has ALL its positions satisfied in active_conditions
- Simple shapes (no subquery dependencies) retain existing behavior:
  row deleted when tag set becomes empty
- Derive disjunct_positions once per shape (not per-row like the Elixir
  client) since the DNF structure is fixed by the WHERE clause

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@kevin-dp
Copy link
Contributor

Also, i think it would be good to add the unit tests we have in ts/db for this feature (cf. https://github.com/TanStack/db/pull/1270/changes#diff-286083ece2106d963fcda491df8579332e28dd8219a45a51c967afff26d70747) to the Elixir client too.

robacourt and others added 15 commits February 24, 2026 19:54
Phase 2 of the arbitrary boolean expressions implementation plan.
SqlGenerator.to_sql/1 is the inverse of Parser — it converts the
parsed WHERE clause AST back into SQL strings, used by querying.ex
for active_conditions SELECT columns and subquery_moves.ex for
exclusion clauses.

Handles all AST node types the parser can produce: comparison,
pattern matching, nullability, boolean tests, logical connectives,
membership, DISTINCT, ANY/ALL, arithmetic, bitwise, string/array
operators, named functions, type casts, date/time/interval constants,
row expressions, and sublink membership checks.

Property-based test generates 1,000 arbitrary WHERE clauses with
column references and verifies to_sql output is re-parseable,
ensuring SqlGenerator stays in sync with the parser.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Limits DNF decomposition to 100 disjuncts, returning {:error, reason}
when exceeded. This prevents unbounded complexity explosion from
deeply nested AND-over-OR distribution.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Encapsulates all DNF-related state in a separate struct that lives in
Consumer.State rather than on the Shape struct. Provides position-to-dependency
mapping, negated position tracking, active_conditions computation, and DNF
evaluation. Built from Shape at consumer startup via from_shape/1.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Update fill_tag_structure to use Decomposer.decompose/1 to build multi-disjunct
tag structures. Each disjunct is a list with one entry per DNF position (nil for
non-participating positions, column name(s) for participating ones).

Update fill_move_tags/make_tags_from_pattern to produce 2D arrays (list of lists)
instead of slash-delimited strings. The wire format conversion happens later in
log_items.ex.

Add build_tag_structure_from_dnf to SubqueryMoves which extracts column names
from each subexpression's AST using Walker.reduce!.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace or_with_subquery? and not_with_subquery? fields with dnf_context
in Consumer.State. DnfContext is built from the Shape at initialization
time via DnfContext.from_shape/1.

Remove AST-walking helpers (has_or_with_subquery?, has_not_with_subquery?,
subtree_has_sublink?) since DNF decomposition handles these cases.

Simplify invalidation check in consumer.ex to only check the feature flag,
since OR/NOT and multi-dependency shapes are now handled by DNF.

Update tests to verify dnf_context behavior with equivalent coverage for
all original OR and NOT with subquery edge cases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
compute_active_conditions/4 and evaluate_dnf/2 were implemented in
DnfContext (Phase 3) rather than WhereClause as originally planned.
This is architecturally cleaner since DnfContext owns the decomposition
state. No additional changes needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add DNF-aware exclusion clause infrastructure for multi-disjunct WHERE
clauses. Move-out control messages now use position-based patterns.

- Add extract_sublink_index/1, find_dnf_positions_for_dep_index/2
- Add build_dnf_exclusion_clauses/4 and generate_disjunct_exclusion/4
- Add get_column_sql/2 for comparison expression SQL generation
- Move-out patterns include pos field for position-aware filtering

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
robacourt and others added 27 commits February 24, 2026 19:54
… visibility

Update the Elixir client to handle the new wire format with slash-delimited
tags, active_conditions, and position-based move-in/move-out.

- Add active_conditions field to Headers struct and parse from messages
- Rewrite TagTracker for position-based {pos, hash} indexing in tag_to_keys
- Add normalize_tags/1 for slash-delimited tag normalization to 2D arrays
- Add row_visible?/2 for DNF-based visibility evaluation (OR of AND of positions)
- Add handle_move_in/3 for activating positions on move-in events
- Update generate_synthetic_deletes/4 for DNF-aware deletion (only delete
  when no disjunct is satisfied)
- Update existing tests for new internal format, add DNF wire format tests
- Widen ResumeMessage types for position-based tag format

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Both stream_initial_data and query_move_in were not passing dnf_context
to json_like_select, so the generated SQL never included active_conditions
in the JSON headers. This meant clients receiving initial snapshot data
or move-in query results had no active_conditions to work with.

Fix: compute DnfContext.from_shape(shape) in both functions and pass it
through to json_like_select, which already supports it as an optional
5th parameter. Also fix a latent SQL generation bug in
build_headers_part where the active_conditions injection added a
trailing single-quote that conflicted with the outer template's
closing quote, producing invalid SQL like '}'' instead of '}'.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Shapes with OR or NOT combined with subqueries produce a wire format
(multi-disjunct tags, active_conditions, position-based move-in/out)
that protocol v1 clients cannot parse. Reject these shapes at request
time with a 400 error directing the client to upgrade.

The plug extracts the Electric-Protocol-Version header (defaulting to
1) and passes it through as a param. After shape creation, the params
module checks whether the shape requires protocol v2 by decomposing
the WHERE clause and looking for multiple disjuncts or negated
subquery positions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Five bugs that caused data mismatches in multi-disjunct (OR) shapes with
subqueries, verified by the oracle property test at SHAPE_COUNT=500:

1. Oracle test client missing protocol version 2 header, causing 400
   rejections for complex DNF shapes.

2. Move-out patterns targeting wrong DNF positions — make_move_out_pattern
   now filters positions by dependency handle via DnfContext.

3. Main shape consumer incorrectly processing materializer changes for
   nested dependencies that should only propagate through the dependency
   chain.

4. Materializer tag indexing using flat strings instead of position-aware
   {pos, hash} tuples, preventing correct position-based move-out matching.

5. Move-in snapshot filtering applying moved_out_tags from ALL dependencies
   instead of only the triggering dependency. When different positions
   reference the same column, a move-out at one position would incorrectly
   filter rows from another position's move-in query.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
-The Phase 4 commit changed `make_tags_from_pattern` to produce
lists-of-lists instead of slash-separated strings, but the
materializer's `add_row_to_tag_indices` / `remove_row_from_tag_indices`
still had `when is_binary(tag)` guards. When tags arrived as lists (from
WAL changes via `fill_move_tags`), the guard failed with a
`function_clause` error, crashing the materializer and cascading to 409s
for all dependent shapes.

-**Fix**: Added a `tag_to_position_entries/1` clause for lists, and
removed the `when is_binary(tag)` guards from the reduce callbacks.

Bug 2: Move-in WHERE clause needs DNF reconstruction (causes 409s
and incorrect queries)

-The old `move_in_where_clause` used string replacement on
`shape.where.query` to replace `IN (SELECT ...)` with `= ANY($1)`. This
breaks for:

-- **NOT IN**: `libpg_query` deparses `x NOT IN (SELECT ...)` as `NOT x
IN (SELECT ...)`, so the string replacement target `NOT IN (SELECT ...)`
isn't found
-- **Multi-disjunct**: The replacement should only apply the triggering
disjunct's conditions, not the whole WHERE
-- **IN-list expansion**: `id IN ('a', 'b', 'c')` becomes `(id='a') OR
(id='b') OR (id='c')` in the AST, creating multiple disjuncts — only
picking the first misses rows

-**Fix**: Implemented `build_dnf_move_in_where` which reconstructs the
WHERE from the DNF decomposition: finds ALL disjuncts containing the
trigger, builds trigger SQL (`= ANY($1)`) plus each disjunct's
non-trigger conditions (OR'd together). Also handles identical
subqueries at different dep indices by collecting trigger positions
across all dep indices sharing the same handle.
The DNF-aware path is always used in production since shapes with
dependencies always have a DnfContext. Remove the dead string-replacement
fallback, make dnf_context a required parameter, and drop the unused
opts/remove_not parameter. Update tests to construct DnfContext and
adjust expected SQL to match SqlGenerator output (quoted column names).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Remove the intermediate list-of-lists tag format. Tags are now
slash-delimited strings from production in make_tags_from_pattern
all the way through to the wire. This eliminates tags_to_wire in
log_items.ex and the is_list guard in materializer's
tag_to_position_entries. The implementation plan is updated to
reflect two formats (condition_hashes and tags) instead of three.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
These tests previously expected 400/409 because OR and NOT combined
with subqueries weren't supported. Now that the DNF-based
implementation handles these cases, the tests pass protocol version 2
and assert correct move-in/move-out events instead of shape
invalidation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Tags now include one hash per DNF position separated by slashes.
Tests for params and quoted column names are updated to construct
the full slash-delimited tag string.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Multiple same-level subqueries now correctly handle move-ins via
DNF decomposition. The test is updated to assert the move-in response
with the expected row data instead of expecting a 409 invalidation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace Enum.take/Enum.random/:rand usage with pure StreamData generators
consumed directly by `check all`, which wires --seed through properly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When generate_synthetic_deletes deleted a key from key_data (all DNF
disjuncts unsatisfied), only the matched pattern entries were removed
from tag_to_keys (first pass via Map.pop). The remaining non-matched
entries for that key persisted as orphans. When the key was later
re-added via move-in INSERT with a new hash, these stale entries could
match future deactivation patterns, corrupting active_conditions and
causing phantom deletes.

Added a third pass to clean up remaining tag_to_keys entries for all
deleted keys.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…l field

disjunct_positions is determined solely by the shape's WHERE clause (DNF
structure), not by individual row data. Previously it was stored per-key
in key_data and re-derived on every tagged message. Now it's derived once
from the first tagged message and stored as a single field alongside
tag_to_keys/key_data, matching the approach used in the TS client.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds 4 new test cases covering DNF scenarios: multi-disjunct partial/full
deactivation, active_conditions overwrite on re-send, simple shapes without
active_conditions, and mixed rows with/without active_conditions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…etes

The materializer stored prev_value_counts which was set from value_counts
before each apply_changes. After the initial snapshot, this was always {}
since the initial load used apply_changes (not apply_changes_and_notify).
The exclusion context used these stale values, causing phantom deletes in
OR shapes when tag re-establishment was prevented.

Replace with LSN-keyed snapshots (at most 2 entries) that store post-apply
value_counts at each tx_offset. The materializer_changes message now
includes tx_offset as a 4th element, enabling consumers to look up the
correct snapshot for each dependency.

Key changes:
- State: seen_deps MapSet → dep_lsns map (dep_handle → tx_offset)
- Materializer: prev_value_counts → snapshots list, new get_link_values/2
  with tx_offset-based lookup, snapshot_values_for returns empty for
  tx_offset=0 (unseen deps) to disable exclusion and allow tag
  re-establishment
- Consumer: reset dep_lsns at start of each materializer_changes handler
  (not just in do_handle_txn) so deps from previous events don't
  incorrectly populate the exclusion context
- Move handling: uses get_dep_lsn + get_link_values with LSN lookup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…e-in queries

Previously, exclusion clauses were skipped when a non-containing disjunct
had non-subquery positions (e.g. literal conditions like status = 'active'),
relying on client-side tag deduplication. Now both generate_disjunct_exclusion
and generate_materialized_disjunct_exclusion handle non-subquery positions by
converting their ASTs to SQL. Also raise on nil dnf_context in
build_exclusion_context since shapes with dependencies must always have one.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…in? row loss bug

When a row's column value changes AND a subquery dependency changes in the
same transaction with an OR-based WHERE clause, the move-in's exclusion
clause (AND NOT ...) filters the row, while change_will_be_covered_by_move_in?
skips the WAL change expecting the move-in to deliver it. Both sides defer
to the other and the row is lost.

Fix: store the non-containing disjunct positions (excluded disjuncts) per
pending move-in at query start time. In change_will_be_covered_by_move_in?,
evaluate the record's active_conditions against those excluded disjuncts.
If the record matches any excluded disjunct, the move-in won't return it,
so the WAL change is processed normally.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@robacourt robacourt force-pushed the rob/arbitrary-boolean-expressions-with-subqueries branch from e5ce334 to 76452d9 Compare March 2, 2026 12:41
@robacourt robacourt force-pushed the rob/arbitrary-boolean-expressions-with-subqueries branch from 76452d9 to d55af83 Compare March 3, 2026 13:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants