diff --git a/.beads/.gitignore b/.beads/.gitignore index 4a7a77d..d27a1db 100644 --- a/.beads/.gitignore +++ b/.beads/.gitignore @@ -32,6 +32,11 @@ beads.left.meta.json beads.right.jsonl beads.right.meta.json +# Sync state (local-only, per-machine) +# These files are machine-specific and should not be shared across clones +.sync.lock +sync_base.jsonl + # NOTE: Do NOT add negation patterns (e.g., !issues.jsonl) here. # They would override fork protection in .git/info/exclude, allowing # contributors to accidentally commit upstream issue databases. diff --git a/.beads/last-touched b/.beads/last-touched index 18c1735..2510cec 100644 --- a/.beads/last-touched +++ b/.beads/last-touched @@ -1 +1 @@ -el-6r5 +el-1p2 diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 2b7b7c8..77f11fd 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -49,7 +49,11 @@ "Bash(git commit:*)", "Bash(git push)", "Bash(git --no-pager status)", - "Bash(cargo deny check:*)" + "Bash(cargo deny check:*)", + "Bash(gh pr diff:*)", + "Bash(gh pr checks:*)", + "Bash(gh run view:*)", + "Bash(gh pr checkout:*)" ], "deny": [], "ask": [] diff --git a/.gitignore b/.gitignore index 8081e34..de6a37a 100644 --- a/.gitignore +++ b/.gitignore @@ -41,5 +41,7 @@ z_ecto_libsql_test* # bv (beads viewer) local config and caches .bv/ + +# Implementation summaries and temporary docs TEST_AUDIT_REPORT.md TEST_COVERAGE_ISSUES_CREATED.md diff --git a/AGENTS.md b/AGENTS.md index f5b308a..122aec3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2610,6 +2610,199 @@ export TURSO_AUTH_TOKEN="eyJ..." - 🌍 **Global distribution** via Turso edge - 💪 **Offline capability** - works without network +### Type Encoding and Parameter Conversion + +EctoLibSql automatically converts Elixir types to SQLite-compatible formats. Understanding these conversions is important for correct database usage. + +#### Automatically Encoded Types + +The following types are automatically converted when passed as query parameters: + +##### Temporal Types + +```elixir +# DateTime → ISO8601 string +dt = DateTime.utc_now() +SQL.query!(Repo, "INSERT INTO events (created_at) VALUES (?)", [dt]) +# Stored as: "2026-01-13T03:45:23.123456Z" + +# NaiveDateTime → ISO8601 string +dt = NaiveDateTime.utc_now() +SQL.query!(Repo, "INSERT INTO events (created_at) VALUES (?)", [dt]) +# Stored as: "2026-01-13T03:45:23.123456" + +# Date → ISO8601 string +date = Date.utc_today() +SQL.query!(Repo, "INSERT INTO events (event_date) VALUES (?)", [date]) +# Stored as: "2026-01-13" + +# Time → ISO8601 string +time = Time.new!(14, 30, 45) +SQL.query!(Repo, "INSERT INTO events (event_time) VALUES (?)", [time]) +# Stored as: "14:30:45.000000" + +# Relative dates (compute absolute date first, then pass) +tomorrow = Date.add(Date.utc_today(), 1) # Becomes a Date struct +SQL.query!(Repo, "INSERT INTO events (event_date) VALUES (?)", [tomorrow]) + +# Third-party date types (Timex, etc.) - pre-convert to standard types +# ❌ NOT SUPPORTED: Timex.DateTime or custom structs +# ✅ DO THIS: Convert to native DateTime first +timex_dt = Timex.now() +native_dt = Timex.to_datetime(timex_dt) # Convert to DateTime +SQL.query!(Repo, "INSERT INTO events (created_at) VALUES (?)", [native_dt]) +``` + +##### Boolean Values + +```elixir +# true → 1, false → 0 +# SQLite uses integers for booleans +SQL.query!(Repo, "INSERT INTO users (active) VALUES (?)", [true]) +# Stored as: 1 + +SQL.query!(Repo, "INSERT INTO users (active) VALUES (?)", [false]) +# Stored as: 0 + +# Works with WHERE clauses +SQL.query!(Repo, "SELECT * FROM users WHERE active = ?", [true]) +# Matches rows where active = 1 +``` + +##### Decimal Values + +```elixir +# Decimal → string representation +decimal = Decimal.new("123.45") +SQL.query!(Repo, "INSERT INTO prices (amount) VALUES (?)", [decimal]) +# Stored as: "123.45" +``` + +##### NULL/nil Values + +```elixir +# nil → NULL +SQL.query!(Repo, "INSERT INTO users (bio) VALUES (?)", [nil]) +# Stored as SQL NULL + +# :null atom → nil → NULL (v0.8.3+) +# Alternative way to represent NULL +SQL.query!(Repo, "INSERT INTO users (bio) VALUES (?)", [:null]) +# Also stored as SQL NULL + +# Both work identically: +SQL.query!(Repo, "SELECT * FROM users WHERE bio IS NULL") # Matches both +``` + +##### UUID Values + +```elixir +# Ecto.UUID strings work directly (already binary strings) +uuid = Ecto.UUID.generate() +SQL.query!(Repo, "INSERT INTO users (id) VALUES (?)", [uuid]) +# Stored as: "550e8400-e29b-41d4-a716-446655440000" + +# Works with WHERE clauses +SQL.query!(Repo, "SELECT * FROM users WHERE id = ?", [uuid]) +``` + +#### Type Encoding Examples + +```elixir +defmodule MyApp.Examples do + def example_with_multiple_types do + import Ecto.Adapters.SQL + + now = DateTime.utc_now() + user_active = true + amount = Decimal.new("99.99") + + # All types are automatically encoded + query!(Repo, + "INSERT INTO transactions (created_at, active, amount) VALUES (?, ?, ?)", + [now, user_active, amount] + ) + end + + def example_with_ecto_queries do + import Ecto.Query + + from(u in User, + where: u.active == ^true, # Boolean encoded to 1 + where: u.created_at > ^DateTime.utc_now() # DateTime encoded to ISO8601 + ) + |> Repo.all() + end + + def example_with_null do + # Both are equivalent: + SQL.query!(Repo, "INSERT INTO users (bio) VALUES (?)", [nil]) + SQL.query!(Repo, "INSERT INTO users (bio) VALUES (?)", [:null]) + + # Query for NULL values + SQL.query!(Repo, "SELECT * FROM users WHERE bio IS NULL") + end +end +``` + +#### Limitations: Nested Structures with Temporal Types + +Nested structures (maps/lists) containing temporal types are **not automatically encoded**. Only top-level parameters are encoded. + +```elixir +# ❌ DOESN'T WORK - Nested DateTime not encoded +nested = %{ + "created_at" => DateTime.utc_now(), # ← Not auto-encoded + "data" => "value" +} +SQL.query!(Repo, "INSERT INTO events (metadata) VALUES (?)", [nested]) +# Error: DateTime struct cannot be serialized to JSON + +# ✅ WORKS - Pre-encode nested values +nested = %{ + "created_at" => DateTime.utc_now() |> DateTime.to_iso8601(), + "data" => "value" +} +json = Jason.encode!(nested) +SQL.query!(Repo, "INSERT INTO events (metadata) VALUES (?)", [json]) + +# ✅ WORKS - Encode before creating map +dt = DateTime.utc_now() |> DateTime.to_iso8601() +nested = %{"created_at" => dt, "data" => "value"} +json = Jason.encode!(nested) +SQL.query!(Repo, "INSERT INTO events (metadata) VALUES (?)", [json]) +``` + +**Workaround:** +When working with maps/lists containing temporal types, manually convert them to JSON strings before passing to queries: + +```elixir +defmodule MyApp.JsonHelpers do + def safe_json_encode(map) when is_map(map) do + map + |> Enum.map(fn + {k, %DateTime{} = v} -> {k, DateTime.to_iso8601(v)} + {k, %NaiveDateTime{} = v} -> {k, NaiveDateTime.to_iso8601(v)} + {k, %Date{} = v} -> {k, Date.to_iso8601(v)} + {k, %Decimal{} = v} -> {k, Decimal.to_string(v)} + {k, v} -> {k, v} + end) + |> Enum.into(%{}) + |> Jason.encode!() + end +end + +# Usage: +nested = %{ + "created_at" => DateTime.utc_now(), + "data" => "value" +} +json = MyApp.JsonHelpers.safe_json_encode(nested) +SQL.query!(Repo, "INSERT INTO events (metadata) VALUES (?)", [json]) +``` + +--- + ### Limitations and Known Issues #### freeze_replica/1 - NOT SUPPORTED diff --git a/lib/ecto/adapters/libsql/connection.ex b/lib/ecto/adapters/libsql/connection.ex index 9ff6b8a..68d94ef 100644 --- a/lib/ecto/adapters/libsql/connection.ex +++ b/lib/ecto/adapters/libsql/connection.ex @@ -422,6 +422,19 @@ 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}" + # 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 + require Logger + + Logger.warning( + "Unsupported default value type in migration: #{inspect(unexpected)} - " <> + "no DEFAULT clause will be generated. This can occur with some generated migrations " <> + "or other third-party integrations that provide unexpected default types." + ) + + "" + end defp table_options(table, columns) do # Validate mutually exclusive options (per libSQL specification) diff --git a/lib/ecto_libsql/native.ex b/lib/ecto_libsql/native.ex index 0c05de2..42ea9f2 100644 --- a/lib/ecto_libsql/native.ex +++ b/lib/ecto_libsql/native.ex @@ -684,7 +684,10 @@ defmodule EctoLibSql.Native do @doc false defp do_query(conn_id, mode, syncx, statement, args_for_execution, query, state) do - case query_args(conn_id, mode, syncx, statement, args_for_execution) do + # Encode parameters to handle complex Elixir types (maps, etc.). + encoded_args = encode_parameters(args_for_execution) + + case query_args(conn_id, mode, syncx, statement, encoded_args) do %{ "columns" => columns, "rows" => rows, @@ -749,6 +752,9 @@ defmodule EctoLibSql.Native do @doc false defp do_execute_with_trx(conn_id, trx_id, statement, args_for_execution, query, state) do + # Encode parameters to handle complex Elixir types (maps, etc.). + encoded_args = encode_parameters(args_for_execution) + # Detect the command type to route correctly. command = detect_command(statement) @@ -761,7 +767,7 @@ defmodule EctoLibSql.Native do if should_query do # Use query_with_trx_args for SELECT or statements with RETURNING. - case query_with_trx_args(trx_id, conn_id, statement, args_for_execution) do + case query_with_trx_args(trx_id, conn_id, statement, encoded_args) do %{ "columns" => columns, "rows" => rows, @@ -790,7 +796,7 @@ defmodule EctoLibSql.Native do end else # Use execute_with_transaction for INSERT/UPDATE/DELETE without RETURNING - case execute_with_transaction(trx_id, conn_id, statement, args_for_execution) do + case execute_with_transaction(trx_id, conn_id, statement, encoded_args) do num_rows when is_integer(num_rows) -> result = %EctoLibSql.Result{ command: command, @@ -2167,4 +2173,22 @@ defmodule EctoLibSql.Native do def freeze_replica(_state) do {:error, :unsupported} end + + # Encode parameters to handle complex Elixir types before passing to NIF. + # The Rust NIF cannot serialize plain Elixir maps, so we convert them to JSON strings. + @doc false + defp encode_parameters(args) when is_list(args) do + Enum.map(args, &encode_param/1) + end + + defp encode_parameters(args), do: args + + @doc false + # Only encode plain maps (not structs) to JSON. + # Structs like DateTime, Decimal etc are handled in query.ex encode. + defp encode_param(value) when is_map(value) and not is_struct(value) do + Jason.encode!(value) + end + + defp encode_param(value), do: value end diff --git a/lib/ecto_libsql/query.ex b/lib/ecto_libsql/query.ex index 02e6620..1105000 100644 --- a/lib/ecto_libsql/query.ex +++ b/lib/ecto_libsql/query.ex @@ -38,8 +38,63 @@ defmodule EctoLibSql.Query do def describe(query, _opts), do: query + # Convert Elixir types to SQLite-compatible values before sending to NIF. + # Rustler cannot automatically serialise complex Elixir structs like DateTime, + # so we convert them to ISO8601 strings that SQLite can handle. + # + # Supported type conversions: + # - DateTime/NaiveDateTime/Date/Time → ISO8601 strings + # - Decimal → string representation + # - true/false → 1/0 (SQLite uses integers for booleans) + # - UUID binary → string representation (if needed) + # - :null atom → nil (SQL NULL) + def encode(_query, params, _opts) when is_list(params) do + Enum.map(params, &encode_param/1) + end + def encode(_query, params, _opts), do: params + # Temporal types + defp encode_param(%DateTime{} = dt), do: DateTime.to_iso8601(dt) + defp encode_param(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt) + defp encode_param(%Date{} = d), do: Date.to_iso8601(d) + defp encode_param(%Time{} = t), do: Time.to_iso8601(t) + + # Decimal + defp encode_param(%Decimal{} = d), do: Decimal.to_string(d) + + # Boolean conversion: SQLite uses 0/1 for boolean values + # This is important for queries like: where u.active == ^true + defp encode_param(true), do: 1 + defp encode_param(false), do: 0 + + # NULL atom conversion: :null → nil (SQL NULL) + # This allows using :null in Ecto queries as an alternative to nil + defp encode_param(:null), do: nil + + # Map encoding: plain maps (not structs) are encoded to JSON + # Maps must contain only JSON-serializable values (strings, numbers, booleans, + # nil, lists, and nested maps). PIDs, functions, references, and other special + # Elixir types are not serializable and will raise a descriptive error. + defp encode_param(value) when is_map(value) and not is_struct(value) do + case Jason.encode(value) do + {:ok, json} -> + json + + {:error, %Jason.EncodeError{message: msg}} -> + raise ArgumentError, + message: + "Cannot encode map parameter to JSON. Map contains non-JSON-serializable value. " <> + "Maps can only contain strings, numbers, booleans, nil, lists, and nested maps. " <> + "Reason: #{msg}. Map: #{inspect(value)}" + end + end + + # Pass through all other values unchanged + defp encode_param(value), do: value + + # Pass through results from Native.ex unchanged. + # Native.ex already handles proper normalisation of columns and rows. def decode(_query, result, _opts), do: result end diff --git a/test/ecto_integration_test.exs b/test/ecto_integration_test.exs index 94fa585..35578ba 100644 --- a/test/ecto_integration_test.exs +++ b/test/ecto_integration_test.exs @@ -855,6 +855,89 @@ defmodule Ecto.Integration.EctoLibSqlTest do end end + describe "map parameter encoding" do + test "plain maps are encoded to JSON before passing to NIF" do + # Create a user + user = TestRepo.insert!(%User{name: "Alice", email: "alice@example.com"}) + + # Test with plain map as parameter (e.g., for metadata/JSON columns) + metadata = %{ + "tags" => ["elixir", "database"], + "priority" => 1, + "nested" => %{"key" => "value"} + } + + # Execute query with raw map to exercise automatic encoding in Query.encode_param + result = + Ecto.Adapters.SQL.query!( + TestRepo, + "INSERT INTO posts (title, body, user_id, inserted_at, updated_at) VALUES (?, ?, ?, datetime('now'), datetime('now'))", + ["Test Post", metadata, user.id] + ) + + # Verify the insert succeeded (automatic encoding worked) + assert result.num_rows == 1 + + # Verify the data was inserted correctly with JSON encoding + posts = TestRepo.all(Post) + assert length(posts) == 1 + post = hd(posts) + assert post.title == "Test Post" + + # Verify the body contains properly encoded JSON + assert {:ok, decoded} = Jason.decode(post.body) + assert decoded["tags"] == ["elixir", "database"] + assert decoded["priority"] == 1 + assert decoded["nested"]["key"] == "value" + end + + test "nested maps in parameters are encoded" do + # Test with nested map structure + complex_data = %{ + "level1" => %{ + "level2" => %{ + "level3" => "deep value" + } + }, + "array" => [1, 2, 3], + "mixed" => ["string", 42, true] + } + + # Pass raw map to verify adapter's automatic encoding + result = + Ecto.Adapters.SQL.query!( + TestRepo, + "SELECT ? as data", + [complex_data] + ) + + assert [[json_str]] = result.rows + assert {:ok, decoded} = Jason.decode(json_str) + assert decoded["level1"]["level2"]["level3"] == "deep value" + end + + test "structs are not encoded as maps" do + # DateTime structs should be automatically encoded (handled by query.ex encoding) + now = DateTime.utc_now() + + # Pass raw DateTime struct to verify automatic encoding + result = + Ecto.Adapters.SQL.query!( + TestRepo, + "SELECT ? as timestamp", + [now] + ) + + assert [[timestamp_str]] = result.rows + assert is_binary(timestamp_str) + # Verify it's a valid ISO8601 string + assert {:ok, decoded_dt, _offset} = DateTime.from_iso8601(timestamp_str) + assert decoded_dt.year == now.year + assert decoded_dt.month == now.month + assert decoded_dt.day == now.day + end + end + # Helper function to extract errors from changeset defp errors_on(changeset) do Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> diff --git a/test/ecto_libsql_query_encoding_test.exs b/test/ecto_libsql_query_encoding_test.exs new file mode 100644 index 0000000..9f2cd4d --- /dev/null +++ b/test/ecto_libsql_query_encoding_test.exs @@ -0,0 +1,199 @@ +defmodule EctoLibSql.QueryEncodingTest do + @moduledoc """ + Tests for query parameter encoding, especially temporal types and Decimal. + + These tests verify that Elixir types are properly converted to SQLite-compatible + values before being sent to the Rust NIF. This is critical because Rustler cannot + automatically serialise complex Elixir structs like DateTime, NaiveDateTime, etc. + """ + use ExUnit.Case, async: true + + alias EctoLibSql.Query + + describe "encode/3 parameter conversion" do + setup do + query = %Query{statement: "INSERT INTO test VALUES (?)"} + {:ok, query: query} + end + + test "converts DateTime to ISO8601 string", %{query: query} do + dt = ~U[2024-01-15 10:30:45.123456Z] + params = [dt] + + encoded = DBConnection.Query.encode(query, params, []) + + assert [iso_string] = encoded + assert is_binary(iso_string) + assert iso_string == "2024-01-15T10:30:45.123456Z" + end + + test "converts NaiveDateTime to ISO8601 string", %{query: query} do + ndt = ~N[2024-01-15 10:30:45.123456] + params = [ndt] + + encoded = DBConnection.Query.encode(query, params, []) + + assert [iso_string] = encoded + assert is_binary(iso_string) + assert iso_string == "2024-01-15T10:30:45.123456" + end + + test "converts Date to ISO8601 string", %{query: query} do + date = ~D[2024-01-15] + params = [date] + + encoded = DBConnection.Query.encode(query, params, []) + + assert [iso_string] = encoded + assert is_binary(iso_string) + assert iso_string == "2024-01-15" + end + + test "converts Time to ISO8601 string", %{query: query} do + time = ~T[10:30:45.123456] + params = [time] + + encoded = DBConnection.Query.encode(query, params, []) + + assert [iso_string] = encoded + assert is_binary(iso_string) + assert iso_string == "10:30:45.123456" + end + + test "converts Decimal to string", %{query: query} do + decimal = Decimal.new("123.456") + params = [decimal] + + encoded = DBConnection.Query.encode(query, params, []) + + assert [string] = encoded + assert is_binary(string) + assert string == "123.456" + end + + test "passes through nil values unchanged", %{query: query} do + params = [nil] + + encoded = DBConnection.Query.encode(query, params, []) + + assert [nil] = encoded + end + + test "passes through integer values unchanged", %{query: query} do + params = [42, -100, 0] + + encoded = DBConnection.Query.encode(query, params, []) + + assert [42, -100, 0] = encoded + end + + test "passes through float values unchanged", %{query: query} do + params = [3.14, -2.5, 1.0] + + encoded = DBConnection.Query.encode(query, params, []) + + assert [3.14, -2.5, 1.0] = encoded + end + + test "passes through string values unchanged", %{query: query} do + params = ["hello", "", "with 'quotes'"] + + encoded = DBConnection.Query.encode(query, params, []) + + assert ["hello", "", "with 'quotes'"] = encoded + end + + test "passes through binary values unchanged", %{query: query} do + binary = <<1, 2, 3, 255>> + params = [binary] + + encoded = DBConnection.Query.encode(query, params, []) + + assert [^binary] = encoded + end + + test "converts boolean values to integers (SQLite representation)", %{query: query} do + params = [true, false] + + encoded = DBConnection.Query.encode(query, params, []) + + assert [1, 0] = encoded + end + + test "handles mixed parameter types", %{query: query} do + params = [ + 42, + "hello", + ~D[2024-01-15], + ~T[10:30:45], + nil, + true, + Decimal.new("99.99"), + ~U[2024-01-15 10:30:45Z] + ] + + encoded = DBConnection.Query.encode(query, params, []) + + assert [ + 42, + "hello", + "2024-01-15", + "10:30:45", + nil, + 1, + "99.99", + "2024-01-15T10:30:45Z" + ] = encoded + end + end + + describe "decode/3 result pass-through" do + setup do + query = %Query{statement: "SELECT * FROM test"} + {:ok, query: query} + end + + test "passes through result unchanged", %{query: query} do + result = %EctoLibSql.Result{ + command: :select, + columns: ["id", "name"], + rows: [[1, "Alice"], [2, "Bob"]], + num_rows: 2 + } + + decoded = DBConnection.Query.decode(query, result, []) + + assert decoded == result + end + + test "preserves nil columns and rows for write operations", %{query: query} do + result = %EctoLibSql.Result{ + command: :insert, + columns: nil, + rows: nil, + num_rows: 1 + } + + decoded = DBConnection.Query.decode(query, result, []) + + assert decoded == result + assert decoded.columns == nil + assert decoded.rows == nil + end + + test "preserves empty lists for queries with no results", %{query: query} do + result = %EctoLibSql.Result{ + command: :select, + columns: [], + rows: [], + num_rows: 0 + } + + decoded = DBConnection.Query.decode(query, result, []) + + assert decoded == result + assert decoded.columns == [] + assert decoded.rows == [] + end + end +end diff --git a/test/ecto_migration_test.exs b/test/ecto_migration_test.exs index dc7afeb..eeb3496 100644 --- a/test/ecto_migration_test.exs +++ b/test/ecto_migration_test.exs @@ -877,4 +877,103 @@ defmodule Ecto.Adapters.LibSql.MigrationTest do end end end + + describe "column_default edge cases" do + test "handles nil default" do + table = %Table{name: :users, prefix: nil} + columns = [{:add, :name, :string, [default: nil]}] + + [sql] = Connection.execute_ddl({:create, table, columns}) + + # nil should result in no DEFAULT clause + refute sql =~ "DEFAULT" + end + + test "handles boolean defaults" do + table = %Table{name: :users, prefix: nil} + + columns = [ + {:add, :active, :boolean, [default: true]}, + {:add, :deleted, :boolean, [default: false]} + ] + + [sql] = Connection.execute_ddl({:create, table, columns}) + + # Booleans should map to 1/0 + assert sql =~ ~r/"active".*INTEGER DEFAULT 1/ + assert sql =~ ~r/"deleted".*INTEGER DEFAULT 0/ + end + + test "handles string defaults" do + table = %Table{name: :users, prefix: nil} + columns = [{:add, :status, :string, [default: "pending"]}] + + [sql] = Connection.execute_ddl({:create, table, columns}) + + assert sql =~ "DEFAULT 'pending'" + end + + test "handles numeric defaults" do + table = %Table{name: :users, prefix: nil} + + columns = [ + {:add, :count, :integer, [default: 0]}, + {:add, :rating, :float, [default: 5.0]} + ] + + [sql] = Connection.execute_ddl({:create, table, columns}) + + assert sql =~ ~r/"count".*INTEGER DEFAULT 0/ + assert sql =~ ~r/"rating".*REAL DEFAULT 5\.0/ + end + + test "handles fragment defaults" do + table = %Table{name: :users, prefix: nil} + columns = [{:add, :created_at, :string, [default: {:fragment, "datetime('now')"}]}] + + [sql] = Connection.execute_ddl({:create, table, columns}) + + assert sql =~ "DEFAULT datetime('now')" + end + + 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. + 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/ + end + + test "handles unexpected types gracefully (list)" do + # Lists are another unexpected type that might appear. + 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/ + end + + test "handles unexpected types gracefully (atom)" do + # Atoms other than booleans might appear as defaults. + table = %Table{name: :users, prefix: nil} + columns = [{:add, :status, :string, [default: :unknown]}] + + # Should not raise FunctionClauseError. + [sql] = Connection.execute_ddl({:create, table, columns}) + + # Unexpected atom should be treated as no default. + assert sql =~ ~r/"status".*TEXT/ + refute sql =~ ~r/"status".*DEFAULT/ + end + end end diff --git a/test/type_encoding_implementation_test.exs b/test/type_encoding_implementation_test.exs new file mode 100644 index 0000000..e6c2179 --- /dev/null +++ b/test/type_encoding_implementation_test.exs @@ -0,0 +1,1005 @@ +defmodule EctoLibSql.TypeEncodingImplementationTest do + use ExUnit.Case, async: false + + # Tests for the type encoding implementation: + # - Boolean encoding (true/false → 1/0) + # - UUID encoding (binary → string if needed) + # - :null atom encoding (:null → nil) + + alias Ecto.Adapters.SQL + + defmodule TestRepo do + use Ecto.Repo, + otp_app: :ecto_libsql, + adapter: Ecto.Adapters.LibSql + end + + defmodule User do + use Ecto.Schema + + schema "users" do + field(:name, :string) + field(:email, :string) + field(:active, :boolean, default: true) + field(:uuid, :string) + + timestamps() + end + end + + @test_db "z_type_encoding_implementation.db" + + setup_all do + {:ok, _pid} = TestRepo.start_link(database: @test_db) + + SQL.query!(TestRepo, """ + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT, + email TEXT, + active INTEGER DEFAULT 1, + uuid TEXT, + inserted_at DATETIME, + updated_at DATETIME + ) + """) + + on_exit(fn -> + EctoLibSql.TestHelpers.cleanup_db_files(@test_db) + end) + + :ok + end + + describe "boolean encoding implementation" do + test "boolean true encoded as 1 in query parameters" do + SQL.query!(TestRepo, "DELETE FROM users") + + # Insert with boolean true + result = + SQL.query!(TestRepo, "INSERT INTO users (name, active) VALUES (?, ?)", ["Alice", true]) + + assert result.num_rows == 1 + + # Verify true was encoded as 1 + result = SQL.query!(TestRepo, "SELECT active FROM users WHERE name = ?", ["Alice"]) + assert [[1]] = result.rows + + # Query with boolean should match + result = SQL.query!(TestRepo, "SELECT COUNT(*) FROM users WHERE active = ?", [true]) + assert [[1]] = result.rows + end + + test "boolean false encoded as 0 in query parameters" do + SQL.query!(TestRepo, "DELETE FROM users") + + # Insert with boolean false + result = + SQL.query!(TestRepo, "INSERT INTO users (name, active) VALUES (?, ?)", ["Bob", false]) + + assert result.num_rows == 1 + + # Verify false was encoded as 0 + result = SQL.query!(TestRepo, "SELECT active FROM users WHERE name = ?", ["Bob"]) + assert [[0]] = result.rows + + # Query with boolean should match + result = SQL.query!(TestRepo, "SELECT COUNT(*) FROM users WHERE active = ?", [false]) + assert [[1]] = result.rows + end + + test "boolean true in WHERE clause" do + SQL.query!(TestRepo, "DELETE FROM users") + SQL.query!(TestRepo, "INSERT INTO users (name, active) VALUES (?, ?)", ["Alice", 1]) + SQL.query!(TestRepo, "INSERT INTO users (name, active) VALUES (?, ?)", ["Bob", 0]) + + # Query with boolean parameter true (should match 1) + result = SQL.query!(TestRepo, "SELECT COUNT(*) FROM users WHERE active = ?", [true]) + assert [[count]] = result.rows + # Exact count: one row with active=1 matches boolean true + assert count == 1 + end + + test "boolean false in WHERE clause" do + SQL.query!(TestRepo, "DELETE FROM users") + SQL.query!(TestRepo, "INSERT INTO users (name, active) VALUES (?, ?)", ["Alice", 1]) + SQL.query!(TestRepo, "INSERT INTO users (name, active) VALUES (?, ?)", ["Bob", 0]) + + # Query with boolean parameter false (should match 0) + result = SQL.query!(TestRepo, "SELECT COUNT(*) FROM users WHERE active = ?", [false]) + assert [[count]] = result.rows + # Exact count: one row with active=0 matches boolean false + assert count == 1 + end + + test "Ecto schema with boolean field uses encoding" do + SQL.query!(TestRepo, "DELETE FROM users") + + # Create changeset with boolean field + user = %User{name: "Charlie", email: "charlie@example.com", active: true} + + {:ok, inserted} = + user + |> Ecto.Changeset.change() + |> TestRepo.insert() + + assert inserted.active == true + + # Verify it was stored as 1 + result = SQL.query!(TestRepo, "SELECT active FROM users WHERE id = ?", [inserted.id]) + assert [[1]] = result.rows + end + + test "Querying boolean via Ecto.Query" do + SQL.query!(TestRepo, "DELETE FROM users") + + # Insert test data + TestRepo.insert!(%User{name: "Dave", email: "dave@example.com", active: true}) + TestRepo.insert!(%User{name: "Eve", email: "eve@example.com", active: false}) + + # Query with boolean parameter + import Ecto.Query + + active_users = TestRepo.all(from(u in User, where: u.active == ^true)) + + assert length(active_users) == 1 + assert hd(active_users).name == "Dave" + end + end + + describe "UUID encoding implementation" do + test "UUID string in query parameters" do + SQL.query!(TestRepo, "DELETE FROM users") + + uuid = Ecto.UUID.generate() + + # Insert with UUID + result = + SQL.query!(TestRepo, "INSERT INTO users (name, uuid) VALUES (?, ?)", ["Alice", uuid]) + + assert result.num_rows == 1 + + # Verify UUID was stored correctly + result = SQL.query!(TestRepo, "SELECT uuid FROM users WHERE uuid = ?", [uuid]) + assert [[^uuid]] = result.rows + end + + test "UUID in WHERE clause" do + SQL.query!(TestRepo, "DELETE FROM users") + + uuid1 = Ecto.UUID.generate() + uuid2 = Ecto.UUID.generate() + + SQL.query!(TestRepo, "INSERT INTO users (name, uuid) VALUES (?, ?)", ["Alice", uuid1]) + SQL.query!(TestRepo, "INSERT INTO users (name, uuid) VALUES (?, ?)", ["Bob", uuid2]) + + # Query with UUID parameter + result = SQL.query!(TestRepo, "SELECT COUNT(*) FROM users WHERE uuid = ?", [uuid1]) + assert [[1]] = result.rows + end + + test "Ecto schema with UUID field" do + SQL.query!(TestRepo, "DELETE FROM users") + + uuid = Ecto.UUID.generate() + + user = %User{name: "Charlie", email: "charlie@example.com", uuid: uuid} + + {:ok, inserted} = + user + |> Ecto.Changeset.change() + |> TestRepo.insert() + + assert inserted.uuid == uuid + + # Verify it was stored correctly + result = SQL.query!(TestRepo, "SELECT uuid FROM users WHERE id = ?", [inserted.id]) + assert [[^uuid]] = result.rows + end + + test "Querying UUID via Ecto.Query" do + SQL.query!(TestRepo, "DELETE FROM users") + + uuid = Ecto.UUID.generate() + + # Insert test data + TestRepo.insert!(%User{name: "Dave", email: "dave@example.com", uuid: uuid}) + + # Query with UUID parameter + import Ecto.Query + + users = TestRepo.all(from(u in User, where: u.uuid == ^uuid)) + + assert length(users) == 1 + assert hd(users).uuid == uuid + end + end + + describe ":null atom encoding implementation" do + test ":null atom encoded as nil for NULL values" do + SQL.query!(TestRepo, "DELETE FROM users") + + # Insert with :null atom (should be converted to nil → NULL) + result = + SQL.query!(TestRepo, "INSERT INTO users (name, uuid) VALUES (?, ?)", ["Alice", :null]) + + assert result.num_rows == 1 + + # Verify NULL was stored + result = + SQL.query!(TestRepo, "SELECT uuid FROM users WHERE name = ? AND uuid IS NULL", ["Alice"]) + + assert [[nil]] = result.rows + end + + test "nil inserted value can be queried with IS NULL" do + SQL.query!(TestRepo, "DELETE FROM users") + + # Insert NULL value + SQL.query!(TestRepo, "INSERT INTO users (name, uuid) VALUES (?, ?)", ["Alice", nil]) + + # Query with IS NULL should find it + result = + SQL.query!(TestRepo, "SELECT COUNT(*) FROM users WHERE uuid IS NULL AND name = ?", [ + "Alice" + ]) + + assert [[1]] = result.rows + end + + test ":null in complex queries" do + SQL.query!(TestRepo, "DELETE FROM users") + + SQL.query!(TestRepo, "INSERT INTO users (name, uuid) VALUES (?, ?)", ["Alice", :null]) + + SQL.query!(TestRepo, "INSERT INTO users (name, uuid) VALUES (?, ?)", [ + "Bob", + Ecto.UUID.generate() + ]) + + # Count non-NULL values + result = SQL.query!(TestRepo, "SELECT COUNT(*) FROM users WHERE uuid IS NOT NULL") + assert [[count]] = result.rows + assert count == 1 + + # Count NULL values + result = SQL.query!(TestRepo, "SELECT COUNT(*) FROM users WHERE uuid IS NULL") + assert [[count]] = result.rows + assert count == 1 + end + end + + describe "combined type encoding" do + test "multiple encoded types in single query" do + SQL.query!(TestRepo, "DELETE FROM users") + + uuid = Ecto.UUID.generate() + + result = + SQL.query!( + TestRepo, + "INSERT INTO users (name, email, active, uuid) VALUES (?, ?, ?, ?)", + ["Alice", "alice@example.com", true, uuid] + ) + + assert result.num_rows == 1 + + # Verify all values + result = + SQL.query!(TestRepo, "SELECT active, uuid FROM users WHERE name = ? AND email = ?", [ + "Alice", + "alice@example.com" + ]) + + assert [[1, ^uuid]] = result.rows + end + + test "boolean, UUID, and :null in batch operations" do + SQL.query!(TestRepo, "DELETE FROM users") + + uuid1 = Ecto.UUID.generate() + uuid2 = Ecto.UUID.generate() + + statements = [ + {"INSERT INTO users (name, active, uuid) VALUES (?, ?, ?)", ["Alice", true, uuid1]}, + {"INSERT INTO users (name, active, uuid) VALUES (?, ?, ?)", ["Bob", false, uuid2]}, + {"INSERT INTO users (name, active, uuid) VALUES (?, ?, ?)", ["Charlie", true, :null]} + ] + + _results = + Enum.map(statements, fn {sql, params} -> + SQL.query!(TestRepo, sql, params) + end) + + # Verify all were inserted + result = SQL.query!(TestRepo, "SELECT COUNT(*) FROM users") + assert [[count]] = result.rows + assert count == 3 + end + + test "Ecto query with multiple encoded types" do + SQL.query!(TestRepo, "DELETE FROM users") + + uuid = Ecto.UUID.generate() + + # Insert test data + TestRepo.insert!(%User{name: "Dave", email: "dave@example.com", active: true, uuid: uuid}) + TestRepo.insert!(%User{name: "Eve", email: "eve@example.com", active: false, uuid: nil}) + + # Query with multiple encoded types + import Ecto.Query + + users = TestRepo.all(from(u in User, where: u.active == ^true and u.uuid == ^uuid)) + + assert length(users) == 1 + assert Enum.all?(users, fn u -> u.active == true and u.uuid == uuid end) + end + end + + describe "edge cases and error conditions" do + test "boolean in comparison queries" do + SQL.query!(TestRepo, "DELETE FROM users") + + SQL.query!(TestRepo, "INSERT INTO users (name, active) VALUES (?, ?)", ["Active", true]) + SQL.query!(TestRepo, "INSERT INTO users (name, active) VALUES (?, ?)", ["Inactive", false]) + + # Count active + result = SQL.query!(TestRepo, "SELECT COUNT(*) FROM users WHERE active = ?", [true]) + assert [[count]] = result.rows + assert count == 1 + + # Count inactive + result = SQL.query!(TestRepo, "SELECT COUNT(*) FROM users WHERE active = ?", [false]) + assert [[count]] = result.rows + assert count == 1 + + # Count with NOT + result = SQL.query!(TestRepo, "SELECT COUNT(*) FROM users WHERE active != ?", [true]) + assert [[count]] = result.rows + assert count == 1 + end + + test "UUID in aggregation queries" do + SQL.query!(TestRepo, "DELETE FROM users") + + uuid = Ecto.UUID.generate() + + SQL.query!(TestRepo, "INSERT INTO users (name, uuid) VALUES (?, ?)", ["A", uuid]) + SQL.query!(TestRepo, "INSERT INTO users (name, uuid) VALUES (?, ?)", ["B", uuid]) + + SQL.query!(TestRepo, "INSERT INTO users (name, uuid) VALUES (?, ?)", [ + "C", + Ecto.UUID.generate() + ]) + + # Count by UUID + result = + SQL.query!(TestRepo, "SELECT COUNT(*) FROM users WHERE uuid = ?", [uuid]) + + assert [[count]] = result.rows + assert count == 2 + end + + test ":null with IS NULL and NOT NULL operators" do + SQL.query!(TestRepo, "DELETE FROM users") + + SQL.query!(TestRepo, "INSERT INTO users (name, uuid) VALUES (?, ?)", ["A", :null]) + + SQL.query!(TestRepo, "INSERT INTO users (name, uuid) VALUES (?, ?)", [ + "B", + Ecto.UUID.generate() + ]) + + # IS NULL should work + result = SQL.query!(TestRepo, "SELECT COUNT(*) FROM users WHERE uuid IS NULL") + assert [[count]] = result.rows + assert count == 1 + + # NOT NULL should work + result = SQL.query!(TestRepo, "SELECT COUNT(*) FROM users WHERE uuid IS NOT NULL") + assert [[count]] = result.rows + assert count == 1 + end + end + + describe "string encoding edge cases" do + setup do + SQL.query!(TestRepo, """ + CREATE TABLE IF NOT EXISTS test_types ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + text_col TEXT, + blob_col BLOB, + int_col INTEGER, + real_col REAL + ) + """) + + on_exit(fn -> + SQL.query!(TestRepo, "DROP TABLE IF EXISTS test_types") + end) + + :ok + end + + test "empty string encoding" do + result = SQL.query!(TestRepo, "INSERT INTO test_types (text_col) VALUES (?)", [""]) + assert result.num_rows == 1 + + result = SQL.query!(TestRepo, "SELECT text_col FROM test_types WHERE text_col = ?", [""]) + assert [[""]] = result.rows + end + + test "special characters in string - quotes and escapes" do + special = "Test: 'single' \"double\" and \\ backslash" + + result = SQL.query!(TestRepo, "INSERT INTO test_types (text_col) VALUES (?)", [special]) + assert result.num_rows == 1 + + result = SQL.query!(TestRepo, "SELECT text_col FROM test_types ORDER BY id DESC LIMIT 1") + [[stored]] = result.rows + assert stored == special + end + + test "unicode characters in string" do + unicode = "Unicode: 你好 مرحبا 🎉 🚀" + + result = SQL.query!(TestRepo, "INSERT INTO test_types (text_col) VALUES (?)", [unicode]) + assert result.num_rows == 1 + + result = SQL.query!(TestRepo, "SELECT text_col FROM test_types ORDER BY id DESC LIMIT 1") + [[stored]] = result.rows + assert stored == unicode + end + + test "newlines and whitespace in string" do + whitespace = "Line 1\nLine 2\tTabbed\r\nWindows line" + + result = SQL.query!(TestRepo, "INSERT INTO test_types (text_col) VALUES (?)", [whitespace]) + assert result.num_rows == 1 + + result = SQL.query!(TestRepo, "SELECT text_col FROM test_types ORDER BY id DESC LIMIT 1") + [[stored]] = result.rows + assert stored == whitespace + end + end + + describe "binary encoding edge cases" do + setup do + SQL.query!(TestRepo, """ + CREATE TABLE IF NOT EXISTS test_types ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + blob_col BLOB + ) + """) + + on_exit(fn -> + SQL.query!(TestRepo, "DROP TABLE IF EXISTS test_types") + end) + + :ok + end + + test "binary data with null bytes preserved" do + binary = <<0, 1, 2, 255, 254, 253>> + + result = SQL.query!(TestRepo, "INSERT INTO test_types (blob_col) VALUES (?)", [binary]) + assert result.num_rows == 1 + + result = SQL.query!(TestRepo, "SELECT blob_col FROM test_types ORDER BY id DESC LIMIT 1") + assert [[^binary]] = result.rows + end + + test "large binary data" do + # Test with 1MB binary to meaningfully test large data handling + binary = :crypto.strong_rand_bytes(1024 * 1024) + + result = SQL.query!(TestRepo, "INSERT INTO test_types (blob_col) VALUES (?)", [binary]) + assert result.num_rows == 1 + + result = SQL.query!(TestRepo, "SELECT blob_col FROM test_types ORDER BY id DESC LIMIT 1") + # Use exact pin matching to verify data integrity, not just size + assert [[^binary]] = result.rows + end + + test "binary with mixed bytes" do + binary = :crypto.strong_rand_bytes(256) + + result = SQL.query!(TestRepo, "INSERT INTO test_types (blob_col) VALUES (?)", [binary]) + assert result.num_rows == 1 + + result = SQL.query!(TestRepo, "SELECT blob_col FROM test_types ORDER BY id DESC LIMIT 1") + assert [[^binary]] = result.rows + end + end + + describe "numeric encoding edge cases" do + setup do + SQL.query!(TestRepo, """ + CREATE TABLE IF NOT EXISTS test_types ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + int_col INTEGER, + real_col REAL, + text_col TEXT + ) + """) + + on_exit(fn -> + SQL.query!(TestRepo, "DROP TABLE IF EXISTS test_types") + end) + + :ok + end + + test "very large integer" do + large_int = 9_223_372_036_854_775_807 + + result = SQL.query!(TestRepo, "INSERT INTO test_types (int_col) VALUES (?)", [large_int]) + assert result.num_rows == 1 + + result = SQL.query!(TestRepo, "SELECT int_col FROM test_types ORDER BY id DESC LIMIT 1") + assert [[^large_int]] = result.rows + end + + test "negative large integer" do + large_negative = -9_223_372_036_854_775_808 + + result = + SQL.query!(TestRepo, "INSERT INTO test_types (int_col) VALUES (?)", [large_negative]) + + assert result.num_rows == 1 + + result = SQL.query!(TestRepo, "SELECT int_col FROM test_types ORDER BY id DESC LIMIT 1") + assert [[^large_negative]] = result.rows + end + + test "zero values" do + SQL.query!(TestRepo, "INSERT INTO test_types (int_col) VALUES (?)", [0]) + SQL.query!(TestRepo, "INSERT INTO test_types (real_col) VALUES (?)", [0.0]) + + result = + SQL.query!(TestRepo, "SELECT int_col FROM test_types WHERE int_col = ?", [0]) + + assert [[0]] = result.rows + + result = + SQL.query!(TestRepo, "SELECT real_col FROM test_types WHERE real_col = ?", [0.0]) + + [[stored_real]] = result.rows + # Float comparison: allow for +0.0 vs -0.0 representation + assert stored_real == +0.0 or stored_real == -0.0 + end + + test "Decimal parameter encoding" do + decimal = Decimal.new("123.45") + + result = SQL.query!(TestRepo, "INSERT INTO test_types (text_col) VALUES (?)", [decimal]) + assert result.num_rows == 1 + + decimal_str = Decimal.to_string(decimal) + + result = + SQL.query!(TestRepo, "SELECT text_col FROM test_types WHERE text_col = ?", [decimal_str]) + + assert result.rows != [] + [[stored]] = result.rows + assert stored == decimal_str + end + + test "Negative Decimal" do + decimal = Decimal.new("-456.789") + + result = SQL.query!(TestRepo, "INSERT INTO test_types (text_col) VALUES (?)", [decimal]) + assert result.num_rows == 1 + + decimal_str = Decimal.to_string(decimal) + + result = + SQL.query!(TestRepo, "SELECT text_col FROM test_types WHERE text_col = ?", [decimal_str]) + + assert result.rows != [] + [[stored]] = result.rows + assert stored == decimal_str + end + end + + describe "temporal type encoding" do + setup do + SQL.query!(TestRepo, """ + CREATE TABLE IF NOT EXISTS test_types ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + text_col TEXT + ) + """) + + on_exit(fn -> + SQL.query!(TestRepo, "DROP TABLE IF EXISTS test_types") + end) + + :ok + end + + test "DateTime parameter encoding" do + dt = DateTime.utc_now() + expected_iso8601 = DateTime.to_iso8601(dt) + + result = SQL.query!(TestRepo, "INSERT INTO test_types (text_col) VALUES (?)", [dt]) + assert result.num_rows == 1 + + result = SQL.query!(TestRepo, "SELECT text_col FROM test_types ORDER BY id DESC LIMIT 1") + assert [[stored]] = result.rows + # Verify exact ISO8601 format, not just LIKE pattern + assert stored == expected_iso8601 + end + + test "NaiveDateTime parameter encoding" do + dt = NaiveDateTime.utc_now() + expected_iso8601 = NaiveDateTime.to_iso8601(dt) + + result = SQL.query!(TestRepo, "INSERT INTO test_types (text_col) VALUES (?)", [dt]) + assert result.num_rows == 1 + + result = SQL.query!(TestRepo, "SELECT text_col FROM test_types ORDER BY id DESC LIMIT 1") + assert [[stored]] = result.rows + # Verify exact ISO8601 format, not just LIKE pattern + assert stored == expected_iso8601 + end + + test "Date parameter encoding" do + date = Date.utc_today() + expected_iso8601 = Date.to_iso8601(date) + + result = SQL.query!(TestRepo, "INSERT INTO test_types (text_col) VALUES (?)", [date]) + assert result.num_rows == 1 + + result = SQL.query!(TestRepo, "SELECT text_col FROM test_types ORDER BY id DESC LIMIT 1") + assert [[stored]] = result.rows + # Verify exact ISO8601 format (YYYY-MM-DD), not just LIKE pattern + assert stored == expected_iso8601 + end + + test "Time parameter encoding" do + time = Time.new!(14, 30, 45) + expected_iso8601 = Time.to_iso8601(time) + + result = SQL.query!(TestRepo, "INSERT INTO test_types (text_col) VALUES (?)", [time]) + assert result.num_rows == 1 + + result = SQL.query!(TestRepo, "SELECT text_col FROM test_types ORDER BY id DESC LIMIT 1") + assert [[stored]] = result.rows + # Verify exact ISO8601 format (HH:MM:SS.ffffff), not just LIKE pattern + assert stored == expected_iso8601 + end + end + + describe "float/real field encoding" do + setup do + SQL.query!(TestRepo, """ + CREATE TABLE IF NOT EXISTS test_types ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + real_col REAL + ) + """) + + on_exit(fn -> + SQL.query!(TestRepo, "DROP TABLE IF EXISTS test_types") + end) + + :ok + end + + test "positive float parameter encoding" do + float_val = 3.14159 + + result = SQL.query!(TestRepo, "INSERT INTO test_types (real_col) VALUES (?)", [float_val]) + assert result.num_rows == 1 + + result = SQL.query!(TestRepo, "SELECT real_col FROM test_types ORDER BY id DESC LIMIT 1") + assert [[stored]] = result.rows + # Floating point comparison allows small precision differences + assert abs(stored - float_val) < 0.00001 + end + + test "negative float parameter encoding" do + float_val = -2.71828 + + result = SQL.query!(TestRepo, "INSERT INTO test_types (real_col) VALUES (?)", [float_val]) + assert result.num_rows == 1 + + result = SQL.query!(TestRepo, "SELECT real_col FROM test_types ORDER BY id DESC LIMIT 1") + assert [[stored]] = result.rows + assert abs(stored - float_val) < 0.00001 + end + + test "very small float" do + float_val = 0.0000001 + + result = SQL.query!(TestRepo, "INSERT INTO test_types (real_col) VALUES (?)", [float_val]) + assert result.num_rows == 1 + + result = SQL.query!(TestRepo, "SELECT real_col FROM test_types ORDER BY id DESC LIMIT 1") + assert [[stored]] = result.rows + assert is_float(stored) + end + + test "very large float" do + float_val = 12_345_678_900.0 + + result = SQL.query!(TestRepo, "INSERT INTO test_types (real_col) VALUES (?)", [float_val]) + assert result.num_rows == 1 + + result = SQL.query!(TestRepo, "SELECT real_col FROM test_types ORDER BY id DESC LIMIT 1") + assert [[stored]] = result.rows + assert is_float(stored) + assert stored > 1.0e9 + end + + test "float zero" do + result = SQL.query!(TestRepo, "INSERT INTO test_types (real_col) VALUES (?)", [0.0]) + assert result.num_rows == 1 + + result = SQL.query!(TestRepo, "SELECT real_col FROM test_types WHERE real_col = ?", [0.0]) + assert [[stored]] = result.rows + assert stored == 0.0 + end + + test "float in WHERE clause comparison" do + SQL.query!(TestRepo, "INSERT INTO test_types (real_col) VALUES (?)", [1.5]) + SQL.query!(TestRepo, "INSERT INTO test_types (real_col) VALUES (?)", [2.7]) + SQL.query!(TestRepo, "INSERT INTO test_types (real_col) VALUES (?)", [0.8]) + + result = + SQL.query!(TestRepo, "SELECT COUNT(*) FROM test_types WHERE real_col > ?", [1.0]) + + assert [[count]] = result.rows + assert count == 2 + end + + test "float in aggregate functions" do + SQL.query!(TestRepo, "INSERT INTO test_types (real_col) VALUES (?)", [1.5]) + SQL.query!(TestRepo, "INSERT INTO test_types (real_col) VALUES (?)", [2.5]) + SQL.query!(TestRepo, "INSERT INTO test_types (real_col) VALUES (?)", [3.5]) + + # SUM aggregate + result = SQL.query!(TestRepo, "SELECT SUM(real_col) FROM test_types") + assert [[sum]] = result.rows + assert abs(sum - 7.5) < 0.001 + + # AVG aggregate + result = SQL.query!(TestRepo, "SELECT AVG(real_col) FROM test_types") + assert [[avg]] = result.rows + assert abs(avg - 2.5) < 0.001 + + # COUNT still works + result = SQL.query!(TestRepo, "SELECT COUNT(*) FROM test_types") + assert [[3]] = result.rows + end + end + + describe "NULL/nil edge cases" do + setup do + SQL.query!(TestRepo, """ + CREATE TABLE IF NOT EXISTS test_types ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + int_col INTEGER, + real_col REAL, + text_col TEXT + ) + """) + + on_exit(fn -> + SQL.query!(TestRepo, "DROP TABLE IF EXISTS test_types") + end) + + :ok + end + + test "NULL in SUM aggregate returns NULL" do + SQL.query!(TestRepo, "INSERT INTO test_types (int_col) VALUES (?)", [10]) + SQL.query!(TestRepo, "INSERT INTO test_types (int_col) VALUES (?)", [nil]) + SQL.query!(TestRepo, "INSERT INTO test_types (int_col) VALUES (?)", [20]) + + result = SQL.query!(TestRepo, "SELECT SUM(int_col) FROM test_types") + assert [[sum]] = result.rows + # SUM ignores NULLs, so should be 30 + assert sum == 30 + end + + test "NULL in AVG aggregate is ignored" do + SQL.query!(TestRepo, "INSERT INTO test_types (int_col) VALUES (?)", [10]) + SQL.query!(TestRepo, "INSERT INTO test_types (int_col) VALUES (?)", [nil]) + SQL.query!(TestRepo, "INSERT INTO test_types (int_col) VALUES (?)", [20]) + + result = SQL.query!(TestRepo, "SELECT AVG(int_col) FROM test_types") + assert [[avg]] = result.rows + # AVG ignores NULLs, so should be 15 (30/2) + assert avg == 15 + end + + test "COUNT with NULL values" do + SQL.query!(TestRepo, "INSERT INTO test_types (int_col) VALUES (?)", [10]) + SQL.query!(TestRepo, "INSERT INTO test_types (int_col) VALUES (?)", [nil]) + SQL.query!(TestRepo, "INSERT INTO test_types (int_col) VALUES (?)", [20]) + + # COUNT(*) counts all rows + result = SQL.query!(TestRepo, "SELECT COUNT(*) FROM test_types") + assert [[3]] = result.rows + + # COUNT(column) ignores NULLs + result = SQL.query!(TestRepo, "SELECT COUNT(int_col) FROM test_types") + assert [[2]] = result.rows + end + + test "COALESCE with NULL values" do + SQL.query!(TestRepo, "INSERT INTO test_types (int_col, text_col) VALUES (?, ?)", [ + nil, + "default" + ]) + + SQL.query!(TestRepo, "INSERT INTO test_types (int_col, text_col) VALUES (?, ?)", [ + 42, + "value" + ]) + + result = SQL.query!(TestRepo, "SELECT COALESCE(int_col, 0) FROM test_types ORDER BY id") + assert [[0], [42]] = result.rows + end + + test "NULL in compound WHERE clause" do + SQL.query!(TestRepo, "INSERT INTO test_types (int_col, text_col) VALUES (?, ?)", [10, "a"]) + SQL.query!(TestRepo, "INSERT INTO test_types (int_col, text_col) VALUES (?, ?)", [nil, "b"]) + SQL.query!(TestRepo, "INSERT INTO test_types (int_col, text_col) VALUES (?, ?)", [20, nil]) + + # Find rows where int_col is NULL + result = SQL.query!(TestRepo, "SELECT COUNT(*) FROM test_types WHERE int_col IS NULL") + assert [[1]] = result.rows + + # Find rows where text_col is NOT NULL + result = SQL.query!(TestRepo, "SELECT COUNT(*) FROM test_types WHERE text_col IS NOT NULL") + assert [[2]] = result.rows + + # Compound condition with NULL + result = + SQL.query!( + TestRepo, + "SELECT COUNT(*) FROM test_types WHERE int_col IS NOT NULL AND text_col IS NOT NULL" + ) + + assert [[1]] = result.rows + end + + test "NULL handling in CASE expressions" do + SQL.query!(TestRepo, "INSERT INTO test_types (int_col) VALUES (?)", [10]) + SQL.query!(TestRepo, "INSERT INTO test_types (int_col) VALUES (?)", [nil]) + + result = + SQL.query!( + TestRepo, + "SELECT CASE WHEN int_col IS NULL THEN 'empty' ELSE 'has value' END FROM test_types ORDER BY id" + ) + + assert [["has value"], ["empty"]] = result.rows + end + + test "NULL in ORDER BY" do + SQL.query!(TestRepo, "INSERT INTO test_types (int_col, text_col) VALUES (?, ?)", [30, "c"]) + SQL.query!(TestRepo, "INSERT INTO test_types (int_col, text_col) VALUES (?, ?)", [nil, "a"]) + SQL.query!(TestRepo, "INSERT INTO test_types (int_col, text_col) VALUES (?, ?)", [10, "b"]) + + # ORDER BY with NULLs (NULLs sort first in SQLite) + result = SQL.query!(TestRepo, "SELECT int_col FROM test_types ORDER BY int_col") + assert [[nil], [10], [30]] = result.rows + end + + test "NULL with DISTINCT" do + SQL.query!(TestRepo, "INSERT INTO test_types (int_col) VALUES (?)", [10]) + SQL.query!(TestRepo, "INSERT INTO test_types (int_col) VALUES (?)", [nil]) + SQL.query!(TestRepo, "INSERT INTO test_types (int_col) VALUES (?)", [10]) + SQL.query!(TestRepo, "INSERT INTO test_types (int_col) VALUES (?)", [nil]) + + result = SQL.query!(TestRepo, "SELECT DISTINCT int_col FROM test_types ORDER BY int_col") + assert [[nil], [10]] = result.rows + end + end + + describe "type coercion edge cases" do + setup do + SQL.query!(TestRepo, """ + CREATE TABLE IF NOT EXISTS test_types ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + int_col INTEGER, + text_col TEXT, + real_col REAL + ) + """) + + on_exit(fn -> + SQL.query!(TestRepo, "DROP TABLE IF EXISTS test_types") + end) + + :ok + end + + test "string that looks like number in text column" do + result = SQL.query!(TestRepo, "INSERT INTO test_types (text_col) VALUES (?)", ["12345"]) + assert result.num_rows == 1 + + result = SQL.query!(TestRepo, "SELECT text_col FROM test_types") + assert [["12345"]] = result.rows + end + + test "empty string vs NULL distinction" do + SQL.query!(TestRepo, "INSERT INTO test_types (text_col) VALUES (?)", [""]) + SQL.query!(TestRepo, "INSERT INTO test_types (text_col) VALUES (?)", [nil]) + + # Empty string is not NULL + result = SQL.query!(TestRepo, "SELECT COUNT(*) FROM test_types WHERE text_col = ''") + assert [[1]] = result.rows + + # NULL is NULL + result = SQL.query!(TestRepo, "SELECT COUNT(*) FROM test_types WHERE text_col IS NULL") + assert [[1]] = result.rows + end + + test "zero vs NULL in numeric columns" do + SQL.query!(TestRepo, "INSERT INTO test_types (int_col) VALUES (?)", [0]) + SQL.query!(TestRepo, "INSERT INTO test_types (int_col) VALUES (?)", [nil]) + + # Zero is not NULL + result = SQL.query!(TestRepo, "SELECT COUNT(*) FROM test_types WHERE int_col = ?", [0]) + assert [[1]] = result.rows + + # NULL is NULL + result = SQL.query!(TestRepo, "SELECT COUNT(*) FROM test_types WHERE int_col IS NULL") + assert [[1]] = result.rows + end + + test "type affinity: integer stored in text column" do + # SQLite has type affinity but is lenient + result = SQL.query!(TestRepo, "INSERT INTO test_types (text_col) VALUES (?)", [123]) + assert result.num_rows == 1 + + result = SQL.query!(TestRepo, "SELECT text_col FROM test_types") + [[stored]] = result.rows + # SQLite stores it, but type depends on what was passed + assert stored == 123 or stored == "123" + end + + test "float precision in arithmetic" do + SQL.query!(TestRepo, "INSERT INTO test_types (real_col) VALUES (?)", [0.1]) + SQL.query!(TestRepo, "INSERT INTO test_types (real_col) VALUES (?)", [0.2]) + + # Floating point arithmetic can have precision issues + result = + SQL.query!( + TestRepo, + "SELECT real_col FROM test_types WHERE real_col + ? > ?", + [0.1, 0.35] + ) + + # Due to floating point precision, this might return 0 or 1 rows + # depending on exact arithmetic + assert length(result.rows) in [0, 1] + end + + test "division by zero handling" do + SQL.query!(TestRepo, "INSERT INTO test_types (int_col) VALUES (?)", [10]) + + result = SQL.query!(TestRepo, "SELECT int_col / 0 FROM test_types") + # SQLite returns NULL for division by zero + assert [[nil]] = result.rows + end + + test "string comparison vs numeric comparison" do + SQL.query!(TestRepo, "INSERT INTO test_types (text_col) VALUES (?)", ["100"]) + SQL.query!(TestRepo, "INSERT INTO test_types (text_col) VALUES (?)", ["20"]) + + # String comparison: "100" < "50" (true), "20" < "50" (true) → 2 matches + result = + SQL.query!(TestRepo, "SELECT COUNT(*) FROM test_types WHERE text_col < ?", ["50"]) + + assert [[count]] = result.rows + # Lexicographic: "100" < "50" (true), "20" < "50" (true) → 2 matches + assert count == 2 + end + end +end