Skip to content

Commit

Permalink
fix cross-module schema/version declaration behaviors (#66)
Browse files Browse the repository at this point in the history
  • Loading branch information
jrevels authored Nov 4, 2022
1 parent 30a2dad commit bae8a4f
Show file tree
Hide file tree
Showing 3 changed files with 114 additions and 79 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.1"
version = "0.5.2"

[deps]
Arrow = "69666777-d1a9-59fb-9406-91d4454c9d45"
Expand Down
141 changes: 72 additions & 69 deletions src/schemas.jl
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ abstract type AbstractRecord <: Tables.AbstractRow end
##### `@schema`
#####

schema_name_from_prefix(::Val) = nothing
_schema_declared_in_module(::Val) = nothing

"""
@schema "name" Prefix
Expand All @@ -316,7 +316,18 @@ macro schema(schema_name, schema_prefix)
is_valid_schema_name(schema_name) || return :(throw(ArgumentError("`name` provided to `@schema` is not a valid `Legolas.SchemaVersion` name: \"" * $schema_name * "\"")))
schema_prefix isa Symbol || return :(throw(ArgumentError(string("`Prefix` provided to `@schema` is not a valid type name: ", $(Base.Meta.quot(schema_prefix))))))
return quote
Legolas.schema_name_from_prefix(::Val{$(Base.Meta.quot(schema_prefix))}) = $schema_name
# This approach provides some safety against accidentally replacing another module's schema's name,
# without making it annoying to reload code/modules in an interactice development context.
m = Legolas._schema_declared_in_module(Val(Symbol($schema_name)))
if m isa Module && string(m) != string(@__MODULE__)
throw(ArgumentError(string("A schema with this name was already declared by a different module: ", m)))
else
Legolas._schema_declared_in_module(::Val{Symbol($schema_name)}) = @__MODULE__
if !isdefined(@__MODULE__, :__legolas_schema_name_from_prefix__)
$(esc(:__legolas_schema_name_from_prefix__))(::Val) = nothing
end
$(esc(:__legolas_schema_name_from_prefix__))(::Val{$(Base.Meta.quot(schema_prefix))}) = $(Base.Meta.quot(Symbol(schema_name)))
end
end
end

Expand Down Expand Up @@ -344,6 +355,9 @@ function Base.showerror(io::IO, e::SchemaVersionDeclarationError)
- `@version` declarations must list at least one required field,
and must not list duplicate fields within the same declaration.
- New versions of a given schema may only be declared within the same
module that declared the schema.
""")
end

Expand Down Expand Up @@ -379,14 +393,6 @@ function _has_valid_child_field_types(child_fields::NamedTuple, parent_fields::N
return true
end

_validate_wrt_parent(::NamedTuple, ::Nothing) = nothing

function _validate_wrt_parent(child_fields::NamedTuple, parent::SchemaVersion)
declared(parent) || throw(SchemaVersionDeclarationError("parent schema version cannot be used before it has been declared: $parent"))
_has_valid_child_field_types(child_fields, required_fields(parent)) || throw(SchemaVersionDeclarationError("declared field types violate parent's field types"))
return nothing
end

function _check_for_expected_field(schema::Tables.Schema, name::Symbol, ::Type{T}) where {T}
i = findfirst(==(name), schema.names)
if isnothing(i)
Expand All @@ -397,6 +403,23 @@ function _check_for_expected_field(schema::Tables.Schema, name::Symbol, ::Type{T
return nothing
end

function _generate_schema_version_definitions(schema_version::SchemaVersion, parent, declared_field_names_types, schema_version_declaration)
identifier_string = string(name(schema_version), '@', version(schema_version))
required_field_names_types = declared_field_names_types
if !isnothing(parent)
identifier_string = string(identifier_string, '>', Legolas.identifier(parent))
required_field_names_types = merge(Legolas.required_fields(parent), required_field_names_types)
end
quoted_schema_version_type = Base.Meta.quot(typeof(schema_version))
return quote
@inline Legolas.declared(::$quoted_schema_version_type) = true
@inline Legolas.identifier(::$quoted_schema_version_type) = $identifier_string
@inline Legolas.parent(::$quoted_schema_version_type) = $(Base.Meta.quot(parent))
Legolas.required_fields(::$quoted_schema_version_type) = $required_field_names_types
Legolas.declaration(::$quoted_schema_version_type) = $(Base.Meta.quot(schema_version_declaration))
end
end

function _generate_validation_definitions(schema_version::SchemaVersion)
field_violation_check_statements = Expr[]
for (fname, ftype) in pairs(required_fields(schema_version))
Expand All @@ -415,7 +438,8 @@ function _generate_validation_definitions(schema_version::SchemaVersion)
end
end

function _record_type end # overloaded by `@version`
_record_type_from_schema_version(::Nothing) = nothing
_schema_version_from_record_type(::Nothing) = nothing

# Note also that this function's implementation is allowed to "observe" `Legolas.required_fields(parent)`
# (if a parent exists), but is NOT allowed to "observe" `Legolas.declaration(parent)`, since the latter
Expand Down Expand Up @@ -458,7 +482,7 @@ function _generate_record_type_definitions(schema_version::SchemaVersion, record
parent = Legolas.parent(schema_version)
if !isnothing(parent)
p = gensym()
P = Base.Meta.quot(_record_type(parent))
P = Base.Meta.quot(_record_type_from_schema_version(parent))
parent_record_field_names = keys(required_fields(parent))
parent_record_application = quote
$p = $P(; $(parent_record_field_names...))
Expand Down Expand Up @@ -510,7 +534,8 @@ function _generate_record_type_definitions(schema_version::SchemaVersion, record
end

# generate `arrow_overload_definitions`
record_type_arrow_name = Base.Meta.quot(Symbol("JuliaLang.Legolas.Generated.$R"))
record_type_arrow_name = string("JuliaLang.Legolas.Generated.", Legolas.name(schema_version), '.', Legolas.version(schema_version))
record_type_arrow_name = Base.Meta.quot(Symbol(record_type_arrow_name))
arrow_overload_definitions = quote
$Arrow.ArrowTypes.arrowname(::Type{<:$R}) = $record_type_arrow_name
$Arrow.ArrowTypes.ArrowType(::Type{R}) where {R<:$R} = NamedTuple{fieldnames(R),Tuple{fieldtypes(R)...}}
Expand All @@ -528,6 +553,8 @@ function _generate_record_type_definitions(schema_version::SchemaVersion, record
$outer_constructor_definitions
$base_overload_definitions
$arrow_overload_definitions
Legolas._record_type_from_schema_version(::$(Base.Meta.quot(typeof(schema_version)))) = $R
Legolas._schema_version_from_record_type(::Type{<:$R}) = $schema_version
end
end

Expand All @@ -537,16 +564,11 @@ function _parse_record_type_symbol(t::Symbol)
p, v = pv
p = Symbol(p)
v = tryparse(Int, v)
if v isa Int
n = schema_name_from_prefix(Val(p))
n isa String || return SchemaVersionDeclarationError("provided record type symbol references undeclared schema: ", t)
return n, p, v
end
v isa Int && return (p, v)
end
return SchemaVersionDeclarationError("provided record type symbol is malformed: ", t)
end


"""
@version RecordType begin
required_field_expression_1
Expand All @@ -564,7 +586,7 @@ Given a prior `@schema` declaration of the form:
@schema "example.name" Name
...the `n`th version of `example.name` can be declared via a `@version` declaration of the form:
...the `n`th version of `example.name` can be declared in the same module via a `@version` declaration of the form:
@version NameV\$(n) begin
required_field_expression_1
Expand All @@ -578,7 +600,7 @@ necessary definitions to overload relevant Legolas methods with specialized beha
the declared required fields.
If the declared schema version has a parent, it should be specified via the optional `> ParentRecordType`
clause.
clause. `ParentRecordType` should refer directly to an existing Legolas-generated record type.
Each `required_field_expression` specifies a required field of the declared schema version, and is an
expression of the form `field::F = rhs` where:
Expand Down Expand Up @@ -629,33 +651,20 @@ For more details and examples, please see `Legolas.jl/examples/tour.jl` and the
"Schema-Related Concepts/Conventions" section of the Legolas.jl documentation.
"""
macro version(record_type, required_field_statements)
# parse `record_type`
if record_type isa Symbol
parent_record_type = nothing
elseif record_type isa Expr && record_type.head == :call && length(record_type.args) == 3 &&
record_type.args[1] == :> &&
record_type.args[2] isa Symbol &&
record_type.args[3] isa Symbol
record_type.args[1] == :> && record_type.args[2] isa Symbol
parent_record_type = record_type.args[3]
record_type = record_type.args[2]
else
return :(throw(SchemaVersionDeclarationError("provided record type expression is malformed: ", $(Base.Meta.quot(record_type)))))
end

x = _parse_record_type_symbol(record_type)
x isa SchemaVersionDeclarationError && return :(throw($x))
schema_name, _, schema_integer = x
schema_version = SchemaVersion(schema_name, schema_integer)
quoted_schema_version = Base.Meta.quot(schema_version)
quoted_schema_version_type = Base.Meta.quot(typeof(schema_version))
parent = nothing
if !isnothing(parent_record_type)
x = _parse_record_type_symbol(parent_record_type)
x isa SchemaVersionDeclarationError && return :(throw($x))
parent_name, _, parent_integer = x
parent_name == schema_name && return :(throw(SchemaVersionDeclarationError("cannot extend from a different version of the same schema")))
parent = SchemaVersion(parent_name, parent_integer)
end
quoted_parent = Base.Meta.quot(parent)
schema_prefix, schema_version_integer = x
quoted_schema_prefix = Base.Meta.quot(schema_prefix)

# parse `required_field_statements`
if !(required_field_statements isa Expr && required_field_statements.head == :block && !isempty(required_field_statements.args))
Expand All @@ -675,41 +684,35 @@ macro version(record_type, required_field_statements)
msg = string("cannot have duplicate field names in `@version` declaration; recieved: ", [f.name for f in required_field_infos])
return :(throw(SchemaVersionDeclarationError($msg)))
end
field_names_types = Expr(:tuple, (:($(f.name) = $(esc(f.type))) for f in required_field_infos)...)

# basic accessor function definitions
full_identifier_string = string(schema_name, '@', schema_integer)
child_identifier_string = full_identifier_string
required_field_names_types = field_names_types
if !isnothing(parent)
full_identifier_string = :(string($full_identifier_string, '>', Legolas.identifier($quoted_parent)))
child_identifier_string = string(child_identifier_string, '>', name(parent), '@', version(parent))
required_field_names_types = :(merge(Legolas.required_fields($quoted_parent), $required_field_names_types))
end
schema_version_declaration = :($child_identifier_string => copy($(Base.Meta.quot(required_field_infos))))
check_against_declaration = :($child_identifier_string => $(Base.Meta.quot(required_field_infos)))
declared_field_names_types = Expr(:tuple, (:($(f.name) = $(esc(f.type))) for f in required_field_infos)...)

return quote
if Legolas.declared($quoted_schema_version) && Legolas.declaration($quoted_schema_version) != $check_against_declaration
throw(SchemaVersionDeclarationError("invalid redeclaration of existing schema version; all `@version` redeclarations must exactly match previous declarations"))
if !isdefined((@__MODULE__), :__legolas_schema_name_from_prefix__)
throw(SchemaVersionDeclarationError("no prior `@schema` declaration found in current module"))
elseif isnothing((@__MODULE__).__legolas_schema_name_from_prefix__(Val($quoted_schema_prefix)))
throw(SchemaVersionDeclarationError(string("missing prior `@schema` declaration for `", $quoted_schema_prefix, "` in current module")))
else
Legolas._validate_wrt_parent($field_names_types, $quoted_parent)

@inline Legolas.declared(::$quoted_schema_version_type) = true
schema_name = (@__MODULE__).__legolas_schema_name_from_prefix__(Val($quoted_schema_prefix))
schema_version = Legolas.SchemaVersion{schema_name,$schema_version_integer}()
parent = Legolas._schema_version_from_record_type($(esc(parent_record_type)))

@inline Legolas.identifier(::$quoted_schema_version_type) = $full_identifier_string

@inline Legolas.parent(::$quoted_schema_version_type) = $quoted_parent

Legolas.required_fields(::$quoted_schema_version_type) = $required_field_names_types

Legolas.declaration(::$quoted_schema_version_type) = $schema_version_declaration

$(esc(:eval))(Legolas._generate_validation_definitions($quoted_schema_version))

$(esc(:eval))(Legolas._generate_record_type_definitions($quoted_schema_version, $(Base.Meta.quot(record_type))))

Legolas._record_type(::$quoted_schema_version_type) = $(esc(record_type))
declared_identifier = string(schema_name, '@', $schema_version_integer)
if !isnothing(parent)
declared_identifier = string(declared_identifier, '>', Legolas.name(parent), '@', Legolas.version(parent))
end
schema_version_declaration = declared_identifier => $(Base.Meta.quot(required_field_infos))

if Legolas.declared(schema_version) && Legolas.declaration(schema_version) != schema_version_declaration
throw(SchemaVersionDeclarationError("invalid redeclaration of existing schema version; all `@version` redeclarations must exactly match previous declarations"))
elseif parent isa Legolas.SchemaVersion && Legolas.name(parent) == schema_name
throw(SchemaVersionDeclarationError("cannot extend from another version of the same schema"))
elseif parent isa Legolas.SchemaVersion && !(Legolas._has_valid_child_field_types($declared_field_names_types, Legolas.required_fields(parent)))
throw(SchemaVersionDeclarationError("declared field types violate parent's field types"))
else
$(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))))
end
end
nothing
end
Expand Down
Loading

2 comments on commit bae8a4f

@jrevels
Copy link
Member Author

@jrevels jrevels commented on bae8a4f Nov 4, 2022

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/71646

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.2 -m "<description of version>" bae8a4f4a4c23e69e04a8385dc4921a312556986
git push origin v0.5.2

Please sign in to comment.