Skip to content
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Changelog

## Unreleased

### Bug Fixes and Improvements

* Count Unicode code points for `minLength` and `maxLength` without allocating a charlist for inputs larger than 256 bytes; retain standard conversion for inputs up to 256 bytes. Malformed UTF-8 now returns a validation error with `error_detail: "invalid_utf8"` instead of raising `UnicodeConversionError`.
* Compile each `patternProperties` regex once when the same schema also uses `additionalProperties`, with a bounded per-schema cache that preserves vocabulary gates, error behavior, and validation semantics.
* Reuse runtime pattern-match classification between sibling `patternProperties` and `additionalProperties` rules within one validation invocation, reducing repeated regex matching for wide objects while preserving rule order, annotations, and diagnostics.
* Adaptively merge evaluated keys from parent-dominated `anyOf` branches with balanced `MapSet` unions, avoiding repeated conversion of shared incoming evaluated keys while preserving full branch traversal, evaluated-key inputs, diagnostics, and legacy list-key behavior.
* Avoid rebuilding already-flat validation error lists at the public validation boundary, and fuse nested error flattening with legacy error formatting while preserving diagnostic order, values, and internal error grouping.
* Remove redundant input-key presence checks when validating `required`, while preserving declaration order, duplicate entries, non-object behavior, and diagnostic shape.
* Avoid rescanning built-in Draft 2020-12 resources after compilation, and reduce scope-scanner traversal intermediates while preserving resource identities, anchor targets, and traversal precedence.

## v0.9.3 (2026-09-07)

### Bug Fixes and Improvements
Expand Down
114 changes: 114 additions & 0 deletions bench/compiler_regex_cache.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# Run from the repository root:
# mix run bench/compiler_regex_cache.exs
# mix run bench/compiler_regex_cache.exs --baseline d3864cf
#
# Loads only the selected compiler source in memory. Input construction, source
# loading, warmup, and explicit GC are outside the timed intervals.

root = Path.expand("..", __DIR__)
compiler_path = Path.join(root, "lib/jsonschex/compiler.ex")

{source, source_label} =
case System.argv() do
[] ->
{File.read!(compiler_path), "working tree (snapshot at script startup)"}

["--baseline", revision] ->
case System.cmd("git", ["--no-pager", "show", "#{revision}:lib/jsonschex/compiler.ex"], cd: root) do
{source, 0} -> {source, "git #{revision}:lib/jsonschex/compiler.ex"}
{output, status} -> raise "Cannot load baseline (exit #{status}): #{output}"
end

_ ->
raise "Usage: mix run bench/compiler_regex_cache.exs [--baseline REVISION]"
end

previous_options = Code.compiler_options(ignore_module_conflict: true)

try do
Code.compile_string(source, compiler_path)
after
Code.compiler_options(previous_options)
end

fingerprint = :crypto.hash(:sha256, source) |> Base.encode16(case: :lower)

IO.puts("Compiler: #{source_label}; sha256=#{fingerprint}")
IO.puts("Elixir #{System.version()}, OTP #{System.otp_release()}, schedulers #{System.schedulers_online()}")
IO.puts("Median ms/op; five adaptive batches targeting 20 ms; construction, source loading, warmup and GC excluded")

# Patterns use both ECMA property and whitespace transformations, so the sibling
# pass represents real regex preparation rather than only trivial Regex.compile/2 work.
defmodule CompilerRegexCacheBench do
alias JSONSchex.Types.Schema

@widths [0, 1, 16, 32, 33, 128, 512]

def cases do
Enum.flat_map(@widths, fn width ->
[
{"patternProperties only width=#{width}", schema(width, :none)},
{"patternProperties + additionalProperties=false width=#{width}", schema(width, false)},
{"patternProperties + additionalProperties=true width=#{width}", schema(width, true)}
]
end)
end

def measure({label, schema}) do
compile = fn ->
{:ok, %Schema{}} = JSONSchex.compile(schema)
:ok
end

compile.()
:erlang.garbage_collect()
{calibration_us, :ok} = :timer.tc(compile)
count = min(10_000, max(1, div(20_000, max(1, calibration_us))))

samples =
for _ <- 1..5 do
:erlang.garbage_collect()

{elapsed_us, :ok} =
:timer.tc(fn ->
Enum.each(1..count, fn _ -> compile.() end)
end)

elapsed_us / count / 1_000
end

median = samples |> Enum.sort() |> Enum.at(2)

IO.puts(
"#{label}: median_ms=#{Float.round(median, 4)} " <>
"(count=#{count}; min_ms=#{Float.round(Enum.min(samples), 4)} " <>
"max_ms=#{Float.round(Enum.max(samples), 4)})"
)
end

defp schema(width, additional) do
patterns = pattern_map(width)
schema = %{"patternProperties" => patterns}

case additional do
:none -> schema
value -> Map.put(schema, "additionalProperties", value)
end
end

defp pattern_map(0), do: %{}

defp pattern_map(width) do
Map.new(1..width, fn index ->
{"^\\p{Letter}" <> Integer.to_string(index) <> "\\s*$", true}
end)
end
end

# A hard timeout prevents reporting an incomplete comparison as successful.
task = Task.async(fn -> Enum.each(CompilerRegexCacheBench.cases(), &CompilerRegexCacheBench.measure/1) end)

case Task.yield(task, 50_000) || Task.shutdown(task, :brutal_kill) do
{:ok, :ok} -> :ok
_ -> raise "Benchmark exceeded the 50 s measurement budget; results are incomplete"
end
Loading