Skip to content

Commit 6a7813c

Browse files
authored
Merge pull request #95 from AlanMcCann/fix/upsert-on-conflict-identifier
Fix upsert ON CONFLICT: missing :identifier expr + numbered parameters
2 parents 954685c + 7831ce1 commit 6a7813c

4 files changed

Lines changed: 212 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Fixed
1111

12+
- **Upsert `ON CONFLICT DO UPDATE` SQL Generation** - Fixed two bugs that caused `"near ?: syntax error"` on any `INSERT ... ON CONFLICT DO UPDATE` query, breaking all upsert operations including Ash Framework's `upsert?` actions. (Thanks [@AlanMcCann](https://github.com/AlanMcCann) for [PR #95](https://github.com/ocean/ecto_libsql/pull/95)!)
13+
- Missing `:identifier` expression handler: Ecto generates `ON CONFLICT UPDATE` clauses using `{:identifier, _, ["column_name"]}` fragment expressions. Without a matching `expr/3` clause these fell through to the catch-all, producing invalid SQL such as `SET "col" = EXCLUDED.?`.
14+
- Bare `?` parameter placeholders: SQLite requires numbered positional parameters (`?1`, `?2`, …) when a statement contains multiple parameter groups (INSERT values + ON CONFLICT UPDATE). The adapter now uses numbered parameters throughout, consistent with the `ecto_sqlite3` adapter.
15+
- `IN` clause with bound list parameters was also left using bare `?` placeholders. The `IN (?, ?, ?)` handler now generates `IN (?1, ?2, ?3)` with correct start-index offsets, matching the numbering scheme used everywhere else.
16+
- Empty `IN` list edge case (`where: field in ^[]`) is handled explicitly with `IN (SELECT NULL WHERE 1=0)` since SQLite rejects `IN ()`.
17+
1218
- **PRAGMA Statement Routing** - PRAGMA statements are now correctly routed through the `query()` path rather than the execute path, fixing incorrect behaviour when reading PRAGMA values (e.g. `PRAGMA journal_mode`, `PRAGMA synchronous`) via the Ecto adapter.
1319

1420
### Changed

lib/ecto/adapters/libsql/connection.ex

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -754,13 +754,14 @@ defmodule Ecto.Adapters.LibSql.Connection do
754754

755755
@impl true
756756
def insert(prefix, table, header, rows, on_conflict, returning, placeholders) do
757+
counter_offset = length(placeholders) + 1
757758
fields = intersperse_map(header, ", ", &quote_name/1)
758759

759760
values =
760761
if rows == [] do
761762
[" DEFAULT VALUES"]
762763
else
763-
[" VALUES ", encode_values(rows)]
764+
[" VALUES ", encode_insert_values(rows, counter_offset)]
764765
end
765766

766767
[
@@ -776,12 +777,26 @@ defmodule Ecto.Adapters.LibSql.Connection do
776777
]
777778
end
778779

779-
defp encode_values(rows) do
780-
rows
781-
|> Enum.map(fn row ->
782-
["(", intersperse_map(row, ", ", fn _ -> "?" end), ")"]
783-
end)
784-
|> Enum.intersperse(", ")
780+
# Generate VALUES with numbered positional parameters (?1, ?2, ...).
781+
# SQLite requires numbered parameters when the same statement contains
782+
# multiple parameter groups (e.g., INSERT values + ON CONFLICT UPDATE).
783+
# Bare `?` causes "near ?: syntax error" in upsert queries.
784+
defp encode_insert_values(rows, counter) do
785+
{encoded, _} =
786+
Enum.map_reduce(rows, counter, fn row, acc ->
787+
{params, new_acc} =
788+
Enum.map_reduce(row, acc, fn
789+
{:placeholder, placeholder_index}, c ->
790+
{[?? | placeholder_index], c}
791+
792+
_, c ->
793+
{[?? | Integer.to_string(c)], c + 1}
794+
end)
795+
796+
{["(", Enum.intersperse(params, ", "), ")"], new_acc}
797+
end)
798+
799+
Enum.intersperse(encoded, ", ")
785800
end
786801

787802
# Helper for INSERT OR ... syntax (not used for now, keeping for SQLite REPLACE compatibility)
@@ -1166,9 +1181,11 @@ defmodule Ecto.Adapters.LibSql.Connection do
11661181
[?(, expr(expr, sources, query), ?)]
11671182
end
11681183

1169-
# Parameter placeholder
1170-
defp expr({:^, [], [_ix]}, _sources, _query) do
1171-
~c"?"
1184+
# Parameter placeholder - use numbered parameters (?1, ?2, ...).
1185+
# SQLite requires numbered parameters when a statement has multiple
1186+
# parameter groups (e.g., INSERT values + ON CONFLICT UPDATE).
1187+
defp expr({:^, [], [ix]}, _sources, _query) do
1188+
[?? | Integer.to_string(ix + 1)]
11721189
end
11731190

11741191
# Qualified field reference: s0.field
@@ -1246,8 +1263,18 @@ defmodule Ecto.Adapters.LibSql.Connection do
12461263
[expr(left, sources, query), " IN (", args, ?)]
12471264
end
12481265

1249-
defp expr({:in, _, [left, {:^, _, [_, length]}]}, sources, query) do
1250-
args = Enum.intersperse(List.duplicate(??, length), ?,)
1266+
defp expr({:in, _, [left, {:^, _, [_start_ix, 0]}]}, sources, query) do
1267+
# Empty IN list - SQLite does not accept IN (), so use always-false predicate.
1268+
[expr(left, sources, query), " IN (SELECT NULL WHERE 1=0)"]
1269+
end
1270+
1271+
defp expr({:in, _, [left, {:^, _, [start_ix, length]}]}, sources, query) do
1272+
args =
1273+
Enum.map(start_ix..(start_ix + length - 1)//1, fn ix ->
1274+
[?? | Integer.to_string(ix + 1)]
1275+
end)
1276+
|> Enum.intersperse(", ")
1277+
12511278
[expr(left, sources, query), " IN (", args, ?)]
12521279
end
12531280

@@ -1306,6 +1333,11 @@ defmodule Ecto.Adapters.LibSql.Connection do
13061333
["max(", expr(arg, sources, query), ?)]
13071334
end
13081335

1336+
# Identifier expression (used in fragment expressions like EXCLUDED."column_name")
1337+
defp expr({:identifier, _, [name]}, _sources, _query) do
1338+
quote_name(name)
1339+
end
1340+
13091341
# Fragment for raw SQL
13101342
defp expr({:fragment, _, parts}, sources, query) do
13111343
Enum.map(parts, fn

test/ecto_connection_test.exs

Lines changed: 90 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,88 @@ defmodule Ecto.Adapters.LibSql.ConnectionTest do
579579
end
580580
end
581581

582+
describe "IN clause parameter numbering" do
583+
test "generates numbered parameters for bound IN list" do
584+
# Simulates: where(query, [u], u.id in ^[1, 2, 3])
585+
# {:^, _, [start_ix, length]} where start_ix=0, length=3.
586+
query = %Ecto.Query{
587+
from: %Ecto.Query.FromExpr{source: {"users", nil}},
588+
sources: {{"users", nil, nil}},
589+
select: %Ecto.Query.SelectExpr{fields: [{:&, [], [0]}]},
590+
wheres: [
591+
%Ecto.Query.BooleanExpr{
592+
op: :and,
593+
expr:
594+
{:in, [],
595+
[
596+
{{:., [], [{:&, [], [0]}, :id]}, [], []},
597+
{:^, [], [0, 3]}
598+
]},
599+
params: []
600+
}
601+
]
602+
}
603+
604+
sql = Connection.all(query) |> IO.iodata_to_binary()
605+
606+
# All three IN parameters must be numbered (?1, ?2, ?3), not bare ?.
607+
assert sql =~ ~s["id" IN (?1, ?2, ?3)]
608+
end
609+
610+
test "IN parameter numbering is consistent with non-zero start index" do
611+
# Simulates a query where previous bound params occupy ?1.
612+
# start_ix=1, length=2 → should produce ?2, ?3.
613+
query = %Ecto.Query{
614+
from: %Ecto.Query.FromExpr{source: {"users", nil}},
615+
sources: {{"users", nil, nil}},
616+
select: %Ecto.Query.SelectExpr{fields: [{:&, [], [0]}]},
617+
wheres: [
618+
%Ecto.Query.BooleanExpr{
619+
op: :and,
620+
expr:
621+
{:in, [],
622+
[
623+
{{:., [], [{:&, [], [0]}, :id]}, [], []},
624+
{:^, [], [1, 2]}
625+
]},
626+
params: []
627+
}
628+
]
629+
}
630+
631+
sql = Connection.all(query) |> IO.iodata_to_binary()
632+
633+
assert sql =~ ~s["id" IN (?2, ?3)]
634+
end
635+
636+
test "empty bound IN list generates always-false sentinel" do
637+
# Simulates: where(query, [u], u.id in ^[])
638+
# After Ecto planning, the empty list becomes {:^, _, [0, 0]}.
639+
query = %Ecto.Query{
640+
from: %Ecto.Query.FromExpr{source: {"users", nil}},
641+
sources: {{"users", nil, nil}},
642+
select: %Ecto.Query.SelectExpr{fields: [{:&, [], [0]}]},
643+
wheres: [
644+
%Ecto.Query.BooleanExpr{
645+
op: :and,
646+
expr:
647+
{:in, [],
648+
[
649+
{{:., [], [{:&, [], [0]}, :id]}, [], []},
650+
{:^, [], [0, 0]}
651+
]},
652+
params: []
653+
}
654+
]
655+
}
656+
657+
sql = Connection.all(query) |> IO.iodata_to_binary()
658+
659+
# SQLite rejects IN () so the zero-length guard emits an always-false subquery.
660+
assert sql =~ ~s["id" IN (SELECT NULL WHERE 1=0)]
661+
end
662+
end
663+
582664
describe "on_conflict insert" do
583665
test "generates INSERT with ON CONFLICT DO NOTHING (no target)" do
584666
sql =
@@ -595,7 +677,7 @@ defmodule Ecto.Adapters.LibSql.ConnectionTest do
595677

596678
assert sql =~ ~s[INSERT INTO "users"]
597679
assert sql =~ ~s[("name", "email")]
598-
assert sql =~ "VALUES (?, ?)"
680+
assert sql =~ "VALUES (?1, ?2)"
599681
assert sql =~ "ON CONFLICT DO NOTHING"
600682
end
601683

@@ -614,7 +696,7 @@ defmodule Ecto.Adapters.LibSql.ConnectionTest do
614696

615697
assert sql =~ ~s[INSERT INTO "users"]
616698
assert sql =~ ~s[("name", "email")]
617-
assert sql =~ "VALUES (?, ?)"
699+
assert sql =~ "VALUES (?1, ?2)"
618700
assert sql =~ ~s[ON CONFLICT ("email") DO NOTHING]
619701
end
620702

@@ -633,7 +715,7 @@ defmodule Ecto.Adapters.LibSql.ConnectionTest do
633715

634716
assert sql =~ ~s[INSERT INTO "locations"]
635717
assert sql =~ ~s[("slug", "parent_slug", "name")]
636-
assert sql =~ "VALUES (?, ?, ?)"
718+
assert sql =~ "VALUES (?1, ?2, ?3)"
637719
assert sql =~ ~s[ON CONFLICT ("slug", "parent_slug") DO NOTHING]
638720
end
639721

@@ -652,7 +734,7 @@ defmodule Ecto.Adapters.LibSql.ConnectionTest do
652734

653735
assert sql =~ ~s[INSERT INTO "users"]
654736
assert sql =~ ~s[("name", "email")]
655-
assert sql =~ "VALUES (?, ?)"
737+
assert sql =~ "VALUES (?1, ?2)"
656738
assert sql =~ ~s[ON CONFLICT ("email") DO UPDATE SET]
657739
assert sql =~ ~s["name" = excluded."name"]
658740
assert sql =~ ~s["email" = excluded."email"]
@@ -673,7 +755,7 @@ defmodule Ecto.Adapters.LibSql.ConnectionTest do
673755

674756
assert sql =~ ~s[INSERT INTO "users"]
675757
assert sql =~ ~s[("name", "email", "updated_at")]
676-
assert sql =~ "VALUES (?, ?, ?)"
758+
assert sql =~ "VALUES (?1, ?2, ?3)"
677759
assert sql =~ ~s[ON CONFLICT ("email") DO UPDATE SET]
678760
assert sql =~ ~s["name" = excluded."name"]
679761
assert sql =~ ~s["updated_at" = excluded."updated_at"]
@@ -687,7 +769,7 @@ defmodule Ecto.Adapters.LibSql.ConnectionTest do
687769

688770
assert sql =~ ~s[INSERT INTO "users"]
689771
assert sql =~ ~s[("name", "email")]
690-
assert sql =~ "VALUES (?, ?)"
772+
assert sql =~ "VALUES (?1, ?2)"
691773
refute sql =~ "ON CONFLICT"
692774
end
693775

@@ -706,7 +788,7 @@ defmodule Ecto.Adapters.LibSql.ConnectionTest do
706788

707789
assert sql =~ ~s[INSERT INTO "users"]
708790
assert sql =~ ~s[("name", "email")]
709-
assert sql =~ "VALUES (?, ?)"
791+
assert sql =~ "VALUES (?1, ?2)"
710792
refute sql =~ "ON CONFLICT"
711793
end
712794

@@ -737,7 +819,7 @@ defmodule Ecto.Adapters.LibSql.ConnectionTest do
737819

738820
assert sql =~ ~s[INSERT INTO "users"]
739821
assert sql =~ ~s[("name", "email")]
740-
assert sql =~ "VALUES (?, ?)"
822+
assert sql =~ "VALUES (?1, ?2)"
741823
assert sql =~ ~s[ON CONFLICT ("email") DO UPDATE SET]
742824
assert sql =~ ~s["name" = 'updated_name']
743825
end

test/ecto_integration_test.exs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -934,6 +934,78 @@ defmodule Ecto.Integration.EctoLibSqlTest do
934934
end
935935
end
936936

937+
describe "upsert via Repo.insert with on_conflict" do
938+
# These tests exercise the bug fixed in PR #95: upsert queries were generating
939+
# invalid SQL ("near ?: syntax error") because the adapter used bare ? placeholders
940+
# instead of the numbered ?1, ?2, ... form that SQLite requires when a statement
941+
# contains multiple parameter groups (INSERT values + ON CONFLICT UPDATE).
942+
943+
test "Repo.insert with on_conflict: :replace_all upserts an existing record" do
944+
{:ok, _user} =
945+
TestRepo.insert(%User{name: "Alice", email: "alice@example.com"},
946+
on_conflict: :replace_all,
947+
conflict_target: :email
948+
)
949+
950+
{:ok, _updated} =
951+
TestRepo.insert(%User{name: "Alice Updated", email: "alice@example.com"},
952+
on_conflict: :replace_all,
953+
conflict_target: :email
954+
)
955+
956+
# Fetch from the DB to confirm the change was actually persisted, not just
957+
# reflected in the returned struct.
958+
reloaded = TestRepo.get_by!(User, email: "alice@example.com")
959+
assert reloaded.name == "Alice Updated"
960+
assert TestRepo.aggregate(User, :count, :id) == 1
961+
end
962+
963+
test "Repo.insert with on_conflict: :nothing leaves existing record unchanged" do
964+
{:ok, original} =
965+
TestRepo.insert(%User{name: "Bob", email: "bob@example.com"})
966+
967+
{:ok, _ignored} =
968+
TestRepo.insert(%User{name: "Bob Changed", email: "bob@example.com"},
969+
on_conflict: :nothing,
970+
conflict_target: :email
971+
)
972+
973+
reloaded = TestRepo.get_by!(User, email: "bob@example.com")
974+
assert reloaded.id == original.id
975+
assert reloaded.name == "Bob"
976+
end
977+
978+
test "Repo.insert with on_conflict: {:replace, fields} updates only specified fields" do
979+
{:ok, original} =
980+
TestRepo.insert(%User{name: "Charlie", email: "charlie@example.com", age: 25})
981+
982+
{:ok, _result} =
983+
TestRepo.insert(%User{name: "Charlie New Name", email: "charlie@example.com", age: 99},
984+
on_conflict: {:replace, [:name]},
985+
conflict_target: :email
986+
)
987+
988+
reloaded = TestRepo.get_by!(User, email: "charlie@example.com")
989+
# Name should be updated, age should remain unchanged.
990+
assert reloaded.id == original.id
991+
assert reloaded.name == "Charlie New Name"
992+
assert reloaded.age == 25
993+
end
994+
995+
test "Repo.insert with on_conflict: :replace_all inserts a new record when no conflict" do
996+
assert TestRepo.aggregate(User, :count, :id) == 0
997+
998+
{:ok, user} =
999+
TestRepo.insert(%User{name: "Dave", email: "dave@example.com"},
1000+
on_conflict: :replace_all,
1001+
conflict_target: :email
1002+
)
1003+
1004+
assert user.id != nil
1005+
assert TestRepo.aggregate(User, :count, :id) == 1
1006+
end
1007+
end
1008+
9371009
# Helper function to extract errors from changeset
9381010
defp errors_on(changeset) do
9391011
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->

0 commit comments

Comments
 (0)