From 873de1c25263e65a6bb3048d8c2c0d5344aab705 Mon Sep 17 00:00:00 2001 From: Drew Robinson Date: Wed, 31 Dec 2025 18:36:41 +1100 Subject: [PATCH] feat: Add query-based on_conflict support for UPSERT operations Extends the on_conflict implementation to support Ecto.Query-based updates, allowing keyword list syntax like: on_conflict: [set: [name: "value"], inc: [count: 1]] Changes: - Add on_conflict pattern for %Ecto.Query{} in connection.ex - Add update_all_for_on_conflict/1 helper for SQL generation - Add 3 new tests for query-based on_conflict - Document UPSERT operations in AGENTS.md and CHANGELOG.md Closes el-ndz --- AGENTS.md | 47 ++++++++++++++ CHANGELOG.md | 15 +++++ lib/ecto/adapters/libsql/connection.ex | 16 +++++ test/ecto_connection_test.exs | 86 ++++++++++++++++++++++++++ 4 files changed, 164 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 45fa4f1..0780204 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -222,6 +222,53 @@ changes = EctoLibSql.Native.get_changes(state) IO.puts("Rows affected: #{changes}") ``` +### UPSERT (INSERT ... ON CONFLICT) + +EctoLibSql supports all Ecto `on_conflict` options for upsert operations: + +```elixir +# Ignore conflicts (do nothing on duplicate key) +{:ok, user} = Repo.insert(changeset, + on_conflict: :nothing, + conflict_target: [:email] +) + +# Replace all fields on conflict +{:ok, user} = Repo.insert(changeset, + on_conflict: :replace_all, + conflict_target: [:email] +) + +# Replace specific fields only +{:ok, user} = Repo.insert(changeset, + on_conflict: {:replace, [:name, :updated_at]}, + conflict_target: [:email] +) + +# Replace all except specific fields +{:ok, user} = Repo.insert(changeset, + on_conflict: {:replace_all_except, [:id, :inserted_at]}, + conflict_target: [:email] +) + +# Query-based update with keyword list syntax +{:ok, user} = Repo.insert(changeset, + on_conflict: [set: [name: "Updated Name", updated_at: DateTime.utc_now()]], + conflict_target: [:email] +) + +# Increment counter on conflict +{:ok, counter} = Repo.insert(counter_changeset, + on_conflict: [inc: [count: 1]], + conflict_target: [:key] +) +``` + +**Notes:** +- `:conflict_target` is required for LibSQL/SQLite (unlike PostgreSQL) +- Composite unique indexes work: `conflict_target: [:slug, :parent_slug]` +- Named constraints (`ON CONFLICT ON CONSTRAINT name`) are not supported + ### SELECT ```elixir diff --git a/CHANGELOG.md b/CHANGELOG.md index 9dbed20..2102630 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Query-Based UPSERT Support (on_conflict with Ecto.Query)** + - Extended `on_conflict` support to handle query-based updates + - Allows using keyword list syntax for dynamic update operations: + ```elixir + Repo.insert(changeset, + on_conflict: [set: [name: "updated", updated_at: DateTime.utc_now()]], + conflict_target: [:email] + ) + ``` + - Supports `:set` and `:inc` operations in the update clause + - Generates proper `ON CONFLICT (...) DO UPDATE SET ...` SQL + - Requires explicit `:conflict_target` (LibSQL/SQLite requirement) + - Implementation in `connection.ex:594-601` with `update_all_for_on_conflict/1` helper + - 3 new tests covering query-based on_conflict with set, inc, and error cases + - **CTE (Common Table Expression) Support** - Full support for SQL WITH clauses in Ecto queries - Both simple and recursive CTEs supported diff --git a/lib/ecto/adapters/libsql/connection.ex b/lib/ecto/adapters/libsql/connection.ex index 86cb181..d0fc23b 100644 --- a/lib/ecto/adapters/libsql/connection.ex +++ b/lib/ecto/adapters/libsql/connection.ex @@ -591,6 +591,15 @@ defmodule Ecto.Adapters.LibSql.Connection do [" ON CONFLICT ", conflict_target(targets), "DO ", replace(fields)] end + # Pattern: {%Ecto.Query{}, _, conflict_target} - for query-based updates + defp on_conflict({%Ecto.Query{} = _query, _, []}, _header, _placeholders) do + raise ArgumentError, "Upsert in LibSQL requires :conflict_target for query-based on_conflict" + end + + defp on_conflict({%Ecto.Query{} = query, _, targets}, _header, _placeholders) do + [" ON CONFLICT ", conflict_target(targets), "DO ", update_all_for_on_conflict(query)] + end + # Fallback for other on_conflict values (including plain :raise, etc.) defp on_conflict(_on_conflict, _header, _placeholders), do: [] @@ -609,6 +618,13 @@ defmodule Ecto.Adapters.LibSql.Connection do [quoted, " = ", "excluded.", quoted] end + # Generates UPDATE SET clause from a query for on_conflict + defp update_all_for_on_conflict(%Ecto.Query{} = query) do + sources = create_names(query, []) + fields = update_fields(query, sources) + ["UPDATE SET " | fields] + end + @impl true def update(prefix, table, fields, filters, returning) do {fields, count} = diff --git a/test/ecto_connection_test.exs b/test/ecto_connection_test.exs index 4b57588..43cad84 100644 --- a/test/ecto_connection_test.exs +++ b/test/ecto_connection_test.exs @@ -709,5 +709,91 @@ defmodule Ecto.Adapters.LibSql.ConnectionTest do assert sql =~ "VALUES (?, ?)" refute sql =~ "ON CONFLICT" end + + test "generates INSERT with ON CONFLICT DO UPDATE from query" do + # Build a query with update clause (simulating what Ecto does with keyword list on_conflict) + # The query must have sources set up as Ecto's planner does + query = %Ecto.Query{ + from: %Ecto.Query.FromExpr{source: {"users", nil}}, + sources: {{"users", nil, nil}}, + updates: [ + %Ecto.Query.QueryExpr{ + expr: [set: [name: "updated_name"]] + } + ] + } + + sql = + Connection.insert( + nil, + "users", + [:name, :email], + [[:name, :email]], + {query, [], [:email]}, + [], + [] + ) + |> IO.iodata_to_binary() + + assert sql =~ ~s[INSERT INTO "users"] + assert sql =~ ~s[("name", "email")] + assert sql =~ "VALUES (?, ?)" + assert sql =~ ~s[ON CONFLICT ("email") DO UPDATE SET] + assert sql =~ ~s["name" = 'updated_name'] + end + + test "generates INSERT with ON CONFLICT DO UPDATE with inc operation" do + query = %Ecto.Query{ + from: %Ecto.Query.FromExpr{source: {"counters", nil}}, + sources: {{"counters", nil, nil}}, + updates: [ + %Ecto.Query.QueryExpr{ + expr: [inc: [count: 1]] + } + ] + } + + sql = + Connection.insert( + nil, + "counters", + [:name, :count], + [[:name, :count]], + {query, [], [:name]}, + [], + [] + ) + |> IO.iodata_to_binary() + + assert sql =~ ~s[INSERT INTO "counters"] + assert sql =~ ~s[ON CONFLICT ("name") DO UPDATE SET] + assert sql =~ ~s["count" = "count" + 1] + end + + test "raises error for query-based on_conflict without conflict_target" do + query = %Ecto.Query{ + from: %Ecto.Query.FromExpr{source: {"users", nil}}, + sources: {{"users", nil, nil}}, + updates: [ + %Ecto.Query.QueryExpr{ + expr: [set: [name: "updated_name"]] + } + ] + } + + assert_raise ArgumentError, + "Upsert in LibSQL requires :conflict_target for query-based on_conflict", + fn -> + Connection.insert( + nil, + "users", + [:name, :email], + [[:name, :email]], + {query, [], []}, + [], + [] + ) + end + end end end