Skip to content
24 changes: 24 additions & 0 deletions lib/ecto/adapters/libsql/connection.ex
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,12 @@ defmodule Ecto.Adapters.LibSql.Connection do
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}"

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 +493,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
131 changes: 125 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,123 @@ 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/
assert sql =~ "theme"
assert sql =~ "dark"
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/
assert sql =~ "tag1"
assert sql =~ "tag2"
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 '\[\]'/
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/
assert sql =~ "user"
assert sql =~ "theme"
assert sql =~ "light"
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
assert String.contains?(sql, ["string", "number", "bool"])
end
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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
end

describe "CHECK constraints" do
Expand Down