From f3b40492dc1364ed4ff7fbdd0912160ab7ebfb0c Mon Sep 17 00:00:00 2001 From: Vincent Lara Date: Thu, 22 Oct 2026 10:22:47 +0200 Subject: [PATCH] fix: translate parent_as/1 references in correlated subqueries The parent_as/1 field references that correlate a subquery to its parent query had no expr/3 clause and fell through to the catch-all, which rendered them as a bare `?`: the next bound parameter stood in for the field, silently changing the query. They are now resolved to the source of the named parent query, as in ecto_sqlite3. The parent query was already recorded under @parent_as by subqueries and CTEs, but never read. --- CHANGELOG.md | 6 +++++ lib/ecto/adapters/libsql/connection.ex | 18 +++++++++++++ test/in_expression_comprehensive_test.exs | 31 +++++++++++++++++++++++ 3 files changed, 55 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c13793..2fbe380 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 + +- **Correlated Subqueries with `parent_as/1`** - The `parent_as/1` field references that correlate a subquery to its parent query had no `expr/3` clause and fell through to the catch-all, which rendered them as a bare `?` placeholder: the next bound parameter stood in for the field, silently changing the query. They are now translated to the parent query's source, 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..3a2a168 100644 --- a/lib/ecto/adapters/libsql/connection.ex +++ b/lib/ecto/adapters/libsql/connection.ex @@ -672,6 +672,15 @@ defmodule Ecto.Adapters.LibSql.Connection do defp quote_table(nil, name), do: quote_name(name) defp quote_table(prefix, name), do: quote_name(prefix) <> "." <> quote_name(name) + # Index and sources of the query named `as`, looked up through the parents + # of a subquery. + defp get_parent_sources_ix(query, as) do + case query.aliases[@parent_as] do + {%{aliases: %{^as => ix}}, sources} -> {ix, sources} + {%{} = parent, _sources} -> get_parent_sources_ix(parent, as) + end + end + defp quote_name(name) when is_atom(name), do: quote_name(Atom.to_string(name)) defp quote_name(name) do @@ -1193,6 +1202,15 @@ defmodule Ecto.Adapters.LibSql.Connection do [name, ?. | quote_name(field)] end + # Field of a parent query named with `as:`, in a correlated subquery: + # parent_as(:user).id -> s0.id + defp expr({{:., _, [{:parent_as, _, [as]}, field]}, _, []}, _sources, query) + when is_atom(field) or is_binary(field) do + {ix, sources} = get_parent_sources_ix(query, as) + {_, name, _} = elem(sources, ix) + [name, ?. | quote_name(field)] + end + # Table reference: &0 -> s0 defp expr({:&, _, [idx]}, sources, _query) do {_, name} = get_source(nil, sources, idx) diff --git a/test/in_expression_comprehensive_test.exs b/test/in_expression_comprehensive_test.exs index d78e4ad..4d3b22c 100644 --- a/test/in_expression_comprehensive_test.exs +++ b/test/in_expression_comprehensive_test.exs @@ -507,4 +507,35 @@ defmodule EctoLibSql.InExpressionComprehensiveTest do assert "item3" in result end end + + describe "parent_as/1 - correlated subqueries" do + test "IN clause with a subquery referring to the parent query" 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: parent_as/1 fell through to the catch-all expr clause, + # generating "?" in place of t.id: the next parameter was bound to it. + archived = + from(a in "archived_items", + where: a.item_id == parent_as(:item).id and a.state == ^"archived", + select: a.item_id + ) + + query = + from(t in "test_items", as: :item, where: t.id in subquery(archived), select: t.name) + + {sql, _params} = Ecto.Adapters.SQL.to_sql(:all, TestRepo, query) + assert sql =~ ~r/\."item_id" = s0\."id"/ + + assert TestRepo.all(query) == ["job1"] + end + end end