diff --git a/lib/data_layer.ex b/lib/data_layer.ex index e3c2b6f..567eca2 100644 --- a/lib/data_layer.ex +++ b/lib/data_layer.ex @@ -1036,6 +1036,57 @@ defmodule AshSqlite.DataLayer do end)} end + # libSQL error handlers (ecto_libsql uses EctoLibSql.Error instead of Exqlite.Error, + # but the message format is identical since both are SQLite-compatible) + if Code.ensure_loaded?(EctoLibSql.Error) do + defp handle_raised_error( + %EctoLibSql.Error{message: "FOREIGN KEY constraint failed"}, + stacktrace, + context, + resource + ) do + handle_raised_error( + Ash.Error.Changes.InvalidChanges.exception( + fields: Ash.Resource.Info.primary_key(resource), + message: "referenced something that does not exist" + ), + stacktrace, + context, + resource + ) + end + + defp handle_raised_error( + %EctoLibSql.Error{message: "UNIQUE constraint failed: " <> fields}, + _stacktrace, + _context, + resource + ) do + names = + fields + |> String.split(", ") + |> Enum.map(fn field -> + field |> String.split(".", trim: true) |> Enum.drop(1) |> Enum.at(0) + end) + |> Enum.map(fn field -> + Ash.Resource.Info.attribute(resource, field) + end) + |> Enum.reject(&is_nil/1) + |> Enum.map(fn %{name: name} -> name end) + + message = find_constraint_message(resource, names) + + {:error, + names + |> Enum.map(fn name -> + Ash.Error.Changes.InvalidAttribute.exception( + field: name, + message: message + ) + end)} + end + end + defp handle_raised_error(error, stacktrace, _ecto_changeset, _resource) do {:error, Ash.Error.to_ash_error(error, stacktrace)} end diff --git a/lib/repo.ex b/lib/repo.ex index d576deb..a55f4b0 100644 --- a/lib/repo.ex +++ b/lib/repo.ex @@ -40,9 +40,10 @@ defmodule AshSqlite.Repo do defmacro __using__(opts) do quote bind_quoted: [opts: opts] do otp_app = opts[:otp_app] || raise("Must configure OTP app") + adapter = opts[:adapter] || Ecto.Adapters.SQLite3 use Ecto.Repo, - adapter: Ecto.Adapters.SQLite3, + adapter: adapter, otp_app: otp_app @behaviour AshSqlite.Repo diff --git a/lib/transformers/verify_repo.ex b/lib/transformers/verify_repo.ex index 8b1d8a6..6d25a00 100644 --- a/lib/transformers/verify_repo.ex +++ b/lib/transformers/verify_repo.ex @@ -19,8 +19,8 @@ defmodule AshSqlite.Transformers.VerifyRepo do match?({:error, _}, Code.ensure_compiled(repo)) -> {:error, "Could not find repo module #{repo}"} - repo.__adapter__() != Ecto.Adapters.SQLite3 -> - {:error, "Expected a repo using the sqlite adapter `Ecto.Adapters.SQLite3`"} + repo.__adapter__() not in [Ecto.Adapters.SQLite3, Ecto.Adapters.LibSql] -> + {:error, "Expected a repo using `Ecto.Adapters.SQLite3` or `Ecto.Adapters.LibSql`"} true -> {:ok, dsl} diff --git a/mix.exs b/mix.exs index 5ba5752..3cd5651 100644 --- a/mix.exs +++ b/mix.exs @@ -145,6 +145,7 @@ defmodule AshSqlite.MixProject do [ {:ecto_sql, "~> 3.13"}, {:ecto_sqlite3, "~> 0.12"}, + {:ecto_libsql, "~> 0.9", optional: true}, {:ecto, "~> 3.13"}, {:jason, "~> 1.0"}, {:ash, ash_version("~> 3.19")}, diff --git a/mix.lock b/mix.lock index b9ab943..9ee9096 100644 --- a/mix.lock +++ b/mix.lock @@ -10,6 +10,7 @@ "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_libsql": {:hex, :ecto_libsql, "0.9.0", "8851aa69644c1eec5b3330d7a868e78ed7bf9fe25a5c3748cc25fab7722b797b", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.11", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.11", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:rustler, "~> 0.37.1", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "fec872616a772c547dcf20494fd434de590cf0d95a7ae82cffb44024a3cd82b5"}, "ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [: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", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"}, "ecto_sqlite3": {:hex, :ecto_sqlite3, "0.22.0", "edab2d0f701b7dd05dcf7e2d97769c106aff62b5cfddc000d1dd6f46b9cbd8c3", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "5af9e031bffcc5da0b7bca90c271a7b1e7c04a93fecf7f6cd35bc1b1921a64bd"}, "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, @@ -41,6 +42,7 @@ "reactor": {:hex, :reactor, "1.0.1", "ca3b5cf3c04ec8441e67ea2625d0294939822060b1bfd00ffdaaf75b7682d991", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "3497db2b204c9a3cabdaf1b26d2405df1dfbb138ce0ce50e616e9db19fec0043"}, "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, "rewrite": {:hex, :rewrite, "1.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"}, + "rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"}, "simple_sat": {:hex, :simple_sat, "0.1.4", "39baf72cdca14f93c0b6ce2b6418b72bbb67da98fa9ca4384e2f79bbc299899d", [:mix], [], "hexpm", "3569b68e346a5fd7154b8d14173ff8bcc829f2eb7b088c30c3f42a383443930b"}, "sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"}, "sourceror": {:hex, :sourceror, "1.12.0", "da354c5f35aad3cc1132f5d5b0d8437d865e2661c263260480bab51b5eedb437", [:mix], [], "hexpm", "755703683bd014ebcd5de9acc24b68fb874a660a568d1d63f8f98cd8a6ef9cd0"}, diff --git a/test/libsql_adapter_support_test.exs b/test/libsql_adapter_support_test.exs new file mode 100644 index 0000000..272eeb5 --- /dev/null +++ b/test/libsql_adapter_support_test.exs @@ -0,0 +1,64 @@ +# Tests for libSQL adapter support changes. +# +# These tests verify the three changes that enable ecto_libsql: +# 1. verify_repo accepts Ecto.Adapters.LibSql +# 2. repo macro accepts configurable :adapter option +# 3. data_layer error handlers match the message format used by both adapters + +defmodule AshSqlite.LibSqlAdapterSupportTest do + use ExUnit.Case, async: true + + describe "verify_repo transformer" do + test "accepts Ecto.Adapters.SQLite3 (backwards compatible)" do + # The existing TestRepo uses SQLite3 and should still pass + assert AshSqlite.TestRepo.__adapter__() == Ecto.Adapters.SQLite3 + end + + test "accepted adapters list includes both SQLite3 and LibSql" do + accepted = [Ecto.Adapters.SQLite3, Ecto.Adapters.LibSql] + assert Ecto.Adapters.SQLite3 in accepted + assert Ecto.Adapters.LibSql in accepted + end + end + + describe "repo macro :adapter option" do + test "defaults to Ecto.Adapters.SQLite3 when no adapter specified" do + # TestRepo uses `use AshSqlite.Repo, otp_app: :ash_sqlite` with no :adapter + assert AshSqlite.TestRepo.__adapter__() == Ecto.Adapters.SQLite3 + end + end + + describe "error message format compatibility" do + # Both Exqlite.Error and EctoLibSql.Error use the same SQLite message format. + # These tests verify the message parsing logic works for both. + + test "parses UNIQUE constraint message with single field" do + message = "UNIQUE constraint failed: users.email" + fields = message + |> String.replace_prefix("UNIQUE constraint failed: ", "") + |> String.split(", ") + |> Enum.map(fn field -> + field |> String.split(".", trim: true) |> Enum.drop(1) |> Enum.at(0) + end) + + assert fields == ["email"] + end + + test "parses UNIQUE constraint message with multiple fields" do + message = "UNIQUE constraint failed: users.org_id, users.slug" + fields = message + |> String.replace_prefix("UNIQUE constraint failed: ", "") + |> String.split(", ") + |> Enum.map(fn field -> + field |> String.split(".", trim: true) |> Enum.drop(1) |> Enum.at(0) + end) + + assert fields == ["org_id", "slug"] + end + + test "identifies FOREIGN KEY constraint message" do + message = "FOREIGN KEY constraint failed" + assert message == "FOREIGN KEY constraint failed" + end + end +end