Skip to content

Commit a4057eb

Browse files
authored
Merge pull request #60 from ocean/fix-defaults-handling
Add code to handle map/list default values
2 parents cbb852d + 506cc2e commit a4057eb

File tree

4 files changed

+288
-8
lines changed

4 files changed

+288
-8
lines changed

lib/ecto/adapters/libsql/connection.ex

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,11 +468,40 @@ defmodule Ecto.Adapters.LibSql.Connection do
468468
end
469469

470470
defp column_default(nil), do: ""
471+
defp column_default(:null), do: ""
471472
defp column_default(true), do: " DEFAULT 1"
472473
defp column_default(false), do: " DEFAULT 0"
473474
defp column_default(value) when is_binary(value), do: " DEFAULT '#{escape_string(value)}'"
474475
defp column_default(value) when is_number(value), do: " DEFAULT #{value}"
475476
defp column_default({:fragment, expr}), do: " DEFAULT #{expr}"
477+
478+
# Temporal types - convert to ISO8601 strings
479+
defp column_default(%DateTime{} = dt) do
480+
" DEFAULT '#{escape_string(DateTime.to_iso8601(dt))}'"
481+
end
482+
483+
defp column_default(%NaiveDateTime{} = dt) do
484+
" DEFAULT '#{escape_string(NaiveDateTime.to_iso8601(dt))}'"
485+
end
486+
487+
defp column_default(%Date{} = d) do
488+
" DEFAULT '#{escape_string(Date.to_iso8601(d))}'"
489+
end
490+
491+
defp column_default(%Time{} = t) do
492+
" DEFAULT '#{escape_string(Time.to_iso8601(t))}'"
493+
end
494+
495+
# Decimal type - convert to string representation
496+
defp column_default(%Decimal{} = d) do
497+
" DEFAULT '#{escape_string(Decimal.to_string(d))}'"
498+
end
499+
500+
defp column_default(value) when is_map(value) or is_list(value) do
501+
type_name = if is_map(value), do: "map", else: "list"
502+
encode_json_default(value, type_name)
503+
end
504+
476505
# Handle any other unexpected types (e.g., empty maps or third-party migrations)
477506
# Logs a warning to help with debugging while gracefully falling back to no DEFAULT clause
478507
defp column_default(unexpected) do
@@ -487,6 +516,24 @@ defmodule Ecto.Adapters.LibSql.Connection do
487516
""
488517
end
489518

519+
# Helper function to encode JSON default values and log failures
520+
defp encode_json_default(value, type_name) do
521+
case Jason.encode(value) do
522+
{:ok, json} ->
523+
" DEFAULT '#{escape_string(json)}'"
524+
525+
{:error, reason} ->
526+
require Logger
527+
528+
Logger.warning(
529+
"Failed to JSON encode #{type_name} default value in migration: #{inspect(value)} - " <>
530+
"Reason: #{inspect(reason)} - no DEFAULT clause will be generated."
531+
)
532+
533+
""
534+
end
535+
end
536+
490537
defp table_options(table, columns) do
491538
# Validate mutually exclusive options (per libSQL specification)
492539
if table.options && Keyword.get(table.options, :random_rowid, false) do

mix.exs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ defmodule EctoLibSql.MixProject do
1717
package: package(),
1818
description: description(),
1919
docs: docs(),
20+
test_pattern: "**/*_test.exs",
21+
test_paths: ["test"],
2022
dialyzer: [
2123
plt_core_path: "priv/plts",
2224
app_tree: true,

mix.lock

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
%{
22
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
33
"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"},
4-
"db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"},
4+
"db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"},
55
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
66
"dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"},
77
"earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"},
@@ -13,7 +13,7 @@
1313
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
1414
"makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
1515
"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"},
16-
"makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"},
16+
"makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"},
1717
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
1818
"rustler": {:hex, :rustler, "0.37.1", "721434020c7f6f8e1cdc57f44f75c490435b01de96384f8ccb96043f12e8a7e0", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "24547e9b8640cf00e6a2071acb710f3e12ce0346692e45098d84d45cdb54fd79"},
1919
"sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"},

test/ecto_migration_test.exs

Lines changed: 237 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -939,28 +939,30 @@ defmodule Ecto.Adapters.LibSql.MigrationTest do
939939
test "handles unexpected types gracefully (empty map)" do
940940
# This test verifies the catch-all clause for unexpected types.
941941
# Empty maps can come from some migrations or other third-party code.
942+
# As of the defaults update, empty maps are JSON encoded like other maps.
942943
table = %Table{name: :users, prefix: nil}
943944
columns = [{:add, :metadata, :string, [default: %{}]}]
944945

945946
# Should not raise FunctionClauseError.
946947
[sql] = Connection.execute_ddl({:create, table, columns})
947948

948-
# Empty map should be treated as no default.
949-
assert sql =~ ~r/"metadata".*TEXT/
950-
refute sql =~ ~r/"metadata".*DEFAULT/
949+
# Empty map should be JSON encoded to '{}'
950+
assert sql =~ ~r/"metadata".*TEXT.*DEFAULT/
951+
assert sql =~ "'{}'"
951952
end
952953

953954
test "handles unexpected types gracefully (list)" do
954955
# Lists are another unexpected type that might appear.
956+
# As of the defaults update, lists are JSON encoded.
955957
table = %Table{name: :users, prefix: nil}
956958
columns = [{:add, :tags, :string, [default: []]}]
957959

958960
# Should not raise FunctionClauseError.
959961
[sql] = Connection.execute_ddl({:create, table, columns})
960962

961-
# Empty list should be treated as no default.
962-
assert sql =~ ~r/"tags".*TEXT/
963-
refute sql =~ ~r/"tags".*DEFAULT/
963+
# Empty list should be JSON encoded to '[]'
964+
assert sql =~ ~r/"tags".*TEXT.*DEFAULT/
965+
assert sql =~ "DEFAULT '[]'"
964966
end
965967

966968
test "handles unexpected types gracefully (atom)" do
@@ -975,6 +977,235 @@ defmodule Ecto.Adapters.LibSql.MigrationTest do
975977
assert sql =~ ~r/"status".*TEXT/
976978
refute sql =~ ~r/"status".*DEFAULT/
977979
end
980+
981+
test "handles map defaults (JSON encoding)" do
982+
table = %Table{name: :users, prefix: nil}
983+
984+
columns = [
985+
{:add, :preferences, :text, [default: %{"theme" => "dark", "notifications" => true}]}
986+
]
987+
988+
[sql] = Connection.execute_ddl({:create, table, columns})
989+
990+
# Map should be JSON encoded
991+
assert sql =~ ~r/"preferences".*TEXT.*DEFAULT/
992+
993+
[_, json] = Regex.run(~r/DEFAULT '([^']*)'/, sql)
994+
995+
assert Jason.decode!(json) == %{
996+
"theme" => "dark",
997+
"notifications" => true
998+
}
999+
end
1000+
1001+
test "handles list defaults (JSON encoding)" do
1002+
table = %Table{name: :items, prefix: nil}
1003+
1004+
columns = [
1005+
{:add, :tags, :text, [default: ["tag1", "tag2", "tag3"]]}
1006+
]
1007+
1008+
[sql] = Connection.execute_ddl({:create, table, columns})
1009+
1010+
# List should be JSON encoded
1011+
assert sql =~ ~r/"tags".*TEXT.*DEFAULT/
1012+
1013+
[_, json] = Regex.run(~r/DEFAULT '([^']*)'/, sql)
1014+
1015+
assert Jason.decode!(json) == ["tag1", "tag2", "tag3"]
1016+
end
1017+
1018+
test "handles empty list defaults" do
1019+
table = %Table{name: :items, prefix: nil}
1020+
columns = [{:add, :tags, :text, [default: []]}]
1021+
1022+
# Empty list encodes to "[]" in JSON
1023+
[sql] = Connection.execute_ddl({:create, table, columns})
1024+
1025+
# Should have a DEFAULT clause with empty array JSON
1026+
assert sql =~ ~r/"tags".*TEXT.*DEFAULT/
1027+
1028+
[_, json] = Regex.run(~r/DEFAULT '([^']*)'/, sql)
1029+
1030+
assert Jason.decode!(json) == []
1031+
end
1032+
1033+
test "handles complex nested map defaults" do
1034+
table = %Table{name: :configs, prefix: nil}
1035+
1036+
columns = [
1037+
{:add, :settings, :text,
1038+
[default: %{"user" => %{"theme" => "light"}, "privacy" => false}]}
1039+
]
1040+
1041+
[sql] = Connection.execute_ddl({:create, table, columns})
1042+
1043+
# Nested map should be JSON encoded
1044+
assert sql =~ ~r/"settings".*TEXT.*DEFAULT/
1045+
1046+
[_, json] = Regex.run(~r/DEFAULT '([^']*)'/, sql)
1047+
1048+
assert Jason.decode!(json) == %{
1049+
"user" => %{"theme" => "light"},
1050+
"privacy" => false
1051+
}
1052+
end
1053+
1054+
test "handles map with various JSON types" do
1055+
table = %Table{name: :data, prefix: nil}
1056+
1057+
columns = [
1058+
{:add, :metadata, :text,
1059+
[default: %{"string" => "value", "number" => 42, "bool" => true, "null" => nil}]}
1060+
]
1061+
1062+
[sql] = Connection.execute_ddl({:create, table, columns})
1063+
1064+
assert sql =~ ~r/"metadata".*TEXT.*DEFAULT/
1065+
1066+
# Verify JSON is properly escaped - all keys must be present including null values
1067+
[_, json] = Regex.run(~r/DEFAULT '([^']*)'/, sql)
1068+
1069+
assert Jason.decode!(json) == %{
1070+
"string" => "value",
1071+
"number" => 42,
1072+
"bool" => true,
1073+
"null" => nil
1074+
}
1075+
end
1076+
1077+
test "logs warning when map has unencodable value (PID)" do
1078+
# Maps containing PIDs or functions cannot be JSON encoded
1079+
table = %Table{name: :data, prefix: nil}
1080+
pid = spawn(fn -> :ok end)
1081+
1082+
columns = [
1083+
{:add, :metadata, :text, [default: %{"pid" => pid}]}
1084+
]
1085+
1086+
# Capture logs to verify warning is logged
1087+
log_output =
1088+
ExUnit.CaptureLog.capture_log(fn ->
1089+
[sql] = Connection.execute_ddl({:create, table, columns})
1090+
1091+
# When encoding fails, no DEFAULT clause should be generated
1092+
assert sql =~ ~r/"metadata".*TEXT/
1093+
refute sql =~ ~r/"metadata".*DEFAULT/
1094+
end)
1095+
1096+
assert log_output =~ "Failed to JSON encode map default value in migration"
1097+
end
1098+
1099+
test "logs warning when list has unencodable value (function)" do
1100+
# Lists containing functions cannot be JSON encoded
1101+
table = %Table{name: :data, prefix: nil}
1102+
func = fn -> :ok end
1103+
1104+
columns = [
1105+
{:add, :callbacks, :text, [default: [func, "other"]]}
1106+
]
1107+
1108+
# Capture logs to verify warning is logged
1109+
log_output =
1110+
ExUnit.CaptureLog.capture_log(fn ->
1111+
[sql] = Connection.execute_ddl({:create, table, columns})
1112+
1113+
# When encoding fails, no DEFAULT clause should be generated
1114+
assert sql =~ ~r/"callbacks".*TEXT/
1115+
refute sql =~ ~r/"callbacks".*DEFAULT/
1116+
end)
1117+
1118+
assert log_output =~ "Failed to JSON encode list default value in migration"
1119+
end
1120+
1121+
test "handles :null atom defaults (same as nil)" do
1122+
table = %Table{name: :users, prefix: nil}
1123+
columns = [{:add, :bio, :text, [default: :null]}]
1124+
1125+
[sql] = Connection.execute_ddl({:create, table, columns})
1126+
1127+
# :null should result in no DEFAULT clause (same as nil)
1128+
refute sql =~ "DEFAULT"
1129+
end
1130+
1131+
test "handles Decimal defaults" do
1132+
table = %Table{name: :products, prefix: nil}
1133+
columns = [{:add, :price, :decimal, [default: Decimal.new("19.99")]}]
1134+
1135+
[sql] = Connection.execute_ddl({:create, table, columns})
1136+
1137+
# Decimal should be converted to string representation
1138+
assert sql =~ ~r/"price".*DECIMAL.*DEFAULT/
1139+
assert sql =~ "'19.99'"
1140+
end
1141+
1142+
test "handles DateTime defaults" do
1143+
table = %Table{name: :events, prefix: nil}
1144+
{:ok, dt, _} = DateTime.from_iso8601("2026-01-16T14:30:00Z")
1145+
columns = [{:add, :created_at, :utc_datetime, [default: dt]}]
1146+
1147+
[sql] = Connection.execute_ddl({:create, table, columns})
1148+
1149+
# DateTime should be converted to ISO8601 string
1150+
assert sql =~ ~r/"created_at".*DATETIME.*DEFAULT/
1151+
assert sql =~ "2026-01-16T14:30:00Z"
1152+
end
1153+
1154+
test "handles NaiveDateTime defaults" do
1155+
table = %Table{name: :logs, prefix: nil}
1156+
dt = ~N[2026-01-16 14:30:00.000000]
1157+
columns = [{:add, :recorded_at, :naive_datetime, [default: dt]}]
1158+
1159+
[sql] = Connection.execute_ddl({:create, table, columns})
1160+
1161+
# NaiveDateTime should be converted to ISO8601 string
1162+
assert sql =~ ~r/"recorded_at".*DATETIME.*DEFAULT/
1163+
assert sql =~ "2026-01-16T14:30:00"
1164+
end
1165+
1166+
test "handles Date defaults" do
1167+
table = %Table{name: :schedules, prefix: nil}
1168+
date = ~D[2026-01-16]
1169+
columns = [{:add, :event_date, :date, [default: date]}]
1170+
1171+
[sql] = Connection.execute_ddl({:create, table, columns})
1172+
1173+
# Date should be converted to ISO8601 string
1174+
assert sql =~ ~r/"event_date".*DATE.*DEFAULT/
1175+
assert sql =~ "'2026-01-16'"
1176+
end
1177+
1178+
test "handles Time defaults" do
1179+
table = %Table{name: :schedules, prefix: nil}
1180+
time = ~T[14:30:45.123456]
1181+
columns = [{:add, :event_time, :time, [default: time]}]
1182+
1183+
[sql] = Connection.execute_ddl({:create, table, columns})
1184+
1185+
# Time should be converted to ISO8601 string
1186+
assert sql =~ ~r/"event_time".*TIME.*DEFAULT/
1187+
assert sql =~ "14:30:45.123456"
1188+
end
1189+
1190+
test "handles Decimal with many decimal places" do
1191+
table = %Table{name: :data, prefix: nil}
1192+
columns = [{:add, :value, :decimal, [default: Decimal.new("123.456789012345")]}]
1193+
1194+
[sql] = Connection.execute_ddl({:create, table, columns})
1195+
1196+
assert sql =~ ~r/"value".*DECIMAL.*DEFAULT/
1197+
assert sql =~ "'123.456789012345'"
1198+
end
1199+
1200+
test "handles negative Decimal defaults" do
1201+
table = %Table{name: :balances, prefix: nil}
1202+
columns = [{:add, :amount, :decimal, [default: Decimal.new("-42.50")]}]
1203+
1204+
[sql] = Connection.execute_ddl({:create, table, columns})
1205+
1206+
assert sql =~ ~r/"amount".*DECIMAL.*DEFAULT/
1207+
assert sql =~ "'-42.50'"
1208+
end
9781209
end
9791210

9801211
describe "CHECK constraints" do

0 commit comments

Comments
 (0)