Skip to content

Commit

Permalink
Add constraint support (#119)
Browse files Browse the repository at this point in the history
* Add constraint support

* Use custom constraint macro

* Test multiple constraints

* Avoid having to deal with macro name collisions

* Avoid requiring Legolas in caller scope

* Fix tests on Julia 1.6

* Update location comment

* Add fail-safe comment

* Set project version to 0.5.21

* Increase coverage

* Add constraints into the tour

* Add docstring for `@check`
  • Loading branch information
omus authored Aug 29, 2024
1 parent 69e11c7 commit 7a50019
Show file tree
Hide file tree
Showing 7 changed files with 173 additions and 5 deletions.
2 changes: 1 addition & 1 deletion Project.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name = "Legolas"
uuid = "741b9549-f6ed-4911-9fbf-4a1c0c97f0cd"
authors = ["Beacon Biosignals, Inc."]
version = "0.5.20"
version = "0.5.21"

[deps]
Arrow = "69666777-d1a9-59fb-9406-91d4454c9d45"
Expand Down
1 change: 1 addition & 0 deletions docs/src/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ CurrentModule = Legolas
Legolas.SchemaVersion
Legolas.@schema
Legolas.@version
Legolas.@check
Legolas.is_valid_schema_name
Legolas.parse_identifier
Legolas.name
Expand Down
47 changes: 47 additions & 0 deletions examples/tour.jl
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,53 @@ end
# of application of constraints (i.e. the parent's are applied before the child's). Lastly, `>` aligns well with the
# property that child schema versions have a greater number of constraints than their parents.

#####
##### Constraints
#####

# Schema authors may want to restrict the allowed values for a field without having to define a new type. The `@check`
# macro provides this functionality in a concise way and provides user friendly error messages

@schema "example.finite-positive" FinitePositive
@version FinitePositiveV1 begin
a::Real
@check a > 0
@check isfinite(a)
end

# We recommend defining multiple constraints instead of combining them into one (e.g. `@check a > 0 && isfinite(a)`)
# as this makes error message clearer:

@test NamedTuple(FinitePositiveV1(; a=1)) == (a=1,)
@test_throws Legolas.CheckConstraintError(:(a > 0)) FinitePositiveV1(; a=-1)
@test_throws Legolas.CheckConstraintError(:(isfinite(a))) FinitePositiveV1(; a=Inf)

# All `@check` constraints must be defined after the fields and any processing on the field will occur before the
# constraints are checked:

@schema "example.clamp" Clamp
@version ClampV1 begin
a
b = clamp(b, 1, 5)
@check a == b
end

@test NamedTuple(ClampV1(; a=1, b=0)) == (a=1, b=1)

# One use case supported by constraints is enforcing mutually exclusive use of multiple fields:

@schema "example.mutually-exclusive" MutuallyExclusive
@version MutuallyExclusiveV1 begin
x::Union{Real,Missing}
y::Union{String,Missing}
z::Union{Char,Missing}
@check !ismissing(x) !ismissing(y) !ismissing(z) # `⊻` is the `xor` function
end

@test_throws Legolas.CheckConstraintError MutuallyExclusiveV1(; x=1, y="hi")
@test_throws Legolas.CheckConstraintError MutuallyExclusiveV1(; x=1, z='a')
@test isequal(NamedTuple(MutuallyExclusiveV1(; x=1)), (x=1, y=missing, z=missing))

#####
##### Schema Versioning
#####
Expand Down
1 change: 1 addition & 0 deletions src/Legolas.jl
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ using Tables, Arrow, UUIDs
const LEGOLAS_SCHEMA_QUALIFIED_METADATA_KEY = "legolas_schema_qualified"

include("lift.jl")
include("constraints.jl")
include("schemas.jl")
include("tables.jl")
include("record_merge.jl")
Expand Down
24 changes: 24 additions & 0 deletions src/constraints.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
struct CheckConstraintError <: Exception
predicate::Expr
end

function Base.showerror(io::IO, ex::CheckConstraintError)
print(io, "$CheckConstraintError: $(ex.predicate)")
return nothing
end

"""
@check expr
Define a constraint for a schema version (e.g. `@check x > 0`) from a boolean expression.
The `expr` should evaulate to `true` if the constraint is met or `false` if the constraint
is violated. Multiple constraints may be defined for a schema version. All `@check`
constraints defined with a [`@version`](@ref) must proceed all fields defined by the schema
version.
For more details and examples, please see `Legolas.jl/examples/tour.jl`.
"""
macro check(expr)
quoted_expr = QuoteNode(expr)
return :($(esc(expr)) || throw(CheckConstraintError($quoted_expr)))
end
27 changes: 24 additions & 3 deletions src/schemas.jl
Original file line number Diff line number Diff line change
Expand Up @@ -520,7 +520,7 @@ _schema_version_from_record_type(::Nothing) = nothing
# includes the parent's declared field RHS statements. We cannot interpolate/incorporate these statements
# in the child's record type definition because they may reference bindings from the parent's `@version`
# callsite that are not available/valid at the child's `@version` callsite.
function _generate_record_type_definitions(schema_version::SchemaVersion, record_type_symbol::Symbol)
function _generate_record_type_definitions(schema_version::SchemaVersion, record_type_symbol::Symbol, constraints::AbstractVector)
# generate `schema_version_type_alias_definition`
T = Symbol(string(record_type_symbol, "SchemaVersion"))
schema_version_type_alias_definition = :(const $T = $(Base.Meta.quot(typeof(schema_version))))
Expand Down Expand Up @@ -616,6 +616,7 @@ function _generate_record_type_definitions(schema_version::SchemaVersion, record
function $R(; $(field_kwargs...))
$parent_record_application
$(field_assignments...)
$(constraints...)
return new($(keys(record_fields)...))
end
end
Expand All @@ -625,11 +626,13 @@ function _generate_record_type_definitions(schema_version::SchemaVersion, record
function $R{$(type_param_names...)}(; $(field_kwargs...)) where {$(type_param_names...)}
$parent_record_application
$(parametric_field_assignments...)
$(constraints...)
return new{$(type_param_names...)}($(keys(record_fields)...))
end
function $R(; $(field_kwargs...))
$parent_record_application
$(field_assignments...)
$(constraints...)
return new{$((:(typeof($n)) for n in names_of_parameterized_fields)...)}($(keys(record_fields)...))
end
end
Expand Down Expand Up @@ -778,8 +781,25 @@ macro version(record_type, declared_fields_block=nothing)

# parse `declared_fields_block`
declared_field_statements = Any[]
declared_constraint_statements = Any[]
if declared_fields_block isa Expr && declared_fields_block.head == :block && !isempty(declared_fields_block.args)
declared_field_statements = [f for f in declared_fields_block.args if !(f isa LineNumberNode)]
for f in declared_fields_block.args
if f isa LineNumberNode
continue
elseif f isa Expr && f.head === :macrocall && f.args[1] === Symbol("@check")
constraint_expr = Base.macroexpand(Legolas, f)
# Update the expression such that a failure shows the location of the user
# defined `@check` call. Ideally `Meta.replace_sourceloc!` would do this.
if f.args[2] isa LineNumberNode
constraint_expr = Expr(:block, f.args[2], constraint_expr)
end
push!(declared_constraint_statements, constraint_expr)
elseif isempty(declared_constraint_statements)
push!(declared_field_statements, f)
else
return :(throw(SchemaVersionDeclarationError("all `@version` field expressions must be defined before constraints:\n", $(Base.Meta.quot(declared_fields_block)))))
end
end
end
declared_field_infos = DeclaredFieldInfo[]
for stmt in declared_field_statements
Expand All @@ -800,6 +820,7 @@ macro version(record_type, declared_fields_block=nothing)
return :(throw(SchemaVersionDeclarationError($msg)))
end
declared_field_names_types = Expr(:tuple, Expr(:parameters, (Expr(:kw, f.name, esc(f.type)) for f in declared_field_infos)...))
constraints = [Base.Meta.quot(ex) for ex in declared_constraint_statements]

return quote
if !isdefined((@__MODULE__), :__legolas_schema_name_from_prefix__)
Expand Down Expand Up @@ -827,7 +848,7 @@ macro version(record_type, declared_fields_block=nothing)
Base.@__doc__($(Base.Meta.quot(record_type)))
$(esc(:eval))($Legolas._generate_schema_version_definitions(schema_version, parent, $declared_field_names_types, schema_version_declaration))
$(esc(:eval))($Legolas._generate_validation_definitions(schema_version))
$(esc(:eval))($Legolas._generate_record_type_definitions(schema_version, $(Base.Meta.quot(record_type))))
$(esc(:eval))($Legolas._generate_record_type_definitions(schema_version, $(Base.Meta.quot(record_type)), [$(constraints...)]))
end
end
nothing
Expand Down
76 changes: 75 additions & 1 deletion test/runtests.jl
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Compat: current_exceptions
using Legolas, Test, DataFrames, Arrow, UUIDs
using Legolas: SchemaVersion, @schema, @version, SchemaVersionDeclarationError, DeclaredFieldInfo
using Legolas: @schema, @version, CheckConstraintError, SchemaVersion,
SchemaVersionDeclarationError, DeclaredFieldInfo
using Accessors
using Aqua

Expand Down Expand Up @@ -817,3 +818,76 @@ end
@test r.i isa UInt16
@test r.i == 1
end

#####
##### constraints
#####

@schema "test.constraint" Constraint

const CONSTRAINT_V1_EQUAL_CONSTRAINT_LINE = @__LINE__() + 4
@version ConstraintV1 begin
a
b = clamp(b, 0, 5)
@check a == b
@check a > 0
end

@testset "constraints" begin
r = ConstraintV1(; a=1, b=1)
@test r isa ConstraintV1
@test r.a === 1
@test r.b === 1

r = ConstraintV1(; a=1, b=1.0)
@test r isa ConstraintV1
@test r.a === 1
@test r.b === 1.0

# In Julia 1.8+ we can test can test against "CheckConstraintError: a == b"
try
ConstraintV1(; a=1, b=2)
@test false # Fail safe if the above line doesn't throw
catch e
@test e isa CheckConstraintError
@test e.predicate == :(a == b)
end

try
ConstraintV1(; a=0, b=0)
@test false # Fail safe if the above line doesn't throw
catch e
@test e isa CheckConstraintError
@test e.predicate == :(a > 0)
end

try
ConstraintV1(; a=6, b=6)
@test false # Fail safe if the above line doesn't throw
catch e
@test e isa CheckConstraintError
@test e.predicate == :(a == b)
end

# For exceptions that occur during processing constraints its convenient to include the
# location of the `@check` in the stacktrace.
try
ConstraintV1(; a=1, b=missing) # Fails when running check `a == b`
@test false # Fail safe if the above line doesn't throw
catch e
@test e isa TypeError

bt = Base.process_backtrace(catch_backtrace())
sf = bt[1][1]::Base.StackFrame
@test string(sf.file) == @__FILE__
@test sf.line == CONSTRAINT_V1_EQUAL_CONSTRAINT_LINE
end
end

@testset "constraints must be after all fields" begin
@test_throws SchemaVersionDeclarationError @version(ConstraintV2, begin a; @check a == 1; b end)
end

@testset "CheckConstraintError" begin
@test sprint(showerror, CheckConstraintError(:(1 == 2))) == "CheckConstraintError: 1 == 2"
end

2 comments on commit 7a50019

@omus
Copy link
Member Author

@omus omus commented on 7a50019 Aug 29, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@JuliaRegistrator
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Registration pull request created: JuliaRegistries/General/114111

Tip: Release Notes

Did you know you can add release notes too? Just add markdown formatted text underneath the comment after the text
"Release notes:" and it will be added to the registry PR, and if TagBot is installed it will also be added to the
release that TagBot creates. i.e.

@JuliaRegistrator register

Release notes:

## Breaking changes

- blah

To add them here just re-invoke and the PR will be updated.

Tagging

After the above pull request is merged, it is recommended that a tag is created on this repository for the registered package version.

This will be done automatically if the Julia TagBot GitHub Action is installed, or can be done manually through the github interface, or via:

git tag -a v0.5.21 -m "<description of version>" 7a50019920edd1ffaf929585cb50a77b8cc3ebeb
git push origin v0.5.21

Please sign in to comment.