From ed619ff5f3f6d98f74470bded34d3742093f2168 Mon Sep 17 00:00:00 2001 From: Vincent Lara Date: Thu, 22 Oct 2026 10:23:14 +0200 Subject: [PATCH] fix: translate exists/1 subqueries exists(subquery) had no expr/3 clause and fell through to the catch-all, which rendered it as a bare `?`. The next bound parameter then stood in for the whole condition, so the filter matched every row or none depending on its value: Ash policies written with exists(...) let every actor through. It is now translated to exists(SELECT ...), as in ecto_sqlite3. --- CHANGELOG.md | 6 +++ lib/ecto/adapters/libsql/connection.ex | 5 ++ test/in_expression_comprehensive_test.exs | 57 +++++++++++++++++++++++ 3 files changed, 68 insertions(+) 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