Current section
Files
Jump to
Current section
Files
lib/exdantic.ex
defmodule Exdantic do
@moduledoc """
Exdantic is a schema definition and validation library for Elixir.
It provides a DSL for defining schemas with rich metadata, validation rules,
and JSON Schema generation capabilities.
For validating non-dictionary types at the root level (similar to Pydantic's RootModel),
see `Exdantic.RootSchema`.
## Struct Pattern Support
Exdantic now supports generating structs alongside validation schemas:
defmodule UserSchema do
use Exdantic, define_struct: true
schema "User account information" do
field :name, :string do
required()
min_length(2)
end
field :age, :integer do
optional()
gt(0)
end
end
end
The schema can then be used for validation and returns struct instances:
# Returns {:ok, %UserSchema{name: "John", age: 30}}
UserSchema.validate(%{name: "John", age: 30})
# Serialize struct back to map
{:ok, map} = UserSchema.dump(user_struct)
## Examples
defmodule UserSchema do
use Exdantic
schema "User registration data" do
field :name, :string do
required()
min_length(2)
end
field :age, :integer do
optional()
gt(0)
lt(150)
end
field :email, Types.Email do
required()
end
config do
title("User Schema")
strict(true)
end
end
end
The schema can then be used for validation and JSON Schema generation:
# Validation (returns map by default)
{:ok, user} = UserSchema.validate(%{
name: "John Doe",
email: "john@example.com",
age: 30
})
# JSON Schema generation
json_schema = UserSchema.json_schema()
"""
alias Exdantic.JsonSchema.EnhancedResolver
@doc """
Configures a module to be an Exdantic schema.
## Options
* `:define_struct` - Whether to generate a struct for validated data.
When `true`, validation returns struct instances instead of maps.
Defaults to `false` for backwards compatibility.
## Examples
# Traditional map-based validation
defmodule UserMapSchema do
use Exdantic
schema do
field :name, :string
end
end
# Struct-based validation
defmodule UserStructSchema do
use Exdantic, define_struct: true
schema do
field :name, :string
end
end
"""
@spec __using__(keyword()) :: Macro.t()
defmacro __using__(opts) do
define_struct? = Keyword.get(opts, :define_struct, false)
quote do
import Exdantic.Schema
# Register accumulating attributes
Module.register_attribute(__MODULE__, :schema_description, [])
Module.register_attribute(__MODULE__, :fields, accumulate: true)
Module.register_attribute(__MODULE__, :validations, accumulate: true)
Module.register_attribute(__MODULE__, :config, [])
Module.register_attribute(__MODULE__, :model_validators, accumulate: true)
Module.register_attribute(__MODULE__, :computed_fields, accumulate: true)
# Store struct option for use in __before_compile__
@exdantic_define_struct unquote(define_struct?)
@before_compile Exdantic
end
end
@doc """
Phase 6 Enhancement: Enhanced schema information with complete feature analysis.
## Examples
iex> UserSchema.__enhanced_schema_info__()
%{
exdantic_version: "Phase 6",
phase_6_enhanced: true,
compatibility: %{...},
performance_profile: %{...},
llm_optimization: %{...}
}
"""
@spec __before_compile__(Macro.Env.t()) :: Macro.t()
# credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
defmacro __before_compile__(_env) do
define_struct? = Module.get_attribute(__CALLER__.module, :exdantic_define_struct)
fields = Module.get_attribute(__CALLER__.module, :fields) || []
computed_fields = Module.get_attribute(__CALLER__.module, :computed_fields) || []
fields_binary = fields |> __encode_schema_fields__() |> :erlang.term_to_binary()
# Extract field names for struct definition
field_names = Enum.map(fields, fn {name, _meta} -> name end)
computed_field_names = Enum.map(computed_fields, fn {name, _meta} -> name end)
all_field_names = field_names ++ computed_field_names
# Generate struct definition if requested
struct_def =
if define_struct? do
quote do
defstruct unquote(all_field_names)
@type t :: %__MODULE__{}
@doc """
Returns the struct definition fields for this schema.
Includes both regular fields and computed fields.
"""
@spec __struct_fields__ :: [atom()]
def __struct_fields__, do: unquote(all_field_names)
@doc """
Returns the regular (non-computed) struct fields for this schema.
"""
@spec __regular_fields__ :: [atom()]
def __regular_fields__, do: unquote(field_names)
@doc """
Returns the computed field names for this schema.
"""
@spec __computed_field_names__ :: [atom()]
def __computed_field_names__, do: unquote(computed_field_names)
@doc """
Returns whether this schema defines a struct.
"""
@spec __struct_enabled__? :: true
def __struct_enabled__?, do: true
@doc """
Serializes a struct instance back to a map.
When serializing structs with computed fields, the computed fields
are included in the resulting map since they are part of the struct.
## Parameters
* `struct_or_map` - The struct instance or map to serialize
## Returns
* `{:ok, map}` on success (includes computed fields)
* `{:error, reason}` on failure
## Examples
iex> user = %UserSchema{
...> name: "John",
...> email: "john@example.com",
...> full_display: "John <john@example.com>" # computed field
...> }
iex> UserSchema.dump(user)
{:ok, %{
name: "John",
email: "john@example.com",
full_display: "John <john@example.com>"
}}
iex> UserSchema.dump(%{name: "John"})
{:ok, %{name: "John"}}
iex> UserSchema.dump("invalid")
{:error, "Expected UserSchema struct or map, got: \"invalid\""}
"""
@spec dump(struct() | map()) :: {:ok, map()} | {:error, String.t()}
def dump(value), do: do_dump(__MODULE__, value)
defp do_dump(module, %mod{} = struct) when mod == module,
do: {:ok, Map.from_struct(struct)}
defp do_dump(_module, map) when is_map(map), do: {:ok, map}
defp do_dump(module, other),
do: {:error, "Expected #{module} struct or map, got: #{inspect(other)}"}
end
else
quote do
@doc """
Returns whether this schema defines a struct.
"""
@spec __struct_enabled__? :: false
def __struct_enabled__?, do: false
@doc """
Returns empty list since no struct is defined.
"""
@spec __struct_fields__ :: []
def __struct_fields__, do: []
@doc """
Returns the regular field names for this schema.
"""
@spec __regular_fields__ :: [atom()]
def __regular_fields__, do: unquote(field_names)
@doc """
Returns the computed field names for this schema.
"""
@spec __computed_field_names__ :: [atom()]
def __computed_field_names__, do: unquote(computed_field_names)
end
end
quote do
# Inject struct definition if requested
unquote(struct_def)
# Define __schema__ functions (updated to include computed_fields)
def __schema__(:description), do: @schema_description
# Regex constraints carry runtime references on newer Elixir/OTP versions.
# Keep field metadata in a serialized literal to avoid compile-time escaping failures.
def __schema__(:fields) do
unquote(fields_binary)
|> :erlang.binary_to_term()
|> Exdantic.__decode_schema_fields__()
end
def __schema__(:validations), do: @validations
def __schema__(:config), do: @config
def __schema__(:model_validators), do: @model_validators || []
def __schema__(:computed_fields), do: @computed_fields || []
# Validation functions
unquote(validation_functions())
# Schema info functions
unquote(schema_info_functions())
# Phase 6 enhancements
unquote(phase_6_functions())
end
end
@doc false
@spec __encode_schema_fields__(term()) :: term()
def __encode_schema_fields__(term), do: encode_schema_term(term)
@doc false
@spec __decode_schema_fields__(term()) :: term()
def __decode_schema_fields__(term), do: decode_schema_term(term)
@regex_marker :__exdantic_regex__
@spec encode_schema_term(term()) :: term()
defp encode_schema_term(%Regex{} = regex) do
{@regex_marker, Regex.source(regex), regex.opts}
end
defp encode_schema_term(%module{} = struct) do
struct
|> Map.from_struct()
|> Map.new(fn {key, value} -> {key, encode_schema_term(value)} end)
|> then(&struct(module, &1))
end
defp encode_schema_term(map) when is_map(map) do
Map.new(map, fn {key, value} -> {key, encode_schema_term(value)} end)
end
defp encode_schema_term(list) when is_list(list), do: Enum.map(list, &encode_schema_term/1)
defp encode_schema_term(tuple) when is_tuple(tuple) do
tuple |> Tuple.to_list() |> Enum.map(&encode_schema_term/1) |> List.to_tuple()
end
defp encode_schema_term(other), do: other
@spec decode_schema_term(term()) :: term()
defp decode_schema_term({@regex_marker, source, opts})
when is_binary(source) and is_list(opts) do
Regex.compile!(source, opts)
end
defp decode_schema_term(%module{} = struct) do
struct
|> Map.from_struct()
|> Map.new(fn {key, value} -> {key, decode_schema_term(value)} end)
|> then(&struct(module, &1))
end
defp decode_schema_term(map) when is_map(map) do
Map.new(map, fn {key, value} -> {key, decode_schema_term(value)} end)
end
defp decode_schema_term(list) when is_list(list), do: Enum.map(list, &decode_schema_term/1)
defp decode_schema_term(tuple) when is_tuple(tuple) do
tuple |> Tuple.to_list() |> Enum.map(&decode_schema_term/1) |> List.to_tuple()
end
defp decode_schema_term(other), do: other
# Private helper functions for generating code blocks
defp validation_functions do
quote do
@doc """
Validates data against this schema with full pipeline support.
"""
@spec validate(map()) :: {:ok, map() | struct()} | {:error, [Exdantic.Error.t()]}
def validate(data) do
Exdantic.StructValidator.validate_schema(__MODULE__, data)
end
@doc """
Validates data against this schema, raising an exception on failure.
"""
@spec validate!(map()) :: map() | struct()
def validate!(data) do
case validate(data) do
{:ok, validated} -> validated
{:error, errors} -> raise Exdantic.ValidationError, errors: errors
end
end
end
end
defp schema_info_functions do
quote do
@doc """
Returns information about the schema including computed fields.
"""
@spec __schema_info__ :: map()
def __schema_info__ do
regular_fields = __schema__(:fields) |> Enum.map(fn {name, _} -> name end)
computed_fields = __schema__(:computed_fields) |> Enum.map(fn {name, _} -> name end)
model_validators = __schema__(:model_validators)
%{
has_struct: __struct_enabled__?(),
field_count: length(regular_fields),
computed_field_count: length(computed_fields),
model_validator_count: length(model_validators),
regular_fields: regular_fields,
computed_fields: computed_fields,
all_fields: regular_fields ++ computed_fields,
model_validators: Enum.map(model_validators, fn {mod, fun} -> "#{mod}.#{fun}/1" end)
}
end
end
end
defp phase_6_functions do
quote do
# Core Phase 6 functions
unquote(phase_6_core_functions())
# Analysis functions
unquote(phase_6_analysis_functions())
# Helper functions
unquote(phase_6_helper_functions())
end
end
defp phase_6_core_functions do
quote do
@doc """
Returns enhanced schema information with Phase 6 features.
"""
@spec __enhanced_schema_info__ :: map()
def __enhanced_schema_info__ do
basic_info = __schema_info__()
# Add Phase 6 specific information
enhanced_info = %{
exdantic_version: "Phase 6",
phase_6_enhanced: true,
json_schema_enhanced: true,
llm_compatible: true,
dspy_ready: analyze_dspy_readiness(),
performance_profile: analyze_performance_profile(),
compatibility_matrix: analyze_compatibility_matrix()
}
Map.merge(basic_info, enhanced_info)
end
@doc """
Validates data with Phase 6 enhanced pipeline and optional reporting.
"""
@spec validate_enhanced(map(), keyword()) ::
{:ok, map() | struct()}
| {:ok, map() | struct(), map()}
| {:error, [Exdantic.Error.t()]}
def validate_enhanced(data, opts \\ []) do
include_metrics = Keyword.get(opts, :include_performance_metrics, false)
test_llm = Keyword.get(opts, :test_llm_compatibility, false)
generate_schema = Keyword.get(opts, :generate_enhanced_schema, false)
start_time = System.monotonic_time(:microsecond)
case validate(data) do
{:ok, validated_data} ->
if include_metrics or test_llm or generate_schema do
additional_info =
build_enhanced_validation_result(
validated_data,
start_time,
include_metrics,
test_llm,
generate_schema
)
{:ok, validated_data, additional_info}
else
{:ok, validated_data}
end
{:error, errors} ->
{:error, errors}
end
end
end
end
defp phase_6_analysis_functions do
quote do
# Core analysis functions
unquote(dspy_readiness_functions())
unquote(performance_profile_functions())
unquote(compatibility_matrix_functions())
end
end
defp dspy_readiness_functions do
quote do
defp analyze_dspy_readiness do
{model_validator_count, computed_field_count} = get_validator_and_field_counts()
build_dspy_readiness_report(model_validator_count, computed_field_count)
end
defp build_dspy_readiness_report(model_validator_count, computed_field_count) do
%{
ready: dspy_ready?(model_validator_count, computed_field_count),
model_validators: model_validator_count,
computed_fields: computed_field_count,
recommendations:
generate_dspy_recommendations(model_validator_count, computed_field_count)
}
end
defp get_validator_and_field_counts do
model_validator_count = length(__schema__(:model_validators) || [])
computed_field_count = length(__schema__(:computed_fields) || [])
{model_validator_count, computed_field_count}
end
defp dspy_ready?(model_validator_count, computed_field_count) do
model_validator_count <= 3 and computed_field_count <= 5
end
end
end
defp performance_profile_functions do
quote do
defp analyze_performance_profile do
{field_count, model_validator_count, computed_field_count} = get_field_counts()
build_performance_profile(field_count, model_validator_count, computed_field_count)
end
defp build_performance_profile(field_count, model_validator_count, computed_field_count) do
complexity_score =
calculate_complexity_score(field_count, model_validator_count, computed_field_count)
build_performance_metrics(complexity_score, field_count, computed_field_count)
end
defp build_performance_metrics(complexity_score, field_count, computed_field_count) do
%{
complexity_score: complexity_score,
estimated_validation_time: estimate_validation_time(complexity_score),
memory_footprint: estimate_memory_footprint(field_count, computed_field_count),
optimization_level: determine_optimization_level(complexity_score)
}
end
defp get_field_counts do
field_count = length(__schema__(:fields) || [])
model_validator_count = length(__schema__(:model_validators) || [])
computed_field_count = length(__schema__(:computed_fields) || [])
{field_count, model_validator_count, computed_field_count}
end
defp calculate_complexity_score(field_count, model_validator_count, computed_field_count) do
field_count + model_validator_count * 2 + computed_field_count * 3
end
end
end
defp compatibility_matrix_functions do
quote do
defp analyze_compatibility_matrix do
{has_struct, has_validators, has_computed} = get_feature_flags()
build_compatibility_matrix(has_struct, has_validators, has_computed)
end
defp build_compatibility_matrix(has_struct, has_validators, has_computed) do
%{
json_schema_generation: true,
llm_providers: get_llm_provider_compatibility(),
dspy_patterns: get_dspy_pattern_compatibility(has_computed, has_validators),
struct_support: has_struct,
enhanced_features: has_validators or has_computed
}
end
defp get_feature_flags do
has_struct = __struct_enabled__?()
has_validators = (__schema__(:model_validators) || []) != []
has_computed = (__schema__(:computed_fields) || []) != []
{has_struct, has_validators, has_computed}
end
defp get_llm_provider_compatibility do
%{
openai: true,
anthropic: true,
generic: true
}
end
defp get_dspy_pattern_compatibility(has_computed, has_validators) do
%{
signature: not has_computed,
chain_of_thought: has_validators,
input_output: true
}
end
end
end
defp phase_6_helper_functions do
quote do
# Core helper functions
unquote(validation_result_functions())
unquote(recommendation_functions())
unquote(estimation_functions())
end
end
defp validation_result_functions do
quote do
# Result building functions
unquote(result_builder_functions())
# Metrics functions
unquote(metrics_functions())
# Feature addition functions
unquote(feature_addition_functions())
end
end
defp result_builder_functions do
quote do
defp build_enhanced_validation_result(
validated_data,
start_time,
include_metrics,
test_llm,
generate_schema
) do
build_result_with_features(start_time, include_metrics, test_llm, generate_schema)
end
defp build_result_with_features(start_time, include_metrics, test_llm, generate_schema) do
%{}
|> add_metrics(start_time, include_metrics)
|> add_llm_compatibility(test_llm)
|> add_enhanced_schema(generate_schema)
end
end
end
defp metrics_functions do
quote do
defp add_metrics(result, start_time, true) do
metrics = calculate_performance_metrics(start_time)
Map.put(result, :performance_metrics, metrics)
end
defp add_metrics(result, _start_time, false), do: result
defp calculate_performance_metrics(start_time) do
end_time = System.monotonic_time(:microsecond)
duration = end_time - start_time
build_metrics_map(duration)
end
defp build_metrics_map(duration) do
%{
validation_duration_microseconds: duration,
validation_duration_milliseconds: duration / 1000,
memory_used: :erlang.memory(:total)
}
end
end
end
defp feature_addition_functions do
quote do
defp add_llm_compatibility(result, true) do
compatibility = test_llm_provider_compatibility()
Map.put(result, :llm_compatibility, compatibility)
end
defp add_llm_compatibility(result, false), do: result
defp add_enhanced_schema(result, true) do
enhanced_schema = EnhancedResolver.resolve_enhanced(__MODULE__)
Map.put(result, :enhanced_schema, enhanced_schema)
end
defp add_enhanced_schema(result, false), do: result
end
end
defp recommendation_functions do
quote do
defp generate_dspy_recommendations(model_validators, computed_fields) do
[]
|> add_model_validator_recommendation(model_validators)
|> add_computed_field_recommendation(computed_fields)
|> finalize_recommendations()
end
defp add_model_validator_recommendation(recommendations, model_validators) do
if model_validators > 3 do
["Consider reducing model validators for DSPy compatibility" | recommendations]
else
recommendations
end
end
defp add_computed_field_recommendation(recommendations, computed_fields) do
if computed_fields > 5 do
["Consider reducing computed fields for DSPy signatures" | recommendations]
else
recommendations
end
end
defp finalize_recommendations([]), do: ["Schema is well-suited for DSPy usage"]
defp finalize_recommendations(recommendations), do: recommendations
end
end
defp estimation_functions do
quote do
# Time estimation functions
unquote(time_estimation_functions())
# Memory estimation functions
unquote(memory_estimation_functions())
# Optimization level functions
unquote(optimization_level_functions())
# Provider compatibility functions
unquote(provider_compatibility_functions())
end
end
defp time_estimation_functions do
quote do
defp estimate_validation_time(complexity_score) when complexity_score < 10, do: "< 1ms"
defp estimate_validation_time(complexity_score) when complexity_score < 25, do: "1-5ms"
defp estimate_validation_time(complexity_score) when complexity_score < 50, do: "5-15ms"
defp estimate_validation_time(_complexity_score), do: "> 15ms"
end
end
defp memory_estimation_functions do
quote do
defp estimate_memory_footprint(field_count, computed_field_count) do
total = calculate_total_memory(field_count, computed_field_count)
format_memory_size(total)
end
defp calculate_total_memory(field_count, computed_field_count) do
base_memory = field_count * 100
computed_memory = computed_field_count * 300
base_memory + computed_memory
end
defp format_memory_size(total) when total < 1000, do: "< 1KB"
defp format_memory_size(total) when total < 5000, do: "1-5KB"
defp format_memory_size(total) when total < 10_000, do: "5-10KB"
defp format_memory_size(_total), do: "> 10KB"
end
end
defp optimization_level_functions do
quote do
defp determine_optimization_level(complexity_score) when complexity_score < 20, do: :high
defp determine_optimization_level(complexity_score) when complexity_score < 50, do: :medium
defp determine_optimization_level(_complexity_score), do: :low
end
end
defp provider_compatibility_functions do
quote do
defp test_llm_provider_compatibility do
%{
openai: %{compatible: true, score: 85},
anthropic: %{compatible: true, score: 80},
generic: %{compatible: true, score: 90}
}
end
end
end
end