diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2296d37..40b561f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,8 @@ jobs: image: crystallang/crystal:latest-alpine steps: - uses: actions/checkout@v2 + - name: Install Dependencies + run: shards install --production --ignore-crystal-version - name: Specs run: crystal spec --order random --error-on-warnings test_nightly: @@ -40,5 +42,7 @@ jobs: image: crystallang/crystal:nightly-alpine steps: - uses: actions/checkout@v2 + - name: Install Dependencies + run: shards install --production --ignore-crystal-version - name: Specs run: crystal spec --order random --error-on-warnings diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index a51569d..2d622f5 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -11,9 +11,11 @@ jobs: container: image: crystallang/crystal steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v2 + - name: Install Dependencies + run: shards install --production - name: Build - run: crystal docs + run: crystal docs lib/athena-config/src/athena-config.cr src/athena-serializer.cr - name: Deploy uses: JamesIves/github-pages-deploy-action@2.0.3 env: diff --git a/shard.yml b/shard.yml index bf1ab98..c1362cd 100644 --- a/shard.yml +++ b/shard.yml @@ -1,6 +1,6 @@ name: athena-serializer -version: 0.1.3 +version: 0.2.0 crystal: 0.35.0 @@ -16,6 +16,11 @@ description: | authors: - George Dietrich +dependencies: + athena-config: + github: athena-framework/config + version: ~> 0.1.2 + development_dependencies: ameba: github: crystal-ameba/ameba diff --git a/spec/exclusion_strategies/custom_strategy_spec.cr b/spec/exclusion_strategies/custom_strategy_spec.cr new file mode 100644 index 0000000..52a598c --- /dev/null +++ b/spec/exclusion_strategies/custom_strategy_spec.cr @@ -0,0 +1,49 @@ +require "../spec_helper" + +ACF.configuration_annotation IsActiveProperty, active : Bool = true + +private struct ActivePropertyExclusionStrategy + include Athena::Serializer::ExclusionStrategies::ExclusionStrategyInterface + + # :inherit: + def skip_property?(metadata : ASR::PropertyMetadataBase, context : ASR::Context) : Bool + return false if context.direction.deserialization? + + ann_configs = metadata.annotation_configurations + + ann_configs.has?(IsActiveProperty) && !ann_configs[IsActiveProperty].active + end +end + +# Mainly testing `Athena::Config` integration in regards to custom annotations accessable via the property metadata. +describe ActivePropertyExclusionStrategy do + describe "#skip_property?" do + describe :deserialization do + it "it should not skip" do + ActivePropertyExclusionStrategy.new.skip_property?(create_metadata, ASR::DeserializationContext.new).should be_false + end + end + + describe :serialization do + describe "without the annotation" do + it "should not skip" do + ActivePropertyExclusionStrategy.new.skip_property?(create_metadata, ASR::SerializationContext.new).should be_false + end + end + + describe "with the annotation" do + it true do + ann_config = ACF::AnnotationConfigurations.new({IsActiveProperty => [IsActivePropertyConfiguration.new] of ACF::AnnotationConfigurations::ConfigurationBase}) + + ActivePropertyExclusionStrategy.new.skip_property?(create_metadata(annotation_configurations: ann_config), ASR::SerializationContext.new).should be_false + end + + it false do + ann_config = ACF::AnnotationConfigurations.new({IsActiveProperty => [IsActivePropertyConfiguration.new(false)] of ACF::AnnotationConfigurations::ConfigurationBase}) + + ActivePropertyExclusionStrategy.new.skip_property?(create_metadata(annotation_configurations: ann_config), ASR::SerializationContext.new).should be_true + end + end + end + end +end diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index 5882b31..2af5eec 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -14,6 +14,7 @@ def get_test_property_metadata : Array(ASR::PropertyMetadataBase) [ASR::PropertyMetadata(String, String, TestObject).new( name: "name", external_name: "external_name", + annotation_configurations: ACF::AnnotationConfigurations.new, value: "YES", skip_when_empty: false, groups: ["default"], @@ -129,8 +130,17 @@ def assert_deserialized_output(visitor_type : ASR::Visitors::DeserializationVisi result.should eq expected end -def create_metadata(*, name : String = "name", external_name : String = "external_name", value : I = "value", skip_when_empty : Bool = false, groups : Array(String) = ["default"], since_version : String? = nil, until_version : String? = nil) : ASR::PropertyMetadata forall I - context = ASR::PropertyMetadata(I, I, EmptyObject).new name, external_name, value, skip_when_empty, groups +def create_metadata( + *, + name : String = "name", + external_name : String = "external_name", + value : I = "value", skip_when_empty : Bool = false, + groups : Array(String) = ["default"], + since_version : String? = nil, + until_version : String? = nil, + annotation_configurations : ACF::AnnotationConfigurations = ACF::AnnotationConfigurations.new +) : ASR::PropertyMetadata forall I + context = ASR::PropertyMetadata(I, I, EmptyObject).new name, external_name, annotation_configurations, value, skip_when_empty, groups context.since_version = SemanticVersion.parse since_version if since_version context.until_version = SemanticVersion.parse until_version if until_version diff --git a/src/athena-serializer.cr b/src/athena-serializer.cr index 067364e..a7141d8 100644 --- a/src/athena-serializer.cr +++ b/src/athena-serializer.cr @@ -4,9 +4,12 @@ require "uuid" require "json" require "yaml" +require "athena-config" + require "./annotations" require "./any" require "./context" +require "./serializable" require "./serializer_interface" require "./serializer" require "./property_metadata" @@ -44,7 +47,7 @@ module YAML; end # dependencies: # athena-serializer: # github: athena-framework/serializer -# version: ~> 0.1.0 +# version: ~> 0.2.0 # ``` # # Run `shards install`. @@ -178,229 +181,4 @@ module Athena::Serializer # record Unionable, type : BaseModel.class # ``` module Athena::Serializer::Model; end - - # Adds the necessary methods to a `struct`/`class` to allow for (de)serialization of that type. - # - # ``` - # require "athena-serializer" - # - # record Example, id : Int32, name : String do - # include ASR::Serializable - # end - # - # obj = ASR.serializer.deserialize Example, %({"id":1,"name":"George"}), :json - # obj # => Example(@id=1, @name="George") - # ASR.serializer.serialize obj, :yaml # => - # # --- - # # id: 1 - # # name: George - # ``` - module Serializable - # :nodoc: - abstract def serialization_properties : Array(ASR::PropertyMetadataBase) - - # :nodoc: - abstract def run_preserialize : Nil - - # :nodoc: - abstract def run_postserialize : Nil - - # :nodoc: - abstract def run_postdeserialize : Nil - - macro included - {% verbatim do %} - include ASR::Model - - # :nodoc: - def run_preserialize : Nil - {% for method in @type.methods.select { |m| m.annotation(ASRA::PreSerialize) } %} - {{method.name}} - {% end %} - end - - # :nodoc: - def run_postserialize : Nil - {% for method in @type.methods.select { |m| m.annotation(ASRA::PostSerialize) } %} - {{method.name}} - {% end %} - end - - # :nodoc: - def run_postdeserialize : Nil - {% for method in @type.methods.select { |m| m.annotation(ASRA::PostDeserialize) } %} - {{method.name}} - {% end %} - end - - # :nodoc: - def serialization_properties : Array(ASR::PropertyMetadataBase) - {% begin %} - # Construct the array of metadata from the properties on `self`. - # Takes into consideration some annotations to control how/when a property should be serialized - {% - instance_vars = @type.instance_vars - .reject { |ivar| ivar.annotation(ASRA::Skip) } - .reject { |ivar| ivar.annotation(ASRA::IgnoreOnSerialize) } - .reject do |ivar| - not_exposed = (ann = @type.annotation(ASRA::ExclusionPolicy)) && ann[0] == :all && !ivar.annotation(ASRA::Expose) - excluded = (ann = @type.annotation(ASRA::ExclusionPolicy)) && ann[0] == :none && ivar.annotation(ASRA::Exclude) - - !ivar.annotation(ASRA::IgnoreOnDeserialize) && (not_exposed || excluded) - end - %} - - {% property_hash = {} of Nil => Nil %} - - {% for ivar in instance_vars %} - {% ivar_name = ivar.name.stringify %} - - # Determine the serialized name of the ivar: - # 1. If the ivar has an `ASRA::Name` annotation with a `serialize` field, use that - # 2. If the type has an `ASRA::Name` annotation with a `strategy`, use that strategy - # 3. Fallback on the name of the ivar - {% external_name = if (name_ann = ivar.annotation(ASRA::Name)) && (serialized_name = name_ann[:serialize]) - serialized_name - elsif (name_ann = @type.annotation(ASRA::Name)) && (strategy = name_ann[:strategy]) - if strategy == :camelcase - ivar_name.camelcase lower: true - elsif strategy == :underscore - ivar_name.underscore - elsif strategy == :identical - ivar_name - else - strategy.raise "Invalid ASRA::Name strategy: '#{strategy}'." - end - else - ivar_name - end %} - - {% property_hash[external_name] = %(ASR::PropertyMetadata(#{ivar.type}, #{ivar.type}, #{@type}).new( - name: #{ivar.name.stringify}, - external_name: #{external_name}, - value: #{(accessor = ivar.annotation(ASRA::Accessor)) && accessor[:getter] != nil ? accessor[:getter].id : %(@#{ivar.id}).id}, - skip_when_empty: #{!!ivar.annotation(ASRA::SkipWhenEmpty)}, - groups: #{(ann = ivar.annotation(ASRA::Groups)) && !ann.args.empty? ? [ann.args.splat] : ["default"]}, - since_version: #{(ann = ivar.annotation(ASRA::Since)) && !ann[0].nil? ? "SemanticVersion.parse(#{ann[0]})".id : nil}, - until_version: #{(ann = ivar.annotation(ASRA::Until)) && !ann[0].nil? ? "SemanticVersion.parse(#{ann[0]})".id : nil}, - )).id %} - {% end %} - - {% for m in @type.methods.select { |method| method.annotation(ASRA::VirtualProperty) } %} - {% method_name = m.name %} - {% m.raise "ASRA::VirtualProperty return type must be set for '#{@type.name}##{method_name}'." if m.return_type.is_a? Nop %} - {% external_name = (ann = m.annotation(ASRA::Name)) && (name = ann[:serialize]) ? name : m.name.stringify %} - - {% property_hash[external_name] = %(ASR::PropertyMetadata(#{m.return_type}, #{m.return_type}, #{@type}).new( - name: #{m.name.stringify}, - external_name: #{external_name}, - value: #{m.name.id}, - skip_when_empty: #{!!m.annotation(ASRA::SkipWhenEmpty)}, - )).id %} - {% end %} - - {% if (ann = @type.annotation(ASRA::AccessorOrder)) && !ann[0].nil? %} - {% if ann[0] == :alphabetical %} - {% properties = property_hash.keys.sort.map { |key| property_hash[key] } %} - {% elsif ann[0] == :custom && !ann[:order].nil? %} - {% ann.raise "Not all properties were defined in the custom order for '#{@type}'." unless property_hash.keys.all? { |prop| ann[:order].map(&.id.stringify).includes? prop } %} - {% properties = ann[:order].map { |val| property_hash[val.id.stringify] || raise "Unknown instance variable: '#{val.id}'." } %} - {% else %} - {% ann.raise "Invalid ASR::AccessorOrder value: '#{ann[0].id}'." %} - {% end %} - {% else %} - {% properties = property_hash.values %} - {% end %} - - {{properties}} of ASR::PropertyMetadataBase - {% end %} - end - - # :nodoc: - def self.deserialization_properties : Array(ASR::PropertyMetadataBase) - {% verbatim do %} - {% begin %} - # Construct the array of metadata from the properties on `self`. - # Takes into consideration some annotations to control how/when a property should be serialized - {% instance_vars = @type.instance_vars - .reject { |ivar| ivar.annotation(ASRA::Skip) } - .reject { |ivar| (ann = ivar.annotation(ASRA::ReadOnly)); ann && !ivar.has_default_value? && !ivar.type.nilable? ? ivar.raise "#{@type}##{ivar.name} is read-only but is not nilable nor has a default value" : ann } - .reject { |ivar| ivar.annotation(ASRA::IgnoreOnDeserialize) } - .reject do |ivar| - not_exposed = (ann = @type.annotation(ASRA::ExclusionPolicy)) && ann[0] == :all && !ivar.annotation(ASRA::Expose) - excluded = (ann = @type.annotation(ASRA::ExclusionPolicy)) && ann[0] == :none && ivar.annotation(ASRA::Exclude) - - !ivar.annotation(ASRA::IgnoreOnSerialize) && (not_exposed || excluded) - end %} - - {{instance_vars.map do |ivar| - %(ASR::PropertyMetadata(#{ivar.type}, #{ivar.type}?, #{@type}).new( - name: #{ivar.name.stringify}, - external_name: #{(ann = ivar.annotation(ASRA::Name)) && (name = ann[:deserialize]) ? name : ivar.name.stringify}, - aliases: #{(ann = ivar.annotation(ASRA::Name)) && (aliases = ann[:aliases]) ? aliases : "[] of String".id}, - groups: #{(ann = ivar.annotation(ASRA::Groups)) && !ann.args.empty? ? [ann.args.splat] : ["default"]}, - since_version: #{(ann = ivar.annotation(ASRA::Since)) && !ann[0].nil? ? "SemanticVersion.parse(#{ann[0]})".id : nil}, - until_version: #{(ann = ivar.annotation(ASRA::Until)) && !ann[0].nil? ? "SemanticVersion.parse(#{ann[0]})".id : nil}, - )).id - end}} of ASR::PropertyMetadataBase - {% end %} - {% end %} - end - - # :nodoc: - def apply(navigator : ASR::Navigators::DeserializationNavigator, properties : Array(ASR::PropertyMetadataBase), data : ASR::Any) - self.initialize navigator, properties, data - end - - # :nodoc: - def initialize(navigator : ASR::Navigators::DeserializationNavigatorInterface, properties : Array(ASR::PropertyMetadataBase), data : ASR::Any) - {% begin %} - {% for ivar, idx in @type.instance_vars %} - if (prop = properties.find { |p| p.name == {{ivar.name.stringify}} }) && (val = extract_value(prop, data, {{(ann = ivar.annotation(ASRA::Accessor)) ? ann[:path] : nil}})) - value = {% if (ann = ivar.annotation(ASRA::Accessor)) && (converter = ann[:converter]) %} - {{converter.id}}.deserialize navigator, prop, val - {% else %} - navigator.accept {{ivar.type}}, val - {% end %} - - unless value.nil? - @{{ivar.id}} = value - else - {% if !ivar.type.nilable? && !ivar.has_default_value? %} - raise Exception.new "Required property '{{ivar}}' cannot be nil." - {% end %} - end - else - {% if !ivar.type.nilable? && !ivar.has_default_value? %} - raise Exception.new "Missing required attribute: '{{ivar}}'." - {% end %} - end - - {% if (ann = ivar.annotation(ASRA::Accessor)) && (setter = ann[:setter]) %} - self.{{setter.id}}(@{{ivar.id}}) - {% end %} - {% end %} - {% end %} - end - - # Attempts to extract a value from the *data* for the given *property*. - # Returns `nil` if a value could not be extracted. - private def extract_value(property : ASR::PropertyMetadataBase, data : ASR::Any, path : Tuple?) : ASR::Any? - if path && (value = data.dig?(*path)) - return value - end - - if (key = property.aliases.find { |a| data[a]? }) && (value = data[key]?) - return value - end - - if value = data[property.external_name]? - return value - end - - nil - end - {% end %} - end - end end diff --git a/src/context.cr b/src/context.cr index 0846426..6b251a7 100644 --- a/src/context.cr +++ b/src/context.cr @@ -4,6 +4,12 @@ # # NOTE: Cannot be used for more than one action. abstract class Athena::Serializer::Context + # The possible (de)serialization actions. + enum Direction + Deserialization + Serialization + end + # The `ASR::ExclusionStrategies::ExclusionStrategyInterface` being used. getter exclusion_strategy : ASR::ExclusionStrategies::ExclusionStrategyInterface? @@ -15,6 +21,9 @@ abstract class Athena::Serializer::Context # Returns the version, if any, currently set on `self`. getter version : SemanticVersion? = nil + # Returns which (de)serialization action `self` represents. + abstract def direction : ASR::Context::Direction + # Adds *strategy* to `self`. # # * `exclusion_strategy` is set to *strategy* if there previously was no strategy. diff --git a/src/deserialization_context.cr b/src/deserialization_context.cr index e365775..6175c40 100644 --- a/src/deserialization_context.cr +++ b/src/deserialization_context.cr @@ -1,3 +1,6 @@ # The `ASR::Context` specific to deserialization. class Athena::Serializer::DeserializationContext < Athena::Serializer::Context + def direction : ASR::Context::Direction + ASR::Context::Direction::Deserialization + end end diff --git a/src/exclusion_strategies/exclusion_strategy_interface.cr b/src/exclusion_strategies/exclusion_strategy_interface.cr index 99544ef..00b8ef0 100644 --- a/src/exclusion_strategies/exclusion_strategy_interface.cr +++ b/src/exclusion_strategies/exclusion_strategy_interface.cr @@ -34,6 +34,52 @@ # ASR.serializer.serialize Values.new, :json, serialization_context # => {"two":2} # ASR.serializer.deserialize Values, %({"one":4,"two":5,"three":6}), :json, deserialization_context # => Values(@one=4, @three=6, @two=5) # ``` +# +# ### Annotation Configurations +# +# Custom annotations can be defined using `Athena::Config.configuration_annotation`. +# These annotations will be exposed at runtime as part of the properties' metadata within exclusion strategies via `ASR::PropertyMetadata#annotation_configurations`. +# The main purpose of this is to allow for more advanced annotation based exclusion strategies. +# +# ``` +# # Define an annotation called `IsActiveProperty` that accepts an optional `active` field. +# ACF.configuration_annotation IsActiveProperty, active : Bool = true +# +# # Define an exclusion strategy that should skip "inactive" properties. +# struct ActivePropertyExclusionStrategy +# include Athena::Serializer::ExclusionStrategies::ExclusionStrategyInterface +# +# # :inherit: +# def skip_property?(metadata : ASR::PropertyMetadataBase, context : ASR::Context) : Bool +# # Don't skip on deserialization. +# return false if context.direction.deserialization? +# +# ann_configs = metadata.annotation_configurations +# +# # Skip if the property has the annotation and it's "inactive". +# ann_configs.has?(IsActiveProperty) && !ann_configs[IsActiveProperty].active +# end +# end +# +# record Example, id : Int32, first_name : String, last_name : String, zip_code : Int32 do +# include ASR::Serializable +# +# @[IsActiveProperty] +# @first_name : String +# +# @[IsActiveProperty(active: false)] +# @last_name : String +# +# # Can also be defined as a positional argument. +# @[IsActiveProperty(false)] +# @zip_code : Int32 +# end +# +# serialization_context = ASR::SerializationContext.new +# serialization_context.add_exclusion_strategy ActivePropertyExclusionStrategy.new +# +# ASR.serializer.serialize Example.new(1, "Jon", "Snow", 90210), :json, serialization_context # => {"id":1,"first_name":"Jon"} +# ``` module Athena::Serializer::ExclusionStrategies::ExclusionStrategyInterface # Returns `true` if a property should _NOT_ be (de)serialized. abstract def skip_property?(metadata : ASR::PropertyMetadataBase, context : ASR::Context) : Bool diff --git a/src/property_metadata.cr b/src/property_metadata.cr index 9399105..3379dcb 100644 --- a/src/property_metadata.cr +++ b/src/property_metadata.cr @@ -49,9 +49,15 @@ struct Athena::Serializer::PropertyMetadata(IvarType, ValueType, ClassType) # See `ASRA::SkipWhenEmpty`. getter? skip_when_empty : Bool + # Returns annotations configurations registered via `Athena::Config.configuration_annotation` and applied to this property. + # + # These configurations could then be accessed within an `ART::ExclusionStrategies::ExclusionStrategyInterface`. + getter annotation_configurations : ACF::AnnotationConfigurations + def initialize( @name : String, @external_name : String, + @annotation_configurations : ACF::AnnotationConfigurations, @value : ValueType = nil, @skip_when_empty : Bool = false, @groups : Array(String) = ["default"], diff --git a/src/serializable.cr b/src/serializable.cr new file mode 100644 index 0000000..0949d76 --- /dev/null +++ b/src/serializable.cr @@ -0,0 +1,275 @@ +# Adds the necessary methods to a `struct`/`class` to allow for (de)serialization of that type. +# +# ``` +# require "athena-serializer" +# +# record Example, id : Int32, name : String do +# include ASR::Serializable +# end +# +# obj = ASR.serializer.deserialize Example, %({"id":1,"name":"George"}), :json +# obj # => Example(@id=1, @name="George") +# ASR.serializer.serialize obj, :yaml # => +# # --- +# # id: 1 +# # name: George +# ``` +module Athena::Serializer::Serializable + # :nodoc: + abstract def serialization_properties : Array(ASR::PropertyMetadataBase) + + # :nodoc: + abstract def run_preserialize : Nil + + # :nodoc: + abstract def run_postserialize : Nil + + # :nodoc: + abstract def run_postdeserialize : Nil + + macro included + {% verbatim do %} + include ASR::Model + + # :nodoc: + def run_preserialize : Nil + {% for method in @type.methods.select { |m| m.annotation(ASRA::PreSerialize) } %} + {{method.name}} + {% end %} + end + + # :nodoc: + def run_postserialize : Nil + {% for method in @type.methods.select { |m| m.annotation(ASRA::PostSerialize) } %} + {{method.name}} + {% end %} + end + + # :nodoc: + def run_postdeserialize : Nil + {% for method in @type.methods.select { |m| m.annotation(ASRA::PostDeserialize) } %} + {{method.name}} + {% end %} + end + + # :nodoc: + def serialization_properties : Array(ASR::PropertyMetadataBase) + {% begin %} + # Construct the array of metadata from the properties on `self`. + # Takes into consideration some annotations to control how/when a property should be serialized + {% + instance_vars = @type.instance_vars + .reject { |ivar| ivar.annotation(ASRA::Skip) } + .reject { |ivar| ivar.annotation(ASRA::IgnoreOnSerialize) } + .reject do |ivar| + not_exposed = (ann = @type.annotation(ASRA::ExclusionPolicy)) && ann[0] == :all && !ivar.annotation(ASRA::Expose) + excluded = (ann = @type.annotation(ASRA::ExclusionPolicy)) && ann[0] == :none && ivar.annotation(ASRA::Exclude) + + !ivar.annotation(ASRA::IgnoreOnDeserialize) && (not_exposed || excluded) + end + %} + + {% property_hash = {} of Nil => Nil %} + + {% for ivar in instance_vars %} + {% ivar_name = ivar.name.stringify %} + + # Determine the serialized name of the ivar: + # 1. If the ivar has an `ASRA::Name` annotation with a `serialize` field, use that + # 2. If the type has an `ASRA::Name` annotation with a `strategy`, use that strategy + # 3. Fallback on the name of the ivar + {% external_name = if (name_ann = ivar.annotation(ASRA::Name)) && (serialized_name = name_ann[:serialize]) + serialized_name + elsif (name_ann = @type.annotation(ASRA::Name)) && (strategy = name_ann[:strategy]) + if strategy == :camelcase + ivar_name.camelcase lower: true + elsif strategy == :underscore + ivar_name.underscore + elsif strategy == :identical + ivar_name + else + strategy.raise "Invalid ASRA::Name strategy: '#{strategy}'." + end + else + ivar_name + end %} + + {% annotation_configurations = {} of Nil => Nil %} + + {% for ann_class in ACF::CUSTOM_ANNOTATIONS %} + {% ann_class = ann_class.resolve %} + {% annotations = [] of Nil %} + + {% for ann in ivar.annotations ann_class %} + {% pos_args = ann.args.empty? ? "Tuple.new".id : ann.args %} + {% named_args = ann.named_args.empty? ? "NamedTuple.new".id : ann.named_args %} + + {% annotations << "#{ann_class}Configuration.new(#{ann.args.empty? ? "".id : "#{ann.args.splat},".id}#{ann.named_args.double_splat})".id %} + {% end %} + + {% annotation_configurations[ann_class] = "#{annotations} of ACF::AnnotationConfigurations::ConfigurationBase".id unless annotations.empty? %} + {% end %} + + {% property_hash[external_name] = %(ASR::PropertyMetadata(#{ivar.type}, #{ivar.type}, #{@type}).new( + name: #{ivar.name.stringify}, + external_name: #{external_name}, + annotation_configurations: ACF::AnnotationConfigurations.new(#{annotation_configurations} of ACF::AnnotationConfigurations::Classes => Array(ACF::AnnotationConfigurations::ConfigurationBase)), + value: #{(accessor = ivar.annotation(ASRA::Accessor)) && accessor[:getter] != nil ? accessor[:getter].id : %(@#{ivar.id}).id}, + skip_when_empty: #{!!ivar.annotation(ASRA::SkipWhenEmpty)}, + groups: #{(ann = ivar.annotation(ASRA::Groups)) && !ann.args.empty? ? [ann.args.splat] : ["default"]}, + since_version: #{(ann = ivar.annotation(ASRA::Since)) && !ann[0].nil? ? "SemanticVersion.parse(#{ann[0]})".id : nil}, + until_version: #{(ann = ivar.annotation(ASRA::Until)) && !ann[0].nil? ? "SemanticVersion.parse(#{ann[0]})".id : nil}, + )).id %} + {% end %} + + {% for m in @type.methods.select { |method| method.annotation(ASRA::VirtualProperty) } %} + {% method_name = m.name %} + {% m.raise "ASRA::VirtualProperty return type must be set for '#{@type.name}##{method_name}'." if m.return_type.is_a? Nop %} + {% external_name = (ann = m.annotation(ASRA::Name)) && (name = ann[:serialize]) ? name : m.name.stringify %} + + {% method_annotation_configurations = {} of Nil => Nil %} + + {% for ann_class in ACF::CUSTOM_ANNOTATIONS %} + {% ann_class = ann_class.resolve %} + {% annotations = [] of Nil %} + + {% for ann in m.annotations ann_class %} + {% pos_args = ann.args.empty? ? "Tuple.new".id : ann.args %} + {% named_args = ann.named_args.empty? ? "NamedTuple.new".id : ann.named_args %} + + {% annotations << "#{ann_class}Configuration.new(#{ann.args.empty? ? "".id : "#{ann.args.splat},".id}#{ann.named_args.double_splat})".id %} + {% end %} + + {% method_annotation_configurations[ann_class] = "#{annotations} of ACF::AnnotationConfigurations::ConfigurationBase".id unless annotations.empty? %} + {% end %} + + {% property_hash[external_name] = %(ASR::PropertyMetadata(#{m.return_type}, #{m.return_type}, #{@type}).new( + name: #{m.name.stringify}, + external_name: #{external_name}, + annotation_configurations: ACF::AnnotationConfigurations.new(#{method_annotation_configurations} of ACF::AnnotationConfigurations::Classes => Array(ACF::AnnotationConfigurations::ConfigurationBase)), + value: #{m.name.id}, + skip_when_empty: #{!!m.annotation(ASRA::SkipWhenEmpty)}, + )).id %} + {% end %} + + {% if (ann = @type.annotation(ASRA::AccessorOrder)) && !ann[0].nil? %} + {% if ann[0] == :alphabetical %} + {% properties = property_hash.keys.sort.map { |key| property_hash[key] } %} + {% elsif ann[0] == :custom && !ann[:order].nil? %} + {% ann.raise "Not all properties were defined in the custom order for '#{@type}'." unless property_hash.keys.all? { |prop| ann[:order].map(&.id.stringify).includes? prop } %} + {% properties = ann[:order].map { |val| property_hash[val.id.stringify] || raise "Unknown instance variable: '#{val.id}'." } %} + {% else %} + {% ann.raise "Invalid ASR::AccessorOrder value: '#{ann[0].id}'." %} + {% end %} + {% else %} + {% properties = property_hash.values %} + {% end %} + + {{properties}} of ASR::PropertyMetadataBase + {% end %} + end + + # :nodoc: + def self.deserialization_properties : Array(ASR::PropertyMetadataBase) + {% verbatim do %} + {% begin %} + # Construct the array of metadata from the properties on `self`. + # Takes into consideration some annotations to control how/when a property should be serialized + {% instance_vars = @type.instance_vars + .reject { |ivar| ivar.annotation(ASRA::Skip) } + .reject { |ivar| (ann = ivar.annotation(ASRA::ReadOnly)); ann && !ivar.has_default_value? && !ivar.type.nilable? ? ivar.raise "#{@type}##{ivar.name} is read-only but is not nilable nor has a default value" : ann } + .reject { |ivar| ivar.annotation(ASRA::IgnoreOnDeserialize) } + .reject do |ivar| + not_exposed = (ann = @type.annotation(ASRA::ExclusionPolicy)) && ann[0] == :all && !ivar.annotation(ASRA::Expose) + excluded = (ann = @type.annotation(ASRA::ExclusionPolicy)) && ann[0] == :none && ivar.annotation(ASRA::Exclude) + + !ivar.annotation(ASRA::IgnoreOnSerialize) && (not_exposed || excluded) + end %} + + {{instance_vars.map do |ivar| + annotation_configurations = {} of Nil => Nil + + ACF::CUSTOM_ANNOTATIONS.each do |ann_class| + ann_class = ann_class.resolve + annotations = [] of Nil + + ivar.annotations(ann_class).each do |ann| + pos_args = ann.args.empty? ? "Tuple.new".id : ann.args + named_args = ann.named_args.empty? ? "NamedTuple.new".id : ann.named_args + + annotations << "#{ann_class}Configuration.new(#{ann.args.empty? ? "".id : "#{ann.args.splat},".id}#{ann.named_args.double_splat})".id + end + + annotation_configurations[ann_class] = "#{annotations} of ACF::AnnotationConfigurations::ConfigurationBase".id unless annotations.empty? + end + + %(ASR::PropertyMetadata(#{ivar.type}, #{ivar.type}?, #{@type}).new( + name: #{ivar.name.stringify}, + external_name: #{(ann = ivar.annotation(ASRA::Name)) && (name = ann[:deserialize]) ? name : ivar.name.stringify}, + annotation_configurations: ACF::AnnotationConfigurations.new(#{annotation_configurations} of ACF::AnnotationConfigurations::Classes => Array(ACF::AnnotationConfigurations::ConfigurationBase)), + aliases: #{(ann = ivar.annotation(ASRA::Name)) && (aliases = ann[:aliases]) ? aliases : "[] of String".id}, + groups: #{(ann = ivar.annotation(ASRA::Groups)) && !ann.args.empty? ? [ann.args.splat] : ["default"]}, + since_version: #{(ann = ivar.annotation(ASRA::Since)) && !ann[0].nil? ? "SemanticVersion.parse(#{ann[0]})".id : nil}, + until_version: #{(ann = ivar.annotation(ASRA::Until)) && !ann[0].nil? ? "SemanticVersion.parse(#{ann[0]})".id : nil}, + )).id + end}} of ASR::PropertyMetadataBase + {% end %} + {% end %} + end + + # :nodoc: + def apply(navigator : ASR::Navigators::DeserializationNavigator, properties : Array(ASR::PropertyMetadataBase), data : ASR::Any) + self.initialize navigator, properties, data + end + + # :nodoc: + def initialize(navigator : ASR::Navigators::DeserializationNavigatorInterface, properties : Array(ASR::PropertyMetadataBase), data : ASR::Any) + {% begin %} + {% for ivar, idx in @type.instance_vars %} + if (prop = properties.find { |p| p.name == {{ivar.name.stringify}} }) && (val = extract_value(prop, data, {{(ann = ivar.annotation(ASRA::Accessor)) ? ann[:path] : nil}})) + value = {% if (ann = ivar.annotation(ASRA::Accessor)) && (converter = ann[:converter]) %} + {{converter.id}}.deserialize navigator, prop, val + {% else %} + navigator.accept {{ivar.type}}, val + {% end %} + + unless value.nil? + @{{ivar.id}} = value + else + {% if !ivar.type.nilable? && !ivar.has_default_value? %} + raise Exception.new "Required property '{{ivar}}' cannot be nil." + {% end %} + end + else + {% if !ivar.type.nilable? && !ivar.has_default_value? %} + raise Exception.new "Missing required attribute: '{{ivar}}'." + {% end %} + end + + {% if (ann = ivar.annotation(ASRA::Accessor)) && (setter = ann[:setter]) %} + self.{{setter.id}}(@{{ivar.id}}) + {% end %} + {% end %} + {% end %} + end + + # Attempts to extract a value from the *data* for the given *property*. + # Returns `nil` if a value could not be extracted. + private def extract_value(property : ASR::PropertyMetadataBase, data : ASR::Any, path : Tuple?) : ASR::Any? + if path && (value = data.dig?(*path)) + return value + end + + if (key = property.aliases.find { |a| data[a]? }) && (value = data[key]?) + return value + end + + if value = data[property.external_name]? + return value + end + + nil + end + {% end %} + end +end diff --git a/src/serialization_context.cr b/src/serialization_context.cr index 7345e2f..964efce 100644 --- a/src/serialization_context.cr +++ b/src/serialization_context.cr @@ -4,4 +4,8 @@ class Athena::Serializer::SerializationContext < Athena::Serializer::Context # If `nil` values should be serialized. property? emit_nil : Bool = false + + def direction : ASR::Context::Direction + ASR::Context::Direction::Serialization + end end