Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions lib/ecto/adapters/libsql/connection.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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: []

Expand All @@ -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} =
Expand Down
86 changes: 86 additions & 0 deletions test/ecto_connection_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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