Skip to content
47 changes: 47 additions & 0 deletions lib/ecto/adapters/libsql/connection.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions mix.lock
Original file line number Diff line number Diff line change
@@ -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"},
Expand All @@ -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"},
Expand Down
243 changes: 237 additions & 6 deletions test/ecto_migration_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down