Skip to content

Commit 721b53a

Browse files
committed
fix: Improve JSON functions and tests
1 parent cd90594 commit 721b53a

3 files changed

Lines changed: 119 additions & 36 deletions

File tree

AGENTS.md

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1359,9 +1359,14 @@ fragment = JSON.arrow_fragment("settings", "theme")
13591359
fragment = JSON.arrow_fragment("settings", "theme", :double_arrow)
13601360
# Returns: "settings ->> 'theme'"
13611361

1362-
# Use in Ecto queries
1362+
# Use in Ecto queries - Option 1: Using the helper function
1363+
arrow_sql = JSON.arrow_fragment("data", "active", :double_arrow)
13631364
from u in User,
1364-
where: fragment(JSON.arrow_fragment("data", "active", :double_arrow), "=", true)
1365+
where: fragment(arrow_sql <> " = ?", true)
1366+
1367+
# Option 2: Direct inline SQL (simpler approach)
1368+
from u in User,
1369+
where: fragment("data ->> 'active' = ?", true)
13651370
```
13661371

13671372
#### Ecto Integration
@@ -1388,9 +1393,14 @@ from u in User,
13881393
where: fragment("json_extract(?, ?) = ?", u.settings, "$.theme", "dark"),
13891394
select: u.name
13901395

1391-
# Or using the helpers
1396+
# Or using the helpers - Option 1: Arrow fragment helper
1397+
arrow_sql = JSON.arrow_fragment("settings", "theme", :double_arrow)
13921398
from u in User,
1393-
where: fragment(JSON.arrow_fragment("settings", "theme", :double_arrow), "=", "dark")
1399+
where: fragment(arrow_sql <> " = ?", "dark")
1400+
1401+
# Option 2: Direct inline SQL (simpler for static fields)
1402+
from u in User,
1403+
where: fragment("settings ->> 'theme' = ?", "dark")
13941404

13951405
# Update JSON fields
13961406
from u in User,
@@ -1435,10 +1445,15 @@ Create, update, and manipulate JSON structures:
14351445
{:ok, json} = JSON.remove(state, ~s({"a":1,"b":2,"c":3}), ["$.a", "$.c"])
14361446
# Returns: {:ok, "{\"b\":2}"}
14371447

1438-
# Apply a JSON patch
1448+
# Apply a JSON Merge Patch (RFC 7396)
1449+
# Keys in patch are object keys, not JSON paths
14391450
{:ok, json} = JSON.patch(state, ~s({"a":1,"b":2}), ~s({"a":10,"c":3}))
14401451
# Returns: {:ok, "{\"a\":10,\"b\":2,\"c\":3}"}
14411452

1453+
# Remove a key by patching with null
1454+
{:ok, json} = JSON.patch(state, ~s({"a":1,"b":2,"c":3}), ~s({"b":null}))
1455+
# Returns: {:ok, "{\"a\":1,\"c\":3}"}
1456+
14421457
# Get all keys from a JSON object (SQLite 3.9.0+)
14431458
{:ok, keys} = JSON.keys(state, ~s({"name":"Alice","age":30}))
14441459
# Returns: {:ok, "[\"age\",\"name\"]"} (sorted)
@@ -1535,35 +1550,47 @@ settings = ~s({"theme":"dark","notifications":true,"language":"es"})
15351550
# Returns: {:ok, %{valid: true, type: "object", depth: 2}}
15361551
```
15371552

1538-
#### Comparison: Set vs Replace vs Insert
1553+
#### Comparison: Set vs Replace vs Insert vs Patch
15391554

1540-
The three modification functions have different behaviors:
1555+
The modification functions have different behaviors:
15411556

15421557
```elixir
15431558
json = ~s({"a":1,"b":2})
15441559

1545-
# SET: Creates or replaces any path
1560+
# SET: Creates or replaces any path (uses JSON paths like "$.key")
15461561
{:ok, result} = JSON.set(state, json, "$.c", 3)
15471562
# Result: {"a":1,"b":2,"c":3}
15481563

15491564
{:ok, result} = JSON.set(state, json, "$.a", 100)
15501565
# Result: {"a":100,"b":2}
15511566

1552-
# REPLACE: Only updates existing paths, ignores new paths
1567+
# REPLACE: Only updates existing paths, ignores new paths (uses JSON paths)
15531568
{:ok, result} = JSON.replace(state, json, "$.c", 3)
15541569
# Result: {"a":1,"b":2} (c not added)
15551570

15561571
{:ok, result} = JSON.replace(state, json, "$.a", 100)
15571572
# Result: {"a":100,"b":2} (existing path updated)
15581573

1559-
# INSERT: Adds new values without replacing existing ones
1574+
# INSERT: Adds new values without replacing existing ones (uses JSON paths)
15601575
{:ok, result} = JSON.insert(state, json, "$.c", 3)
15611576
# Result: {"a":1,"b":2,"c":3}
15621577

15631578
{:ok, result} = JSON.insert(state, json, "$.a", 100)
15641579
# Result: {"a":1,"b":2} (existing path unchanged)
1580+
1581+
# PATCH: Applies JSON Merge Patch (RFC 7396) - keys are object keys, not paths
1582+
{:ok, result} = JSON.patch(state, json, ~s({"a":10,"c":3}))
1583+
# Result: {"a":10,"b":2,"c":3}
1584+
1585+
# Use null to remove keys
1586+
{:ok, result} = JSON.patch(state, json, ~s({"b":null}))
1587+
# Result: {"a":1}
15651588
```
15661589

1590+
**When to use each function:**
1591+
- **SET/REPLACE/INSERT**: For path-based updates using JSON paths (e.g., "$.user.name")
1592+
- **PATCH**: For bulk top-level key updates (implements RFC 7396 JSON Merge Patch)
1593+
15671594
#### Performance Notes
15681595

15691596
- JSONB format reduces storage by 5-10% vs text JSON

lib/ecto_libsql/json.ex

Lines changed: 67 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,13 @@ defmodule EctoLibSql.JSON do
8989
9090
## Returns
9191
92-
- `{:ok, value}` - Extracted value, or nil if path doesn't exist
93-
- `{:error, reason}` on failure
92+
The return type depends on the extracted JSON value:
93+
- `{:ok, string}` - For JSON text values (e.g., "dark")
94+
- `{:ok, integer}` - For JSON integer values (e.g., 30)
95+
- `{:ok, float}` - For JSON real/float values (e.g., 99.99)
96+
- `{:ok, nil}` - For JSON null values or non-existent paths
97+
- `{:ok, json_text}` - For JSON objects/arrays, returned as JSON text string
98+
- `{:error, reason}` - On query failure
9499
95100
## Examples
96101
@@ -100,14 +105,22 @@ defmodule EctoLibSql.JSON do
100105
{:ok, age} = EctoLibSql.JSON.extract(state, ~s({"user":{"age":30}}), "$.user.age")
101106
# Returns: {:ok, 30}
102107
108+
{:ok, items} = EctoLibSql.JSON.extract(state, ~s({"items":[1,2,3]}), "$.items")
109+
# Returns: {:ok, "[1,2,3]"} (JSON array as text)
110+
111+
{:ok, nil} = EctoLibSql.JSON.extract(state, ~s({"a":1}), "$.missing")
112+
# Returns: {:ok, nil} (path doesn't exist)
113+
103114
## Notes
104115
105-
- Returns JSON types as-is (objects and arrays returned as JSON text)
106-
- Use json_extract to preserve JSON structure, or ->> operator to convert to SQL types
107-
- Works with both text JSON and JSONB binary format
116+
- JSON objects and arrays are returned as JSON text strings
117+
- Use `-> operator in SQL queries to preserve JSON structure, or `->> operator to convert to SQL types
118+
- Works with both text JSON and JSONB binary format (format conversion is automatic)
119+
- For nested JSON structures, you can chain extractions or use JSON paths like "$.user.address.city"
108120
109121
"""
110-
@spec extract(State.t(), String.t() | binary, String.t()) :: {:ok, term()} | {:error, term()}
122+
@spec extract(State.t(), String.t() | binary, String.t()) ::
123+
{:ok, String.t() | integer() | float() | nil} | {:error, term()}
111124
def extract(%State{} = state, json, path) when is_binary(json) and is_binary(path) do
112125
Native.query_args(
113126
state.conn_id,
@@ -509,8 +522,19 @@ defmodule EctoLibSql.JSON do
509522
end
510523

511524
# Private helpers: Result handling patterns
512-
# All Native.query_args/5 calls return a response map that needs handling.
525+
# All Native.query_args/5 calls return a response map from Rust that needs handling.
513526
# These helpers reduce duplication and provide consistent error handling.
527+
#
528+
# Expected response shape from Native.query_args/5:
529+
# %{
530+
# "columns" => [list of column names],
531+
# "rows" => [list of result rows, where each row is a list of values],
532+
# "num_rows" => total number of rows
533+
# }
534+
#
535+
# Error responses are:
536+
# %{"error" => reason_string} (from Rust)
537+
# {:error, reason} (from Elixir error handling)
514538

515539
@doc false
516540
defp handle_single_result(response) do
@@ -702,7 +726,8 @@ defmodule EctoLibSql.JSON do
702726
# Returns: {:ok, "{\"a\":1,\"c\":3}"}
703727
704728
{:ok, json} = EctoLibSql.JSON.remove(state, ~s([1,2,3,4,5]), ["$[0]", "$[2]"])
705-
# Returns: {:ok, "[2,4,5]"}
729+
# Returns: {:ok, "[2,3,5]"}
730+
# Note: Paths are removed in order; after removing $[0], the original $[2] is now at $[1]
706731
707732
"""
708733
@spec remove(State.t(), String.t() | binary, String.t() | [String.t()]) ::
@@ -845,26 +870,52 @@ defmodule EctoLibSql.JSON do
845870
end
846871

847872
@doc """
848-
Apply a JSON patch to modify JSON.
873+
Apply a JSON Merge Patch to modify JSON (RFC 7396).
849874
850-
The patch is itself a JSON object where keys are paths and values are the updates to apply.
851-
Effectively performs multiple set/replace operations in one call.
875+
Implements RFC 7396 JSON Merge Patch semantics. The patch is a JSON object where:
876+
- **Top-level keys** are object keys in the target, not JSON paths
877+
- **Values** replace the corresponding object values in the target
878+
- **Nested objects** are merged recursively
879+
- **null values** remove the key from the target object
880+
881+
To update nested structures, the patch object must reflect the nesting level.
852882
853883
## Parameters
854884
855885
- state: Connection state
856-
- json: JSON text or JSONB binary data
857-
- patch: JSON patch object (keys are paths, values are replacements)
886+
- json: JSON text or JSONB binary data (must be an object)
887+
- patch: JSON object with merge patch semantics (keys are object keys, not paths)
858888
859889
## Returns
860890
861-
- `{:ok, modified_json}` - JSON after applying patch
891+
- `{:ok, modified_json}` - JSON after applying merge patch
862892
- `{:error, reason}` on failure
863893
864894
## Examples
865895
866-
{:ok, json} = EctoLibSql.JSON.patch(state, ~s({"a":1,"b":2}), ~s({"$.a":10,"$.c":3}))
867-
# Returns: {:ok, "{\"a\":10,\"b\":2,\"c\":3}"}
896+
# Top-level key replacement
897+
{:ok, json} = EctoLibSql.JSON.patch(state, ~s({"a":1,"b":2}), ~s({"a":10}))
898+
# Returns: {:ok, "{\"a\":10,\"b\":2}"}
899+
900+
# Add new top-level key
901+
{:ok, json} = EctoLibSql.JSON.patch(state, ~s({"a":1,"b":2}), ~s({"c":3}))
902+
# Returns: {:ok, "{\"a\":1,\"b\":2,\"c\":3}"}
903+
904+
# Remove key with null
905+
{:ok, json} = EctoLibSql.JSON.patch(state, ~s({"a":1,"b":2,"c":3}), ~s({"b":null}))
906+
# Returns: {:ok, "{\"a\":1,\"c\":3}"}
907+
908+
# Nested object merge (replaces entire nested object)
909+
{:ok, json} = EctoLibSql.JSON.patch(state, ~s({"user":{"name":"Alice","age":30}}), ~s({"user":{"age":31}}))
910+
# Returns: {:ok, "{\"user\":{\"age\":31}}"} (replaces entire user object, not a deep merge)
911+
912+
## Notes
913+
914+
- This implements RFC 7396 JSON Merge Patch, NOT RFC 6902 JSON Patch
915+
- Object keys in the patch are literal keys, not JSON paths (use "a" not "$.a")
916+
- For nested structures, the patch replaces the entire value at that key (not a deep recursive merge)
917+
- To perform deep merges or path-based updates, use `json_set/4` or `json_replace/4` instead
918+
- Works with both text JSON and JSONB binary format
868919
869920
"""
870921
@spec patch(State.t(), String.t() | binary, String.t() | binary) ::

test/json_helpers_test.exs

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,9 @@ defmodule EctoLibSql.JSONHelpersTest do
6666
{:ok, result} = JSON.extract(state, json, "$.items")
6767
# Arrays are returned as JSON text
6868
assert is_binary(result)
69-
assert String.contains?(result, ["1", "2", "3"])
69+
# Parse the JSON array to verify exact content
70+
{:ok, decoded} = Jason.decode(result)
71+
assert decoded == [1, 2, 3]
7072
end
7173
end
7274

@@ -180,9 +182,11 @@ defmodule EctoLibSql.JSONHelpersTest do
180182

181183
test "arrays with strings containing special chars", %{state: state} do
182184
{:ok, json} = JSON.array(state, ["hello \"world\"", "tab\there"])
183-
# Should be escaped properly - look for the escaped version
184-
assert String.contains?(json, "hello")
185-
assert String.contains?(json, "world")
185+
# Parse and validate the structure with proper escape sequences
186+
{:ok, decoded} = Jason.decode(json)
187+
assert length(decoded) == 2
188+
assert Enum.at(decoded, 0) == "hello \"world\""
189+
assert Enum.at(decoded, 1) == "tab\there"
186190
end
187191
end
188192

@@ -430,10 +434,10 @@ defmodule EctoLibSql.JSONHelpersTest do
430434
values = Enum.to_list(1..100)
431435
{:ok, json} = JSON.array(state, values)
432436
assert is_binary(json)
433-
# Should contain all values
434-
Enum.each(values, fn v ->
435-
assert String.contains?(json, Integer.to_string(v))
436-
end)
437+
# Parse and verify exact array content
438+
{:ok, decoded} = Jason.decode(json)
439+
assert decoded == values
440+
assert length(decoded) == 100
437441
end
438442

439443
test "handles JSON with reserved characters", %{state: state} do
@@ -552,8 +556,9 @@ defmodule EctoLibSql.JSONHelpersTest do
552556

553557
test "removes single index from array", %{state: state} do
554558
{:ok, result} = JSON.remove(state, ~s([1,2,3,4,5]), "$[2]")
555-
# Should remove the 3 (index 2)
556-
assert String.contains?(result, "[1,2,4,5]")
559+
# Should remove the 3 (index 2), resulting in [1,2,4,5]
560+
{:ok, decoded} = Jason.decode(result)
561+
assert decoded == [1, 2, 4, 5]
557562
end
558563

559564
test "removes multiple paths from object", %{state: state} do

0 commit comments

Comments
 (0)