diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c13793..a17a851 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/ecto/adapters/libsql/connection.ex b/lib/ecto/adapters/libsql/connection.ex index 72da826..5650502 100644 --- a/lib/ecto/adapters/libsql/connection.ex +++ b/lib/ecto/adapters/libsql/connection.ex @@ -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 = diff --git a/test/in_expression_comprehensive_test.exs b/test/in_expression_comprehensive_test.exs index d78e4ad..9233733 100644 --- a/test/in_expression_comprehensive_test.exs +++ b/test/in_expression_comprehensive_test.exs @@ -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