Skip to content

Commit e27bca3

Browse files
nshkrdotcomnshkrdotcom
authored andcommitted
add strict mode analyis
1 parent f4d6365 commit e27bca3

4 files changed

Lines changed: 556 additions & 0 deletions

File tree

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
# JSV/Exdantic Compatibility Analysis
2+
3+
## Executive Summary
4+
5+
The reported incompatibility between JSV and Exdantic stems from a **key type handling mismatch** in strict mode validation, not from the libraries' core validation approaches. JSV provides raw JSON data with binary (string) keys, while Exdantic's strict mode validation expects the exact keys that were processed during field validation.
6+
7+
## Root Cause Analysis
8+
9+
### The Problem
10+
11+
When using JSV with Exdantic through the `defcast` integration pattern:
12+
13+
1. **JSV receives JSON data** with string keys: `%{"name" => "alice", "email" => "foo@bar.com"}`
14+
2. **Exdantic processes fields** by looking for both atom and string keys, but stores results with atom keys
15+
3. **Strict mode validation fails** because it compares the original input keys (strings) against the processed keys (atoms)
16+
17+
### Key Code Analysis
18+
19+
#### Exdantic Field Processing (`lib/exdantic/validator.ex:84`)
20+
```elixir
21+
value = Map.get(data, name) || Map.get(data, Atom.to_string(name))
22+
```
23+
- Exdantic tries both atom keys and string keys when extracting field values
24+
- This allows flexible input but creates the mismatch with strict validation
25+
26+
#### Strict Mode Validation (`lib/exdantic/validator.ex:106-113`)
27+
```elixir
28+
defp validate_strict(%{strict: true}, validated, original, path) do
29+
case Map.keys(original) -- Map.keys(validated) do
30+
[] -> :ok
31+
extra -> {:error, Error.new(path, :additional_properties, "unknown fields: #{inspect(extra)}")}
32+
end
33+
end
34+
```
35+
- Compares original input keys directly against processed output keys
36+
- Fails when input has string keys but output has atom keys
37+
38+
### Test Results
39+
40+
| Input Keys | Exdantic Result | JSV Integration Result |
41+
|------------|-----------------|----------------------|
42+
| String keys (`%{"name" => "alice"}`) | ❌ Strict mode error | ❌ Same error |
43+
| Atom keys (`%{name: "alice"}`) | ✅ Success | ❌ Model validator error |
44+
| Mixed keys (`%{"name" => "alice", email: "foo"}`) | ❌ Partial strict error | ❌ Same error |
45+
46+
## Technical Investigation Details
47+
48+
### Reproduction Steps
49+
50+
1. **Created integration test** (`test_jsv_integration.exs`):
51+
- Defines UserSchema with JSV.Schema integration
52+
- Tests with string keys (typical JSON)
53+
- Tests with atom keys
54+
55+
2. **Isolated Exdantic behavior** (`test_key_handling.exs`):
56+
- Confirmed strict mode fails with string keys
57+
- Confirmed success with atom keys
58+
- Identified the exact validation step that fails
59+
60+
### Error Analysis
61+
62+
#### String Keys Error (Primary Issue)
63+
```
64+
unknown fields: ["email", "name"]
65+
```
66+
- **Cause**: Strict validation sees string keys in input, atom keys in output
67+
- **Location**: `validate_strict/4` function
68+
- **Impact**: Blocks all JSON data in strict mode
69+
70+
#### Model Validator Error (Secondary Issue)
71+
```
72+
key :age not found in: %{active: true, name: "alice", email: "foo@bar.com"}
73+
```
74+
- **Cause**: Custom validator assumes atom key access
75+
- **Location**: User-defined `validate_adult_email/1` function
76+
- **Impact**: Breaks even when strict validation passes
77+
78+
## Potential Solutions
79+
80+
### 1. Fix Strict Mode Key Handling (Recommended)
81+
82+
**Problem**: Strict validation doesn't account for key type normalization.
83+
84+
**Solution**: Modify `validate_strict/4` to handle key type mismatches:
85+
86+
```elixir
87+
defp validate_strict(%{strict: true}, validated, original, path) do
88+
# Normalize both sets of keys for comparison
89+
original_keys = Map.keys(original) |> normalize_keys()
90+
validated_keys = Map.keys(validated) |> normalize_keys()
91+
92+
case original_keys -- validated_keys do
93+
[] -> :ok
94+
extra -> {:error, Error.new(path, :additional_properties, "unknown fields: #{inspect(extra)}")}
95+
end
96+
end
97+
98+
defp normalize_keys(keys) do
99+
keys
100+
|> Enum.map(fn
101+
key when is_atom(key) -> key
102+
key when is_binary(key) -> String.to_existing_atom(key)
103+
key -> key
104+
end)
105+
|> Enum.sort()
106+
end
107+
```
108+
109+
**Pros**: Fixes the root cause, maintains strict validation semantics
110+
**Cons**: Requires modification to Exdantic core
111+
112+
### 2. Key Normalization in JSV Integration
113+
114+
**Problem**: JSV passes string keys but user code expects atom keys.
115+
116+
**Solution**: Add key transformation in the cast function:
117+
118+
```elixir
119+
defcast from_jsv(data) do
120+
# Convert string keys to atom keys before validation
121+
normalized_data =
122+
data
123+
|> Enum.map(fn {k, v} ->
124+
key = if is_binary(k), do: String.to_existing_atom(k), else: k
125+
{key, v}
126+
end)
127+
|> Map.new()
128+
129+
validate(normalized_data)
130+
end
131+
```
132+
133+
**Pros**: Quick fix, doesn't require Exdantic changes
134+
**Cons**: Requires manual implementation in each schema, potential atom exhaustion
135+
136+
### 3. Disable Strict Mode for JSV Integration
137+
138+
**Problem**: Strict mode is incompatible with string key inputs.
139+
140+
**Solution**: Use non-strict schemas for JSV integration:
141+
142+
```elixir
143+
schema "User account information" do
144+
# ... fields ...
145+
146+
config do
147+
title("User Schema")
148+
strict(false) # Allow string keys
149+
end
150+
end
151+
```
152+
153+
**Pros**: Immediate fix, no code changes needed
154+
**Cons**: Loses strict validation benefits
155+
156+
### 4. Dual Validation Approach
157+
158+
**Problem**: Need both JSV compliance and Exdantic strict validation.
159+
160+
**Solution**: Separate validation stages:
161+
162+
```elixir
163+
def from_jsv(data) do
164+
# Stage 1: Validate with relaxed Exdantic (no strict)
165+
with {:ok, basic_valid} <- BasicUserSchema.validate(data),
166+
# Stage 2: Convert to atom keys
167+
atom_data <- normalize_keys(basic_valid),
168+
# Stage 3: Validate with strict Exdantic
169+
{:ok, final} <- StrictUserSchema.validate(atom_data) do
170+
{:ok, final}
171+
end
172+
end
173+
```
174+
175+
**Pros**: Maintains both validations, clear separation of concerns
176+
**Cons**: More complex, potential performance impact
177+
178+
## Recommendations
179+
180+
### Immediate Actions
181+
182+
1. **For JSV Integration**: Use Solution #2 (key normalization) or #3 (disable strict mode) as a quick fix
183+
2. **Document the limitation**: Add clear documentation about key type requirements
184+
185+
### Long-term Solutions
186+
187+
1. **Enhance Exdantic**: Implement Solution #1 to fix strict mode key handling
188+
2. **Improve JSV Integration**: Add built-in key normalization utilities
189+
3. **Add Integration Tests**: Create comprehensive test suite for JSV/Exdantic compatibility
190+
191+
### Code Quality Improvements
192+
193+
The current JSV/Exdantic integration attempts show that both libraries are well-designed individually but need better integration patterns. The issue is not fundamental incompatibility but rather a need for better key handling in edge cases.
194+
195+
## Conclusion
196+
197+
The JSV/Exdantic incompatibility is **solvable** and stems from strict mode validation logic rather than fundamental design differences. The libraries can work together effectively with proper key handling. The recommended approach is to fix the strict validation logic in Exdantic while providing temporary workarounds for immediate use cases.
198+
199+
This analysis demonstrates that LLM-generated code performed reasonably well in identifying a real integration challenge, though it didn't account for the key type handling nuances in strict mode validation.
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
Mix.install([
2+
{:exdantic, "~> 0.0.2"},
3+
{:jason, "~> 1.4"}
4+
])
5+
6+
defmodule StrictSchema do
7+
use Exdantic, define_struct: true
8+
9+
schema "Strict validation example" do
10+
field :name, :string do
11+
required()
12+
min_length(2)
13+
end
14+
15+
field :email, :string do
16+
required()
17+
format(~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/)
18+
end
19+
20+
field :age, :integer do
21+
optional()
22+
gt(0)
23+
lt(120)
24+
end
25+
26+
config do
27+
title("Strict User Schema")
28+
strict(true) # Rejects unknown fields
29+
end
30+
end
31+
end
32+
33+
defmodule NonStrictSchema do
34+
use Exdantic, define_struct: true
35+
36+
schema "Non-strict validation example" do
37+
field :name, :string do
38+
required()
39+
min_length(2)
40+
end
41+
42+
field :email, :string do
43+
required()
44+
format(~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/)
45+
end
46+
47+
field :age, :integer do
48+
optional()
49+
gt(0)
50+
lt(120)
51+
end
52+
53+
config do
54+
title("Non-Strict User Schema")
55+
strict(false) # Allows unknown fields
56+
end
57+
end
58+
end
59+
60+
IO.puts("=== EXDANTIC STRICT vs NON-STRICT BEHAVIOR ANALYSIS ===\n")
61+
62+
# Test cases covering various scenarios
63+
test_cases = [
64+
# Basic valid data with atom keys
65+
{"Valid atom keys", %{name: "Alice", email: "alice@example.com", age: 30}},
66+
67+
# Basic valid data with string keys
68+
{"Valid string keys", %{"name" => "Bob", "email" => "bob@example.com", "age" => 25}},
69+
70+
# Mixed keys
71+
{"Mixed keys", %{"name" => "Charlie", "email" => "charlie@example.com", "age" => 35}},
72+
73+
# Extra fields with atom keys
74+
{"Extra fields (atom keys)", %{name: "David", email: "david@example.com", age: 40, role: "admin", active: true}},
75+
76+
# Extra fields with string keys (typical JSON)
77+
{"Extra fields (string keys)", %{"name" => "Eve", "email" => "eve@example.com", "age" => 28, "role" => "user", "created_at" => "2024-01-01"}},
78+
79+
# Missing required field
80+
{"Missing required field", %{name: "Frank"}},
81+
82+
# Invalid field value
83+
{"Invalid email", %{name: "Grace", email: "invalid-email", age: 32}},
84+
85+
# Nested extra data (common in APIs)
86+
{"Nested extra data", %{
87+
"name" => "Henry",
88+
"email" => "henry@example.com",
89+
"age" => 45,
90+
"profile" => %{"bio" => "Software engineer", "skills" => ["elixir", "rust"]},
91+
"metadata" => %{"source" => "api", "version" => "v2"}
92+
}}
93+
]
94+
95+
test_schema = fn schema_name, schema_module, test_name, data ->
96+
IO.puts("#{schema_name} - #{test_name}:")
97+
IO.puts(" Input: #{inspect(data, limit: :infinity)}")
98+
99+
case schema_module.validate(data) do
100+
{:ok, result} ->
101+
IO.puts(" ✅ SUCCESS: #{inspect(result, limit: :infinity)}")
102+
{:error, errors} ->
103+
IO.puts(" ❌ ERROR: #{inspect(errors, limit: :infinity)}")
104+
end
105+
IO.puts("")
106+
end
107+
108+
# Run all test cases for both schemas
109+
Enum.each(test_cases, fn {test_name, data} ->
110+
test_schema.("STRICT", StrictSchema, test_name, data)
111+
test_schema.("NON-STRICT", NonStrictSchema, test_name, data)
112+
IO.puts("---")
113+
end)
114+
115+
# Real-world JSON API example
116+
IO.puts("=== REAL-WORLD JSON API SCENARIO ===\n")
117+
118+
json_from_api = """
119+
{
120+
"name": "API User",
121+
"email": "user@api.com",
122+
"age": 29,
123+
"id": "12345",
124+
"created_at": "2024-01-15T10:30:00Z",
125+
"updated_at": "2024-01-15T10:30:00Z",
126+
"metadata": {
127+
"source": "registration",
128+
"ip_address": "192.168.1.1"
129+
}
130+
}
131+
"""
132+
133+
IO.puts("Typical JSON from API:")
134+
IO.puts(json_from_api)
135+
136+
parsed_json = Jason.decode!(json_from_api)
137+
IO.puts("Parsed JSON: #{inspect(parsed_json, limit: :infinity)}\n")
138+
139+
test_schema.("STRICT", StrictSchema, "Real API JSON", parsed_json)
140+
test_schema.("NON-STRICT", NonStrictSchema, "Real API JSON", parsed_json)
141+
142+
# Performance comparison
143+
IO.puts("=== PERFORMANCE IMPLICATIONS ===\n")
144+
145+
large_data_atom = %{
146+
name: "Performance Test",
147+
email: "perf@test.com",
148+
age: 30
149+
}
150+
151+
large_data_string = %{
152+
"name" => "Performance Test",
153+
"email" => "perf@test.com",
154+
"age" => 30
155+
}
156+
157+
large_data_with_extras = Map.merge(large_data_string, %{
158+
"extra1" => "value1", "extra2" => "value2", "extra3" => "value3",
159+
"extra4" => "value4", "extra5" => "value5", "extra6" => "value6"
160+
})
161+
162+
# Simple timing function
163+
defmodule Benchmark do
164+
def time(fun) do
165+
{time, result} = :timer.tc(fun)
166+
{time / 1000, result} # Convert to milliseconds
167+
end
168+
end
169+
170+
{strict_time, _} = Benchmark.time(fn ->
171+
Enum.each(1..1000, fn _ -> StrictSchema.validate(large_data_atom) end)
172+
end)
173+
174+
{nonstrict_time, _} = Benchmark.time(fn ->
175+
Enum.each(1..1000, fn _ -> NonStrictSchema.validate(large_data_atom) end)
176+
end)
177+
178+
{strict_with_extras_time, _} = Benchmark.time(fn ->
179+
Enum.each(1..1000, fn _ -> StrictSchema.validate(large_data_with_extras) end)
180+
end)
181+
182+
{nonstrict_with_extras_time, _} = Benchmark.time(fn ->
183+
Enum.each(1..1000, fn _ -> NonStrictSchema.validate(large_data_with_extras) end)
184+
end)
185+
186+
IO.puts("Performance (1000 validations):")
187+
IO.puts(" Strict mode (valid data): #{Float.round(strict_time, 2)}ms")
188+
IO.puts(" Non-strict mode (valid data): #{Float.round(nonstrict_time, 2)}ms")
189+
IO.puts(" Strict mode (extra fields): #{Float.round(strict_with_extras_time, 2)}ms")
190+
IO.puts(" Non-strict mode (extra fields): #{Float.round(nonstrict_with_extras_time, 2)}ms")
191+
192+
IO.puts("\n=== KEY INSIGHTS ===")
193+
IO.puts("1. Strict mode ONLY works with atom keys in practice")
194+
IO.puts("2. Non-strict mode handles both atom and string keys gracefully")
195+
IO.puts("3. Real-world JSON APIs always have extra fields")
196+
IO.puts("4. String keys are the norm for external data")
197+
IO.puts("5. Strict mode limits interoperability significantly")

0 commit comments

Comments
 (0)