Exdantic.Schema (exdantic v0.0.2)

View Source

Schema DSL for defining data schemas with validation rules and metadata.

This module provides macros and functions for defining structured data schemas with rich validation capabilities, type safety, and comprehensive error reporting.

Phase 4 Enhancement: Anonymous Function Support

Added support for inline anonymous functions in model validators and computed fields:

schema do
  field :password, :string
  field :password_confirmation, :string

  # Named function (existing)
  model_validator :validate_passwords_match

  # Anonymous function (new)
  model_validator fn input ->
    if input.password == input.password_confirmation do
      {:ok, input}
    else
      {:error, "passwords do not match"}
    end
  end

  # Anonymous function with do-end block (new)
  model_validator do
    if input.password == input.password_confirmation do
      {:ok, input}
    else
      {:error, "passwords do not match"}
    end
  end

  computed_field :display_name, :string do
    String.upcase(input.name)
  end
end

Summary

Functions

Adds an enumeration constraint, limiting values to a predefined set.

Defines a computed field that generates a value based on validated data.

Defines configuration settings for the schema.

Sets the description for the schema configuration.

Sets a default value for the field and marks it as optional. The default value will be used if the field is omitted from input data.

Sets a description for the field.

Sets a single example value for the field.

Sets multiple example values for the field.

Sets arbitrary extra metadata for the field.

Defines a field in the schema with a name, type, and optional constraints.

Adds a format constraint to a string field.

Adds a greater than constraint to a numeric field.

Adds a greater than or equal to constraint to a numeric field.

Adds a less than constraint to a numeric field.

Adds a less than or equal to constraint to a numeric field.

Adds a maximum items constraint to an array field.

Adds a maximum length constraint to a string field.

Adds a minimum items constraint to an array field.

Adds a minimum length constraint to a string field.

Defines a model-level validator that runs after field validation.

Marks the field as optional. An optional field may be omitted from the input data during validation.

Marks the field as required (this is the default behavior). A required field must be present in the input data during validation.

Defines a new schema with optional description.

Sets whether the schema should enforce strict validation. When strict is true, unknown fields will cause validation to fail.

Sets the title for the schema configuration.

Types

macro_ast()

@type macro_ast() :: term()

model_validator_ast()

@type model_validator_ast() ::
  {:@, [context: Exdantic.Schema, imports: [...]],
   [{:model_validators, [...], [...]}]}
  | {:__block__, [], [{:def, [...], [...]} | {:@, [...], [...]}]}

schema_config()

@type schema_config() :: %{
  optional(:title) => String.t(),
  optional(:description) => String.t(),
  optional(:strict) => boolean()
}

Functions

choices(values)

(macro)
@spec choices([term()]) :: Macro.t()

Adds an enumeration constraint, limiting values to a predefined set.

Parameters

  • values - List of allowed values

Examples

field :status, :string do
  choices(["pending", "active", "completed"])
end

field :priority, :integer do
  choices([1, 2, 3])
end

field :size, :string do
  choices(["small", "medium", "large"])
end

computed_field(name, type, function_name)

(macro)
@spec computed_field(atom(), term(), atom()) :: macro_ast()
@spec computed_field(atom(), term(), (map() ->
                                  {:ok, term()}
                                  | {:error, String.t() | Exdantic.Error.t()})) ::
  macro_ast()

Defines a computed field that generates a value based on validated data.

Computed fields execute after field and model validation, generating additional data that becomes part of the final validated result. They are particularly useful for derived values, formatted representations, or aggregated data.

Parameters

  • name - Field name (atom)
  • type - Field type specification (same as regular fields)
  • function_name - Name of the function to call for computation (atom) or anonymous function
  • opts - Optional keyword list with :description and :example (when using named function)

Function Signature

The computation function must accept one parameter (the validated data) and return:

  • {:ok, computed_value} - computation succeeds
  • {:error, message} - computation fails with error message
  • {:error, %Exdantic.Error{}} - computation fails with detailed error

Execution Order

Computed fields execute after:

  1. Field validation
  2. Model validation

This ensures computed fields have access to fully validated and transformed data.

Examples

# Using named function
defmodule UserSchema do
  use Exdantic, define_struct: true

  schema do
    field :first_name, :string, required: true
    field :last_name, :string, required: true
    field :email, :string, required: true

    computed_field :full_name, :string, :generate_full_name
    computed_field :email_domain, :string, :extract_email_domain,
      description: "Domain part of the email address",
      example: "example.com"
  end

  def generate_full_name(input) do
    {:ok, "#{input.first_name} #{input.last_name}"}
  end

  def extract_email_domain(input) do
    domain = input.email |> String.split("@") |> List.last()
    {:ok, domain}
  end
end

# Using anonymous function
schema do
  field :first_name, :string
  field :last_name, :string

  computed_field :full_name, :string, fn input ->
    {:ok, "#{input.first_name} #{input.last_name}"}
  end

  computed_field :initials, :string, fn input ->
    first = String.first(input.first_name)
    last = String.first(input.last_name)
    {:ok, "#{first}#{last}"}
  end
end

Error Handling

Computed field functions can return errors that will be included in validation results:

def risky_computation(data) do
  if valid_computation?(data) do
    {:ok, compute_value(data)}
  else
    {:error, "Computation failed due to invalid data"}
  end
end

Type Safety

Computed field return values are validated against their declared types:

computed_field :score, :integer, :calculate_score

def calculate_score(data) do
  # This will fail validation if score is not an integer
  {:ok, "not an integer"}
end

JSON Schema Integration

Computed fields are automatically included in generated JSON schemas and marked as readOnly:

%{
  "type" => "object",
  "properties" => %{
    "first_name" => %{"type" => "string"},
    "full_name" => %{"type" => "string", "readOnly" => true}
  }
}

With Struct Definition

When using define_struct: true, computed fields are included in the struct definition:

defstruct [:first_name, :last_name, :email, :full_name, :email_domain]

computed_field(name, type, function_name, opts)

(macro)
@spec computed_field(atom(), term(), atom(), description: String.t(), example: term()) ::
  macro_ast()

config(list)

(macro)
@spec config(keyword()) :: Macro.t()

Defines configuration settings for the schema.

Configuration options can include:

  • title - Schema title
  • description - Schema description
  • strict - Whether to enforce strict validation

Examples

config do
  title("User Schema")
  config_description("Validates user registration data")
  strict(true)
end

config do
  strict(false)
end

config_description(text)

(macro)
@spec config_description(String.t()) :: Macro.t()

Sets the description for the schema configuration.

Parameters

  • text - String description of the schema

Examples

config do
  config_description("Validates user data for registration")
end

config do
  title("User Schema")
  config_description("Comprehensive user validation with email format checking")
end

default(value)

(macro)
@spec default(term()) :: Macro.t()

Sets a default value for the field and marks it as optional. The default value will be used if the field is omitted from input data.

Parameters

  • value - The default value to use when the field is not provided

Examples

field :status, :string do
  default("pending")
end

field :active, :boolean do
  default(true)
end

field :retry_count, :integer do
  default(0)
  gteq(0)
end

description(text)

(macro)
@spec description(String.t()) :: Macro.t()

Sets a description for the field.

Parameters

  • text - String description of the field's purpose or usage

Examples

field :age, :integer do
  description("User's age in years")
end

field :email, :string do
  description("Primary contact email address")
  format(~r/@/)
end

example(value)

(macro)
@spec example(term()) :: Macro.t()

Sets a single example value for the field.

Parameters

  • value - An example value that would be valid for this field

Examples

field :age, :integer do
  example(25)
end

field :name, :string do
  example("John Doe")
end

examples(values)

(macro)
@spec examples([term()]) :: Macro.t()

Sets multiple example values for the field.

Parameters

  • values - List of example values that would be valid for this field

Examples

field :status, :string do
  examples(["pending", "active", "completed"])
end

field :score, :integer do
  examples([85, 92, 78])
end

extra(key, value)

(macro)
@spec extra(String.t(), term()) :: Macro.t()

Sets arbitrary extra metadata for the field.

This allows storing custom key-value pairs in the field metadata, which is particularly useful for DSPy-style field type annotations and other framework-specific metadata.

Parameters

  • key - String key for the metadata
  • value - The metadata value

Examples

field :answer, :string do
  extra("__dspy_field_type", "output")
  extra("prefix", "Answer:")
end

field :question, :string do
  extra("__dspy_field_type", "input")
end

# Can also be used with map
field :data, :string, extra: %{"custom_key" => "custom_value"}

field(name, type, opts \\ [do: {:__block__, [], []}])

(macro)
@spec field(atom(), term(), keyword()) :: Macro.t()
@spec field(atom(), term(), keyword()) :: Macro.t()

Defines a field in the schema with a name, type, and optional constraints.

Parameters

  • name - Atom representing the field name
  • type - The field's type, which can be:
    • A built-in type (:string, :integer, :float, :boolean, :any)
    • An array type ({:array, type})
    • A map type ({:map, {key_type, value_type}})
    • A union type ({:union, [type1, type2, ...]})
    • A reference to another schema (atom)
  • opts - Optional block containing field constraints and metadata

Examples

# Simple field
field :name, :string

# Field with constraints
field :age, :integer do
  description("User's age in years")
  gt(0)
  lt(150)
end

# Array field
field :tags, {:array, :string} do
  min_items(1)
  max_items(10)
end

# Map field
field :metadata, {:map, {:string, :any}}

# Reference to another schema
field :address, Address

# Optional field with default
field :active, :boolean do
  default(true)
end

format(value)

(macro)
@spec format(Regex.t()) :: Macro.t()

Adds a format constraint to a string field.

Parameters

  • value - The format pattern (regular expression)

Examples

field :email, :string do
  format(~r/^[^ @]+@[^ @]+.[^ @]+$/)
end

field :phone, :string do
  format(~r/^+?[1-9]{1,14}$/)
end

gt(value)

(macro)
@spec gt(number()) :: Macro.t()

Adds a greater than constraint to a numeric field.

Parameters

  • value - The minimum value (exclusive)

Examples

field :age, :integer do
  gt(0)
end

field :score, :float do
  gt(0.0)
  lt(100.0)
end

gteq(value)

(macro)
@spec gteq(number()) :: Macro.t()

Adds a greater than or equal to constraint to a numeric field.

Parameters

  • value - The minimum value (inclusive)

Examples

field :age, :integer do
  gteq(18)
end

field :rating, :float do
  gteq(0.0)
  lteq(5.0)
end

lt(value)

(macro)
@spec lt(number()) :: Macro.t()

Adds a less than constraint to a numeric field.

Parameters

  • value - The maximum value (exclusive)

Examples

field :age, :integer do
  lt(100)
end

field :temperature, :float do
  gt(-50.0)
  lt(100.0)
end

lteq(value)

(macro)
@spec lteq(number()) :: Macro.t()

Adds a less than or equal to constraint to a numeric field.

Parameters

  • value - The maximum value (inclusive)

Examples

field :rating, :float do
  lteq(5.0)
end

field :percentage, :integer do
  gteq(0)
  lteq(100)
end

max_items(value)

(macro)
@spec max_items(non_neg_integer()) :: Macro.t()

Adds a maximum items constraint to an array field.

Parameters

  • value - The maximum number of items allowed (must be a non-negative integer)

Examples

field :tags, {:array, :string} do
  max_items(10)
end

field :favorites, {:array, :integer} do
  min_items(1)
  max_items(3)
end

max_length(value)

(macro)
@spec max_length(non_neg_integer()) :: Macro.t()

Adds a maximum length constraint to a string field.

Parameters

  • value - The maximum length allowed (must be a non-negative integer)

Examples

field :username, :string do
  max_length(20)
end

field :description, :string do
  max_length(500)
end

min_items(value)

(macro)
@spec min_items(non_neg_integer()) :: Macro.t()

Adds a minimum items constraint to an array field.

Parameters

  • value - The minimum number of items required (must be a non-negative integer)

Examples

field :tags, {:array, :string} do
  min_items(1)
end

field :categories, {:array, :string} do
  min_items(2)
  max_items(5)
end

min_length(value)

(macro)
@spec min_length(non_neg_integer()) :: Macro.t()

Adds a minimum length constraint to a string field.

Parameters

  • value - The minimum length required (must be a non-negative integer)

Examples

field :username, :string do
  min_length(3)
end

field :password, :string do
  min_length(8)
  max_length(100)
end

model_validator(validator_fn)

(macro)
@spec model_validator((map() ->
                   {:ok, map()} | {:error, String.t() | Exdantic.Error.t()})) ::
  macro_ast()
@spec model_validator(atom()) :: macro_ast()
@spec model_validator(keyword()) :: macro_ast()

Defines a model-level validator that runs after field validation.

Model validators receive the validated data (as a map or struct) and can perform cross-field validation, data transformation, or complex business logic validation.

Parameters

  • function_name - Name of the function to call for model validation (when using named function)
  • validator_fn - Anonymous function that accepts validated data and returns result (when using anonymous function)
  • do block - Block of code with implicit input variable (when using do-end block)

Function Signature

The validator must accept one parameter (the validated data) and return:

  • {:ok, data} - validation succeeds, optionally with transformed data
  • {:error, message} - validation fails with error message
  • {:error, %Exdantic.Error{}} - validation fails with detailed error

Examples

defmodule UserSchema do
  use Exdantic, define_struct: true

  schema do
    field :password, :string, required: true
    field :password_confirmation, :string, required: true

    # Using named function
    model_validator :validate_passwords_match

    # Using anonymous function
    model_validator fn input ->
      if input.password == input.password_confirmation do
        {:ok, input}
      else
        {:error, "passwords do not match"}
      end
    end

    # Using do-end block with implicit input
    model_validator do
      if input.password == input.password_confirmation do
        {:ok, input}
      else
        {:error, "passwords do not match"}
      end
    end
  end

  def validate_passwords_match(input) do
    if input.password == input.password_confirmation do
      {:ok, input}
    else
      {:error, "passwords do not match"}
    end
  end
end

Multiple Validators

Multiple model validators can be defined and will execute in the order they are declared:

schema do
  field :username, :string, required: true
  field :email, :string, required: true

  model_validator :validate_username_unique
  model_validator :validate_email_format
  model_validator :send_welcome_email
end

Data Transformation

Model validators can transform the data by returning modified data:

def normalize_email(input) do
  normalized = %{input | email: String.downcase(input.email)}
  {:ok, normalized}
end

optional()

(macro)
@spec optional() :: Macro.t()

Marks the field as optional. An optional field may be omitted from the input data during validation.

Examples

field :middle_name, :string do
  optional()
end

field :bio, :string do
  optional()
  max_length(500)
end

required()

(macro)
@spec required() :: Macro.t()

Marks the field as required (this is the default behavior). A required field must be present in the input data during validation.

Examples

field :email, :string do
  required()
  format(~r/@/)
end

field :name, :string do
  required()
  min_length(1)
end

schema(description \\ nil, list)

(macro)
@spec schema(
  String.t() | nil,
  keyword()
) :: Macro.t()

Defines a new schema with optional description.

Parameters

  • description - Optional string describing the schema's purpose
  • do - Block containing field definitions and configuration

Examples

schema "User registration data" do
  field :name, :string do
    required()
    min_length(2)
  end

  field :age, :integer do
    optional()
    gt(0)
  end
end

schema do
  field :email, :string
  field :active, :boolean, default: true
end

strict(bool)

(macro)
@spec strict(boolean()) :: Macro.t()

Sets whether the schema should enforce strict validation. When strict is true, unknown fields will cause validation to fail.

Parameters

  • bool - Boolean indicating if strict validation should be enabled

Examples

config do
  strict(true)
end

config do
  title("Flexible Schema")
  strict(false)
end

title(text)

(macro)
@spec title(String.t()) :: Macro.t()

Sets the title for the schema configuration.

Parameters

  • text - String title for the schema

Examples

config do
  title("User Schema")
end

config do
  title("Product Validation Schema")
  strict(true)
end