Skip to content

Commit d475dcc

Browse files
authored
Merge pull request #58 from ocean/fix-pr57-issues
fix: add DateTime/Decimal parameter encoding for wider compatibility
2 parents d3fd488 + 87bfaa9 commit d475dcc

12 files changed

Lines changed: 1697 additions & 5 deletions

.beads/.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ beads.left.meta.json
3232
beads.right.jsonl
3333
beads.right.meta.json
3434

35+
# Sync state (local-only, per-machine)
36+
# These files are machine-specific and should not be shared across clones
37+
.sync.lock
38+
sync_base.jsonl
39+
3540
# NOTE: Do NOT add negation patterns (e.g., !issues.jsonl) here.
3641
# They would override fork protection in .git/info/exclude, allowing
3742
# contributors to accidentally commit upstream issue databases.

.beads/last-touched

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
el-6r5
1+
el-1p2

.claude/settings.local.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,11 @@
4949
"Bash(git commit:*)",
5050
"Bash(git push)",
5151
"Bash(git --no-pager status)",
52-
"Bash(cargo deny check:*)"
52+
"Bash(cargo deny check:*)",
53+
"Bash(gh pr diff:*)",
54+
"Bash(gh pr checks:*)",
55+
"Bash(gh run view:*)",
56+
"Bash(gh pr checkout:*)"
5357
],
5458
"deny": [],
5559
"ask": []

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,5 +41,7 @@ z_ecto_libsql_test*
4141

4242
# bv (beads viewer) local config and caches
4343
.bv/
44+
45+
# Implementation summaries and temporary docs
4446
TEST_AUDIT_REPORT.md
4547
TEST_COVERAGE_ISSUES_CREATED.md

AGENTS.md

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2610,6 +2610,199 @@ export TURSO_AUTH_TOKEN="eyJ..."
26102610
- 🌍 **Global distribution** via Turso edge
26112611
- 💪 **Offline capability** - works without network
26122612

2613+
### Type Encoding and Parameter Conversion
2614+
2615+
EctoLibSql automatically converts Elixir types to SQLite-compatible formats. Understanding these conversions is important for correct database usage.
2616+
2617+
#### Automatically Encoded Types
2618+
2619+
The following types are automatically converted when passed as query parameters:
2620+
2621+
##### Temporal Types
2622+
2623+
```elixir
2624+
# DateTime → ISO8601 string
2625+
dt = DateTime.utc_now()
2626+
SQL.query!(Repo, "INSERT INTO events (created_at) VALUES (?)", [dt])
2627+
# Stored as: "2026-01-13T03:45:23.123456Z"
2628+
2629+
# NaiveDateTime → ISO8601 string
2630+
dt = NaiveDateTime.utc_now()
2631+
SQL.query!(Repo, "INSERT INTO events (created_at) VALUES (?)", [dt])
2632+
# Stored as: "2026-01-13T03:45:23.123456"
2633+
2634+
# Date → ISO8601 string
2635+
date = Date.utc_today()
2636+
SQL.query!(Repo, "INSERT INTO events (event_date) VALUES (?)", [date])
2637+
# Stored as: "2026-01-13"
2638+
2639+
# Time → ISO8601 string
2640+
time = Time.new!(14, 30, 45)
2641+
SQL.query!(Repo, "INSERT INTO events (event_time) VALUES (?)", [time])
2642+
# Stored as: "14:30:45.000000"
2643+
2644+
# Relative dates (compute absolute date first, then pass)
2645+
tomorrow = Date.add(Date.utc_today(), 1) # Becomes a Date struct
2646+
SQL.query!(Repo, "INSERT INTO events (event_date) VALUES (?)", [tomorrow])
2647+
2648+
# Third-party date types (Timex, etc.) - pre-convert to standard types
2649+
# ❌ NOT SUPPORTED: Timex.DateTime or custom structs
2650+
# ✅ DO THIS: Convert to native DateTime first
2651+
timex_dt = Timex.now()
2652+
native_dt = Timex.to_datetime(timex_dt) # Convert to DateTime
2653+
SQL.query!(Repo, "INSERT INTO events (created_at) VALUES (?)", [native_dt])
2654+
```
2655+
2656+
##### Boolean Values
2657+
2658+
```elixir
2659+
# true → 1, false → 0
2660+
# SQLite uses integers for booleans
2661+
SQL.query!(Repo, "INSERT INTO users (active) VALUES (?)", [true])
2662+
# Stored as: 1
2663+
2664+
SQL.query!(Repo, "INSERT INTO users (active) VALUES (?)", [false])
2665+
# Stored as: 0
2666+
2667+
# Works with WHERE clauses
2668+
SQL.query!(Repo, "SELECT * FROM users WHERE active = ?", [true])
2669+
# Matches rows where active = 1
2670+
```
2671+
2672+
##### Decimal Values
2673+
2674+
```elixir
2675+
# Decimal → string representation
2676+
decimal = Decimal.new("123.45")
2677+
SQL.query!(Repo, "INSERT INTO prices (amount) VALUES (?)", [decimal])
2678+
# Stored as: "123.45"
2679+
```
2680+
2681+
##### NULL/nil Values
2682+
2683+
```elixir
2684+
# nil → NULL
2685+
SQL.query!(Repo, "INSERT INTO users (bio) VALUES (?)", [nil])
2686+
# Stored as SQL NULL
2687+
2688+
# :null atom → nil → NULL (v0.8.3+)
2689+
# Alternative way to represent NULL
2690+
SQL.query!(Repo, "INSERT INTO users (bio) VALUES (?)", [:null])
2691+
# Also stored as SQL NULL
2692+
2693+
# Both work identically:
2694+
SQL.query!(Repo, "SELECT * FROM users WHERE bio IS NULL") # Matches both
2695+
```
2696+
2697+
##### UUID Values
2698+
2699+
```elixir
2700+
# Ecto.UUID strings work directly (already binary strings)
2701+
uuid = Ecto.UUID.generate()
2702+
SQL.query!(Repo, "INSERT INTO users (id) VALUES (?)", [uuid])
2703+
# Stored as: "550e8400-e29b-41d4-a716-446655440000"
2704+
2705+
# Works with WHERE clauses
2706+
SQL.query!(Repo, "SELECT * FROM users WHERE id = ?", [uuid])
2707+
```
2708+
2709+
#### Type Encoding Examples
2710+
2711+
```elixir
2712+
defmodule MyApp.Examples do
2713+
def example_with_multiple_types do
2714+
import Ecto.Adapters.SQL
2715+
2716+
now = DateTime.utc_now()
2717+
user_active = true
2718+
amount = Decimal.new("99.99")
2719+
2720+
# All types are automatically encoded
2721+
query!(Repo,
2722+
"INSERT INTO transactions (created_at, active, amount) VALUES (?, ?, ?)",
2723+
[now, user_active, amount]
2724+
)
2725+
end
2726+
2727+
def example_with_ecto_queries do
2728+
import Ecto.Query
2729+
2730+
from(u in User,
2731+
where: u.active == ^true, # Boolean encoded to 1
2732+
where: u.created_at > ^DateTime.utc_now() # DateTime encoded to ISO8601
2733+
)
2734+
|> Repo.all()
2735+
end
2736+
2737+
def example_with_null do
2738+
# Both are equivalent:
2739+
SQL.query!(Repo, "INSERT INTO users (bio) VALUES (?)", [nil])
2740+
SQL.query!(Repo, "INSERT INTO users (bio) VALUES (?)", [:null])
2741+
2742+
# Query for NULL values
2743+
SQL.query!(Repo, "SELECT * FROM users WHERE bio IS NULL")
2744+
end
2745+
end
2746+
```
2747+
2748+
#### Limitations: Nested Structures with Temporal Types
2749+
2750+
Nested structures (maps/lists) containing temporal types are **not automatically encoded**. Only top-level parameters are encoded.
2751+
2752+
```elixir
2753+
# ❌ DOESN'T WORK - Nested DateTime not encoded
2754+
nested = %{
2755+
"created_at" => DateTime.utc_now(), # ← Not auto-encoded
2756+
"data" => "value"
2757+
}
2758+
SQL.query!(Repo, "INSERT INTO events (metadata) VALUES (?)", [nested])
2759+
# Error: DateTime struct cannot be serialized to JSON
2760+
2761+
# ✅ WORKS - Pre-encode nested values
2762+
nested = %{
2763+
"created_at" => DateTime.utc_now() |> DateTime.to_iso8601(),
2764+
"data" => "value"
2765+
}
2766+
json = Jason.encode!(nested)
2767+
SQL.query!(Repo, "INSERT INTO events (metadata) VALUES (?)", [json])
2768+
2769+
# ✅ WORKS - Encode before creating map
2770+
dt = DateTime.utc_now() |> DateTime.to_iso8601()
2771+
nested = %{"created_at" => dt, "data" => "value"}
2772+
json = Jason.encode!(nested)
2773+
SQL.query!(Repo, "INSERT INTO events (metadata) VALUES (?)", [json])
2774+
```
2775+
2776+
**Workaround:**
2777+
When working with maps/lists containing temporal types, manually convert them to JSON strings before passing to queries:
2778+
2779+
```elixir
2780+
defmodule MyApp.JsonHelpers do
2781+
def safe_json_encode(map) when is_map(map) do
2782+
map
2783+
|> Enum.map(fn
2784+
{k, %DateTime{} = v} -> {k, DateTime.to_iso8601(v)}
2785+
{k, %NaiveDateTime{} = v} -> {k, NaiveDateTime.to_iso8601(v)}
2786+
{k, %Date{} = v} -> {k, Date.to_iso8601(v)}
2787+
{k, %Decimal{} = v} -> {k, Decimal.to_string(v)}
2788+
{k, v} -> {k, v}
2789+
end)
2790+
|> Enum.into(%{})
2791+
|> Jason.encode!()
2792+
end
2793+
end
2794+
2795+
# Usage:
2796+
nested = %{
2797+
"created_at" => DateTime.utc_now(),
2798+
"data" => "value"
2799+
}
2800+
json = MyApp.JsonHelpers.safe_json_encode(nested)
2801+
SQL.query!(Repo, "INSERT INTO events (metadata) VALUES (?)", [json])
2802+
```
2803+
2804+
---
2805+
26132806
### Limitations and Known Issues
26142807

26152808
#### freeze_replica/1 - NOT SUPPORTED

lib/ecto/adapters/libsql/connection.ex

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,19 @@ defmodule Ecto.Adapters.LibSql.Connection do
422422
defp column_default(value) when is_binary(value), do: " DEFAULT '#{escape_string(value)}'"
423423
defp column_default(value) when is_number(value), do: " DEFAULT #{value}"
424424
defp column_default({:fragment, expr}), do: " DEFAULT #{expr}"
425+
# Handle any other unexpected types (e.g., empty maps or third-party migrations)
426+
# Logs a warning to help with debugging while gracefully falling back to no DEFAULT clause
427+
defp column_default(unexpected) do
428+
require Logger
429+
430+
Logger.warning(
431+
"Unsupported default value type in migration: #{inspect(unexpected)} - " <>
432+
"no DEFAULT clause will be generated. This can occur with some generated migrations " <>
433+
"or other third-party integrations that provide unexpected default types."
434+
)
435+
436+
""
437+
end
425438

426439
defp table_options(table, columns) do
427440
# Validate mutually exclusive options (per libSQL specification)

lib/ecto_libsql/native.ex

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -684,7 +684,10 @@ defmodule EctoLibSql.Native do
684684

685685
@doc false
686686
defp do_query(conn_id, mode, syncx, statement, args_for_execution, query, state) do
687-
case query_args(conn_id, mode, syncx, statement, args_for_execution) do
687+
# Encode parameters to handle complex Elixir types (maps, etc.).
688+
encoded_args = encode_parameters(args_for_execution)
689+
690+
case query_args(conn_id, mode, syncx, statement, encoded_args) do
688691
%{
689692
"columns" => columns,
690693
"rows" => rows,
@@ -749,6 +752,9 @@ defmodule EctoLibSql.Native do
749752

750753
@doc false
751754
defp do_execute_with_trx(conn_id, trx_id, statement, args_for_execution, query, state) do
755+
# Encode parameters to handle complex Elixir types (maps, etc.).
756+
encoded_args = encode_parameters(args_for_execution)
757+
752758
# Detect the command type to route correctly.
753759
command = detect_command(statement)
754760

@@ -761,7 +767,7 @@ defmodule EctoLibSql.Native do
761767

762768
if should_query do
763769
# Use query_with_trx_args for SELECT or statements with RETURNING.
764-
case query_with_trx_args(trx_id, conn_id, statement, args_for_execution) do
770+
case query_with_trx_args(trx_id, conn_id, statement, encoded_args) do
765771
%{
766772
"columns" => columns,
767773
"rows" => rows,
@@ -790,7 +796,7 @@ defmodule EctoLibSql.Native do
790796
end
791797
else
792798
# Use execute_with_transaction for INSERT/UPDATE/DELETE without RETURNING
793-
case execute_with_transaction(trx_id, conn_id, statement, args_for_execution) do
799+
case execute_with_transaction(trx_id, conn_id, statement, encoded_args) do
794800
num_rows when is_integer(num_rows) ->
795801
result = %EctoLibSql.Result{
796802
command: command,
@@ -2167,4 +2173,22 @@ defmodule EctoLibSql.Native do
21672173
def freeze_replica(_state) do
21682174
{:error, :unsupported}
21692175
end
2176+
2177+
# Encode parameters to handle complex Elixir types before passing to NIF.
2178+
# The Rust NIF cannot serialize plain Elixir maps, so we convert them to JSON strings.
2179+
@doc false
2180+
defp encode_parameters(args) when is_list(args) do
2181+
Enum.map(args, &encode_param/1)
2182+
end
2183+
2184+
defp encode_parameters(args), do: args
2185+
2186+
@doc false
2187+
# Only encode plain maps (not structs) to JSON.
2188+
# Structs like DateTime, Decimal etc are handled in query.ex encode.
2189+
defp encode_param(value) when is_map(value) and not is_struct(value) do
2190+
Jason.encode!(value)
2191+
end
2192+
2193+
defp encode_param(value), do: value
21702194
end

lib/ecto_libsql/query.ex

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,63 @@ defmodule EctoLibSql.Query do
3838

3939
def describe(query, _opts), do: query
4040

41+
# Convert Elixir types to SQLite-compatible values before sending to NIF.
42+
# Rustler cannot automatically serialise complex Elixir structs like DateTime,
43+
# so we convert them to ISO8601 strings that SQLite can handle.
44+
#
45+
# Supported type conversions:
46+
# - DateTime/NaiveDateTime/Date/Time → ISO8601 strings
47+
# - Decimal → string representation
48+
# - true/false → 1/0 (SQLite uses integers for booleans)
49+
# - UUID binary → string representation (if needed)
50+
# - :null atom → nil (SQL NULL)
51+
def encode(_query, params, _opts) when is_list(params) do
52+
Enum.map(params, &encode_param/1)
53+
end
54+
4155
def encode(_query, params, _opts), do: params
4256

57+
# Temporal types
58+
defp encode_param(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
59+
defp encode_param(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt)
60+
defp encode_param(%Date{} = d), do: Date.to_iso8601(d)
61+
defp encode_param(%Time{} = t), do: Time.to_iso8601(t)
62+
63+
# Decimal
64+
defp encode_param(%Decimal{} = d), do: Decimal.to_string(d)
65+
66+
# Boolean conversion: SQLite uses 0/1 for boolean values
67+
# This is important for queries like: where u.active == ^true
68+
defp encode_param(true), do: 1
69+
defp encode_param(false), do: 0
70+
71+
# NULL atom conversion: :null → nil (SQL NULL)
72+
# This allows using :null in Ecto queries as an alternative to nil
73+
defp encode_param(:null), do: nil
74+
75+
# Map encoding: plain maps (not structs) are encoded to JSON
76+
# Maps must contain only JSON-serializable values (strings, numbers, booleans,
77+
# nil, lists, and nested maps). PIDs, functions, references, and other special
78+
# Elixir types are not serializable and will raise a descriptive error.
79+
defp encode_param(value) when is_map(value) and not is_struct(value) do
80+
case Jason.encode(value) do
81+
{:ok, json} ->
82+
json
83+
84+
{:error, %Jason.EncodeError{message: msg}} ->
85+
raise ArgumentError,
86+
message:
87+
"Cannot encode map parameter to JSON. Map contains non-JSON-serializable value. " <>
88+
"Maps can only contain strings, numbers, booleans, nil, lists, and nested maps. " <>
89+
"Reason: #{msg}. Map: #{inspect(value)}"
90+
end
91+
end
92+
93+
# Pass through all other values unchanged
94+
defp encode_param(value), do: value
95+
96+
# Pass through results from Native.ex unchanged.
97+
# Native.ex already handles proper normalisation of columns and rows.
4398
def decode(_query, result, _opts), do: result
4499
end
45100

0 commit comments

Comments
 (0)