diff --git a/lib/ecto/adapters/libsql/connection.ex b/lib/ecto/adapters/libsql/connection.ex index 28e193fc..19c10423 100644 --- a/lib/ecto/adapters/libsql/connection.ex +++ b/lib/ecto/adapters/libsql/connection.ex @@ -468,11 +468,40 @@ defmodule Ecto.Adapters.LibSql.Connection do end defp column_default(nil), do: "" + defp column_default(:null), do: "" defp column_default(true), do: " DEFAULT 1" defp column_default(false), do: " DEFAULT 0" defp column_default(value) when is_binary(value), do: " DEFAULT '#{escape_string(value)}'" defp column_default(value) when is_number(value), do: " DEFAULT #{value}" defp column_default({:fragment, expr}), do: " DEFAULT #{expr}" + + # Temporal types - convert to ISO8601 strings + defp column_default(%DateTime{} = dt) do + " DEFAULT '#{escape_string(DateTime.to_iso8601(dt))}'" + end + + defp column_default(%NaiveDateTime{} = dt) do + " DEFAULT '#{escape_string(NaiveDateTime.to_iso8601(dt))}'" + end + + defp column_default(%Date{} = d) do + " DEFAULT '#{escape_string(Date.to_iso8601(d))}'" + end + + defp column_default(%Time{} = t) do + " DEFAULT '#{escape_string(Time.to_iso8601(t))}'" + end + + # Decimal type - convert to string representation + defp column_default(%Decimal{} = d) do + " DEFAULT '#{escape_string(Decimal.to_string(d))}'" + end + + defp column_default(value) when is_map(value) or is_list(value) do + type_name = if is_map(value), do: "map", else: "list" + encode_json_default(value, type_name) + end + # Handle any other unexpected types (e.g., empty maps or third-party migrations) # Logs a warning to help with debugging while gracefully falling back to no DEFAULT clause defp column_default(unexpected) do @@ -487,6 +516,24 @@ defmodule Ecto.Adapters.LibSql.Connection do "" end + # Helper function to encode JSON default values and log failures + defp encode_json_default(value, type_name) do + case Jason.encode(value) do + {:ok, json} -> + " DEFAULT '#{escape_string(json)}'" + + {:error, reason} -> + require Logger + + Logger.warning( + "Failed to JSON encode #{type_name} default value in migration: #{inspect(value)} - " <> + "Reason: #{inspect(reason)} - no DEFAULT clause will be generated." + ) + + "" + end + end + defp table_options(table, columns) do # Validate mutually exclusive options (per libSQL specification) if table.options && Keyword.get(table.options, :random_rowid, false) do diff --git a/mix.exs b/mix.exs index 0223aabd..a65dbc4c 100644 --- a/mix.exs +++ b/mix.exs @@ -17,6 +17,8 @@ defmodule EctoLibSql.MixProject do package: package(), description: description(), docs: docs(), + test_pattern: "**/*_test.exs", + test_paths: ["test"], dialyzer: [ plt_core_path: "priv/plts", app_tree: true, diff --git a/mix.lock b/mix.lock index b3e06160..d8877590 100644 --- a/mix.lock +++ b/mix.lock @@ -1,7 +1,7 @@ %{ "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "credo": {:hex, :credo, "1.7.15", "283da72eeb2fd3ccf7248f4941a0527efb97afa224bcdef30b4b580bc8258e1c", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "291e8645ea3fea7481829f1e1eb0881b8395db212821338e577a90bf225c5607"}, - "db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"}, + "db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"}, "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"}, @@ -13,7 +13,7 @@ "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"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, - "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, + "makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "rustler": {:hex, :rustler, "0.37.1", "721434020c7f6f8e1cdc57f44f75c490435b01de96384f8ccb96043f12e8a7e0", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "24547e9b8640cf00e6a2071acb710f3e12ce0346692e45098d84d45cdb54fd79"}, "sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"}, diff --git a/test/ecto_migration_test.exs b/test/ecto_migration_test.exs index 72ec71de..bb22d478 100644 --- a/test/ecto_migration_test.exs +++ b/test/ecto_migration_test.exs @@ -939,28 +939,30 @@ defmodule Ecto.Adapters.LibSql.MigrationTest do test "handles unexpected types gracefully (empty map)" do # This test verifies the catch-all clause for unexpected types. # Empty maps can come from some migrations or other third-party code. + # As of the defaults update, empty maps are JSON encoded like other maps. table = %Table{name: :users, prefix: nil} columns = [{:add, :metadata, :string, [default: %{}]}] # Should not raise FunctionClauseError. [sql] = Connection.execute_ddl({:create, table, columns}) - # Empty map should be treated as no default. - assert sql =~ ~r/"metadata".*TEXT/ - refute sql =~ ~r/"metadata".*DEFAULT/ + # Empty map should be JSON encoded to '{}' + assert sql =~ ~r/"metadata".*TEXT.*DEFAULT/ + assert sql =~ "'{}'" end test "handles unexpected types gracefully (list)" do # Lists are another unexpected type that might appear. + # As of the defaults update, lists are JSON encoded. table = %Table{name: :users, prefix: nil} columns = [{:add, :tags, :string, [default: []]}] # Should not raise FunctionClauseError. [sql] = Connection.execute_ddl({:create, table, columns}) - # Empty list should be treated as no default. - assert sql =~ ~r/"tags".*TEXT/ - refute sql =~ ~r/"tags".*DEFAULT/ + # Empty list should be JSON encoded to '[]' + assert sql =~ ~r/"tags".*TEXT.*DEFAULT/ + assert sql =~ "DEFAULT '[]'" end test "handles unexpected types gracefully (atom)" do @@ -975,6 +977,235 @@ defmodule Ecto.Adapters.LibSql.MigrationTest do assert sql =~ ~r/"status".*TEXT/ refute sql =~ ~r/"status".*DEFAULT/ end + + test "handles map defaults (JSON encoding)" do + table = %Table{name: :users, prefix: nil} + + columns = [ + {:add, :preferences, :text, [default: %{"theme" => "dark", "notifications" => true}]} + ] + + [sql] = Connection.execute_ddl({:create, table, columns}) + + # Map should be JSON encoded + assert sql =~ ~r/"preferences".*TEXT.*DEFAULT/ + + [_, json] = Regex.run(~r/DEFAULT '([^']*)'/, sql) + + assert Jason.decode!(json) == %{ + "theme" => "dark", + "notifications" => true + } + end + + test "handles list defaults (JSON encoding)" do + table = %Table{name: :items, prefix: nil} + + columns = [ + {:add, :tags, :text, [default: ["tag1", "tag2", "tag3"]]} + ] + + [sql] = Connection.execute_ddl({:create, table, columns}) + + # List should be JSON encoded + assert sql =~ ~r/"tags".*TEXT.*DEFAULT/ + + [_, json] = Regex.run(~r/DEFAULT '([^']*)'/, sql) + + assert Jason.decode!(json) == ["tag1", "tag2", "tag3"] + end + + test "handles empty list defaults" do + table = %Table{name: :items, prefix: nil} + columns = [{:add, :tags, :text, [default: []]}] + + # Empty list encodes to "[]" in JSON + [sql] = Connection.execute_ddl({:create, table, columns}) + + # Should have a DEFAULT clause with empty array JSON + assert sql =~ ~r/"tags".*TEXT.*DEFAULT/ + + [_, json] = Regex.run(~r/DEFAULT '([^']*)'/, sql) + + assert Jason.decode!(json) == [] + end + + test "handles complex nested map defaults" do + table = %Table{name: :configs, prefix: nil} + + columns = [ + {:add, :settings, :text, + [default: %{"user" => %{"theme" => "light"}, "privacy" => false}]} + ] + + [sql] = Connection.execute_ddl({:create, table, columns}) + + # Nested map should be JSON encoded + assert sql =~ ~r/"settings".*TEXT.*DEFAULT/ + + [_, json] = Regex.run(~r/DEFAULT '([^']*)'/, sql) + + assert Jason.decode!(json) == %{ + "user" => %{"theme" => "light"}, + "privacy" => false + } + end + + test "handles map with various JSON types" do + table = %Table{name: :data, prefix: nil} + + columns = [ + {:add, :metadata, :text, + [default: %{"string" => "value", "number" => 42, "bool" => true, "null" => nil}]} + ] + + [sql] = Connection.execute_ddl({:create, table, columns}) + + assert sql =~ ~r/"metadata".*TEXT.*DEFAULT/ + + # Verify JSON is properly escaped - all keys must be present including null values + [_, json] = Regex.run(~r/DEFAULT '([^']*)'/, sql) + + assert Jason.decode!(json) == %{ + "string" => "value", + "number" => 42, + "bool" => true, + "null" => nil + } + end + + test "logs warning when map has unencodable value (PID)" do + # Maps containing PIDs or functions cannot be JSON encoded + table = %Table{name: :data, prefix: nil} + pid = spawn(fn -> :ok end) + + columns = [ + {:add, :metadata, :text, [default: %{"pid" => pid}]} + ] + + # Capture logs to verify warning is logged + log_output = + ExUnit.CaptureLog.capture_log(fn -> + [sql] = Connection.execute_ddl({:create, table, columns}) + + # When encoding fails, no DEFAULT clause should be generated + assert sql =~ ~r/"metadata".*TEXT/ + refute sql =~ ~r/"metadata".*DEFAULT/ + end) + + assert log_output =~ "Failed to JSON encode map default value in migration" + end + + test "logs warning when list has unencodable value (function)" do + # Lists containing functions cannot be JSON encoded + table = %Table{name: :data, prefix: nil} + func = fn -> :ok end + + columns = [ + {:add, :callbacks, :text, [default: [func, "other"]]} + ] + + # Capture logs to verify warning is logged + log_output = + ExUnit.CaptureLog.capture_log(fn -> + [sql] = Connection.execute_ddl({:create, table, columns}) + + # When encoding fails, no DEFAULT clause should be generated + assert sql =~ ~r/"callbacks".*TEXT/ + refute sql =~ ~r/"callbacks".*DEFAULT/ + end) + + assert log_output =~ "Failed to JSON encode list default value in migration" + end + + test "handles :null atom defaults (same as nil)" do + table = %Table{name: :users, prefix: nil} + columns = [{:add, :bio, :text, [default: :null]}] + + [sql] = Connection.execute_ddl({:create, table, columns}) + + # :null should result in no DEFAULT clause (same as nil) + refute sql =~ "DEFAULT" + end + + test "handles Decimal defaults" do + table = %Table{name: :products, prefix: nil} + columns = [{:add, :price, :decimal, [default: Decimal.new("19.99")]}] + + [sql] = Connection.execute_ddl({:create, table, columns}) + + # Decimal should be converted to string representation + assert sql =~ ~r/"price".*DECIMAL.*DEFAULT/ + assert sql =~ "'19.99'" + end + + test "handles DateTime defaults" do + table = %Table{name: :events, prefix: nil} + {:ok, dt, _} = DateTime.from_iso8601("2026-01-16T14:30:00Z") + columns = [{:add, :created_at, :utc_datetime, [default: dt]}] + + [sql] = Connection.execute_ddl({:create, table, columns}) + + # DateTime should be converted to ISO8601 string + assert sql =~ ~r/"created_at".*DATETIME.*DEFAULT/ + assert sql =~ "2026-01-16T14:30:00Z" + end + + test "handles NaiveDateTime defaults" do + table = %Table{name: :logs, prefix: nil} + dt = ~N[2026-01-16 14:30:00.000000] + columns = [{:add, :recorded_at, :naive_datetime, [default: dt]}] + + [sql] = Connection.execute_ddl({:create, table, columns}) + + # NaiveDateTime should be converted to ISO8601 string + assert sql =~ ~r/"recorded_at".*DATETIME.*DEFAULT/ + assert sql =~ "2026-01-16T14:30:00" + end + + test "handles Date defaults" do + table = %Table{name: :schedules, prefix: nil} + date = ~D[2026-01-16] + columns = [{:add, :event_date, :date, [default: date]}] + + [sql] = Connection.execute_ddl({:create, table, columns}) + + # Date should be converted to ISO8601 string + assert sql =~ ~r/"event_date".*DATE.*DEFAULT/ + assert sql =~ "'2026-01-16'" + end + + test "handles Time defaults" do + table = %Table{name: :schedules, prefix: nil} + time = ~T[14:30:45.123456] + columns = [{:add, :event_time, :time, [default: time]}] + + [sql] = Connection.execute_ddl({:create, table, columns}) + + # Time should be converted to ISO8601 string + assert sql =~ ~r/"event_time".*TIME.*DEFAULT/ + assert sql =~ "14:30:45.123456" + end + + test "handles Decimal with many decimal places" do + table = %Table{name: :data, prefix: nil} + columns = [{:add, :value, :decimal, [default: Decimal.new("123.456789012345")]}] + + [sql] = Connection.execute_ddl({:create, table, columns}) + + assert sql =~ ~r/"value".*DECIMAL.*DEFAULT/ + assert sql =~ "'123.456789012345'" + end + + test "handles negative Decimal defaults" do + table = %Table{name: :balances, prefix: nil} + columns = [{:add, :amount, :decimal, [default: Decimal.new("-42.50")]}] + + [sql] = Connection.execute_ddl({:create, table, columns}) + + assert sql =~ ~r/"amount".*DECIMAL.*DEFAULT/ + assert sql =~ "'-42.50'" + end end describe "CHECK constraints" do