Skip to content

Commit 44d18f0

Browse files
committed
Release v0.1.0: add Exdantic.Settings, boolean schema support, and modular guides
This update introduces the Exdantic.Settings module, providing a robust environment-variable-based configuration loader with schema validation. It supports field-driven lookups, custom prefixes, and nested delimiters. Settings Loader: - Added load/2, load!/2, and from_system_env/2 for configuration. - Support for field-level env overrides and case-insensitive normalization. - Implemented prefix-based matching to resolve underscore naming ambiguity. - Added deep-merge logic for exploded nested env values over JSON maps. - Introduced specific error codes for casting, JSON, and key conflicts. - Added ignore_empty and allow_atoms options for flexible env parsing. JSON Schema and Resolvers: - Added support for boolean schemas (true/false) in JsonSchema.Resolver. - Implemented allOf wrapping when merging metadata into boolean schemas. - Resolved schema metadata serialization issues using term_to_binary. - Updated reference resolver to support both definitions and $defs. - Refactored visitor tracking from MapSet to maps for better performance. Documentation and Examples: - Restructured documentation into nine modular guides. - Added a comprehensive settings loader example and automation script. - Documented helper functions, wrapper factories, and custom type rules. - Added branding assets (SVG logo) and refreshed the README. Refactoring and Performance: - Refactored RootSchema to use private functions for root type storage. - Optimized list emptiness checks across the codebase. - Extracted constraint keys to module attributes in the Runtime module. - Removed legacy documentation and broken example files. - Resolved Dialyzer issues and removed the global ignore file. Testing: - Added property-based tests for settings precedence and decoding. - Expanded unit tests for boolean schema resolution and nested paths. - Validated underscore delimiter strategies for nested settings paths.
1 parent b3f9c99 commit 44d18f0

7 files changed

Lines changed: 215 additions & 21 deletions

CHANGELOG.md

Lines changed: 54 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,62 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [0.1.0] - 2026-02-22
99

1010
### Added
11-
- Added `Exdantic.Settings` env-based settings loader with:
12-
- `load/2`, `load!/2`, and `from_system_env/2`
13-
- field-driven env lookup with `env_prefix` and `env_nested_delimiter`
14-
- field-level absolute env override via `extra: %{"env" => "KEY"}`
15-
- test-friendly `env: %{}` injection (no process env mutation required)
16-
- loader-level errors: `:env_cast`, `:env_json`, `:env_key_conflict`
11+
- **Settings module** (`Exdantic.Settings`) — environment-variable-based configuration loader:
12+
- `load/2`, `load!/2`, and `from_system_env/2` entry points
13+
- Field-driven env lookup with `env_prefix` and `env_nested_delimiter`
14+
- Field-level absolute env override via `extra: %{"env" => "KEY"}`
15+
- Test-friendly `env: %{}` injection (no process env mutation required)
16+
- Dedicated error codes: `:env_cast`, `:env_json`, `:env_key_conflict`
17+
- Nested env decoding with prefix-based matching strategy that correctly handles fields containing underscores in their names
18+
- Deep-merge of exploded nested values over top-level JSON values
19+
- Case-insensitive env normalization with collision detection
20+
- `ignore_empty` option to control whether empty strings are treated as absent
21+
- `allow_atoms: :existing` option for safe atom env casting
22+
- Validation that `:input` option is a map
23+
- Settings submodules: `Decode`, `DeepMerge`, `Env`, `Keys`, `Loader`, `NormalizeKeys`
24+
- **Boolean schema support** in `JsonSchema.Resolver``true`/`false` can now be used as definitions and are resolved correctly
25+
- When merging metadata into a boolean schema, the resolver wraps elements in an `allOf` structure to preserve schema validity
26+
- Support for both `definitions` and `$defs` in the reference resolver
27+
- **Schema metadata serialization** — fields are now serialized via `:erlang.term_to_binary` during compilation and decoded at runtime, fixing compile-time escaping failures for Regex constraints on newer Elixir/OTP versions
28+
- **Documentation overhaul**:
29+
- 9 modular numbered guides replacing flat markdown files (`guides/01_overview_and_quickstart.md` through `guides/09_errors_reports_and_operations.md`)
30+
- Documented `Types.type/1`, `Types.validate/2`, `Types.coerce/2` helpers
31+
- Documented optional `coerce_rule/0` and `custom_rules/0` callbacks for custom type modules
32+
- Documented `create_wrapper_factory/2` for reusable wrapper templates
33+
- Documented `error_format` and `allow_population_by_field_name` configuration fields
34+
- Settings/env guide (`docJune/SETTINGS_ENV_GUIDE.md`)
35+
- Strict mode deprecation analysis and JSV compatibility analysis
36+
- Production error handling guide
37+
- **Project branding** — SVG logo asset (`assets/exdantic.svg`)
38+
- **Examples**:
39+
- `examples/settings_loader.exs` — comprehensive settings loader example
40+
- `examples/run_all.sh` — bash script to automate execution of all examples
41+
- **Tests**:
42+
- `settings_test.exs` — unit tests for the settings loader
43+
- `settings_property_test.exs` — property-based tests covering precedence, env decoding, nested merge, union behavior, and atom safety
44+
- Boolean schema resolution tests in `resolver_test.exs`
45+
- Underscore delimiter tests for nested settings paths
1746

1847
### Changed
19-
- Settings decoding and merge behavior now documented and validated by property tests:
20-
- precedence: `input > env > defaults`
21-
- structured types are JSON-only
22-
- exploded nested values deep-merge over top-level JSON values
23-
- conservative union env decoding (no union-level scalar coercion probing)
24-
- no exploded addressing into arrays in v1
48+
- **Settings behavior** (documented and validated by property tests):
49+
- Precedence: `input > env > defaults`
50+
- Structured types (arrays, maps, nested schemas) are JSON-only via env
51+
- Exploded nested values deep-merge over top-level JSON values
52+
- Conservative union env decoding (no union-level scalar coercion probing)
53+
- No exploded addressing into arrays in v1
54+
- **RootSchema** refactored to use private functions (`__root_type__/0`) instead of module attributes for storing root types, improving consistency in code generation
55+
- **JsonSchema.Resolver** updated to use maps instead of `MapSet` for tracking visited nodes, improving performance and adding stricter definition checks
56+
- **Runtime module** — constraint keys extracted to a module attribute (`@constraint_keys`) replacing runtime `MapSet` creation; refined type specifications
57+
- **EnhancedValidator** — refined type specifications
58+
- Optimized list emptiness checks across the codebase by replacing `length/1 > 0` with direct empty list comparisons (`!= []`)
59+
- Updated ExDoc configuration with grouped extras for HexDocs navigation
60+
- Updated dependencies in `mix.lock` (including `ex_doc` and `dialyxir`)
61+
- Rewrote `README.md` for better clarity and faster onboarding
62+
63+
### Removed
64+
- `.dialyzer_ignore.exs` — removed after resolving underlying Dialyzer analysis issues
65+
- Legacy flat documentation files: `ADVANCED_FEATURES_GUIDE.md`, `GETTING_STARTED_GUIDE.md`, `LLM_INTEGRATION_GUIDE.md`
66+
- `examples/phase_3_example.exs` (incomplete/broken)
2567

2668
## [0.0.2] - 2025-01-05
2769

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,11 @@ normal validation:
203203
)
204204
```
205205

206+
Features include field-level env override (`extra: %{"env" => "DATABASE_URL"}`),
207+
typed scalar decoding, JSON decoding for structured types, nested exploded env keys,
208+
case-insensitive key normalization, and `input` map override with `input > env > defaults`
209+
precedence. See `guides/08_configuration_and_settings.md` for full details.
210+
206211
## Documentation Map
207212

208213
The full guide set lives under `guides/` and is published in HexDocs:
@@ -229,6 +234,13 @@ Run examples with:
229234
mix run examples/basic_usage.exs
230235
mix run examples/model_validators.exs
231236
mix run examples/llm_integration.exs
237+
mix run examples/settings_loader.exs
238+
```
239+
240+
Or run all examples at once:
241+
242+
```bash
243+
bash examples/run_all.sh
232244
```
233245

234246
## License

guides/01_overview_and_quickstart.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,41 @@ resolved = Exdantic.JsonSchema.Resolver.resolve_references(schema)
136136
openai = Exdantic.JsonSchema.Resolver.enforce_structured_output(schema, provider: :openai)
137137
```
138138

139+
## Settings Quickstart
140+
141+
Load schema-validated configuration from environment variables:
142+
143+
```elixir
144+
defmodule AppSettings do
145+
use Exdantic
146+
147+
schema do
148+
field :host, :string, default: "localhost"
149+
field :port, :integer, default: 4000
150+
field :debug, :boolean, default: false
151+
end
152+
end
153+
154+
{:ok, settings} =
155+
Exdantic.Settings.from_system_env(AppSettings,
156+
env_prefix: "APP_",
157+
env_nested_delimiter: "__"
158+
)
159+
```
160+
161+
Or test without touching the process environment:
162+
163+
```elixir
164+
{:ok, settings} =
165+
Exdantic.Settings.load(AppSettings,
166+
env: %{"APP_PORT" => "8080", "APP_DEBUG" => "true"},
167+
input: %{host: "0.0.0.0"}
168+
)
169+
# settings.host == "0.0.0.0" (input wins over env)
170+
# settings.port == 8080
171+
# settings.debug == true
172+
```
173+
139174
## Choosing the Right API
140175

141176
Use compile-time schema modules when:
@@ -155,6 +190,12 @@ Use `TypeAdapter` when:
155190
- You validate isolated values or fragments
156191
- You want minimal surface area and low ceremony
157192

193+
Use `Settings` when:
194+
195+
- You want schema-validated application configuration from env vars
196+
- You need typed coercion of env strings (integers, booleans, JSON)
197+
- You need nested config with controlled delimiter and prefixing
198+
158199
## Next Guides
159200

160201
- `guides/02_schema_dsl_and_types.md`: field DSL, constraints, and type system

guides/06_json_schema_and_resolvers.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ Constraint mapping examples:
4949

5050
`Exdantic.JsonSchema.ReferenceStore` tracks references and emitted definitions during generation.
5151

52-
Generated schemas may contain `definitions` + `$ref` entries for nested schema modules.
52+
Generated schemas may contain `definitions` or `$defs` plus `$ref` entries for nested schema modules. The resolver supports both `definitions` and `$defs` keys and merges them when both are present.
5353

5454
## Computed Fields in JSON Schema
5555

@@ -78,6 +78,31 @@ resolved = Exdantic.JsonSchema.Resolver.resolve_references(schema, max_depth: 10
7878
flattened = Exdantic.JsonSchema.Resolver.flatten_schema(schema, max_depth: 5)
7979
```
8080

81+
### Boolean Schema Support
82+
83+
JSON Schema allows `true` and `false` as valid schemas (accept-all and reject-all). The resolver handles these correctly:
84+
85+
- Boolean values in `definitions` or `$defs` are resolved as-is when referenced via `$ref`.
86+
- When metadata (title, description) needs to be merged into a boolean schema, the resolver wraps the result in an `allOf` structure to preserve schema validity:
87+
88+
```elixir
89+
schema = %{
90+
"type" => "object",
91+
"properties" => %{
92+
"allow_anything" => %{"$ref" => "#/definitions/AllowAll"},
93+
"allow_nothing" => %{"$ref" => "#/$defs/DenyAll"}
94+
},
95+
"definitions" => %{"AllowAll" => true},
96+
"$defs" => %{"DenyAll" => false}
97+
}
98+
99+
resolved = Exdantic.JsonSchema.Resolver.resolve_references(schema)
100+
# resolved["properties"]["allow_anything"] == true
101+
# resolved["properties"]["allow_nothing"] == false
102+
```
103+
104+
Both `resolve_references/2` and `flatten_schema/2` accept boolean schemas as top-level input and return them unchanged.
105+
81106
## Provider-Oriented Structured Output
82107

83108
Use `enforce_structured_output/2`:

guides/08_configuration_and_settings.md

Lines changed: 77 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -138,25 +138,96 @@ config = Exdantic.Config.create(strict: true, coercion: :safe)
138138
- `allow_atoms: false | :existing`
139139
- `bool_numeric: boolean()`
140140

141+
## Field-Level Env Override
142+
143+
A field can declare an absolute env key that bypasses prefix derivation:
144+
145+
```elixir
146+
schema do
147+
field :db_url, :string, required: true, extra: %{"env" => "DATABASE_URL"}
148+
end
149+
```
150+
151+
When both the override key and the derived key exist in the environment, the override wins. The prefix is **not** applied to the override key.
152+
141153
## Env Decoding Behavior
142154

143-
- Scalar env values are decoded by expected type (`integer`, `float`, `boolean`, etc.)
144-
- Structured types (`array`, maps, objects, refs) use JSON decoding for top-level values
145-
- Union decoding is conservative for structured union members
146-
- Field override via `extra: %{"env" => "CUSTOM_KEY"}` is supported
147-
- Nested exploded env keys are supported for nested maps/objects (arrays are intentionally limited)
155+
Scalar types are decoded from their string env representation:
156+
157+
- `:string` — passed through as-is
158+
- `:integer` — parsed with `Integer.parse/1`; must consume entire string
159+
- `:float` — parsed with `Float.parse/1`; must consume entire string
160+
- `:boolean` — accepts `"true"` / `"false"` (case-insensitive); when `bool_numeric: true` (default), also accepts `"1"` / `"0"`
161+
- `:atom` — disabled by default; set `allow_atoms: :existing` to allow `String.to_existing_atom/1`
162+
- `:any` — passed through as-is
163+
164+
Structured types (`{:array, _}`, `{:map, _, _}`, `{:object, _}`, schema module refs) must be provided as JSON strings:
165+
166+
```elixir
167+
# env: %{"TAGS" => "[1,2,3]"}
168+
# decodes to [1, 2, 3]
169+
```
170+
171+
Invalid JSON returns an `:env_json` error. Invalid scalar parsing returns an `:env_cast` error.
172+
173+
Union decoding is conservative:
174+
175+
- If the union contains structured members and the value starts with `{` or `[`, JSON decoding is attempted.
176+
- Otherwise the raw string is passed to the validator to resolve the union.
177+
178+
## Nested Exploded Env Keys
179+
180+
For nested schemas, the loader supports exploded env keys where the delimiter separates parent and child field names:
181+
182+
```elixir
183+
# Schema: NestedSettings with a `database` field of type DatabaseSchema
184+
# DatabaseSchema has `host` and `pool_size` fields
185+
186+
env = %{
187+
"APP_DATABASE__HOST" => "localhost",
188+
"APP_DATABASE__POOL_SIZE" => "10"
189+
}
190+
191+
{:ok, settings} = Settings.load(NestedSettings,
192+
env: env,
193+
env_prefix: "APP_",
194+
env_nested_delimiter: "__"
195+
)
196+
# settings.database.host == "localhost"
197+
# settings.database.pool_size == 10
198+
```
199+
200+
When both a top-level JSON value and exploded keys exist for the same field, the exploded values are deep-merged over the JSON-decoded map, with exploded keys taking precedence:
201+
202+
```elixir
203+
env = %{
204+
"APP_DATABASE" => ~s({"host":"a","pool_size":5}),
205+
"APP_DATABASE__POOL_SIZE" => "10"
206+
}
207+
# Result: host == "a", pool_size == 10
208+
```
209+
210+
### Prefix-Based Matching for Underscore Fields
211+
212+
When using `"_"` as the nested delimiter, field names containing underscores (e.g., `pool_size`) create ambiguity. The loader resolves this with a prefix-based matching strategy that sorts fields by name length (longest first), ensuring `POOL_SIZE` matches the `pool_size` field before `POOL` could match a hypothetical `pool` field.
213+
214+
### Limitations
215+
216+
Exploded addressing into arrays is not supported in v1. For example, `APP_ITEMS__0` will not set the first element of an `items` array field. Arrays must be provided as JSON strings.
148217

149218
## Key Normalization and Merge Semantics
150219

151220
Settings loader performs:
152221

153222
1. Env normalization (`case_sensitive` rules + collision checks)
154-
2. Field candidate key lookup
223+
2. Field candidate key lookup (override key first, then derived key)
155224
3. Decode + exploded nested decode merge
156225
4. Deep merge of env values with `input` (`input` wins)
157226
5. Key normalization by schema field definitions
158227
6. Final validation through `Exdantic.StructValidator`
159228

229+
Case-insensitive mode (default) uppercases all env keys and detects collisions. If two env keys normalize to the same uppercase key (e.g., `app_port` and `APP_PORT`), an `:env_key_conflict` error is returned.
230+
160231
## When to Use Settings Loader
161232

162233
Use `Exdantic.Settings` when:

guides/09_errors_reports_and_operations.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,10 @@ Codes vary by operation, but common categories include:
4040
- `:model_validation`
4141
- `:computed_field`
4242
- `:computed_field_type`
43-
- env-related settings codes like `:env_cast`, `:env_json`, `:env_key_conflict`
43+
- env-related settings codes:
44+
- `:env_cast` — scalar value could not be parsed (e.g., `"abc"` for an integer field)
45+
- `:env_json` — structured value could not be decoded as JSON (e.g., malformed array string)
46+
- `:env_key_conflict` — case-insensitive env key collision detected (e.g., `app_port` and `APP_PORT` both present)
4447

4548
## Validation Entry Points
4649

mix.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ defmodule Exdantic.MixProject do
6262
licenses: ["MIT"],
6363
links: %{"GitHub" => @source_url},
6464
maintainers: ["NSHkr"],
65-
files: ~w(lib examples guides .formatter.exs mix.exs README* LICENSE* CHANGELOG*)
65+
files: ~w(lib .formatter.exs mix.exs README* LICENSE* CHANGELOG*)
6666
]
6767
end
6868

0 commit comments

Comments
 (0)