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
25 changes: 25 additions & 0 deletions .dialyzer_ignore.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{"lib/ecto/adapters/libsql.ex", "Function rollback/2 has no local return."}
{"lib/ecto/adapters/libsql.ex", "The pattern can never match the type
{:error, %EctoLibSql.Error{:__exception__ => true, :message => _, :sqlite => nil},
%EctoLibSql.State{:conn_id => _, _ => _}}
| {:ok, %EctoLibSql.Query{:statement => _, _ => _},
%EctoLibSql.Result{
:columns => _,
:command =>
:begin
| :commit
| :create
| :delete
| :insert
| :rollback
| :select
| :unknown
| :update,
:num_rows => _,
:rows => _
}, %EctoLibSql.State{:conn_id => _, _ => _}}
."}
{"lib/ecto/adapters/libsql.ex", "Type mismatch for @callback dump_cmd."}
{"lib/ecto/adapters/libsql/connection.ex", "Spec type mismatch in argument to callback to_constraints."}
{"lib/ecto/adapters/libsql/connection.ex", "Type mismatch with behaviour callback to explain_query/4."}
{"lib/ecto/adapters/libsql/connection.ex", "List construction (cons) will produce an improper list, because its second argument is <<_::64>>."}
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ ecto_libsql-*.tar
/priv/native/*.dll
/priv/native/*.dylib

# Erlang PLTs for Dialyzer
/priv/plts/

# Test databases
z_ecto_libsql_test-*.db
z_ecto_libsql_test-*.db-*
Expand Down
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,21 @@ 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

- **Constraint Error Handling: Index Name Reconstruction (Issue #34)**
- Improved constraint name extraction to reconstruct full index names from SQLite error messages
- Now follows Ecto's naming convention: `table_column1_column2_index`
- **Single-column constraints**: `"UNIQUE constraint failed: users.email"` → `"users_email_index"` (previously just `"email"`)
- **Multi-column constraints**: `"UNIQUE constraint failed: users.slug, users.parent_slug"` → `"users_slug_parent_slug_index"`
- **Backtick handling**: Properly strips trailing backticks appended by libSQL to error messages
- **Enhanced error messages**: Preserves custom index names from enhanced format `(index: custom_index_name)`
- **NOT NULL constraints**: Reconstructs index names following same convention
- Enables accurate `unique_constraint/3` and `check_constraint/3` matching with custom index names in Ecto changesets
- Added comprehensive test coverage for all constraint scenarios (4 new tests)

## [0.8.0] - 2025-12-17

### Changed
Expand Down
82 changes: 66 additions & 16 deletions lib/ecto/adapters/libsql/connection.ex
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,21 @@ defmodule Ecto.Adapters.LibSql.Connection do
end

@impl true
@doc """
Parse a SQLite error message and map it to a list of Ecto constraint tuples.

Accepts an exception-like map containing a SQLite error `:message` and returns recognised constraint information such as unique, foreign_key or check constraints; returns an empty list when no known constraint pattern is found.

## Parameters

- error: Map containing a `:message` string produced by SQLite.
- _opts: Options (unused).

## Returns

- A keyword list of constraint tuples, for example `[unique: "table_column_index"]`, `[foreign_key: :unknown]`, `[check: "constraint_name"]`, or `[]` when no constraint is recognised.
"""
@spec to_constraints(%{message: String.t()}, Keyword.t()) :: Keyword.t()
def to_constraints(%{message: message}, _opts) do
cond do
String.contains?(message, "UNIQUE constraint failed") ->
Expand All @@ -87,36 +102,71 @@ defmodule Ecto.Adapters.LibSql.Connection do
end

defp extract_constraint_name(message) do
# Extract constraint name from SQLite error messages
# Extract constraint name from SQLite error messages.
#
# SQLite only reports column names in constraint errors, not index names.
# However, ecto_libsql enhances error messages to include the actual index name
# by querying SQLite metadata. This allows users to use custom index names in
# their changesets with unique_constraint/3.
#
# Enhanced format (when index is found):
# "UNIQUE constraint failed: users.email (index: users_email_index)" -> "users_email_index"
# We reconstruct the index name following Ecto's naming convention:
# table_column1_column2_index
#
# Standard formats (fallback to column name):
# "UNIQUE constraint failed: users.email" -> "email"
# "NOT NULL constraint failed: users.name" -> "name"
# "UNIQUE constraint failed: users.slug, users.parent_slug" -> "slug"
# Examples:
# "UNIQUE constraint failed: users.email" -> "users_email_index"
# "UNIQUE constraint failed: users.slug, users.parent_slug" -> "users_slug_parent_slug_index"
# "NOT NULL constraint failed: users.name" -> "users_name_index"
# "CHECK constraint failed: positive_age" -> "positive_age"
#
# First, try to extract the index name from enhanced error messages
# First, try to extract the index name from enhanced error messages (if present)
case Regex.run(~r/\(index: ([\w_]+)\)/, message) do
[_, index_name] ->
# Found enhanced error with actual index name
index_name

nil ->
# No index name in message, fall back to column name extraction
case Regex.run(~r/constraint failed: (?:\w+\.)?(\w+)/, message) do
[_, name] -> name
_ -> "unknown"
# No index name in message, reconstruct from column names
case Regex.run(~r/constraint failed: (.+)$/, message) do
[_, constraint_part] ->
# Strip any trailing backticks that libSQL might add to error messages
cleaned = constraint_part |> String.trim() |> String.trim_trailing("`")
constraint_name_hack(cleaned)

_ ->
"unknown"
end
end
end

# Reconstruct index names from SQLite constraint error messages.
# This follows Ecto's convention: table_column1_column2_index
defp constraint_name_hack(constraint) do
# Helper to clean backticks from identifiers (libSQL sometimes adds them)
clean = fn s -> String.trim(s, "`") end

if String.contains?(constraint, ", ") do
# Multi-column constraint: "table.col1, table.col2" -> "table_col1_col2_index"
[first | rest] = String.split(constraint, ", ")

table_col = first |> clean.() |> String.replace(".", "_")

cols =
Enum.map(rest, fn col ->
col |> clean.() |> String.split(".") |> List.last()
end)

[table_col | cols] |> Enum.concat(["index"]) |> Enum.join("_")
else
if String.contains?(constraint, ".") do
# Single column: "table.column" -> "table_column_index"
constraint
|> clean.()
|> String.split(".")
|> Enum.concat(["index"])
|> Enum.join("_")
else
# No table prefix (e.g., CHECK constraint name): return as-is
clean.(constraint)
end
end
end

## DDL Generation

@impl true
Expand Down
17 changes: 14 additions & 3 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,17 @@ defmodule EctoLibSql.MixProject do
homepage_url: @source_url,
package: package(),
description: description(),
docs: docs()
docs: docs(),
dialyzer: [
plt_core_path: "priv/plts",
app_tree: true,
plt_add_apps: [:mix, :ex_unit],
ignore_warnings: ".dialyzer_ignore.exs",
list_unused_filters: true
],
aliases: [
"check.dialyzer": "dialyzer"
]
]
end

Expand All @@ -38,12 +48,13 @@ defmodule EctoLibSql.MixProject do

defp deps do
[
{:rustler, "~> 0.37.1"},
{:db_connection, "~> 2.1"},
{:dialyxir, "~> 1.4", only: [:dev], runtime: false},
{:ecto, "~> 3.11"},
{:ecto_sql, "~> 3.11"},
{:ex_doc, "~> 0.31", only: :dev, runtime: false},
{:jason, "~> 1.4"}
{:jason, "~> 1.4"},
{:rustler, "~> 0.37.1"}
]
end

Expand Down
2 changes: 2 additions & 0 deletions mix.lock
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
%{
"db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"},
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
"dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"},
"earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"},
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
"ecto_sql": {:hex, :ecto_sql, "3.13.3", "81f7067dd1951081888529002dbc71f54e5e891b69c60195040ea44697e1104a", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5751caea36c8f5dd0d1de6f37eceffea19d10bd53f20e5bbe31c45f2efc8944a"},
"erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"},
"ex_doc": {:hex, :ex_doc, "0.39.3", "519c6bc7e84a2918b737aec7ef48b96aa4698342927d080437f61395d361dcee", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "0590955cf7ad3b625780ee1c1ea627c28a78948c6c0a9b0322bd976a079996e1"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
Expand Down
36 changes: 34 additions & 2 deletions test/ecto_connection_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -413,8 +413,8 @@ defmodule Ecto.Adapters.LibSql.ConnectionTest do
error = %{message: "UNIQUE constraint failed: users.email"}
constraints = Connection.to_constraints(error, [])

# Returns string constraint names to match Ecto changeset format
assert [unique: "email"] = constraints
# Reconstructs index name following Ecto's naming convention: table_column_index
assert [unique: "users_email_index"] = constraints
end

test "converts FOREIGN KEY constraint errors" do
Expand All @@ -438,6 +438,38 @@ defmodule Ecto.Adapters.LibSql.ConnectionTest do

assert [] = constraints
end

test "converts multi-column UNIQUE constraint errors" do
error = %{message: "UNIQUE constraint failed: users.slug, users.parent_slug"}
constraints = Connection.to_constraints(error, [])

# Reconstructs index name from multiple columns: table_col1_col2_index
assert [unique: "users_slug_parent_slug_index"] = constraints
end

test "converts NOT NULL constraint errors" do
error = %{message: "NOT NULL constraint failed: users.name"}
constraints = Connection.to_constraints(error, [])

# NOT NULL constraints are reported as check constraints with reconstructed index name
assert [check: "users_name_index"] = constraints
end

test "handles backticks in constraint error messages" do
error = %{message: "UNIQUE constraint failed: users.email`"}
constraints = Connection.to_constraints(error, [])

# Properly strips backticks appended by libSQL
assert [unique: "users_email_index"] = constraints
end

test "preserves enhanced error messages with index name" do
error = %{message: "UNIQUE constraint failed: users.email (index: custom_email_index)"}
constraints = Connection.to_constraints(error, [])

# Uses the provided index name from enhanced error
assert [unique: "custom_email_index"] = constraints
end
end

describe "on_conflict insert" do
Expand Down
2 changes: 1 addition & 1 deletion test/ecto_integration_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ defmodule Ecto.Integration.EctoLibSqlTest do
|> cast(attrs, [:name, :email, :age, :active, :balance, :bio])
|> validate_required([:name, :email])
|> validate_format(:email, ~r/@/)
|> unique_constraint(:email, name: "email")
|> unique_constraint(:email, name: "users_email_index")
end
end

Expand Down
Loading