Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed

- **`exists/1` Subqueries** - `exists(subquery)` had no `expr/3` clause and fell through to the catch-all, which rendered it as a bare `?` placeholder. The next bound parameter then stood in for the whole condition, so the filter silently matched every row or none depending on its value (for example, the policies Ash Framework expresses with `exists(...)` let every actor through). It is now translated to `exists(SELECT ...)`, as in the `ecto_sqlite3` adapter.

## [0.9.1] - 2026-05-07

### Fixed
Expand Down
5 changes: 5 additions & 0 deletions lib/ecto/adapters/libsql/connection.ex
Original file line number Diff line number Diff line change
Expand Up @@ -1359,6 +1359,11 @@ defmodule Ecto.Adapters.LibSql.Connection do
expr(value, sources, query)
end

# EXISTS subquery: exists(subquery) -> exists(SELECT ...)
defp expr({:exists, _, [subquery]}, sources, query) do
["exists", expr(subquery, sources, query)]
end

# SubQuery expression - generates inline SQL subquery
defp expr(%Ecto.SubQuery{query: query}, sources, parent_query) do
combinations =
Expand Down
57 changes: 57 additions & 0 deletions test/in_expression_comprehensive_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -507,4 +507,61 @@ defmodule EctoLibSql.InExpressionComprehensiveTest do
assert "item3" in result
end
end

describe "exists/1" do
test "keeps every row when the subquery matches, none otherwise" do
Ecto.Adapters.SQL.query!(TestRepo, """
INSERT INTO test_items (id, state, name, inserted_at, updated_at)
VALUES (1, 'scheduled', 'job1', datetime('now'), datetime('now')),
(2, 'completed', 'job2', datetime('now'), datetime('now'))
""")

Ecto.Adapters.SQL.query!(TestRepo, """
INSERT INTO archived_items (item_id, state, name, inserted_at, updated_at)
VALUES (1, 'archived', 'job1_archived', datetime('now'), datetime('now'))
""")

# BEFORE FIX: exists/1 fell through to the catch-all expr clause, so the
# whole condition became "WHERE (?)": the parameter bound in its place
# matched every row or none depending on its value.
query = fn state ->
from(t in "test_items",
where: exists(from(a in "archived_items", where: a.state == ^state, select: 1)),
select: t.name,
order_by: t.name
)
end

{sql, _params} = Ecto.Adapters.SQL.to_sql(:all, TestRepo, query.("archived"))
assert sql =~ "WHERE (exists(SELECT "

assert TestRepo.all(query.("archived")) == ["job1", "job2"]
# "1" alone would have been read as true.
assert TestRepo.all(query.("1")) == []
end

test "negated" do
Ecto.Adapters.SQL.query!(TestRepo, """
INSERT INTO test_items (id, state, name, inserted_at, updated_at)
VALUES (1, 'scheduled', 'job1', datetime('now'), datetime('now')),
(2, 'completed', 'job2', datetime('now'), datetime('now'))
""")

Ecto.Adapters.SQL.query!(TestRepo, """
INSERT INTO archived_items (item_id, state, name, inserted_at, updated_at)
VALUES (1, 'archived', 'job1_archived', datetime('now'), datetime('now'))
""")

query = fn state ->
from(t in "test_items",
where: not exists(from(a in "archived_items", where: a.state == ^state, select: 1)),
select: t.name,
order_by: t.name
)
end

assert TestRepo.all(query.("archived")) == []
assert TestRepo.all(query.("deleted")) == ["job1", "job2"]
end
end
end