diff --git a/Project.toml b/Project.toml index eda87365..0a301825 100644 --- a/Project.toml +++ b/Project.toml @@ -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" diff --git a/docs/src/index.md b/docs/src/index.md index 55b4b52e..e366f78d 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -14,6 +14,7 @@ CurrentModule = Legolas Legolas.SchemaVersion Legolas.@schema Legolas.@version +Legolas.@check Legolas.is_valid_schema_name Legolas.parse_identifier Legolas.name diff --git a/examples/tour.jl b/examples/tour.jl index ac0327f7..2ee88fcd 100644 --- a/examples/tour.jl +++ b/examples/tour.jl @@ -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 ##### diff --git a/src/Legolas.jl b/src/Legolas.jl index b43de7a6..9c27d940 100644 --- a/src/Legolas.jl +++ b/src/Legolas.jl @@ -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") diff --git a/src/constraints.jl b/src/constraints.jl new file mode 100644 index 00000000..086472c7 --- /dev/null +++ b/src/constraints.jl @@ -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 diff --git a/src/schemas.jl b/src/schemas.jl index 5704aafd..6d01bd19 100644 --- a/src/schemas.jl +++ b/src/schemas.jl @@ -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)))) @@ -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 @@ -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 @@ -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 @@ -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__) @@ -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 diff --git a/test/runtests.jl b/test/runtests.jl index 116c1e56..27443691 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -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 @@ -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