Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

implement multiple inheritance #107

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.16"
version = "0.6.0"
Copy link
Member Author

Choose a reason for hiding this comment

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

#106 will presumably land before this, in which case this should be bumped again


[deps]
Arrow = "69666777-d1a9-59fb-9406-91d4454c9d45"
Expand Down
91 changes: 91 additions & 0 deletions examples/tour.jl
Original file line number Diff line number Diff line change
Expand Up @@ -424,3 +424,94 @@ end
#
# Schema version authors should feel free to override these `Legolas.accepted_field_type` definitions (and/or add new definitions)
# for their own `SchemaVersion` types.

#####
##### Multiple Inheritance
#####

@schema "example.overlapping" Overlapping

# fields overlap with both BazV1 and FooV2, but it extends neither
@version OverlappingV1 begin
c::Union{Int64,Float64}
y::String
k::Int64
o::String
end

@schema "example.mult" Mult

@version MultV1 > [BazV1, OverlappingV1, FooV2] begin
# inherited fields:
# x::Int8 (BazV1)
# y::String (BazV1×OverlappingV1)
# z::String (BazV1)
# k::Int64 (BazV1×OverlappingV1)
# c::Int64 (OverlappingV1×FooV2)
# o::String (OverlappingV1)
# a::Float64 (FooV2)
# b::String (FooV2)
# d::Vector (FooV2)
m::String
end

# `MultV1`'s generated constructor looks roughly like:
#
# function MultV1(; a=missing, b=missing, c=missing, d=missing,
# x=missing, y=missing, z=missing, k=missing,
# o=missing, m=missing)
# __p__ = Baz1(; x, y, z, k)
# x, y, z, k = __p__.x, __p__.y, __p__.z, __p__.k
# __p__ = OverlappingV1(; c, y, k, o)
# c, y, k, o = __p__.c, __p__.y, __p__.k, __p__.o
# __p__ = FooV2(; a, b, c, d)
# a, b, c, d = __p__.a, __p__.b, __p__.c, __p__.d
# m::String = m
# return new(x, y, z, k, c, o, a, b, d, m)
# end

@test MultV1(; a=1.0, b="b", c=3, d=["d"], x=Int8(5), y="y", z="z", k=8, o="o", m="m") isa MultV1

#=
# properly throws an error, since FooV2 cannot be a "downstream coparent" of OverlappingV1
@schema "example.incompatible" Incompatible
@version IncompatibleV1 > [BazV1, FooV2, OverlappingV1] begin
m::String
end
=#

#=
# properly throws an error, since you can't extend an earlier version of the same schema
@version MultV2 > [BazV1, OverlappingV1, MultV1, FooV2] begin
m::String
end
=#

#=
# check that fan in/out hiearchy works

@schema "example.fan-root" FanRoot
@version FanRootV1 begin
x
end

@schema "example.fan-left" FanLeft
@version FanLeftV1 > FanRootV1 begin
# TODO
end

@schema "example.fan-middle" FanMiddle
@version FanMiddleV1 > FanRootV1 begin
# TODO
end

@schema "example.fan-right" FanRight
@version FanRightV1 > FanRootV1 begin
# TODO
end

@schema "example.fan-child" FanChild
@version FanChildV1 > [FanLeftV1, FanMiddleV1, FanRightV1] begin
# TODO
end
=#
101 changes: 68 additions & 33 deletions src/schemas.jl
Original file line number Diff line number Diff line change
Expand Up @@ -142,11 +142,17 @@ Return `v`.
@inline version(::SchemaVersion{n,v}) where {n,v} = v

"""
Legolas.parent(sv::Legolas.SchemaVersion)
Legolas.parents(sv::Legolas.SchemaVersion)

Return the `Legolas.SchemaVersion` instance that corresponds to `sv`'s declared parent.
Return the `Tuple` of `Legolas.SchemaVersion` instances corresponding to `sv`'s declared parents.
"""
@inline parent(::SchemaVersion) = nothing
@inline parents(::SchemaVersion) = ()

# xref https://github.com/beacon-biosignals/Legolas.jl/pull/107
@deprecate parent(s) begin
p = Legolas.parents(s)
isempty(p) ? nothing : only(p)
end false

"""
Legolas.declared(sv::Legolas.SchemaVersion{name,version})
Expand All @@ -172,7 +178,7 @@ Return a `NamedTuple{...,Tuple{Vararg{DataType}}` whose fields take the form:

<name of field declared by `sv`> = <field's type>

If `sv` has a parent, the returned fields will include `declared_fields(parent(sv))`.
If `sv` has parents, the returned fields will include the declared fields of its parents.
"""
declared_fields(sv::SchemaVersion) = throw(UnknownSchemaVersionError(sv))

Expand All @@ -193,7 +199,7 @@ where `DeclaredFieldInfo` has the fields:
- `statement::Expr`: the declared field's full assignment statement (as processed by `@version`, not necessarily as written)

Note that `declaration` is primarily intended to be used for interactive discovery purposes, and
does not include the contents of `declaration(parent(sv))`.
does not include the results of `declaration` on any of `parents(sv)`.
"""
declaration(sv::SchemaVersion) = throw(UnknownSchemaVersionError(sv))

Expand Down Expand Up @@ -483,18 +489,26 @@ 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)
function _generate_schema_version_definitions(schema_version::SchemaVersion, parents, declared_field_names_types, schema_version_declaration)
identifier_string = string(name(schema_version), '@', version(schema_version))
declared_field_names_types = declared_field_names_types
if !isnothing(parent)
identifier_string = string(identifier_string, '>', Legolas.identifier(parent))
declared_field_names_types = merge(Legolas.declared_fields(parent), declared_field_names_types)
if !isempty(parents)
parents_string = if length(parents) > 1
string('[', join(map(Legolas.identifier, parents), ','), ']')
else
Legolas.identifier(only(parents))
end
identifier_string = string(identifier_string, '>', parents_string)
# NOTE: This line assumes that fields of later parents have already
# been verified to be either non-overlapping with, or otherwise
# valid subtypes of, earlier parents' fields
declared_field_names_types = merge(mapfoldl(Legolas.declared_fields, merge, parents), declared_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))
@inline $Legolas.parents(::$quoted_schema_version_type) = $(Base.Meta.quot(parents))
$Legolas.declared_fields(::$quoted_schema_version_type) = $declared_field_names_types
$Legolas.declaration(::$quoted_schema_version_type) = $(Base.Meta.quot(schema_version_declaration))
end
Expand Down Expand Up @@ -533,11 +547,11 @@ end

_schema_version_from_record_type(::Nothing) = nothing

# Note also that this function's implementation is allowed to "observe" `Legolas.declared_fields(parent)`
# (if a parent exists), but is NOT allowed to "observe" `Legolas.declaration(parent)`, since the latter
# 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.
# Note also that this function's implementation is allowed to "observe" the `Legolas.declared_fields` of
# any parent of `schema_version`, but is NOT allowed to "observe" any `Legolas.declaration` of a parent,
# since the latter includes parents' declared field RHS statements. We cannot interpolate/incorporate these
# statements in the child's record type definition because they may reference bindings from parents'
# `@version` callsites that are not available/valid at the child's `@version` callsite.
function _generate_record_type_definitions(schema_version::SchemaVersion, record_type_symbol::Symbol)
# generate `schema_version_type_alias_definition`
T = Symbol(string(record_type_symbol, "SchemaVersion"))
Expand Down Expand Up @@ -611,16 +625,16 @@ function _generate_record_type_definitions(schema_version::SchemaVersion, record

# generate `parent_record_application`
field_kwargs = [Expr(:kw, n, :missing) for n in keys(record_fields)]
parent_record_application = nothing
parent = Legolas.parent(schema_version)
if !isnothing(parent)
parent_record_application = Expr(:block)
parents = Legolas.parents(schema_version)
for parent in parents
p = gensym()
P = Base.Meta.quot(record_type(parent))
parent_record_field_names = keys(declared_fields(parent))
parent_record_application = quote
push!(parent_record_application.args, quote
$p = $P(; $(parent_record_field_names...))
$((:($n = $p.$n) for n in parent_record_field_names)...)
end
end)
end

# generate `inner_constructor_definitions` and `outer_constructor_definitions`
Expand Down Expand Up @@ -781,10 +795,16 @@ For more details and examples, please see `Legolas.jl/examples/tour.jl` and the
macro version(record_type, declared_fields_block=nothing)
# parse `record_type`
if record_type isa Symbol
parent_record_type = nothing
parent_record_types = ()
elseif record_type isa Expr && record_type.head == :call && length(record_type.args) == 3 &&
record_type.args[1] == :> && record_type.args[2] isa Symbol
parent_record_type = record_type.args[3]
parent_record_types = record_type.args[3]
if parent_record_types isa Expr && parent_record_types.head == :vect &&
all(T isa Symbol for T in parent_record_types.args)
parent_record_types = (parent_record_types.args...,)
else
parent_record_types = (parent_record_types,)
end
record_type = record_type.args[2]
else
return :(throw(SchemaVersionDeclarationError("provided record type expression is malformed: ", $(Base.Meta.quot(record_type)))))
Expand Down Expand Up @@ -827,26 +847,41 @@ macro version(record_type, declared_fields_block=nothing)
else
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)))
parents = $(Expr(:tuple, [:($Legolas._schema_version_from_record_type($(esc(P)))) for P in parent_record_types]...))

declared_identifier = string(schema_name, '@', $schema_version_integer)
if !isnothing(parent)
declared_identifier = string(declared_identifier, '>', $Legolas.name(parent), '@', $Legolas.version(parent))
if !isempty(parents)
parent_ids = map(p -> string($Legolas.name(p), '@', $Legolas.version(p)), parents)
parent_ids = length(parent_ids) > 1 ? string('[', join(parent_ids, ','), ']') : only(parent_ids)
declared_identifier = string(declared_identifier, '>', parent_ids)
end
schema_version_declaration = declared_identifier => $(Base.Meta.quot(declared_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
end

if any($Legolas.name(p) == schema_name for p in parents)
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.declared_fields(parent)))
throw(SchemaVersionDeclarationError("declared field types violate parent's field types"))
else
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))))
end

let accumulated_parent_field_name_types = (;)
for p in parents
current_parent_field_name_types = $Legolas.declared_fields(p)
if !($Legolas._has_valid_child_field_types(current_parent_field_name_types, accumulated_parent_field_name_types))
throw(SchemaVersionDeclarationError(string("a declared parent's fields are incompatible with prior declared parents' fields: ", p)))
end
accumulated_parent_field_name_types = merge(accumulated_parent_field_name_types, current_parent_field_name_types)
end
if !($Legolas._has_valid_child_field_types($declared_field_names_types, accumulated_parent_field_name_types))
throw(SchemaVersionDeclarationError(string("declared field types violate parents' field types")))
end
end

Base.@__doc__($(Base.Meta.quot(record_type)))
$(esc(:eval))($Legolas._generate_schema_version_definitions(schema_version, parents, $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
nothing
end
Expand Down
4 changes: 2 additions & 2 deletions test/runtests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -331,8 +331,8 @@ end
@test_throws SchemaVersionDeclarationError("cannot have duplicate field names in `@version` declaration; received: $([:x, :y, :x, :z])") @version(ChildV2, begin x; y; x; z end)
@test_throws SchemaVersionDeclarationError("cannot have field name which start with an underscore in `@version` declaration: $([:_X])") @version(ChildV2, begin x; X; _X end)
@test_throws SchemaVersionDeclarationError("cannot extend from another version of the same schema") @version(ChildV2 > ChildV1, begin x end)
@test_throws SchemaVersionDeclarationError("declared field types violate parent's field types") @version(NewV1 > ParentV1, begin y::Int end)
@test_throws SchemaVersionDeclarationError("declared field types violate parent's field types") @version(NewV1 > ChildV1, begin y::Int end)
@test_throws SchemaVersionDeclarationError("declared field types violate parents' field types") @version(NewV1 > ParentV1, begin y::Int end)
@test_throws SchemaVersionDeclarationError("declared field types violate parents' field types") @version(NewV1 > ChildV1, begin y::Int end)
@test_throws SchemaVersionDeclarationError("invalid redeclaration of existing schema version; all `@version` redeclarations must exactly match previous declarations") @version(ParentV1, begin x; y end)
@test_throws SchemaVersionDeclarationError("malformed `@version` field expression: f()") @version(ChildV2, begin f() end)
end
Expand Down
Loading