diff --git a/lib/compat/opal/lutaml_model_boot.rb b/lib/compat/opal/lutaml_model_boot.rb index 0f384ed5..c94ebd46 100644 --- a/lib/compat/opal/lutaml_model_boot.rb +++ b/lib/compat/opal/lutaml_model_boot.rb @@ -81,6 +81,7 @@ require "lutaml/model/register" require "lutaml/model/registrable" require "lutaml/model/render_policy" +require "lutaml/model/restriction_validation" require "lutaml/model/runtime_compatibility" require "lutaml/model/schema" require "lutaml/model/schema_location" @@ -226,6 +227,7 @@ require "lutaml/model/error/collection_true_missing_error" require "lutaml/model/error/element_count_out_of_range_error" require "lutaml/model/error/format_adapter_not_specified_error" +require "lutaml/model/error/fraction_digits_error" require "lutaml/model/error/import_model_with_root_error" require "lutaml/model/error/incorrect_mapping_argument_error" require "lutaml/model/error/incorrect_sequence_error" @@ -235,12 +237,19 @@ require "lutaml/model/error/invalid_choice_range_error" require "lutaml/model/error/invalid_format_error" require "lutaml/model/error/invalid_value_error" +require "lutaml/model/error/length_error" require "lutaml/model/error/liquid_class_not_found_error" require "lutaml/model/error/liquid_drop_already_registered_error" require "lutaml/model/error/liquid_not_enabled_error" require "lutaml/model/error/mapping_already_exists_error" require "lutaml/model/error/mapping_attribute_missing_error" require "lutaml/model/error/mapping_attribute_type_error" +require "lutaml/model/error/max_exclusive_error" +require "lutaml/model/error/max_inclusive_error" +require "lutaml/model/error/max_length_error" +require "lutaml/model/error/min_exclusive_error" +require "lutaml/model/error/min_inclusive_error" +require "lutaml/model/error/min_length_error" require "lutaml/model/error/mixed_content_collection_error" require "lutaml/model/error/multiple_mappings_error" require "lutaml/model/error/no_attributes_defined_liquid_error" @@ -251,9 +260,11 @@ require "lutaml/model/error/pattern_not_matched_error" require "lutaml/model/error/polymorphic_error" require "lutaml/model/error/required_attribute_missing_error" +require "lutaml/model/error/restriction_error" require "lutaml/model/error/reverse_transform_block_not_defined_error" require "lutaml/model/error/reverse_transformation_declaration_error" require "lutaml/model/error/sorting_configuration_conflict_error" +require "lutaml/model/error/total_digits_error" require "lutaml/model/error/transform_block_not_defined_error" require "lutaml/model/error/type_error" require "lutaml/model/error/type_not_enabled_error" @@ -428,8 +439,10 @@ require "lutaml/model/error/register/not_registrable_class_error" require "lutaml/model/error/type/invalid_value_error" require "lutaml/model/error/type/max_bound_error" +require "lutaml/model/error/type/max_exclusive_error" require "lutaml/model/error/type/max_length_error" require "lutaml/model/error/type/min_bound_error" +require "lutaml/model/error/type/min_exclusive_error" require "lutaml/model/error/type/min_length_error" require "lutaml/model/error/type/pattern_not_matched_error" require "lutaml/model/schema/decorators/attribute" diff --git a/lib/lutaml/model.rb b/lib/lutaml/model.rb index 7aab5468..1432d9bf 100644 --- a/lib/lutaml/model.rb +++ b/lib/lutaml/model.rb @@ -70,6 +70,7 @@ module Model autoload :ComparableNil, "#{__dir__}/model/comparable_nil" autoload :ComparableModel, "#{__dir__}/model/comparable_model" autoload :CollectionHandler, "#{__dir__}/model/collection_handler" + autoload :RestrictionValidation, "#{__dir__}/model/restriction_validation" autoload :AttributeValidator, "#{__dir__}/model/attribute_validator" autoload :Attribute, "#{__dir__}/model/attribute" autoload :JsonAdapter, "#{__dir__}/model/json_adapter" @@ -198,6 +199,21 @@ module Model "#{__dir__}/model/error/mixed_content_collection_error" autoload :OrderedContentMappingError, "#{__dir__}/model/error/ordered_content_mapping_error" + autoload :RestrictionError, "#{__dir__}/model/error/restriction_error" + autoload :MinInclusiveError, + "#{__dir__}/model/error/min_inclusive_error" + autoload :MaxInclusiveError, + "#{__dir__}/model/error/max_inclusive_error" + autoload :MinExclusiveError, + "#{__dir__}/model/error/min_exclusive_error" + autoload :MaxExclusiveError, + "#{__dir__}/model/error/max_exclusive_error" + autoload :MinLengthError, "#{__dir__}/model/error/min_length_error" + autoload :MaxLengthError, "#{__dir__}/model/error/max_length_error" + autoload :LengthError, "#{__dir__}/model/error/length_error" + autoload :TotalDigitsError, "#{__dir__}/model/error/total_digits_error" + autoload :FractionDigitsError, + "#{__dir__}/model/error/fraction_digits_error" # Error for passing incorrect model type # diff --git a/lib/lutaml/model/attribute.rb b/lib/lutaml/model/attribute.rb index 3535f283..79ccc68b 100644 --- a/lib/lutaml/model/attribute.rb +++ b/lib/lutaml/model/attribute.rb @@ -7,6 +7,7 @@ class Attribute include CollectionHandler include DeepDupable + include RestrictionValidation ALLOWED_OPTIONS = %i[ raw @@ -29,6 +30,11 @@ class Attribute ref_key_attribute xsd_type union_member_types + min + max + signed + min_length + max_length ].freeze MODEL_STRINGS = [ @@ -480,12 +486,15 @@ def validate_value!(value, register, instance_object: nil) value = cast_value(default_value(register, instance_object), register) if value.nil? resolved_type = type(register) + validate_restriction_configuration!(resolved_type) valid_value!(value) && valid_collection!(value, self) && valid_pattern!(value, resolved_type) && validate_polymorphic!(value, resolved_type) && execute_validations!(value) + + validate_restriction_values!(value, resolved_type) end # execute custom validations on the attribute value @@ -1020,6 +1029,28 @@ def validate_options!(options) raise StandardError, "Invalid option `initialize_empty` given without `collection: true` option" end + + # Length facets are XSD nonNegativeInteger; reject a misconfigured + # type here so a String/Float/nil fails fast instead of raising an + # opaque TypeError later at `value.length >= min`. + %i[min_length max_length].each do |key| + next unless options.key?(key) + + value = options[key] + next if value.is_a?(::Integer) && !value.negative? + + raise ArgumentError, + "Invalid options for `#{name}`: " \ + "`#{key}` must be a non-negative Integer, got #{value.inspect}" + end + + # `signed` is true|false per issue #191; a non-boolean (e.g. "false") + # would silently read as signed, so reject it up front. + if options.key?(:signed) && ![true, false].include?(options[:signed]) + raise ArgumentError, + "Invalid options for `#{name}`: " \ + "`signed` must be true or false, got #{options[:signed].inspect}" + end true end diff --git a/lib/lutaml/model/error/fraction_digits_error.rb b/lib/lutaml/model/error/fraction_digits_error.rb new file mode 100644 index 00000000..cf86d558 --- /dev/null +++ b/lib/lutaml/model/error/fraction_digits_error.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Lutaml + module Model + class FractionDigitsError < RestrictionError + def initialize(attr_name, value, fraction_digits) + @attr_name = attr_name + @value = value + @fraction_digits = fraction_digits + + super() + end + + def to_s + "#{@attr_name} (#{@value}) exceeds the maximum of " \ + "#{@fraction_digits} fraction digits" + end + end + end +end diff --git a/lib/lutaml/model/error/length_error.rb b/lib/lutaml/model/error/length_error.rb new file mode 100644 index 00000000..a5732972 --- /dev/null +++ b/lib/lutaml/model/error/length_error.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Lutaml + module Model + class LengthError < RestrictionError + def initialize(attr_name, value, length) + @attr_name = attr_name + @value = value + @length = length + + super() + end + + def to_s + "#{@attr_name} length (#{@value.to_s.length}) must be exactly #{@length}" + end + end + end +end diff --git a/lib/lutaml/model/error/max_exclusive_error.rb b/lib/lutaml/model/error/max_exclusive_error.rb new file mode 100644 index 00000000..5eb0b5aa --- /dev/null +++ b/lib/lutaml/model/error/max_exclusive_error.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Lutaml + module Model + class MaxExclusiveError < RestrictionError + def initialize(attr_name, value, max) + @attr_name = attr_name + @value = value + @max = max + + super() + end + + def to_s + "#{@attr_name} is `#{@value}`, must be less than #{@max}" + end + end + end +end diff --git a/lib/lutaml/model/error/max_inclusive_error.rb b/lib/lutaml/model/error/max_inclusive_error.rb new file mode 100644 index 00000000..bf459aea --- /dev/null +++ b/lib/lutaml/model/error/max_inclusive_error.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Lutaml + module Model + class MaxInclusiveError < RestrictionError + def initialize(attr_name, value, max) + @attr_name = attr_name + @value = value + @max = max + + super() + end + + def to_s + "#{@attr_name} is `#{@value}`, " \ + "must be less than or equal to #{@max}" + end + end + end +end diff --git a/lib/lutaml/model/error/max_length_error.rb b/lib/lutaml/model/error/max_length_error.rb new file mode 100644 index 00000000..02f3fe0b --- /dev/null +++ b/lib/lutaml/model/error/max_length_error.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Lutaml + module Model + class MaxLengthError < RestrictionError + def initialize(attr_name, value, max_length) + @attr_name = attr_name + @value = value + @max_length = max_length + + super() + end + + def to_s + "#{@attr_name} length (#{@value.to_s.length}) is greater than " \ + "the maximum allowed length #{@max_length}" + end + end + end +end diff --git a/lib/lutaml/model/error/min_exclusive_error.rb b/lib/lutaml/model/error/min_exclusive_error.rb new file mode 100644 index 00000000..6a6d626d --- /dev/null +++ b/lib/lutaml/model/error/min_exclusive_error.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Lutaml + module Model + class MinExclusiveError < RestrictionError + def initialize(attr_name, value, min) + @attr_name = attr_name + @value = value + @min = min + + super() + end + + def to_s + "#{@attr_name} is `#{@value}`, must be greater than #{@min}" + end + end + end +end diff --git a/lib/lutaml/model/error/min_inclusive_error.rb b/lib/lutaml/model/error/min_inclusive_error.rb new file mode 100644 index 00000000..b5de38c7 --- /dev/null +++ b/lib/lutaml/model/error/min_inclusive_error.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Lutaml + module Model + class MinInclusiveError < RestrictionError + def initialize(attr_name, value, min) + @attr_name = attr_name + @value = value + @min = min + + super() + end + + def to_s + "#{@attr_name} is `#{@value}`, " \ + "must be greater than or equal to #{@min}" + end + end + end +end diff --git a/lib/lutaml/model/error/min_length_error.rb b/lib/lutaml/model/error/min_length_error.rb new file mode 100644 index 00000000..ee7e3d0a --- /dev/null +++ b/lib/lutaml/model/error/min_length_error.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Lutaml + module Model + class MinLengthError < RestrictionError + def initialize(attr_name, value, min_length) + @attr_name = attr_name + @value = value + @min_length = min_length + + super() + end + + def to_s + "#{@attr_name} length (#{@value.to_s.length}) is less than " \ + "the minimum required length #{@min_length}" + end + end + end +end diff --git a/lib/lutaml/model/error/restriction_error.rb b/lib/lutaml/model/error/restriction_error.rb new file mode 100644 index 00000000..56ec1b24 --- /dev/null +++ b/lib/lutaml/model/error/restriction_error.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Lutaml + module Model + class RestrictionError < Error + end + end +end diff --git a/lib/lutaml/model/error/total_digits_error.rb b/lib/lutaml/model/error/total_digits_error.rb new file mode 100644 index 00000000..f5ec0ca7 --- /dev/null +++ b/lib/lutaml/model/error/total_digits_error.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Lutaml + module Model + class TotalDigitsError < RestrictionError + def initialize(attr_name, value, total_digits) + @attr_name = attr_name + @value = value + @total_digits = total_digits + + super() + end + + def to_s + "#{@attr_name} (#{@value}) exceeds the maximum of " \ + "#{@total_digits} total digits" + end + end + end +end diff --git a/lib/lutaml/model/error/type.rb b/lib/lutaml/model/error/type.rb index 185d0111..4c0bce49 100644 --- a/lib/lutaml/model/error/type.rb +++ b/lib/lutaml/model/error/type.rb @@ -7,6 +7,8 @@ module Type autoload :InvalidValueError, "#{__dir__}/type/invalid_value_error" autoload :MinBoundError, "#{__dir__}/type/min_bound_error" autoload :MaxBoundError, "#{__dir__}/type/max_bound_error" + autoload :MinExclusiveError, "#{__dir__}/type/min_exclusive_error" + autoload :MaxExclusiveError, "#{__dir__}/type/max_exclusive_error" autoload :PatternNotMatchedError, "#{__dir__}/type/pattern_not_matched_error" autoload :MinLengthError, "#{__dir__}/type/min_length_error" diff --git a/lib/lutaml/model/error/type/max_exclusive_error.rb b/lib/lutaml/model/error/type/max_exclusive_error.rb new file mode 100644 index 00000000..34e422d5 --- /dev/null +++ b/lib/lutaml/model/error/type/max_exclusive_error.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Lutaml + module Model + module Type + class MaxExclusiveError < Error + def initialize(value, max_bound) + @value = value + @max_bound = max_bound + + super() + end + + def to_s + "Value #{@value} is not less than the exclusive maximum #{@max_bound}" + end + end + end + end +end diff --git a/lib/lutaml/model/error/type/min_exclusive_error.rb b/lib/lutaml/model/error/type/min_exclusive_error.rb new file mode 100644 index 00000000..50da1b5c --- /dev/null +++ b/lib/lutaml/model/error/type/min_exclusive_error.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Lutaml + module Model + module Type + class MinExclusiveError < Error + def initialize(value, min_bound) + @value = value + @min_bound = min_bound + + super() + end + + def to_s + "Value #{@value} is not greater than the exclusive minimum #{@min_bound}" + end + end + end + end +end diff --git a/lib/lutaml/model/restriction_validation.rb b/lib/lutaml/model/restriction_validation.rb new file mode 100644 index 00000000..64d7c15d --- /dev/null +++ b/lib/lutaml/model/restriction_validation.rb @@ -0,0 +1,437 @@ +# frozen_string_literal: true + +module Lutaml + module Model + # Module for enforcing value restrictions on attributes. + # + # Provides the lazy validation seam used by `Attribute#validate_value!`: + # a configuration check (applicability of the effective facets to the + # resolved type) and a value check (ordered bounds / string length) that + # reuses the shared Type validators and translates their errors into + # model-level errors carrying the attribute name. + # + # The effective facet set is the conjunctive merge of Layer-1 attribute + # options (lowered to canonical keys) and the Layer-2 facets declared on a + # `Type::Value` subclass; the tightest constraint per key wins. + module RestrictionValidation + # Verify the effective facets are applicable to the resolved type. The + # type is only known after register resolution, so this runs at + # validation time and raises ahead of the value checks. + def validate_restriction_configuration!(resolved_type) + facets = effective_facets(resolved_type) + + if @options.key?(:signed) && !numeric_type?(resolved_type) + raise ArgumentError, + "Invalid options for `#{name}`: " \ + "`signed` is only allowed for numeric types" + end + + if ordered_facets?(facets) && !ordered_type?(resolved_type) + raise ArgumentError, + "Invalid options for `#{name}`: " \ + "`min`, `max`, `inclusive` and `exclusive` are only allowed " \ + "for numeric types or temporal (date/time) types" + end + + if length_facets?(facets) && !string_type?(resolved_type) + raise ArgumentError, + "Invalid options for `#{name}`: " \ + "`min_length` and `max_length` are only allowed for :string type" + end + + if facets.key?(:pattern) && !string_type?(resolved_type) + raise ArgumentError, + "Invalid options for `#{name}`: " \ + "`pattern` is only allowed for string-derived types" + end + + if digit_facets?(facets) && !digit_type?(resolved_type) + raise ArgumentError, + "Invalid options for `#{name}`: " \ + "`total_digits` and `fraction_digits` are only allowed " \ + "for :integer and :decimal types" + end + end + + # Enforce the effective facets against the value, translating the reused + # validator's Type::* errors into model-level errors that carry the + # attribute name so `validate` can collect them. + def validate_restriction_values!(value, resolved_type) + facets = effective_facets(resolved_type) + return if facets.empty? + return if value.nil? || Utils.uninitialized?(value) + + unless collection? && collection_instance?(value) + return validate_element_facets!(value, resolved_type, facets) + end + + value.each do |element| + next if element.nil? || Utils.uninitialized?(element) + + validate_element_facets!(element, resolved_type, facets) + end + end + + # Effective facet set for schema emission: the validator's merge plus the + # pre-#191 `values:`/`pattern:` options. Those are enforced at runtime by + # a separate path (so they are absent from the validation facet set); fold + # them in here so the exported XSD is not weaker than what is enforced. + def effective_restriction_facets(resolved_type) + facets = effective_facets(resolved_type) + facets = merge_values_option(facets, resolved_type) if @options.key?(:values) + facets = merge_pattern_option(facets) if @options.key?(:pattern) + # Folding in the values: option can empty an enumeration (a disjoint + # set enforced by both layers); that is an unsatisfiable restriction, + # not an unrestricted one, so reject it as the merged interval does. + reject_empty_enumeration!(facets) + facets + end + + private + + # Value facets apply to a single value: the whole value for a singular + # attribute, or each present element for a collection. Cardinality (item + # count) is governed separately by `collection:` in the `&&`-chain. + def validate_element_facets!(value, resolved_type, facets) + if ordered_type?(resolved_type) + validate_inclusive_bounds!(value, facets) + validate_exclusive_bounds!(value, facets) + end + if string_type?(resolved_type) + # Length and pattern facets operate on the lexical (serialized) form: + # a string-derived type whose cast value is not a ::String (e.g. + # Type::Uri holding a ::URI) has no #length/#match?, so measuring or + # matching the raw cast object would raise. Serialize once, share it. + lexical = resolved_type.serialize(value) + validate_length_bounds!(value, lexical, facets) + validate_patterns!(value, lexical, facets) + end + validate_digit_bounds!(value, facets) if digit_type?(resolved_type) + validate_enumeration!(value, facets) + end + + # Conjunctive merge of Layer-1 options and Layer-2 type facets, both in + # canonical-key form. Ordered bounds and enumeration members are cast to + # the resolved type per layer (before the merge, so `tighter_facet`/`&` + # compare homogeneous values). An empty resulting interval is a config + # error. + def effective_facets(resolved_type) + facets = option_facets(resolved_type) + .merge(type_facets(resolved_type)) do |key, a, b| + Type::Value.tighter_facet(key, a, b) + end + ensure_consistent_interval!(facets) + facets + end + + def option_facets(resolved_type) + facets = {} + facets[:min_inclusive] = cast_bound(@options[:min], resolved_type) if @options.key?(:min) + facets[:max_inclusive] = cast_bound(@options[:max], resolved_type) if @options.key?(:max) + if @options[:signed] == false + zero = cast_bound(0, resolved_type) + facets[:min_inclusive] = [facets[:min_inclusive], zero].compact.max + end + facets[:min_length] = @options[:min_length] if @options.key?(:min_length) + facets[:max_length] = @options[:max_length] if @options.key?(:max_length) + # An explicit `min: nil` / `max: nil` means "no bound": drop the nil + # key so it neither emits an empty nor masks a genuinely + # empty interval during consolidation. + facets.compact + end + + # Layer-2 facets are already cast to the type at declaration; only the + # Layer-1 option bounds (below) still need casting. + def type_facets(resolved_type) + return {} unless castable_type?(resolved_type) + + resolved_type.facets + end + + # Fold the Layer-1 `values:` option into the schema enumeration. Runtime + # enforces both sets conjunctively, so a Layer-2 enumeration is + # intersected with the values option (both cast to the type so the + # intersection compares like values). + def merge_values_option(facets, resolved_type) + members = Array(@options[:values]).map { |v| cast_bound(v, resolved_type) } + existing = facets[:enumeration] + facets.merge(enumeration: existing ? existing & members : members) + end + + # Fold the Layer-1 `pattern:` option into the schema pattern list (the + # emitter fails fast if that yields conjunctive patterns XSD cannot OR). + def merge_pattern_option(facets) + option = @options[:pattern] + regexp = option.is_a?(Regexp) ? option : Regexp.new(option) + facets.merge(pattern: Array(facets[:pattern]) + [regexp]) + end + + def cast_bound(value, resolved_type) + return value unless castable_type?(resolved_type) + + cast_facet_value(value, resolved_type) + end + + # Cast a bound/enumeration literal to the resolved type. A non-nil literal + # that casts to nil is an unparseable restriction (e.g. `min: "abc"` on an + # integer); raise rather than silently drop the constraint. + def cast_facet_value(value, resolved_type) + cast = resolved_type.cast(value) + return cast if !cast.nil? || value.nil? + + raise ArgumentError, + "Invalid restrictions for `#{name}`: " \ + "#{value.inspect} is not a valid #{resolved_type} value" + end + + def castable_type?(resolved_type) + resolved_type.is_a?(Class) && resolved_type <= Type::Value + end + + def ensure_consistent_interval!(facets) + consolidate_min_bounds!(facets) + consolidate_max_bounds!(facets) + reject_empty_ordered_interval!(facets) + reject_empty_interval!(facets, :min_length, :max_length) + reject_exact_length_conflict!(facets) + reject_empty_enumeration!(facets) + reject_fraction_exceeding_total!(facets) + end + + # A same-side inclusive+exclusive pair is a conjunction (both must hold), + # so the tighter bound binds and the looser one is dropped. Lower side: + # `>= inc AND > exc`; inclusive binds only when it excludes strictly more + # (`inc > exc`), otherwise the exclusive bound wins (tie included, since + # `> n` is tighter than `>= n`). + def consolidate_min_bounds!(facets) + inc = facets[:min_inclusive] + exc = facets[:min_exclusive] + return if inc.nil? || exc.nil? + + facets.delete(inc > exc ? :min_exclusive : :min_inclusive) + end + + # Upper side: `<= inc AND < exc`; inclusive binds only when it excludes + # strictly more (`inc < exc`), otherwise the exclusive bound wins (tie + # included, since `< n` is tighter than `<= n`). + def consolidate_max_bounds!(facets) + inc = facets[:max_inclusive] + exc = facets[:max_exclusive] + return if inc.nil? || exc.nil? + + facets.delete(inc < exc ? :max_exclusive : :max_inclusive) + end + + def reject_fraction_exceeding_total!(facets) + total = facets[:total_digits] + fraction = facets[:fraction_digits] + return if total.nil? || fraction.nil? || fraction <= total + + raise ArgumentError, + "Invalid restrictions for `#{name}`: " \ + "fraction_digits (#{fraction}) exceeds total_digits (#{total})" + end + + def reject_empty_enumeration!(facets) + enumeration = facets[:enumeration] + return if enumeration.nil? || !enumeration.empty? + + raise ArgumentError, + "Invalid restrictions for `#{name}`: " \ + "enumeration allows no values" + end + + # Reject an empty interval formed by the merged ordered bounds. After + # consolidation at most one lower and one upper bound remain; a shared + # endpoint is valid only when both bounds are inclusive (single point). + def reject_empty_ordered_interval!(facets) + lo_key = facets.key?(:min_inclusive) ? :min_inclusive : :min_exclusive + hi_key = facets.key?(:max_inclusive) ? :max_inclusive : :max_exclusive + lo = facets[lo_key] + hi = facets[hi_key] + return if lo.nil? || hi.nil? + + if lo > hi + raise ArgumentError, + "Invalid restrictions for `#{name}`: " \ + "#{lo_key} (#{lo}) exceeds #{hi_key} (#{hi})" + end + + return unless lo == hi + return if lo_key == :min_inclusive && hi_key == :max_inclusive + + raise ArgumentError, + "Invalid restrictions for `#{name}`: " \ + "#{lo_key} (#{lo}) and #{hi_key} (#{hi}) define an empty interval" + end + + def reject_empty_interval!(facets, min_key, max_key) + min = facets[min_key] + max = facets[max_key] + return if min.nil? || max.nil? || min <= max + + raise ArgumentError, + "Invalid restrictions for `#{name}`: " \ + "#{min_key} (#{min}) exceeds #{max_key} (#{max})" + end + + def reject_exact_length_conflict!(facets) + length = facets[:length] + min = facets[:min_length] + max = facets[:max_length] + return if length.nil? + return if (min.nil? || length >= min) && (max.nil? || length <= max) + + raise ArgumentError, + "Invalid restrictions for `#{name}`: " \ + "length (#{length}) is outside [#{min}, #{max}]" + end + + def ordered_facets?(facets) + facets.key?(:min_inclusive) || facets.key?(:max_inclusive) || + facets.key?(:min_exclusive) || facets.key?(:max_exclusive) + end + + def length_facets?(facets) + facets.key?(:min_length) || facets.key?(:max_length) || + facets.key?(:length) + end + + def digit_facets?(facets) + facets.key?(:total_digits) || facets.key?(:fraction_digits) + end + + # Digit facets are countable only on integer- and decimal-derived types. + # Float is intentionally excluded: its binary representation makes the + # significant-digit count unreliable. + def digit_type?(resolved_type) + resolved_type <= Type::Integer || resolved_type <= Type::Decimal + end + + def ordered_type?(resolved_type) + numeric_type?(resolved_type) || temporal_type?(resolved_type) + end + + def numeric_type?(resolved_type) + resolved_type <= Type::Integer || + resolved_type <= Type::Decimal || + resolved_type <= Type::Float + end + + # Ordered facets apply to temporal types whose cast value has a reliable + # total order (Date/Time/DateTime respond to <=>). Duration and + # TimeWithoutDate are intentionally excluded: their cast value is a + # lexical String, so comparing them as ordered bounds would be incorrect. + def temporal_type?(resolved_type) + resolved_type <= Type::Date || + resolved_type <= Type::Time || + resolved_type <= Type::DateTime + end + + def string_type?(resolved_type) + resolved_type <= Type::String + end + + def validate_inclusive_bounds!(value, facets) + bounds = { min: facets[:min_inclusive], + max: facets[:max_inclusive] }.compact + return if bounds.empty? + + Services::Type::Validator.validate_min_max_bounds!(value, bounds) + rescue Lutaml::Model::Type::MinBoundError + raise Lutaml::Model::MinInclusiveError.new(name, value, bounds[:min]) + rescue Lutaml::Model::Type::MaxBoundError + raise Lutaml::Model::MaxInclusiveError.new(name, value, bounds[:max]) + end + + def validate_exclusive_bounds!(value, facets) + min = facets[:min_exclusive] + max = facets[:max_exclusive] + return if min.nil? && max.nil? + + Services::Type::Validator.validate_exclusive_bounds!(value, facets) + rescue Lutaml::Model::Type::MinExclusiveError + raise Lutaml::Model::MinExclusiveError.new(name, value, min) + rescue Lutaml::Model::Type::MaxExclusiveError + raise Lutaml::Model::MaxExclusiveError.new(name, value, max) + end + + # `lexical` is the serialized form measured for length; `value` is the + # original cast value carried in the model error (as the pattern path does). + def validate_length_bounds!(value, lexical, facets) + validate_exact_length!(value, lexical, facets[:length]) + min = facets[:min_length] + max = facets[:max_length] + Services::Type::Validator::String.validate_min_length!(lexical, min) if min + Services::Type::Validator::String.validate_max_length!(lexical, max) if max + rescue Lutaml::Model::Type::MinLengthError + raise Lutaml::Model::MinLengthError.new(name, value, min) + rescue Lutaml::Model::Type::MaxLengthError + raise Lutaml::Model::MaxLengthError.new(name, value, max) + end + + def validate_exact_length!(value, lexical, length) + return if length.nil? || lexical.length == length + + raise Lutaml::Model::LengthError.new(name, value, length) + end + + def validate_digit_bounds!(value, facets) + total_limit = facets[:total_digits] + fraction_limit = facets[:fraction_digits] + return if total_limit.nil? && fraction_limit.nil? + + total, fraction = digit_counts(value) + if total_limit && total > total_limit + raise Lutaml::Model::TotalDigitsError.new(name, value, total_limit) + end + return unless fraction_limit && fraction > fraction_limit + + raise Lutaml::Model::FractionDigitsError.new(name, value, fraction_limit) + end + + # Count digits of an already-cast numeric value using XSD semantics + # (xs:totalDigits / xs:fractionDigits). From the fixed-notation string + # drop the sign and strip trailing zeros from the fraction part; the + # significand is the integer part concatenated with that fraction, with + # leading zeros removed. total = significant digits in the significand; + # fraction = remaining fraction digits. A leading zero before the point + # (values < 1) is insignificant, so `0.05` is [1, 2]; zero is [0, 0]. + # + # @return [Array(Integer, Integer)] [total_digits, fraction_digits] + def digit_counts(value) + fixed = value.is_a?(::Integer) ? value.to_s : value.to_s("F") + int_part, frac_part = fixed.delete("-").split(".") + significant_frac = frac_part.to_s.sub(/0+\z/, "") + significand = (int_part + significant_frac).sub(/\A0+/, "") + [significand.length, significant_frac.length] + end + + def validate_enumeration!(value, facets) + allowed = facets[:enumeration] + return if allowed.nil? + + Services::Type::Validator.validate_values!(value, allowed) + rescue Lutaml::Model::Type::InvalidValueError + raise Lutaml::Model::InvalidValueError.new(name, value, allowed) + end + + # Each accumulated pattern must match against the lexical form (already + # serialized by the caller) so non-::String string-derived types are + # matched on their serialized text rather than their cast object; the + # model error still carries the original `value`. + def validate_patterns!(value, lexical, facets) + patterns = facets[:pattern] + return if patterns.nil? + + patterns.each do |pattern| + Services::Type::Validator::String.validate_pattern!( + lexical, pattern: pattern + ) + rescue Lutaml::Model::Type::PatternNotMatchedError + raise Lutaml::Model::PatternNotMatchedError.new(name, pattern, value) + end + end + end + end +end diff --git a/lib/lutaml/model/schema/definitions/restricted_type.rb b/lib/lutaml/model/schema/definitions/restricted_type.rb index d84f08d1..56f30bb5 100644 --- a/lib/lutaml/model/schema/definitions/restricted_type.rb +++ b/lib/lutaml/model/schema/definitions/restricted_type.rb @@ -7,16 +7,18 @@ module Definitions # Restricted simple type — `class X < Lutaml::Model::Type::Y` # with a cast body that applies facets and delegates to super. class RestrictedType - attr_accessor :class_name, :parent_class, :facets, + attr_accessor :class_name, :parent_class, :base_class, :facets, :transform_facet, :required_files, :keep_register_when_namespaced, :namespace_class_name def initialize(class_name:, facets:, parent_class: nil, - transform_facet: nil, required_files: [], + base_class: nil, transform_facet: nil, + required_files: [], keep_register_when_namespaced: false, namespace_class_name: nil) @class_name = class_name @parent_class = parent_class + @base_class = base_class @facets = facets @transform_facet = transform_facet @required_files = required_files diff --git a/lib/lutaml/model/schema/renderers/restricted_type.rb b/lib/lutaml/model/schema/renderers/restricted_type.rb index 3659aa94..1d00c3a3 100644 --- a/lib/lutaml/model/schema/renderers/restricted_type.rb +++ b/lib/lutaml/model/schema/renderers/restricted_type.rb @@ -7,9 +7,40 @@ module Model module Schema module Renderers # Renders a Definitions::RestrictedType into a Ruby class extending - # a Lutaml::Model::Type::* with a cast body that mutates options - # with facet values and delegates to super. + # a Lutaml::Model::Type::* with Layer-2 facet macros (lazy validation + # + `.facets` round-trip) and a cast body that mutates options with + # the eager numeric facet values and delegates to super. class RestrictedType < Base + # XSD base types whose bounds/enumeration values are integer literals. + INTEGER_BASES = %i[ + integer int long short byte + positiveInteger nonNegativeInteger negativeInteger nonPositiveInteger + unsignedLong unsignedInt unsignedShort unsignedByte + ].freeze + + # Base types whose facet values must be emitted as a `cast` call on + # the Lutaml type, so the generated literal equals the cast attribute + # value it is compared against (temporal/boolean/float parse + # specially). Keyed on both the XSD spelling (`dateTime`, from the XSD + # compiler) and the snake_case spelling (`date_time`, from the RNG + # compiler) since both feed this shared renderer. + CAST_BASES = { + boolean: "Lutaml::Model::Type::Boolean", + float: "Lutaml::Model::Type::Float", + double: "Lutaml::Model::Type::Float", + date: "Lutaml::Model::Type::Date", + dateTime: "Lutaml::Model::Type::DateTime", + date_time: "Lutaml::Model::Type::DateTime", + time: "Lutaml::Model::Type::Time", + }.freeze + + # Bases whose bounds render as bare numeric literals, so the eager + # `options[:min]/[:max]` path is valid Ruby. Every other base + # (temporal, boolean, string, or a user-defined type) carries a bound + # that is not a bare literal, so it stays macro-only and the eager + # path is skipped — the Layer-2 facet macros enforce it lazily. + EAGER_NUMERIC_BASES = (INTEGER_BASES + %i[decimal float double]).freeze + def render Templates::RESTRICTED_SIMPLE_TYPE.result(binding) end @@ -26,6 +57,28 @@ def xml_namespace_line def restricted_simple_type_required_files = required_files_block + # Layer-2 facet macros declared in the class body. They store the + # facets on the type so generated models validate lazily through + # RestrictionValidation and round-trip via `.facets` back to XSD. + def restricted_simple_type_facet_declarations + f = @spec.facets + lines = [ + ordered("inclusive", f.min_inclusive, f.max_inclusive), + ordered("exclusive", f.min_exclusive, f.max_exclusive), + length_macro, + # Mirror the eager `%r{#{pattern}}` form (not `pattern.inspect`): + # a built-in pattern (e.g. anyURI) is stored with live `#{...}` + # interpolation that `inspect` would escape, matching the literal + # source instead of the value. + (Utils.present?(f.pattern) ? "pattern(%r{#{f.pattern}})" : nil), + enumeration_macro(f.enumerations), + white_space_macro(f.white_space), + (Utils.present?(f.total_digits) ? "total_digits #{f.total_digits}" : nil), + (Utils.present?(f.fraction_digits) ? "fraction_digits #{f.fraction_digits}" : nil), + ] + lines.compact.map { |line| "#{@indent}#{line}\n" }.join + end + def restricted_simple_type_cast_body [ render_min_max, @@ -35,15 +88,21 @@ def restricted_simple_type_cast_body ].compact.join end + # Eager numeric option path, valid only for bases whose bounds are + # bare numeric literals; other bases are macro-only (see above). The + # bound is rendered through `literal_for` so a decimal keeps its exact + # value (`BigDecimal("...")`, not a lossy/`.5`-invalid Float literal). def render_min_max + return nil unless EAGER_NUMERIC_BASES.include?(base_class_name) + f = @spec.facets max = f.max_inclusive || f.max_exclusive min = f.min_inclusive || f.min_exclusive return nil unless max || min out = +"" - out << "#{@extended_indent}options[:max] = #{max}\n" if max - out << "#{@extended_indent}options[:min] = #{min}\n" if min + out << "#{@extended_indent}options[:max] = #{literal_for(max)}\n" if max + out << "#{@extended_indent}options[:min] = #{literal_for(min)}\n" if min out end @@ -65,6 +124,77 @@ def render_transform t && "#{@extended_indent}value = #{t.expression}\n" end + # Format an ordered-facet macro (`inclusive`/`exclusive`) from its + # base-typed bounds, emitting only the bounds that are present. + def ordered(macro, min, max) + args = [] + args << "min: #{literal_for(min)}" if Utils.present?(min) + args << "max: #{literal_for(max)}" if Utils.present?(max) + return if args.empty? + + "#{macro} #{args.join(', ')}" + end + + # Length facets are always plain integers (character/octet counts), + # independent of the base type: exact `length N`, or a min/max range. + def length_macro + f = @spec.facets + return "length #{f.length}" if Utils.present?(f.length) + + args = [] + args << "min: #{f.min_length}" if Utils.present?(f.min_length) + args << "max: #{f.max_length}" if Utils.present?(f.max_length) + return if args.empty? + + "length #{args.join(', ')}" + end + + def enumeration_macro(enumerations) + return nil unless Utils.present?(enumerations) + + "enumeration(#{enumerations.map { |v| literal_for(v) }.join(', ')})" + end + + # xs:whiteSpace is a string-only facet; the runtime macro rejects it + # on non-string bases. Emit it only for string-derived bases so the + # generated code loads. + def white_space_macro(white_space) + return nil unless string_derived_base? && Utils.present?(white_space) + + "white_space #{white_space.inspect}" + end + + # The one renderer for base-typed facet values (bounds, enumeration): + # decimal an exact BigDecimal, temporal/boolean/float a cast value + # comparable via `<=>`, integer a bare literal, a user-defined base a + # `cast` on its generated parent class (in scope as this class's + # superclass), and a built-in string base a quoted string. + def literal_for(raw) + name = base_class_name + return %{BigDecimal("#{raw}")} if name == :decimal + if (klass = CAST_BASES[name]) + return "#{klass}.cast(#{raw.to_s.inspect})" + end + return raw.to_i.to_s if INTEGER_BASES.include?(name) + unless XmlCompiler::SupportedDataTypes[name] + return "#{Utils.camel_case(name.to_s)}.cast(#{raw.to_s.inspect})" + end + + raw.to_s.inspect + end + + def string_derived_base? + name = base_class_name + return false if name == :decimal + return false if INTEGER_BASES.include?(name) + + !CAST_BASES.key?(name) + end + + def base_class_name + @spec.base_class&.to_sym + end + def registration_methods Registration.methods_block( class_name: @spec.class_name, diff --git a/lib/lutaml/model/schema/rng_compiler/define_classifier.rb b/lib/lutaml/model/schema/rng_compiler/define_classifier.rb index 8051d948..3a674e65 100644 --- a/lib/lutaml/model/schema/rng_compiler/define_classifier.rb +++ b/lib/lutaml/model/schema/rng_compiler/define_classifier.rb @@ -66,7 +66,9 @@ def data_restricted_type Definitions::RestrictedType.new( class_name: @class_name, parent_class: RngHelpers.parent_class_for(base), + base_class: base.to_s, facets: RngHelpers.facet_from_data(data), + required_files: RngHelpers.required_files_for(base), ) end @@ -77,6 +79,7 @@ def enum_restricted_type Definitions::RestrictedType.new( class_name: @class_name, parent_class: RngHelpers.parent_class_for(:string), + base_class: "string", facets: RngHelpers.facet_from_values(choice.value), ) end diff --git a/lib/lutaml/model/schema/rng_compiler/rng_helpers.rb b/lib/lutaml/model/schema/rng_compiler/rng_helpers.rb index 60a145de..268504b5 100644 --- a/lib/lutaml/model/schema/rng_compiler/rng_helpers.rb +++ b/lib/lutaml/model/schema/rng_compiler/rng_helpers.rb @@ -121,6 +121,12 @@ def parent_class_for(base_symbol) ).to_s end + # A decimal-based restricted type renders `BigDecimal(...)` facet + # literals, so the generated file must require bigdecimal to load. + def required_files_for(base_symbol) + base_symbol == :decimal ? [%(require "bigdecimal")] : [] + end + def apply_param(facet, name, value) case name when "minInclusive" then facet.min_inclusive = numeric_or_string(value) @@ -136,8 +142,10 @@ def apply_param(facet, name, value) def numeric_or_string(value) return value.to_i if /\A-?\d+\z/.match?(value) - return value.to_f if /\A-?\d+\.\d+\z/.match?(value) + # Keep a fractional bound as its exact lexical string; casting through + # Float would truncate a high-precision decimal before it reaches the + # `BigDecimal("...")` facet literal (mirrors the XSD compiler). value end diff --git a/lib/lutaml/model/schema/rng_compiler/value_type_resolver.rb b/lib/lutaml/model/schema/rng_compiler/value_type_resolver.rb index 99028560..fb75ef3a 100644 --- a/lib/lutaml/model/schema/rng_compiler/value_type_resolver.rb +++ b/lib/lutaml/model/schema/rng_compiler/value_type_resolver.rb @@ -73,7 +73,9 @@ def anonymous_restricted_type(container, base, facet) @classes, "#{Utils.camel_case(container.attr_name.to_s)}Type" ), parent_class: RngHelpers.parent_class_for(base), + base_class: base.to_s, facets: facet, + required_files: RngHelpers.required_files_for(base), ) end diff --git a/lib/lutaml/model/schema/templates.rb b/lib/lutaml/model/schema/templates.rb index 57fdd61b..83626169 100644 --- a/lib/lutaml/model/schema/templates.rb +++ b/lib/lutaml/model/schema/templates.rb @@ -111,6 +111,7 @@ class <%= class_name %> < Lutaml::Xml::W3c::XmlNamespace # Binding: module_opening, module_closing, registration_methods, # registration_execution, rendered_class_name, parent_class, # xml_namespace_line, restricted_simple_type_required_files, + # restricted_simple_type_facet_declarations, # restricted_simple_type_cast_body, boilerplate_indent_str. RESTRICTED_SIMPLE_TYPE = ERB.new(<<~TMPL, trim_mode: "-") # frozen_string_literal: true @@ -123,6 +124,7 @@ class <%= rendered_class_name %><%= " < \#{parent_class}" if parent_class %> <%= boilerplate_indent_str * 2 %><%= xml_namespace_line %> <%= boilerplate_indent_str %>end <%- end -%> + <%= restricted_simple_type_facet_declarations -%> <%= boilerplate_indent_str %>def self.cast(value, options = {}) <%= boilerplate_indent_str * 2 %>return if value.nil? diff --git a/lib/lutaml/model/schema/xml_compiler/spec_builder/simple_types.rb b/lib/lutaml/model/schema/xml_compiler/spec_builder/simple_types.rb index fdc375cf..ed124e23 100644 --- a/lib/lutaml/model/schema/xml_compiler/spec_builder/simple_types.rb +++ b/lib/lutaml/model/schema/xml_compiler/spec_builder/simple_types.rb @@ -39,6 +39,7 @@ def build_supported(name, info) Definitions::RestrictedType.new( class_name: Utils.camel_case(name), parent_class: restricted_parent_class(base), + base_class: base, facets: facet, transform_facet: transform, required_files: supported_required_files(base), @@ -80,6 +81,7 @@ def build_restricted_type(name, restriction) Definitions::RestrictedType.new( class_name: Utils.camel_case(name), parent_class: restricted_parent_class(base_class), + base_class: base_class, facets: facet, transform_facet: nil, required_files: restricted_required_files(base_class), @@ -122,28 +124,58 @@ def build_facet(restriction) min_length: pick_minmax(restriction.min_length, :max), min_inclusive: pick_minmax(restriction.min_inclusive, :max), max_inclusive: pick_minmax(restriction.max_inclusive, :min), - max_exclusive: pick_minmax(restriction.max_exclusive, :max), - min_exclusive: pick_minmax(restriction.min_exclusive, :min), - length: restriction.length&.any? ? restriction_length(restriction.length) : nil, + max_exclusive: pick_minmax(restriction.max_exclusive, :min), + min_exclusive: pick_minmax(restriction.min_exclusive, :max), + length: pick_minmax(restriction.length, :min), pattern: build_pattern(restriction.pattern), enumerations: restriction.enumeration&.any? ? restriction.enumeration.map(&:value) : nil, + white_space: single_facet(restriction.white_space, &:to_sym), + total_digits: single_facet(restriction.total_digits, &:to_i), + fraction_digits: single_facet(restriction.fraction_digits, &:to_i), ) end + # Pick the tightest bound as its exact lexical string. A single + # value (the only schema-valid case) is returned verbatim so a + # decimal keeps its precision; a repeated bound is ordered by + # numeric magnitude (not lexical order, under which "5" > "10"), + # falling back to lexical order for non-numeric temporal bounds. def pick_minmax(field_value, method) return nil unless field_value&.any? - field_value.map(&:value).public_send(method).to_s + values = field_value.map(&:value) + return values.first if values.one? + + values.public_send(:"#{method}_by") { |v| comparable_bound(v) } + end + + # Lazy, guarded require (mirrors Type::Decimal) so this Opal-booted + # file has no load-time bigdecimal dependency; the XSD compiler is a + # native-only path, so this only runs under MRI. + def comparable_bound(value) + require "bigdecimal" unless defined?(BigDecimal) + BigDecimal(value.to_s) + rescue ArgumentError + value.to_s end - def restriction_length(lengths) - lengths.map { |l| { value: l.value, fixed: l.fixed } } + # Carry a single-valued facet (whiteSpace/totalDigits/ + # fractionDigits), normalizing its lexical value through the block. + def single_facet(field_value) + return nil unless field_value&.any? + + yield(field_value.first.value) end + # Multiple in one restriction are alternatives (OR), + # each grouped so a `|` inside one does not leak across; a single + # pattern needs no grouping and is carried verbatim so it round-trips + # exactly (and keeps any live `#{...}` interpolation, e.g. anyURI). def build_pattern(patterns) return nil if Utils.blank?(patterns) - patterns.map { |p| "(#{p.value})" }.join("|") + values = patterns.map(&:value) + values.one? ? values.first : values.map { |v| "(#{v})" }.join("|") end end end diff --git a/lib/lutaml/model/schema/xml_compiler/supported_data_types.rb b/lib/lutaml/model/schema/xml_compiler/supported_data_types.rb index 3dcf4755..81bbb58a 100644 --- a/lib/lutaml/model/schema/xml_compiler/supported_data_types.rb +++ b/lib/lutaml/model/schema/xml_compiler/supported_data_types.rb @@ -33,10 +33,13 @@ module SupportedDataTypes language: { skippable: false, class_name: TC[:string], validations: { pattern: /\A[a-zA-Z]{1,8}(-[a-zA-Z0-9]{1,8})*\z/ } }, dateTime: { skippable: true, class_name: TC[:date_time] }, + date: { skippable: true, class_name: TC[:date] }, + time: { skippable: true, class_name: TC[:time] }, boolean: { skippable: true, class_name: TC[:boolean] }, integer: { skippable: true, class_name: TC[:integer] }, decimal: { skippable: true, class_name: TC[:decimal] }, string: { skippable: true, class_name: TC[:string] }, + float: { skippable: true, class_name: TC[:float] }, double: { skippable: true, class_name: TC[:float] }, NCName: { skippable: false, class_name: TC[:string], validations: { pattern: /\A[a-zA-Z_][\w.-]*\z/ } }, diff --git a/lib/lutaml/model/services/type/validator.rb b/lib/lutaml/model/services/type/validator.rb index 70936370..02a1005b 100644 --- a/lib/lutaml/model/services/type/validator.rb +++ b/lib/lutaml/model/services/type/validator.rb @@ -35,6 +35,29 @@ def validate_max_bound!(value, max) raise Lutaml::Model::Type::MaxBoundError.new(value, max) end + + # Reads the canonical facet keys (:min_exclusive/:max_exclusive); + # the inclusive `validate_min_max_bounds!` above reads the legacy + # :min/:max keys it shares with the eager cast path. + def validate_exclusive_bounds!(value, options) + min, max = options&.values_at(:min_exclusive, :max_exclusive) + return if min.nil? && max.nil? + + validate_min_exclusive_bound!(value, min) if min + validate_max_exclusive_bound!(value, max) if max + end + + def validate_min_exclusive_bound!(value, min) + return if value > min + + raise Lutaml::Model::Type::MinExclusiveError.new(value, min) + end + + def validate_max_exclusive_bound!(value, max) + return if value < max + + raise Lutaml::Model::Type::MaxExclusiveError.new(value, max) + end end extend ClassMethods diff --git a/lib/lutaml/model/type.rb b/lib/lutaml/model/type.rb index 6aeccb65..0d3a1fcc 100644 --- a/lib/lutaml/model/type.rb +++ b/lib/lutaml/model/type.rb @@ -29,6 +29,8 @@ module Type autoload :InvalidValueError, "#{__dir__}/error/type/invalid_value_error" autoload :MinBoundError, "#{__dir__}/error/type/min_bound_error" autoload :MaxBoundError, "#{__dir__}/error/type/max_bound_error" + autoload :MinExclusiveError, "#{__dir__}/error/type/min_exclusive_error" + autoload :MaxExclusiveError, "#{__dir__}/error/type/max_exclusive_error" autoload :PatternNotMatchedError, "#{__dir__}/error/type/pattern_not_matched_error" autoload :MinLengthError, "#{__dir__}/error/type/min_length_error" diff --git a/lib/lutaml/model/type/string.rb b/lib/lutaml/model/type/string.rb index 52f3c935..074f6d83 100644 --- a/lib/lutaml/model/type/string.rb +++ b/lib/lutaml/model/type/string.rb @@ -8,13 +8,20 @@ class String < Value def self.cast(value, options = {}) return nil if value.nil? return value if Utils.uninitialized?(value) - # Short-circuit: return immediately if already a String with no options - # Use identity check for EMPTY_OPTIONS (faster than .empty?) - if value.is_a?(::String) && options.equal?(EMPTY_OPTIONS) + + # Short-circuit an already-::String value with no options only when + # no xs:whiteSpace normalization is requested (the common :string + # case); a non-preserve mode must fall through to transform the text. + mode = white_space_mode + if value.is_a?(::String) && options.equal?(EMPTY_OPTIONS) && + mode == :preserve return value end value = value.to_s + value = value.gsub(/[\t\n\r]/, " ") unless mode == :preserve + value = value.squeeze(" ").strip if mode == :collapse + unless options.equal?(EMPTY_OPTIONS) Model::Services::Type::Validator::String.validate!(value, options) @@ -22,6 +29,24 @@ def self.cast(value, options = {}) value end + # Effective xs:whiteSpace mode for this type, defaulting to :preserve. + # Memoized per class so the hot cast path avoids re-walking the facet + # chain; class-instance variables are not inherited, so each subclass + # computes (and freezes) its own effective mode. + def self.white_space_mode + @white_space_mode ||= facets[:white_space] || :preserve + end + + # Drop the memoized mode when a facet is declared after it was first + # computed (e.g. a cast before the `white_space` declaration), so the + # next cast recomputes it from the updated facets. + def self.reset_facet_cache + return unless instance_variable_defined?(:@white_space_mode) + + remove_instance_variable(:@white_space_mode) + end + private_class_method :reset_facet_cache + # Default XSD type for String # # @return [String] xs:string diff --git a/lib/lutaml/model/type/value.rb b/lib/lutaml/model/type/value.rb index c3331d5b..72245fef 100644 --- a/lib/lutaml/model/type/value.rb +++ b/lib/lutaml/model/type/value.rb @@ -11,6 +11,28 @@ class Value # Use options.equal?(EMPTY_OPTIONS) for fast-path checks EMPTY_OPTIONS = {}.freeze + # Canonical facet keys grouped by how a tighter constraint compares: a + # min-like facet tightens upward (greater wins), a max-like facet + # tightens downward (lesser wins). `:length` (exact) only matches equal. + MIN_FACETS = %i[min_inclusive min_exclusive min_length].freeze + MAX_FACETS = %i[ + max_inclusive + max_exclusive + max_length + total_digits + fraction_digits + ].freeze + + # Facets whose combined value is the concatenation of every declaration + # in the chain: accumulating them is always a tightening (all patterns + # must match), so they bypass the widen check on inheritance. + LIST_FACETS = %i[pattern].freeze + + # xs:whiteSpace normalization modes in ascending strictness: a stricter + # mode transforms at least as much as a looser one, so `tighter_facet` + # picks the stricter and a subclass may tighten but not loosen it. + WHITE_SPACE_MODES = %i[preserve replace collapse].freeze + # Format type serializer registry # Keys: [format, TypeClass] => { to: Proc, from: Proc } @format_type_serializers = {} @@ -44,6 +66,215 @@ def format_type_serializer_for(format, type_class) end nil end + + # Freeze a type's facets to further declarations once it is + # subclassed, so a parent cannot widen facets after children exist. + def inherited(subclass) + super + @facets_closed = true + end + + # Effective canonical facets for this type, merged parent-first. A + # subclass may only tighten an inherited facet; widening raises. + def facets + facet_layers.each_with_object({}) do |layer, merged| + layer.each do |key, value| + merged[key] = inherit_facet(key, merged[key], value) + end + end + end + + # Conjunctive-merge primitive for two facet values of the same key: + # the tighter one wins (greater for min-like, lesser for max-like), + # an exact `:length` must agree. The canonical home for facet + # combination, reused by the Layer-1/Layer-2 merge. + def tighter_facet(key, existing, incoming) + return incoming if existing.nil? + return existing if incoming.nil? + return incoming & existing if key == :enumeration + return existing + incoming if LIST_FACETS.include?(key) + return [existing, incoming].max if MIN_FACETS.include?(key) + return [existing, incoming].min if MAX_FACETS.include?(key) + return stricter_white_space(existing, incoming) if key == :white_space + return existing if existing == incoming + + raise ArgumentError, + "conflicting `#{key}` facets: #{existing} and #{incoming}" + end + + # Constrain ordered values via min/maxInclusive. + def inclusive(min: nil, max: nil) + min = cast_facet_value(min) + max = cast_facet_value(max) + raise_unordered!("inclusive", min, max) + declare_facets(min_inclusive: min, max_inclusive: max) + end + + # Constrain ordered values via min/maxExclusive. + def exclusive(min: nil, max: nil) + min = cast_facet_value(min) + max = cast_facet_value(max) + raise_unordered!("exclusive", min, max) + declare_facets(min_exclusive: min, max_exclusive: max) + end + + # Constrain length: exact `length N`, or `length min:, max:`. + def length(exact = nil, min: nil, max: nil) + return declare_exact_length(exact, min, max) unless exact.nil? + + raise_unordered!("length", min, max) + raise_negative_length!(min) + raise_negative_length!(max) + declare_facets(min_length: min, max_length: max) + end + + # Restrict to an enumerated set of allowed values (any type). A + # subclass narrows the parent's set; the effective set is the + # intersection across the chain (see `tighter_facet`). + def enumeration(*values) + declare_facets(enumeration: values.map { |v| cast_facet_value(v) }) + end + + # Restrict a string-derived value to match a regular expression. A + # String argument is compiled to a Regexp. Patterns accumulate across + # the chain; a value must match all of them. + def pattern(regex_or_string) + regexp = regex_or_string.is_a?(Regexp) ? regex_or_string : Regexp.new(regex_or_string) + declare_facets(pattern: [regexp]) + end + + # Normalize a string-derived value's whitespace at cast time + # (xs:whiteSpace). Unlike every other facet this transforms the + # stored value rather than validating it, so it is applied in + # `String.cast`, not the lazy validator. Only string-derived types + # carry lexical text, so declaring it elsewhere fails fast. + def white_space(mode) + unless self <= Type::String + raise ArgumentError, + "`white_space` is only allowed for string-derived types" + end + unless WHITE_SPACE_MODES.include?(mode) + raise ArgumentError, + "`white_space` must be one of " \ + "#{WHITE_SPACE_MODES.map(&:inspect).join(', ')}" + end + + declare_facets(white_space: mode) + end + + # Cap the total number of significant digits (xs:totalDigits) an + # integer- or decimal-derived value may carry. A maximum; the value + # must have at most `count` significant digits. Applicability to the + # resolved type is enforced lazily at validation time. + def total_digits(count) + declare_digit_facet(:total_digits, count, minimum: 1) + end + + # Cap the number of significant fraction digits (xs:fractionDigits) an + # integer- or decimal-derived value may carry (a maximum). + def fraction_digits(count) + declare_digit_facet(:fraction_digits, count, minimum: 0) + end + + private + + # Cast a bound/enumeration literal to this type at declaration, so + # facets accumulate and compare as typed values (not raw strings whose + # lexical order or `eql?` would differ from the cast value). A non-nil + # literal that will not cast is a misdeclared facet. + def cast_facet_value(value) + return nil if value.nil? + + cast = cast(value) + return cast unless cast.nil? + + raise ArgumentError, + "`#{self}` facet value #{value.inspect} is not castable" + end + + def declare_digit_facet(key, count, minimum:) + unless count.is_a?(::Integer) && count >= minimum + raise ArgumentError, + "`#{key}` must be an Integer >= #{minimum}, " \ + "got #{count.inspect}" + end + + declare_facets(key => count) + end + + def stricter_white_space(existing, incoming) + [existing, incoming].max_by { |mode| WHITE_SPACE_MODES.index(mode) } + end + + def facet_layers + layers = [] + klass = self + while klass && klass <= Value + own = klass.instance_variable_get(:@facets) + layers.unshift(own) if own + klass = klass.superclass + end + layers + end + + def inherit_facet(key, inherited, declared) + return declared if inherited.nil? + if LIST_FACETS.include?(key) + return tighter_facet(key, inherited, declared) + end + return declared if tighter_facet(key, inherited, declared) == declared + + raise ArgumentError, + "#{self} cannot widen inherited facet `#{key}` " \ + "from #{inherited} to #{declared}" + end + + def declare_exact_length(exact, min, max) + unless min.nil? && max.nil? + raise ArgumentError, + "`length #{exact}` cannot be combined with min:/max:" + end + + raise_negative_length!(exact) + declare_facets(length: exact) + end + + def declare_facets(values) + ensure_facets_open! + facets = values.compact + raise ArgumentError, "at least one facet value is required" if facets.empty? + + store = (@facets ||= {}) + facets.each do |key, value| + store[key] = tighter_facet(key, store[key], value) + end + reset_facet_cache + end + + # Hook: drop any per-class value derived from the facet set when a + # facet is (re)declared, so a stale memo cannot outlive the facets it + # was computed from. A no-op here; subclasses that memoize override it. + def reset_facet_cache; end + + def ensure_facets_open! + return unless @facets_closed + + raise ArgumentError, + "Cannot declare facets on #{self}: it already has subclasses" + end + + def raise_unordered!(macro, min, max) + return if min.nil? || max.nil? || min <= max + + raise ArgumentError, + "`#{macro}` min (#{min}) must not exceed max (#{max})" + end + + def raise_negative_length!(value) + return if value.nil? || value >= 0 + + raise ArgumentError, "length facet must not be negative: #{value}" + end end attr_reader :value diff --git a/lib/lutaml/model/validation.rb b/lib/lutaml/model/validation.rb index b7a723a9..90af7350 100644 --- a/lib/lutaml/model/validation.rb +++ b/lib/lutaml/model/validation.rb @@ -23,7 +23,8 @@ def validate(register: Lutaml::Model::Config.default_register) Lutaml::Model::PolymorphicError, Lutaml::Model::ValidationFailedError, Lutaml::Model::RequiredAttributeMissingError, - Lutaml::Model::PatternNotMatchedError => e + Lutaml::Model::PatternNotMatchedError, + Lutaml::Model::RestrictionError => e errors << e end end diff --git a/lib/lutaml/xml/schema/xsd/max_exclusive.rb b/lib/lutaml/xml/schema/xsd/max_exclusive.rb index ae19e399..5c75c015 100644 --- a/lib/lutaml/xml/schema/xsd/max_exclusive.rb +++ b/lib/lutaml/xml/schema/xsd/max_exclusive.rb @@ -6,7 +6,7 @@ module Schema module Xsd class MaxExclusive < Base attribute :fixed, :string - attribute :value, :integer + attribute :value, :string xml do element "maxExclusive" diff --git a/lib/lutaml/xml/schema/xsd/min_exclusive.rb b/lib/lutaml/xml/schema/xsd/min_exclusive.rb index c20fe211..9611da2e 100644 --- a/lib/lutaml/xml/schema/xsd/min_exclusive.rb +++ b/lib/lutaml/xml/schema/xsd/min_exclusive.rb @@ -6,7 +6,7 @@ module Schema module Xsd class MinExclusive < Base attribute :fixed, :string - attribute :value, :integer + attribute :value, :string xml do element "minExclusive" diff --git a/lib/lutaml/xml/schema/xsd_schema.rb b/lib/lutaml/xml/schema/xsd_schema.rb index 627b06fe..79077166 100644 --- a/lib/lutaml/xml/schema/xsd_schema.rb +++ b/lib/lutaml/xml/schema/xsd_schema.rb @@ -329,7 +329,8 @@ def self.generate_elements(xml, klass, register, xml_mapping) xml.element(element_attrs) do xml.complexType do xml.sequence do - xml.element(name: "item", type: xsd_type) + emit_value_element(xml, { name: "item" }, xsd_type, + attr_type, attr) end end end @@ -337,7 +338,7 @@ def self.generate_elements(xml, klass, register, xml_mapping) # Simple element element_attrs = build_element_attributes(name, xsd_type, attr, xml_mapping, name) - xml.element(element_attrs) + emit_value_element(xml, element_attrs, xsd_type, attr_type, attr) end end end @@ -352,20 +353,29 @@ def self.generate_attributes(xml, klass, register, xml_mapping) attr_type = attr.type(register) xsd_type = get_attribute_xsd_type(attr, attr_type, register, rule) + emit_xsd_attribute(xml, attr, rule, xsd_type, attr_type) + end + end + + # Emit an `xs:attribute`, inlining an `xs:restriction` when the attribute + # is a constrained value type (its `type=` moves into the restriction + # base) and/or an annotation when the mapping carries documentation. + def self.emit_xsd_attribute(xml, attr, rule, xsd_type, attr_type) + facets = effective_facets(attr, attr_type) + restricted = facets.any? + + attr_attrs = { name: rule.name } + attr_attrs[:type] = xsd_type unless restricted + attr_attrs[:use] = "required" if attr.options[:required] + attr_attrs[:form] = rule.form.to_s if rule.form - attr_attrs = { name: rule.name, type: xsd_type } - attr_attrs[:use] = "required" if attr.options[:required] - attr_attrs[:form] = rule.form.to_s if rule.form + return xml.attribute(attr_attrs) unless restricted || rule.documentation + xml.attribute(attr_attrs) do if rule.documentation - xml.attribute(attr_attrs) do - xml.annotation do - xml.documentation(rule.documentation) - end - end - else - xml.attribute(attr_attrs) + xml.annotation { xml.documentation(rule.documentation) } end + emit_restriction(xml, xsd_type, facets, attr_type) if restricted end end @@ -400,6 +410,209 @@ def self.build_element_attributes(name, xsd_type, attr, xml_mapping, attrs end + # Emit an `xs:element` for a value type, inlining an `xs:restriction` + # (its `type=` moving into the restriction base) when the type is a + # constrained value type, else a flat `type=` reference. `merge` keeps an + # existing `type` key in position; `except` drops it for the inline case. + def self.emit_value_element(xml, attrs, xsd_type, attr_type, attr) + facets = effective_facets(attr, attr_type) + if facets.any? + xml.element(attrs.except(:type)) do + emit_restriction(xml, xsd_type, facets, attr_type) + end + else + xml.element(attrs.merge(type: xsd_type)) + end + end + + # Effective xs:restriction facet set for an attribute: the conjunctive + # merge of its Layer-1 options and the Layer-2 facets on its value type + # (reusing the runtime resolver so the schema matches what is enforced). + def self.effective_facets(attr, attr_type) + return {} unless attr_type.is_a?(Class) && + attr_type < Lutaml::Model::Type::Value + + attr.effective_restriction_facets(attr_type) + end + + def self.emit_restriction(xml, base, facets, type) + xml.simpleType do + xml.restriction(base: base) do + emit_restriction_facets(xml, facets, type) + end + end + end + + # Emit the facet child elements in canonical W3C xs:restriction order. + # Bounds/enumeration carry base-typed values (serialized to their lexical + # form); lengths/digits/whiteSpace are plain scalars. Accumulated patterns + # are conjunctive (a value must match all), which one xs:restriction + # cannot express — sibling xs:pattern facets are alternatives (OR) — so a + # multi-pattern type fails fast rather than export a weaker OR schema. + def self.emit_restriction_facets(xml, facets, type) + emit_bound(xml, :minExclusive, facets[:min_exclusive], type) + emit_bound(xml, :minInclusive, facets[:min_inclusive], type) + emit_bound(xml, :maxExclusive, facets[:max_exclusive], type) + emit_bound(xml, :maxInclusive, facets[:max_inclusive], type) + emit_facet(xml, :totalDigits, facets[:total_digits]) + emit_facet(xml, :fractionDigits, facets[:fraction_digits]) + emit_facet(xml, :length, facets[:length]) + emit_facet(xml, :minLength, facets[:min_length]) + emit_facet(xml, :maxLength, facets[:max_length]) + Array(facets[:enumeration]).each do |value| + emit_facet(xml, :enumeration, lexical_value(type, value)) + end + emit_facet(xml, :whiteSpace, facets[:white_space]) + patterns = Array(facets[:pattern]) + if patterns.size > 1 + raise Lutaml::Model::Error, + "Cannot export #{patterns.size} conjunctive patterns on " \ + "#{type} to a single xs:restriction: sibling xs:pattern " \ + "facets are alternatives (OR), and XSD cannot express " \ + "conjunction (AND) without nested restriction derivation, " \ + "which is not yet supported." + end + patterns.each { |re| emit_facet(xml, :pattern, pattern_value(re)) } + end + + # Ruby regexp flags whose semantics XSD's regular-expression subset + # cannot carry (case-insensitive, dot-matches-newline, extended). These + # are not visible in the pattern source, so the XSD validator below + # cannot catch them. + UNSUPPORTED_XSD_REGEX_FLAGS = + Regexp::IGNORECASE | Regexp::MULTILINE | Regexp::EXTENDED + + # Translate a Ruby Regexp into an XSD pattern string. XSD patterns are + # implicitly whole-string anchored, so strip Ruby's whole-string/line + # anchors, then let the XSD regexp validator reject any remaining + # construct XSD cannot express (rather than emit invalid XSD). + def self.pattern_value(regexp) + reject_unsupported_regex_flags!(regexp) + source = strip_ruby_anchors(regexp.source) + reject_invalid_xsd_pattern!(regexp, source) + source + end + + def self.reject_unsupported_regex_flags!(regexp) + return if regexp.options.nobits?(UNSUPPORTED_XSD_REGEX_FLAGS) + + raise Lutaml::Model::Error, + "Cannot export pattern #{regexp.inspect} to xs:pattern: " \ + "the i/m/x flags are not expressible in XSD's regexp subset." + end + + # Remove a leading `\A`/`^` and a trailing `\z`/`\Z`/`$`, treating each + # only as an anchor. Whether a `\`-prefixed token is an anchor or an + # escaped literal depends on backslash-run parity: an ODD run ends in a + # lone backslash that binds the following char as the anchor (`\z`), + # while an EVEN run is all escaped-backslash pairs, leaving a literal + # (`\\z` == backslash + "z"). Likewise `$` is an anchor only when an + # EVEN number of backslashes precede it (`\$` == literal dollar). + def self.strip_ruby_anchors(source) + source = strip_leading_anchor(source) + strip_trailing_anchor(source) + end + + def self.strip_leading_anchor(source) + return source[1..] if source.start_with?("^") + + run = leading_backslash_run(source) + return source unless run.odd? && source[run] == "A" + + # Keep the escaped-pair backslashes; drop the anchor's `\A`. + source[0, run - 1] + source[(run + 1)..] + end + + def self.strip_trailing_anchor(source) + last = source[-1] + before = source.length - 1 + if ["z", "Z"].include?(last) && + trailing_backslash_run(source, before).odd? + return source[0...-2] + end + if last == "$" && trailing_backslash_run(source, before).even? + return source[0...-1] + end + + source + end + + # Count consecutive backslash characters at the start / immediately + # before `pos` by linear scan. A plain-string scan (not a `/\\+/` regex + # searched over library input) keeps this O(n) and free of the + # polynomial backtracking a run-quantifier can exhibit on all-backslash + # input. + def self.leading_backslash_run(source) + run = 0 + run += 1 while source[run] == "\\" + run + end + + def self.trailing_backslash_run(source, pos) + run = 0 + i = pos - 1 + while i >= 0 && source[i] == "\\" + run += 1 + i -= 1 + end + run + end + + # Validate the pattern against XSD's regexp grammar using Nokogiri as the + # XSD reference validator. This is exact — no false positives/negatives + # from a hand-maintained construct list — so any lazy quantifier, + # lookaround, or char-class intersection XSD cannot express is rejected + # with the validator's own diagnostic. The Oga adapter does not load + # Nokogiri, so when it is absent the anchor-stripped source is emitted + # best-effort without this deep validation. + def self.reject_invalid_xsd_pattern!(regexp, source) + return unless defined?(::Nokogiri::XML::Schema) + + probe = <<~XSD + + + + + + + + XSD + schema = ::Nokogiri::XML::Schema(probe) + return if schema.errors.empty? + + raise_inexpressible_pattern!(regexp, schema.errors.first.message) + rescue ::Nokogiri::XML::SyntaxError => e + raise_inexpressible_pattern!(regexp, e.message) + end + + def self.raise_inexpressible_pattern!(regexp, detail) + raise Lutaml::Model::Error, + "Cannot export pattern #{regexp.inspect} to xs:pattern: " \ + "it is not expressible in XSD's regexp subset (#{detail})." + end + + def self.xsd_escape(text) + text.gsub("&", "&").gsub("<", "<").gsub('"', """) + end + + def self.emit_bound(xml, element, value, type) + emit_facet(xml, element, lexical_value(type, value)) unless value.nil? + end + + # Serialize a base-typed facet value to its XSD lexical form, tolerating + # both already-cast values and raw literals from the facet DSL (e.g. a + # temporal bound declared as an ISO string). `cast` is idempotent for an + # already-cast value. + def self.lexical_value(type, value) + type.serialize(type.cast(value)) + end + + def self.emit_facet(xml, element, value) + return if value.nil? + + xml.public_send(element, value: value.to_s) + end + def self.has_explicit_xml_mapping?(klass, xml_mapping) return true unless xml_mapping.root_element diff --git a/spec/fixtures/xml/restriction_facets.xsd b/spec/fixtures/xml/restriction_facets.xsd new file mode 100644 index 00000000..e9039213 --- /dev/null +++ b/spec/fixtures/xml/restriction_facets.xsd @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spec/lutaml/model/attribute_restriction_spec.rb b/spec/lutaml/model/attribute_restriction_spec.rb new file mode 100644 index 00000000..ce1cb65c --- /dev/null +++ b/spec/lutaml/model/attribute_restriction_spec.rb @@ -0,0 +1,472 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "Attribute value restrictions" do + describe "numeric min/max bounds" do + before do + stub_const("BoundedInteger", Class.new(Lutaml::Model::Serializable) do + attribute :age, :integer, min: 0, max: 120 + end) + end + + it "passes validation for a value within the range" do + expect(BoundedInteger.new(age: 30).validate).to be_empty + end + + it "collects a MinInclusiveError when below the minimum" do + instance = BoundedInteger.new(age: -1) + errors = instance.validate + + expect(errors.size).to eq(1) + expect(errors.first).to be_a(Lutaml::Model::MinInclusiveError) + expect(errors.first.to_s).to include("age") + end + + it "collects a MaxInclusiveError when above the maximum" do + instance = BoundedInteger.new(age: 200) + errors = instance.validate + + expect(errors.first).to be_a(Lutaml::Model::MaxInclusiveError) + end + + it "raises ValidationError from validate! for an out-of-range value" do + instance = BoundedInteger.new(age: -1) + + expect { instance.validate! } + .to raise_error(Lutaml::Model::ValidationError) + end + end + + describe "numeric bounds across numeric types" do + it "enforces bounds on Decimal values" do + stub_const("BoundedDecimal", Class.new(Lutaml::Model::Serializable) do + attribute :score, :decimal, min: 0, max: 100 + end) + + expect(BoundedDecimal.new(score: 50).validate).to be_empty + expect(BoundedDecimal.new(score: 150).validate.first) + .to be_a(Lutaml::Model::MaxInclusiveError) + end + + it "enforces bounds on Float values" do + stub_const("BoundedFloat", Class.new(Lutaml::Model::Serializable) do + attribute :ratio, :float, min: 0.0, max: 1.0 + end) + + expect(BoundedFloat.new(ratio: 0.5).validate).to be_empty + expect(BoundedFloat.new(ratio: 1.5).validate.first) + .to be_a(Lutaml::Model::MaxInclusiveError) + end + end + + describe "signed: false" do + before do + stub_const("UnsignedInteger", Class.new(Lutaml::Model::Serializable) do + attribute :count, :integer, signed: false + end) + end + + it "accepts zero" do + expect(UnsignedInteger.new(count: 0).validate).to be_empty + end + + it "accepts positive values" do + expect(UnsignedInteger.new(count: 42).validate).to be_empty + end + + it "rejects negative values" do + expect(UnsignedInteger.new(count: -1).validate.first) + .to be_a(Lutaml::Model::MinInclusiveError) + end + + it "merges with an explicit min by taking the greater bound" do + stub_const("FlooredInteger", Class.new(Lutaml::Model::Serializable) do + attribute :count, :integer, min: -10, signed: false + end) + + expect(FlooredInteger.new(count: 0).validate).to be_empty + expect(FlooredInteger.new(count: -1).validate.first) + .to be_a(Lutaml::Model::MinInclusiveError) + end + end + + describe "temporal bounds (Layer 1)" do + before do + stub_const("DatedEvent", Class.new(Lutaml::Model::Serializable) do + attribute :on, :date, + min: Date.new(2000, 1, 1), max: Date.new(2020, 12, 31) + end) + end + + it "passes for a date within the range" do + expect(DatedEvent.new(on: "2010-06-15").validate).to be_empty + end + + it "collects a MinInclusiveError before the minimum date" do + expect(DatedEvent.new(on: "1999-12-31").validate.first) + .to be_a(Lutaml::Model::MinInclusiveError) + end + + it "collects a MaxInclusiveError after the maximum date" do + expect(DatedEvent.new(on: "2021-01-01").validate.first) + .to be_a(Lutaml::Model::MaxInclusiveError) + end + end + + describe "bounds declared as strings are cast to the resolved type (issue #191)" do + it "compares a string date bound against a cast Date value" do + model = Class.new(Lutaml::Model::Serializable) do + attribute :on, :date, min: "2000-01-01" + end + + expect(model.new(on: Date.new(2010, 1, 1)).validate).to be_empty + expect(model.new(on: Date.new(1990, 1, 1)).validate.first) + .to be_a(Lutaml::Model::MinInclusiveError) + end + + it "compares a string decimal bound against a cast Decimal value" do + model = Class.new(Lutaml::Model::Serializable) do + attribute :price, :decimal, min: "1.5", max: "9.5" + end + + expect(model.new(price: "5.0").validate).to be_empty + expect(model.new(price: "0.5").validate.first) + .to be_a(Lutaml::Model::MinInclusiveError) + end + + it "compares a string integer bound against a cast Integer value" do + model = Class.new(Lutaml::Model::Serializable) do + attribute :age, :integer, min: "0", max: "120" + end + + expect(model.new(age: 30).validate).to be_empty + expect(model.new(age: 200).validate.first) + .to be_a(Lutaml::Model::MaxInclusiveError) + end + + it "raises rather than silently dropping a bound that cannot be cast" do + model = Class.new(Lutaml::Model::Serializable) do + attribute :age, :integer, min: "not-a-number" + end + + expect { model.new(age: 5).validate } + .to raise_error(ArgumentError, /not a valid/) + end + end + + describe "string length bounds" do + before do + stub_const("BoundedString", Class.new(Lutaml::Model::Serializable) do + attribute :name, :string, min_length: 1, max_length: 5 + end) + end + + it "passes validation for a length within the range" do + expect(BoundedString.new(name: "abc").validate).to be_empty + end + + it "rejects a value shorter than min_length" do + expect(BoundedString.new(name: "").validate.first) + .to be_a(Lutaml::Model::MinLengthError) + end + + it "rejects a value longer than max_length" do + expect(BoundedString.new(name: "abcdef").validate.first) + .to be_a(Lutaml::Model::MaxLengthError) + end + + it "rejects the empty string when min_length is 1" do + errors = BoundedString.new(name: "").validate + + expect(errors.size).to eq(1) + expect(errors.first).to be_a(Lutaml::Model::MinLengthError) + end + end + + describe "length bounds on a non-::String string-derived type (issue #191)" do + # Type::Uri is string-derived (Uri < String) but its cast value is a ::URI, + # which has no #length. Length facets must measure the lexical (serialized) + # form, not the raw cast object, or validation crashes with NoMethodError. + it "measures the lexical form and passes a URI within range" do + stub_const("UriInRange", Class.new(Lutaml::Model::Serializable) do + attribute :homepage, :uri, min_length: 5, max_length: 100 + end) + + instance = UriInRange.new(homepage: URI.parse("http://example.com")) + + expect(instance.validate).to be_empty + end + + it "collects a MaxLengthError (never NoMethodError) for a too-long URI" do + stub_const("UriTooLong", Class.new(Lutaml::Model::Serializable) do + attribute :homepage, :uri, max_length: 5 + end) + + instance = UriTooLong.new(homepage: URI.parse("http://example.com")) + + expect(instance.validate.first).to be_a(Lutaml::Model::MaxLengthError) + end + + it "renders the length error message without crashing on the ::URI value" do + stub_const("UriTooLong", Class.new(Lutaml::Model::Serializable) do + attribute :homepage, :uri, max_length: 5 + end) + + instance = UriTooLong.new(homepage: URI.parse("http://example.com")) + + expect { instance.validate! } + .to raise_error(Lutaml::Model::ValidationError, /homepage/) + end + end + + describe "lazy validation" do + before do + stub_const("LazyModel", Class.new(Lutaml::Model::Serializable) do + attribute :age, :integer, min: 0, max: 120 + attribute :name, :string, min_length: 1 + end) + end + + it "does not raise when an out-of-range value is assigned" do + expect { LazyModel.new(age: -5, name: "") }.not_to raise_error + end + + it "only surfaces the error on validate!" do + instance = LazyModel.new(age: -5, name: "ok") + + expect { instance.validate! } + .to raise_error(Lutaml::Model::ValidationError) + end + end + + describe "configuration guards" do + it "rejects numeric bounds on a string attribute" do + stub_const("MisconfiguredString", Class.new(Lutaml::Model::Serializable) do + attribute :name, :string, min: 0 + end) + + expect { MisconfiguredString.new(name: "a").validate } + .to raise_error(ArgumentError, /only allowed for numeric types/) + end + + it "rejects length bounds on a numeric attribute" do + stub_const("MisconfiguredInteger", Class.new(Lutaml::Model::Serializable) do + attribute :age, :integer, min_length: 1 + end) + + expect { MisconfiguredInteger.new(age: 1).validate } + .to raise_error(ArgumentError, /only allowed for :string type/) + end + + it "rejects signed: true on a string attribute" do + stub_const("SignedString", Class.new(Lutaml::Model::Serializable) do + attribute :name, :string, signed: true + end) + + expect { SignedString.new(name: "a").validate } + .to raise_error(ArgumentError, /only allowed for numeric types/) + end + + it "treats signed: true on a numeric attribute as a no-op" do + stub_const("SignedInteger", Class.new(Lutaml::Model::Serializable) do + attribute :count, :integer, signed: true + end) + + expect(SignedInteger.new(count: -5).validate).to be_empty + expect { SignedInteger.new(count: -5).validate! }.not_to raise_error + end + + it "accepts negative values when signed is not given (signed defaults to true)" do + stub_const("DefaultInteger", Class.new(Lutaml::Model::Serializable) do + attribute :count, :integer + end) + + expect(DefaultInteger.new(count: -5).validate).to be_empty + end + + it "rejects a negative min_length at attribute definition" do + expect do + Class.new(Lutaml::Model::Serializable) do + attribute :name, :string, min_length: -1 + end + end.to raise_error(ArgumentError, + /`min_length` must be a non-negative Integer/) + end + + it "rejects a negative max_length at attribute definition" do + expect do + Class.new(Lutaml::Model::Serializable) do + attribute :name, :string, max_length: -1 + end + end.to raise_error(ArgumentError, + /`max_length` must be a non-negative Integer/) + end + + it "rejects a non-Integer min_length at attribute definition" do + expect do + Class.new(Lutaml::Model::Serializable) do + attribute :name, :string, min_length: "5" + end + end.to raise_error(ArgumentError, + /`min_length` must be a non-negative Integer/) + end + + it "rejects a Float max_length at attribute definition" do + expect do + Class.new(Lutaml::Model::Serializable) do + attribute :name, :string, max_length: 2.5 + end + end.to raise_error(ArgumentError, + /`max_length` must be a non-negative Integer/) + end + + it "rejects a non-boolean signed at attribute definition" do + expect do + Class.new(Lutaml::Model::Serializable) do + attribute :count, :integer, signed: "false" + end + end.to raise_error(ArgumentError, /`signed` must be true or false/) + end + + it "accepts signed: false at attribute definition" do + expect do + Class.new(Lutaml::Model::Serializable) do + attribute :count, :integer, signed: false + end + end.not_to raise_error + end + end + + describe "collections (per-element restrictions)" do + before do + stub_const("BoundedCollection", Class.new(Lutaml::Model::Serializable) do + attribute :ages, :integer, collection: true, min: 0 + end) + end + + it "passes when every element satisfies a Layer-1 numeric bound" do + expect(BoundedCollection.new(ages: [0, 5, 120]).validate).to be_empty + end + + it "collects a MinInclusiveError when an element violates the bound" do + errors = BoundedCollection.new(ages: [1, -5, 10]).validate + + expect(errors.size).to eq(1) + expect(errors.first).to be_a(Lutaml::Model::MinInclusiveError) + end + + it "raises from validate! when an element is out of range" do + expect { BoundedCollection.new(ages: [1, -5]).validate! } + .to raise_error(Lutaml::Model::ValidationError) + end + + it "skips nil elements, checking only present ones" do + expect(BoundedCollection.new(ages: [nil, 5]).validate).to be_empty + expect(BoundedCollection.new(ages: [nil, -5]).validate.first) + .to be_a(Lutaml::Model::MinInclusiveError) + end + + it "applies the facet to a scalar assigned to a collection attribute" do + expect(BoundedCollection.new(ages: 5).validate).to be_empty + expect(BoundedCollection.new(ages: -5).validate.first) + .to be_a(Lutaml::Model::MinInclusiveError) + end + + it "enforces a Layer-2 inclusive facet per element" do + stub_const("Percentage", Class.new(Lutaml::Model::Type::Integer) do + inclusive min: 0, max: 100 + end) + stub_const("Report", Class.new(Lutaml::Model::Serializable) do + attribute :scores, Percentage, collection: true + end) + + expect(Report.new(scores: [10, 50, 100]).validate).to be_empty + expect(Report.new(scores: [10, 150]).validate.first) + .to be_a(Lutaml::Model::MaxInclusiveError) + end + + it "enforces a Layer-1 min_length per string element" do + stub_const("Names", Class.new(Lutaml::Model::Serializable) do + attribute :tags, :string, collection: true, min_length: 2 + end) + + expect(Names.new(tags: %w[ab cde]).validate).to be_empty + expect(Names.new(tags: %w[ab c]).validate.first) + .to be_a(Lutaml::Model::MinLengthError) + end + + it "enforces a Layer-2 pattern per string element" do + stub_const("Code", Class.new(Lutaml::Model::Type::String) do + pattern '\A[a-z]+\z' + end) + stub_const("Codes", Class.new(Lutaml::Model::Serializable) do + attribute :list, Code, collection: true + end) + + expect(Codes.new(list: %w[abc def]).validate).to be_empty + expect(Codes.new(list: %w[abc DEF]).validate.first) + .to be_a(Lutaml::Model::PatternNotMatchedError) + end + + it "enforces a Layer-2 enumeration per element" do + stub_const("Status", Class.new(Lutaml::Model::Type::String) do + enumeration "active", "inactive" + end) + stub_const("Flags", Class.new(Lutaml::Model::Serializable) do + attribute :states, Status, collection: true + end) + + expect(Flags.new(states: %w[active inactive]).validate).to be_empty + expect(Flags.new(states: %w[active deleted]).validate.first) + .to be_a(Lutaml::Model::InvalidValueError) + end + + it "enforces a digit facet per element" do + stub_const("Money", Class.new(Lutaml::Model::Type::Decimal) do + total_digits 5 + end) + stub_const("Ledger", Class.new(Lutaml::Model::Serializable) do + attribute :amounts, Money, collection: true + end) + + expect(Ledger.new(amounts: ["12.34", "1.5"]).validate).to be_empty + expect(Ledger.new(amounts: ["12.34", "123456"]).validate.first) + .to be_a(Lutaml::Model::TotalDigitsError) + end + + it "governs item count via the collection range, not the length facet" do + stub_const("Sized", Class.new(Lutaml::Model::Serializable) do + attribute :list, :string, collection: 2..3, min_length: 3 + end) + + expect(Sized.new(list: %w[abc def]).validate).to be_empty + expect(Sized.new(list: %w[abc de]).validate.first) + .to be_a(Lutaml::Model::MinLengthError) + expect(Sized.new(list: %w[abc def ghi jkl]).validate.first) + .to be_a(Lutaml::Model::CollectionCountOutOfRangeError) + end + end + + describe "backward compatibility" do + it "keeps values: enumeration validation working" do + stub_const("EnumModel", Class.new(Lutaml::Model::Serializable) do + attribute :status, :string, values: %w[active inactive] + end) + + expect(EnumModel.new(status: "active").validate).to be_empty + expect(EnumModel.new(status: "unknown").validate.first) + .to be_a(Lutaml::Model::InvalidValueError) + end + + it "keeps pattern: validation working" do + stub_const("PatternModel", Class.new(Lutaml::Model::Serializable) do + attribute :code, :string, pattern: /\A[a-z]+\z/ + end) + + expect(PatternModel.new(code: "abc").validate).to be_empty + expect(PatternModel.new(code: "ABC").validate.first) + .to be_a(Lutaml::Model::PatternNotMatchedError) + end + end +end diff --git a/spec/lutaml/model/attribute_spec.rb b/spec/lutaml/model/attribute_spec.rb index f6808dee..601df15c 100644 --- a/spec/lutaml/model/attribute_spec.rb +++ b/spec/lutaml/model/attribute_spec.rb @@ -189,15 +189,20 @@ describe "#validate_options!" do let(:validate_options) { name_attr.method(:validate_options!) } + # Options whose value is type-checked at definition need a valid sample; + # the rest accept any value here (applicability is validated lazily). + let(:option_sample_values) { { min_length: 1, max_length: 1, signed: true } } + Lutaml::Model::Attribute::ALLOWED_OPTIONS.each do |option| it "return true if option is `#{option}`" do + value = option_sample_values.fetch(option, "value") if option == :xsd_type expect do - result = validate_options.call({ option => "value" }) + result = validate_options.call({ option => value }) expect(result).to be(true) end.to output.to_stderr else - expect(validate_options.call({ option => "value" })).to be(true) + expect(validate_options.call({ option => value })).to be(true) end end end diff --git a/spec/lutaml/model/schema/renderers/restricted_type_spec.rb b/spec/lutaml/model/schema/renderers/restricted_type_spec.rb index 1b719d1d..ceb1146d 100644 --- a/spec/lutaml/model/schema/renderers/restricted_type_spec.rb +++ b/spec/lutaml/model/schema/renderers/restricted_type_spec.rb @@ -11,6 +11,7 @@ spec = defs::RestrictedType.new( class_name: "PositiveInt", parent_class: "Lutaml::Model::Type::Integer", + base_class: "integer", facets: defs::Facet.new(min_inclusive: 1, max_inclusive: 100), ) diff --git a/spec/lutaml/model/schema/xsd_schema_spec.rb b/spec/lutaml/model/schema/xsd_schema_spec.rb index b9b667fa..6403c778 100644 --- a/spec/lutaml/model/schema/xsd_schema_spec.rb +++ b/spec/lutaml/model/schema/xsd_schema_spec.rb @@ -1,5 +1,6 @@ require "spec_helper" require "lutaml/model/schema" +require "bigdecimal" module SchemaGeneration class Glaze < Lutaml::Model::Serializable @@ -13,6 +14,136 @@ class Vase < Lutaml::Model::Serializable attribute :glaze, Glaze attribute :materials, Lutaml::Model::Type::String, collection: true end + + class Percent < Lutaml::Model::Type::Integer + inclusive min: 0, max: 100 + end + + class Money < Lutaml::Model::Type::Decimal + exclusive min: BigDecimal("1.5") + total_digits 5 + fraction_digits 2 + end + + class Code < Lutaml::Model::Type::String + length min: 2, max: 8 + enumeration "AB", "CD" + white_space :collapse + pattern(/[A-Z]+/) + end + + class Tag < Lutaml::Model::Type::String + length min: 1, max: 3 + end + + # Two accumulated patterns are conjunctive (a value must match BOTH). XSD has + # no way to express that in one restriction (sibling is OR), so + # exporting it must fail fast rather than silently emit a weaker OR schema. + class MultiPattern < Lutaml::Model::Type::String + pattern(/\A\d+\z/) + pattern(/\A.{4}\z/) + end + + class MultiPatternHolder < Lutaml::Model::Serializable + attribute :field, MultiPattern + xml do + root "MultiPatternHolder" + map_element "field", to: :field + end + end + + # XSD patterns are implicitly whole-string anchored, so the Ruby \A...\z + # anchors must be stripped on export or Nokogiri rejects the pattern. + class Anchored < Lutaml::Model::Type::String + pattern(/\A[A-Z]{3}\z/) + end + + class AnchoredHolder < Lutaml::Model::Serializable + attribute :field, Anchored + xml do + root "AnchoredHolder" + map_element "field", to: :field + end + end + + # A lookahead is not expressible in XSD's regexp subset; export must raise + # rather than emit invalid XSD. + class Lookahead < Lutaml::Model::Type::String + pattern(/(?=.*[0-9])[a-z0-9]+/) + end + + class LookaheadHolder < Lutaml::Model::Serializable + attribute :field, Lookahead + xml do + root "LookaheadHolder" + map_element "field", to: :field + end + end + + # A case-insensitive flag cannot be expressed in XSD; export must raise + # rather than silently emit a case-sensitive pattern. + class FlaggedPattern < Lutaml::Model::Type::String + pattern(/[a-z]+/i) + end + + class FlaggedHolder < Lutaml::Model::Serializable + attribute :field, FlaggedPattern + xml do + root "FlaggedHolder" + map_element "field", to: :field + end + end + + # Layer-1 attribute facets (no Layer-2 constrained type) must still emit an + # inline xs:restriction so the schema matches what the model enforces. + class Layered < Lutaml::Model::Serializable + attribute :age, :integer, min: 0, max: 120 + attribute :code, :string, min_length: 2, max_length: 8 + xml do + root "Layered" + map_element "age", to: :age + map_element "code", to: :code + end + end + + # The pre-#191 values:/pattern: options are enforced at runtime and must also + # reach the exported schema. + class LegacyOptions < Lutaml::Model::Serializable + attribute :state, :string, values: %w[on off] + attribute :ref, :string, pattern: /[A-Z]{2}/ + xml do + root "LegacyOptions" + map_element "state", to: :state + map_element "ref", to: :ref + end + end + + # A Layer-1 option (max: 50) that tightens a Layer-2 type (Percent, max 100): + # the merge is consistent, so export must emit the tighter bound, not raise. + class MergedLayers < Lutaml::Model::Serializable + attribute :n, Percent, max: 50 + xml do + root "MergedLayers" + map_element "n", to: :n + end + end + + class Record < Lutaml::Model::Serializable + attribute :code, Code + attribute :percent, Percent + attribute :money, Money + attribute :tags, Tag, collection: true + attribute :note, Lutaml::Model::Type::String + + xml do + root "Record" + map_attribute "code", to: :code + map_element "percent", to: :percent + map_element "money", to: :money + map_element "tags", to: :tags + map_element "note", to: :note + end + end end RSpec.describe Lutaml::Xml::Schema::XsdSchema do @@ -51,5 +182,279 @@ class Vase < Lutaml::Model::Serializable expect(schema).to eq(expected_schema) end + + context "with constrained value types (issue #191)" do + subject(:schema) do + described_class.generate(SchemaGeneration::Record, pretty: true) + end + + # Indentation depends on nesting depth; compare with leading whitespace + # stripped so the assertions pin structure and order, not column counts. + let(:unindented) { schema.gsub(/^ +/, "") } + + it "inlines an xs:restriction with facets in canonical order for an attribute" do + expect(unindented).to include(<<~XSD.chomp) + + + + + + + + + + + + + XSD + end + + it "emits integer inclusive bounds on a simple element" do + expect(unindented).to include(<<~XSD.chomp) + + + + + + + + + XSD + end + + it "renders decimal bounds and digit facets with exact lexical values" do + expect(schema).to include('') + expect(schema).to include('') + expect(schema).to include('') + expect(schema).to include('') + end + + it "inlines the restriction on the item of a constrained collection" do + expect(unindented).to include(<<~XSD.chomp) + + + + + + + + + XSD + end + + it "leaves an unconstrained type as a flat type reference" do + expect(schema).to include('') + expect(schema).not_to match(/name="code"[^>]*type=/) + end + + it "fails fast on conjunctive patterns rather than emitting OR siblings" do + expect do + described_class.generate(SchemaGeneration::MultiPatternHolder) + end.to raise_error(Lutaml::Model::Error, /conjunctive pattern/) + end + end + + context "with a Ruby pattern exported to XSD (issue #191)" do + it "strips the Ruby whole-string anchors" do + schema = described_class.generate(SchemaGeneration::AnchoredHolder) + + expect(schema).to include('') + end + + it "emits a pattern Nokogiri accepts as valid XSD" do + schema = described_class.generate(SchemaGeneration::AnchoredHolder) + value = schema[/ + + + + + + + XSD + + expect { Nokogiri::XML::Schema(wrapped) }.not_to raise_error + end + + it "raises for a construct XSD cannot express" do + expect do + described_class.generate(SchemaGeneration::LookaheadHolder) + end.to raise_error(Lutaml::Model::Error, /not expressible/) + end + + it "raises for a regexp flag XSD cannot express" do + expect do + described_class.generate(SchemaGeneration::FlaggedHolder) + end.to raise_error(Lutaml::Model::Error, /i\/m\/x flags/) + end + + it "raises for a lazy quantifier XSD cannot express" do + holder = Class.new(Lutaml::Model::Serializable) do + attribute :field, Class.new(Lutaml::Model::Type::String) { + pattern(/a+?/) + } + xml do + root "LazyHolder" + map_element "field", to: :field + end + end + + expect { described_class.generate(holder) } + .to raise_error(Lutaml::Model::Error, /not expressible in XSD/) + end + + # Backslash-run parity: `\\z` is an escaped backslash + literal "z", not + # the `\z` anchor, so it must survive stripping (and be valid XSD) rather + # than being truncated to a dangling backslash and spuriously rejected. + it "keeps a literal escaped-backslash before z (even run)" do + holder = Class.new(Lutaml::Model::Serializable) do + attribute :field, Class.new(Lutaml::Model::Type::String) { + pattern(/\\z/) + } + xml do + root "BackslashZ" + map_element "field", to: :field + end + end + + expect(described_class.generate(holder)) + .to include('') + end + + # `\\$` is an escaped backslash followed by the end anchor `$` (even run + # before `$`), so the anchor strips, leaving the literal backslash. + it "strips a trailing $ anchor after an escaped backslash" do + holder = Class.new(Lutaml::Model::Serializable) do + attribute :field, Class.new(Lutaml::Model::Type::String) { + pattern(/\\$/) + } + xml do + root "BackslashDollar" + map_element "field", to: :field + end + end + + expect(described_class.generate(holder)) + .to include('') + end + + # A long run of backslashes must be counted correctly (and in linear time, + # not the polynomial backtracking a run-quantifier regex would incur). An + # even run before `z` is all escaped pairs -> literal, kept verbatim. + it "handles a long even backslash run in linear time" do + holder = Class.new(Lutaml::Model::Serializable) do + attribute :field, Class.new(Lutaml::Model::Type::String) { + pattern(/\\\\\\\\z/) # 8 source backslashes -> 4 literal, then z + } + xml do + root "LongBackslash" + map_element "field", to: :field + end + end + + expect(described_class.generate(holder)) + .to include('') + end + end + + context "with Layer-1 attribute facets (issue #191)" do + subject(:schema) do + described_class.generate(SchemaGeneration::Layered, pretty: true) + end + + let(:unindented) { schema.gsub(/^ +/, "") } + + it "inlines an xs:restriction for attribute min/max bounds" do + expect(unindented).to include(<<~XSD.chomp) + + + + + + + + + XSD + end + + it "inlines an xs:restriction for attribute length bounds" do + expect(unindented).to include(<<~XSD.chomp) + + + + + + + + + XSD + end + + it "emits the tighter merged bound without a spurious raise" do + schema = described_class.generate(SchemaGeneration::MergedLayers) + + expect(schema).to include('') + expect(schema).to include('') + end + + it "emits the pre-#191 values:/pattern: options as facets" do + schema = described_class.generate(SchemaGeneration::LegacyOptions) + + expect(schema).to include('') + expect(schema).to include('') + expect(schema).to include('') + end + + it "intersects a values: option with a Layer-2 enumeration" do + enum_type = Class.new(Lutaml::Model::Type::String) do + enumeration "a", "b", "c" + end + model = Class.new(Lutaml::Model::Serializable) do + attribute :s, enum_type, values: %w[a b] + xml do + root "R" + map_element "s", to: :s + end + end + schema = described_class.generate(model) + + expect(schema).to include('') + expect(schema).to include('') + expect(schema).not_to include('') + end + + it "raises when values: is disjoint from the Layer-2 enumeration" do + enum_type = Class.new(Lutaml::Model::Type::String) do + enumeration "a", "b" + end + model = Class.new(Lutaml::Model::Serializable) do + attribute :s, enum_type, values: %w[c d] + xml do + root "R" + map_element "s", to: :s + end + end + + expect { described_class.generate(model) } + .to raise_error(ArgumentError, /enumeration allows no values/) + end + + # An explicit `min: nil` means "no bound"; it must not leave a nil facet + # that emits an empty around the element. + it "treats an explicit nil bound as absent (no empty restriction)" do + model = Class.new(Lutaml::Model::Serializable) do + attribute :x, :integer, min: nil + xml do + root "R" + map_element "x", to: :x + end + end + schema = described_class.generate(model) + + expect(schema).to include('') + expect(schema).not_to include(" 1 + expect(errors_for(base, value, total: total - 1).first) + .to be_a(Lutaml::Model::TotalDigitsError) + end + if fraction.positive? + expect(errors_for(base, value, fraction: fraction - 1).first) + .to be_a(Lutaml::Model::FractionDigitsError) + end + end + end + + it "counts only significant digits toward the total (XSD)" do + # `0.05` has one significant digit (`5`), so a `total_digits 1` type + # accepts it: the leading zero before the point is insignificant. This + # is required for XSD round-trip conformance -- a model generated from + # `` must accept 0.05 and reject 12. + model = build_model(decimal, total: 1) + + expect(model.new(n: "0.05").validate).to be_empty + expect(model.new(n: "12").validate.first) + .to be_a(Lutaml::Model::TotalDigitsError) + end + + it "treats integer zero as zero significant digits" do + expect(errors_for(integer, 0, total: 1)).to be_empty + expect(errors_for(integer, 0, fraction: 0)).to be_empty + end + + it "treats decimal zero as zero significant digits" do + expect(errors_for(decimal, "0", total: 1)).to be_empty + expect(errors_for(decimal, "0.0", fraction: 0)).to be_empty + end + end + + describe "total_digits enforcement" do + it "passes a Decimal within the digit budget" do + expect(errors_for(decimal, "123.45", total: 8)).to be_empty + end + + it "collects a TotalDigitsError when a Decimal exceeds the budget" do + expect(errors_for(decimal, "123.456", total: 5).first) + .to be_a(Lutaml::Model::TotalDigitsError) + end + + it "passes an Integer within the digit budget" do + expect(errors_for(integer, 12345, total: 5)).to be_empty + end + + it "collects a TotalDigitsError when an Integer exceeds the budget" do + expect(errors_for(integer, 123456, total: 5).first) + .to be_a(Lutaml::Model::TotalDigitsError) + end + end + + describe "fraction_digits enforcement" do + it "passes a Decimal within the fraction budget" do + expect(errors_for(decimal, "1.23", fraction: 2)).to be_empty + end + + it "collects a FractionDigitsError when the fraction is too long" do + expect(errors_for(decimal, "1.234", fraction: 2).first) + .to be_a(Lutaml::Model::FractionDigitsError) + end + + it "enforces both facets together (Money-style)" do + model = build_model(decimal, total: 8, fraction: 2) + + expect(model.new(n: "123456.78").validate).to be_empty + expect(model.new(n: "1234567.89").validate.first) + .to be_a(Lutaml::Model::TotalDigitsError) + expect(model.new(n: "1.234").validate.first) + .to be_a(Lutaml::Model::FractionDigitsError) + end + end + + describe "laziness" do + it "does not raise until validate!" do + model = build_model(decimal, total: 3) + + expect { model.new(n: "123456") }.not_to raise_error + expect(model.new(n: "123456").validate.first) + .to be_a(Lutaml::Model::TotalDigitsError) + end + end + + describe "declaration guards" do + it "rejects a negative total_digits" do + expect { Class.new(decimal) { total_digits(-1) } } + .to raise_error(ArgumentError) + end + + it "rejects total_digits 0" do + expect { Class.new(decimal) { total_digits 0 } } + .to raise_error(ArgumentError) + end + + it "rejects a non-integer total_digits" do + expect { Class.new(decimal) { total_digits 2.5 } } + .to raise_error(ArgumentError) + end + + it "rejects a negative fraction_digits" do + expect { Class.new(decimal) { fraction_digits(-1) } } + .to raise_error(ArgumentError) + end + + it "allows fraction_digits 0" do + expect { Class.new(decimal) { fraction_digits 0 } }.not_to raise_error + end + end + + describe "applicability guard" do + it "rejects digit facets on a Float subclass" do + expect { errors_for(Lutaml::Model::Type::Float, 1.5, total: 5) } + .to raise_error(ArgumentError, /:integer and :decimal/) + end + + it "rejects digit facets on a String subclass" do + expect { errors_for(Lutaml::Model::Type::String, "12", total: 5) } + .to raise_error(ArgumentError, /:integer and :decimal/) + end + + it "rejects digit facets on a temporal subclass" do + expect do + errors_for(Lutaml::Model::Type::Date, "2020-01-01", total: 5) + end.to raise_error(ArgumentError, /:integer and :decimal/) + end + end + + describe "consistency guard" do + it "rejects fraction_digits exceeding total_digits" do + expect { errors_for(decimal, "1.5", total: 3, fraction: 5) } + .to raise_error(ArgumentError, /fraction_digits.*exceeds.*total_digits/) + end + + it "accepts fraction_digits equal to total_digits" do + expect(errors_for(decimal, "0.12", total: 2, fraction: 2)).to be_empty + end + end + + describe "MAX-like merge and inheritance" do + it "keeps the smaller total_digits when re-declared" do + type = Class.new(decimal) do + total_digits 8 + total_digits 5 + end + + expect(type.facets).to eq(total_digits: 5) + end + + it "lets a child tighten total_digits" do + base = Class.new(decimal) { total_digits 8 } + child = Class.new(base) { total_digits 5 } + + expect(child.facets).to eq(total_digits: 5) + end + + it "raises when a child widens total_digits" do + base = Class.new(decimal) { total_digits 5 } + child = Class.new(base) { total_digits 8 } + + expect { child.facets }.to raise_error(ArgumentError, /cannot widen/) + end + + it "lets a child tighten fraction_digits" do + base = Class.new(decimal) { fraction_digits 4 } + child = Class.new(base) { fraction_digits 2 } + + expect(child.facets).to eq(fraction_digits: 2) + end + + it "raises when a child widens fraction_digits" do + base = Class.new(decimal) { fraction_digits 2 } + child = Class.new(base) { fraction_digits 4 } + + expect { child.facets }.to raise_error(ArgumentError, /cannot widen/) + end + + it "enforces the tightest inherited total_digits" do + base = Class.new(decimal) { total_digits 8 } + child = Class.new(base) { total_digits 6 } + + expect(errors_for(child, "12345.6")).to be_empty + expect(errors_for(child, "1234567").first) + .to be_a(Lutaml::Model::TotalDigitsError) + end + end +end diff --git a/spec/lutaml/model/type_facet_restriction_spec.rb b/spec/lutaml/model/type_facet_restriction_spec.rb new file mode 100644 index 00000000..0d330281 --- /dev/null +++ b/spec/lutaml/model/type_facet_restriction_spec.rb @@ -0,0 +1,667 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "Type subclass facet restrictions" do + describe "Layer-2 inclusive on an Integer subclass" do + before do + stub_const("Percentage", Class.new(Lutaml::Model::Type::Integer) do + inclusive min: 0, max: 100 + end) + stub_const("Score", Class.new(Lutaml::Model::Serializable) do + attribute :value, Percentage + end) + end + + it "passes for an in-range value" do + expect(Score.new(value: 50).validate).to be_empty + end + + it "collects a MinInclusiveError below the minimum" do + expect(Score.new(value: -1).validate.first) + .to be_a(Lutaml::Model::MinInclusiveError) + end + + it "collects a MaxInclusiveError above the maximum" do + expect(Score.new(value: 150).validate.first) + .to be_a(Lutaml::Model::MaxInclusiveError) + end + + it "does not raise until validate! (lazy)" do + expect { Score.new(value: 999) }.not_to raise_error + end + end + + describe "Layer-2 length on a String subclass" do + before do + stub_const("Username", Class.new(Lutaml::Model::Type::String) do + length min: 3, max: 8 + end) + stub_const("Account", Class.new(Lutaml::Model::Serializable) do + attribute :name, Username + end) + end + + it "passes for an in-range length" do + expect(Account.new(name: "john").validate).to be_empty + end + + it "rejects values shorter than the minimum length" do + expect(Account.new(name: "jo").validate.first) + .to be_a(Lutaml::Model::MinLengthError) + end + + it "rejects values longer than the maximum length" do + expect(Account.new(name: "jonathan_doe").validate.first) + .to be_a(Lutaml::Model::MaxLengthError) + end + end + + describe "exact length facet" do + before do + stub_const("CountryCode", Class.new(Lutaml::Model::Type::String) do + length 2 + end) + stub_const("Place", Class.new(Lutaml::Model::Serializable) do + attribute :code, CountryCode + end) + end + + it "passes when the length matches exactly" do + expect(Place.new(code: "us").validate).to be_empty + end + + it "collects a LengthError when the length differs" do + expect(Place.new(code: "usa").validate.first) + .to be_a(Lutaml::Model::LengthError) + end + + it "rejects combining exact length with min:/max:" do + expect do + Class.new(Lutaml::Model::Type::String) { length 2, min: 1 } + end.to raise_error(ArgumentError, /cannot be combined/) + end + end + + describe "Layer-1 and Layer-2 merge (tightest wins)" do + before do + stub_const("Percentage", Class.new(Lutaml::Model::Type::Integer) do + inclusive min: 0, max: 100 + end) + stub_const("Capped", Class.new(Lutaml::Model::Serializable) do + attribute :pct, Percentage, max: 90 + end) + end + + it "accepts a value within the tighter bound" do + expect(Capped.new(pct: 90).validate).to be_empty + end + + it "rejects a value the type alone would allow" do + expect(Capped.new(pct: 95).validate.first) + .to be_a(Lutaml::Model::MaxInclusiveError) + end + + it "still enforces the type's lower bound" do + expect(Capped.new(pct: -1).validate.first) + .to be_a(Lutaml::Model::MinInclusiveError) + end + + it "raises a configuration error for a contradictory merge" do + stub_const("HighFloor", Class.new(Lutaml::Model::Type::Integer) do + inclusive min: 50, max: 100 + end) + model = Class.new(Lutaml::Model::Serializable) do + attribute :x, HighFloor, max: 40 + end + + expect { model.new(x: 45).validate } + .to raise_error(ArgumentError, /exceeds/) + end + + it "raises for a contradictory length merge" do + stub_const("MidLen", Class.new(Lutaml::Model::Type::String) do + length min: 10, max: 20 + end) + model = Class.new(Lutaml::Model::Serializable) do + attribute :s, MidLen, max_length: 5 + end + + expect { model.new(s: "abc").validate } + .to raise_error(ArgumentError, /exceeds/) + end + + it "raises when an exact length falls outside a merged length range" do + stub_const("FiveChars", Class.new(Lutaml::Model::Type::String) do + length 5 + end) + model = Class.new(Lutaml::Model::Serializable) do + attribute :s, FiveChars, min_length: 10 + end + + expect { model.new(s: "abcde").validate } + .to raise_error(ArgumentError, /length \(5\) is outside/) + end + end + + describe "facet inheritance" do + before do + stub_const("BasePct", Class.new(Lutaml::Model::Type::Integer) do + inclusive min: 0, max: 100 + end) + end + + it "lets a child tighten an inherited bound" do + stub_const("TightPct", Class.new(BasePct) { inclusive max: 50 }) + + expect(TightPct.facets).to eq(min_inclusive: 0, max_inclusive: 50) + end + + it "raises when a child tries to widen an inherited bound" do + wide = Class.new(BasePct) { inclusive max: 200 } + + expect { wide.facets }.to raise_error(ArgumentError, /cannot widen/) + end + + it "isolates sibling subclasses" do + a = Class.new(BasePct) { inclusive max: 50 } + b = Class.new(BasePct) + + expect(a.facets).to eq(min_inclusive: 0, max_inclusive: 50) + expect(b.facets).to eq(min_inclusive: 0, max_inclusive: 100) + end + + it "rejects declaring a facet on an already-subclassed parent" do + Class.new(BasePct) + + expect { BasePct.inclusive(min: 5) } + .to raise_error(ArgumentError, /already has subclasses/) + end + + it "closes a facet-less parent once it is subclassed" do + stub_const("PlainInt", Class.new(Lutaml::Model::Type::Integer)) + Class.new(PlainInt) + + expect { PlainInt.inclusive(min: 0) } + .to raise_error(ArgumentError, /already has subclasses/) + end + end + + describe "applicability guard on the merged facet set" do + it "rejects a length facet on a numeric subclass" do + stub_const("BadInt", Class.new(Lutaml::Model::Type::Integer) do + length min: 1, max: 5 + end) + model = Class.new(Lutaml::Model::Serializable) do + attribute :x, BadInt + end + + expect { model.new(x: 3).validate } + .to raise_error(ArgumentError, /only allowed for :string type/) + end + + it "rejects an inclusive facet on a string subclass" do + stub_const("BadStr", Class.new(Lutaml::Model::Type::String) do + inclusive min: 0, max: 10 + end) + model = Class.new(Lutaml::Model::Serializable) do + attribute :s, BadStr + end + + expect { model.new(s: "a").validate } + .to raise_error(ArgumentError, /only allowed for numeric types/) + end + end + + describe "same-class facet re-declaration" do + it "keeps the tighter bound when a key is declared twice" do + stub_const("DoubleDecl", Class.new(Lutaml::Model::Type::Integer) do + inclusive max: 100 + inclusive max: 200 + end) + model = Class.new(Lutaml::Model::Serializable) do + attribute :n, DoubleDecl + end + + expect(DoubleDecl.facets).to eq(max_inclusive: 100) + expect(model.new(n: 150).validate.first) + .to be_a(Lutaml::Model::MaxInclusiveError) + end + + it "raises when a different exact length is re-declared" do + expect do + Class.new(Lutaml::Model::Type::String) do + length 5 + length 3 + end + end.to raise_error(ArgumentError, /conflicting `length`/) + end + end + + describe "Layer-2 exclusive on an Integer subclass" do + before do + stub_const("OpenRange", Class.new(Lutaml::Model::Type::Integer) do + exclusive min: 0, max: 100 + end) + stub_const("Reading", Class.new(Lutaml::Model::Serializable) do + attribute :value, OpenRange + end) + end + + it "passes for a value strictly inside the bounds" do + expect(Reading.new(value: 50).validate).to be_empty + end + + it "collects a MinExclusiveError at the lower bound" do + expect(Reading.new(value: 0).validate.first) + .to be_a(Lutaml::Model::MinExclusiveError) + end + + it "collects a MaxExclusiveError at the upper bound" do + expect(Reading.new(value: 100).validate.first) + .to be_a(Lutaml::Model::MaxExclusiveError) + end + + it "does not raise until validate! (lazy)" do + expect { Reading.new(value: 0) }.not_to raise_error + end + end + + describe "ordered facets on temporal types" do + it "enforces inclusive bounds on a Date subclass" do + stub_const("Era", Class.new(Lutaml::Model::Type::Date) do + inclusive min: Date.new(2000, 1, 1), max: Date.new(2020, 12, 31) + end) + model = Class.new(Lutaml::Model::Serializable) do + attribute :day, Era + end + + expect(model.new(day: "2010-06-15").validate).to be_empty + expect(model.new(day: "2000-01-01").validate).to be_empty + expect(model.new(day: "1999-12-31").validate.first) + .to be_a(Lutaml::Model::MinInclusiveError) + end + + it "enforces exclusive bounds on a Date subclass" do + stub_const("OpenEra", Class.new(Lutaml::Model::Type::Date) do + exclusive min: Date.new(2000, 1, 1), max: Date.new(2020, 1, 1) + end) + model = Class.new(Lutaml::Model::Serializable) do + attribute :day, OpenEra + end + + expect(model.new(day: "2010-06-15").validate).to be_empty + expect(model.new(day: "2000-01-01").validate.first) + .to be_a(Lutaml::Model::MinExclusiveError) + expect(model.new(day: "2020-01-01").validate.first) + .to be_a(Lutaml::Model::MaxExclusiveError) + end + end + + describe "exclusive facet inheritance and merge" do + it "lets a child tighten an inherited exclusive bound" do + stub_const("BaseOpen", Class.new(Lutaml::Model::Type::Integer) do + exclusive min: 0, max: 100 + end) + tight = Class.new(BaseOpen) { exclusive max: 50 } + + expect(tight.facets).to eq(min_exclusive: 0, max_exclusive: 50) + end + + it "raises when a child widens an inherited exclusive bound" do + stub_const("BaseOpen", Class.new(Lutaml::Model::Type::Integer) do + exclusive min: 0, max: 100 + end) + wide = Class.new(BaseOpen) { exclusive max: 200 } + + expect { wide.facets }.to raise_error(ArgumentError, /cannot widen/) + end + + it "keeps the tighter bound when an exclusive key is declared twice" do + stub_const("DoubleOpen", Class.new(Lutaml::Model::Type::Integer) do + exclusive max: 100 + exclusive max: 200 + end) + + expect(DoubleOpen.facets).to eq(max_exclusive: 100) + end + end + + describe "interval consistency across inclusive and exclusive bounds" do + it "rejects an empty interval when one bound is exclusive" do + stub_const("Empty", Class.new(Lutaml::Model::Type::Integer) do + inclusive min: 5 + exclusive max: 5 + end) + model = Class.new(Lutaml::Model::Serializable) do + attribute :n, Empty + end + + expect { model.new(n: 5).validate } + .to raise_error(ArgumentError, /empty interval/) + end + + it "accepts a non-empty interval between two exclusive bounds" do + stub_const("Narrow", Class.new(Lutaml::Model::Type::Integer) do + exclusive min: 5 + exclusive max: 6 + end) + model = Class.new(Lutaml::Model::Serializable) do + attribute :n, Narrow + end + + expect { model.new(n: 6).validate }.not_to raise_error + end + + it "consolidates same-side inclusive+exclusive to the tighter (inclusive) bound" do + stub_const("Tightened", Class.new(Lutaml::Model::Type::Integer) do + inclusive min: 5 + exclusive min: 3 + end) + model = Class.new(Lutaml::Model::Serializable) do + attribute :n, Tightened + end + + expect(model.new(n: 5).validate).to be_empty + expect(model.new(n: 4).validate.first) + .to be_a(Lutaml::Model::MinInclusiveError) + end + + it "consolidates a cross-layer min pair, inclusive winning when tighter" do + stub_const("OpenFloor", Class.new(Lutaml::Model::Type::Integer) do + exclusive min: 3 + end) + model = Class.new(Lutaml::Model::Serializable) do + attribute :n, OpenFloor, min: 5 + end + + expect(model.new(n: 5).validate).to be_empty + expect(model.new(n: 4).validate.first) + .to be_a(Lutaml::Model::MinInclusiveError) + end + + it "consolidates a cross-layer min pair, exclusive winning (and on ties)" do + stub_const("TightFloor", Class.new(Lutaml::Model::Type::Integer) do + exclusive min: 5 + end) + model = Class.new(Lutaml::Model::Serializable) do + attribute :n, TightFloor, min: 3 + end + + expect(model.new(n: 6).validate).to be_empty + expect(model.new(n: 5).validate.first) + .to be_a(Lutaml::Model::MinExclusiveError) + end + + it "consolidates a cross-layer max pair to the tighter bound" do + stub_const("Capped", Class.new(Lutaml::Model::Type::Integer) do + exclusive max: 8 + end) + model = Class.new(Lutaml::Model::Serializable) do + attribute :n, Capped, max: 10 + end + + expect(model.new(n: 7).validate).to be_empty + expect(model.new(n: 8).validate.first) + .to be_a(Lutaml::Model::MaxExclusiveError) + end + + # An explicit `min: nil` must not add a nil facet that makes consolidation + # bail out and hide a genuinely empty interval declared on the type. + it "still flags an empty interval when a nil option bound is present" do + stub_const("EmptyRange", Class.new(Lutaml::Model::Type::Integer) do + exclusive min: 5 + inclusive max: 5 + end) + model = Class.new(Lutaml::Model::Serializable) do + attribute :n, EmptyRange, min: nil + end + + expect { model.new(n: 5).validate } + .to raise_error(ArgumentError, /empty interval/) + end + end + + describe "applicability of ordered facets" do + it "rejects an exclusive facet on a string subclass" do + stub_const("BadOpen", Class.new(Lutaml::Model::Type::String) do + exclusive min: 0, max: 10 + end) + model = Class.new(Lutaml::Model::Serializable) do + attribute :s, BadOpen + end + + expect { model.new(s: "a").validate } + .to raise_error(ArgumentError, /numeric types or temporal/) + end + + it "accepts ordered facets on a temporal subclass" do + stub_const("BoundedDay", Class.new(Lutaml::Model::Type::Date) do + inclusive min: Date.new(2000, 1, 1) + end) + model = Class.new(Lutaml::Model::Serializable) do + attribute :day, BoundedDay + end + + expect { model.new(day: "2010-01-01").validate }.not_to raise_error + end + end + + describe "Layer-2 enumeration on a String subclass" do + before do + stub_const("Status", Class.new(Lutaml::Model::Type::String) do + enumeration "active", "inactive", "archived" + end) + stub_const("Ticket", Class.new(Lutaml::Model::Serializable) do + attribute :state, Status + end) + end + + it "passes for an allowed value" do + expect(Ticket.new(state: "active").validate).to be_empty + end + + it "collects an InvalidValueError for a value outside the set" do + expect(Ticket.new(state: "deleted").validate.first) + .to be_a(Lutaml::Model::InvalidValueError) + end + + it "rejects the empty string when it is not enumerated" do + expect(Ticket.new(state: "").validate.first) + .to be_a(Lutaml::Model::InvalidValueError) + end + + it "does not raise until validate! (lazy)" do + expect { Ticket.new(state: "deleted") }.not_to raise_error + end + end + + describe "Layer-2 facet values are cast at declaration (issue #191)" do + it "orders repeated string bounds numerically, not lexically" do + stub_const("Repeated", Class.new(Lutaml::Model::Type::Integer) do + inclusive min: "9" + inclusive min: "10" + end) + model = Class.new(Lutaml::Model::Serializable) { attribute :n, Repeated } + + expect(model.new(n: 10).validate).to be_empty + expect(model.new(n: 9).validate.first) + .to be_a(Lutaml::Model::MinInclusiveError) + end + + it "raises at declaration for a facet value the type cannot cast" do + expect do + Class.new(Lutaml::Model::Type::Integer) { inclusive min: "abc" } + end.to raise_error(ArgumentError, /not castable/) + end + end + + describe "enumeration members declared as strings are cast (issue #191)" do + it "matches an Integer value against string-declared enumeration members" do + stub_const("Digit", Class.new(Lutaml::Model::Type::Integer) do + enumeration "1", "2" + end) + model = Class.new(Lutaml::Model::Serializable) { attribute :d, Digit } + + expect(model.new(d: 1).validate).to be_empty + expect(model.new(d: 3).validate.first) + .to be_a(Lutaml::Model::InvalidValueError) + end + + it "matches a Decimal value against string-declared enumeration members" do + stub_const("Rate", Class.new(Lutaml::Model::Type::Decimal) do + enumeration "1.5", "2.5" + end) + model = Class.new(Lutaml::Model::Serializable) { attribute :r, Rate } + + expect(model.new(r: "1.5").validate).to be_empty + expect(model.new(r: "9.9").validate.first) + .to be_a(Lutaml::Model::InvalidValueError) + end + end + + describe "enumeration intersection across inheritance" do + before do + stub_const("BaseStatus", Class.new(Lutaml::Model::Type::String) do + enumeration "active", "inactive", "archived" + end) + end + + it "narrows the allowed set in a subclass (intersection)" do + stub_const("LiveStatus", Class.new(BaseStatus) do + enumeration "active", "inactive" + end) + + expect(LiveStatus.facets).to eq(enumeration: %w[active inactive]) + end + + it "rejects a value the parent allows but the child excludes" do + stub_const("LiveStatus", Class.new(BaseStatus) do + enumeration "active", "inactive" + end) + model = Class.new(Lutaml::Model::Serializable) do + attribute :state, LiveStatus + end + + expect(model.new(state: "active").validate).to be_empty + expect(model.new(state: "archived").validate.first) + .to be_a(Lutaml::Model::InvalidValueError) + end + + it "raises when a child introduces a value outside the parent set" do + widen = Class.new(BaseStatus) { enumeration "active", "deleted" } + + expect { widen.facets }.to raise_error(ArgumentError, /cannot widen/) + end + + it "raises a config error for an empty intersection (same class)" do + stub_const("Disjoint", Class.new(Lutaml::Model::Type::String) do + enumeration "a", "b" + enumeration "c", "d" + end) + model = Class.new(Lutaml::Model::Serializable) do + attribute :s, Disjoint + end + + expect { model.new(s: "a").validate } + .to raise_error(ArgumentError, /enumeration/) + end + end + + describe "Layer-2 pattern on a String subclass" do + before do + stub_const("Code", Class.new(Lutaml::Model::Type::String) do + pattern '\A[A-Z]{3}\z' + end) + stub_const("Item", Class.new(Lutaml::Model::Serializable) do + attribute :code, Code + end) + end + + it "passes for a matching value" do + expect(Item.new(code: "ABC").validate).to be_empty + end + + it "collects a PatternNotMatchedError for a non-matching value" do + expect(Item.new(code: "ab").validate.first) + .to be_a(Lutaml::Model::PatternNotMatchedError) + end + + it "rejects the empty string" do + expect(Item.new(code: "").validate.first) + .to be_a(Lutaml::Model::PatternNotMatchedError) + end + + it "does not raise until validate! (lazy)" do + expect { Item.new(code: "ab") }.not_to raise_error + end + end + + describe "pattern accumulation across inheritance" do + it "requires a value to match every pattern in the chain" do + stub_const("Alpha", Class.new(Lutaml::Model::Type::String) do + pattern '\A[A-Za-z]+\z' + end) + stub_const("ShortAlpha", Class.new(Alpha) do + pattern '\A.{1,3}\z' + end) + model = Class.new(Lutaml::Model::Serializable) do + attribute :s, ShortAlpha + end + + expect(model.new(s: "abc").validate).to be_empty + expect(model.new(s: "abcd").validate.first) + .to be_a(Lutaml::Model::PatternNotMatchedError) + expect(model.new(s: "12").validate.first) + .to be_a(Lutaml::Model::PatternNotMatchedError) + end + end + + describe "applicability of the pattern facet" do + it "rejects a pattern facet on a non-string subclass" do + stub_const("BadNum", Class.new(Lutaml::Model::Type::Integer) do + pattern '\A[0-9]+\z' + end) + model = Class.new(Lutaml::Model::Serializable) do + attribute :n, BadNum + end + + expect { model.new(n: 5).validate } + .to raise_error(ArgumentError, /pattern.*string/) + end + end + + describe "Layer-2 facets coexist with Layer-1 options" do + it "applies both a Layer-1 values: option and a Layer-2 enumeration" do + stub_const("Status", Class.new(Lutaml::Model::Type::String) do + enumeration "active", "inactive", "archived" + end) + model = Class.new(Lutaml::Model::Serializable) do + attribute :state, Status, values: %w[active inactive] + end + + expect(model.new(state: "active").validate).to be_empty + expect(model.new(state: "archived").validate.first) + .to be_a(Lutaml::Model::InvalidValueError) + expect(model.new(state: "deleted").validate.first) + .to be_a(Lutaml::Model::InvalidValueError) + end + + it "applies both a Layer-1 values: option and a Layer-2 pattern" do + stub_const("Code", Class.new(Lutaml::Model::Type::String) do + pattern '\A[A-Z]{3}\z' + end) + model = Class.new(Lutaml::Model::Serializable) do + attribute :code, Code, values: %w[ABC XY] + end + + expect(model.new(code: "ABC").validate).to be_empty + # "XY" satisfies the Layer-1 values: list but not the Layer-2 pattern, + # proving the Layer-2 facet is enforced in addition to the option. + expect(model.new(code: "XY").validate.first) + .to be_a(Lutaml::Model::PatternNotMatchedError) + expect(model.new(code: "DEF").validate.first) + .to be_a(Lutaml::Model::InvalidValueError) + end + end +end diff --git a/spec/lutaml/model/type_white_space_facet_spec.rb b/spec/lutaml/model/type_white_space_facet_spec.rb new file mode 100644 index 00000000..d1706269 --- /dev/null +++ b/spec/lutaml/model/type_white_space_facet_spec.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "whiteSpace facet (cast-time normalization)" do + describe ":collapse mode" do + before do + stub_const("CollapsedToken", Class.new(Lutaml::Model::Type::String) do + white_space :collapse + end) + stub_const("Doc", Class.new(Lutaml::Model::Serializable) do + attribute :token, CollapsedToken + end) + end + + it "collapses internal runs and strips edges at cast time" do + expect(CollapsedToken.cast(" a\t b\n\n c ")).to eq("a b c") + end + + it "normalizes the value stored on the model instance" do + expect(Doc.new(token: " hello world ").token).to eq("hello world") + end + + it "normalizes values loaded from a serialized document" do + expect(Doc.from_yaml("token: \" hello world \"\n").token) + .to eq("hello world") + end + end + + describe ":replace mode" do + before do + stub_const("ReplacedToken", Class.new(Lutaml::Model::Type::String) do + white_space :replace + end) + end + + it "replaces tab/newline/carriage-return with a single space" do + expect(ReplacedToken.cast("a\tb\nc\rd")).to eq("a b c d") + end + + it "keeps existing spaces and edges (no collapse, no strip)" do + expect(ReplacedToken.cast(" a\tb ")).to eq(" a b ") + end + end + + describe ":preserve mode" do + before do + stub_const("PreservedToken", Class.new(Lutaml::Model::Type::String) do + white_space :preserve + end) + end + + it "leaves the value untouched" do + expect(PreservedToken.cast(" a\t b ")).to eq(" a\t b ") + end + end + + describe "declaration guards" do + it "rejects an unknown mode" do + expect do + Class.new(Lutaml::Model::Type::String) { white_space :squash } + end.to raise_error(ArgumentError, /must be one of/) + end + + it "rejects white_space on a non-string type at declaration" do + expect do + Class.new(Lutaml::Model::Type::Integer) { white_space :collapse } + end.to raise_error(ArgumentError, /string-derived/) + end + end + + describe "inheritance (stricter wins)" do + before do + stub_const("ReplaceBase", Class.new(Lutaml::Model::Type::String) do + white_space :replace + end) + end + + it "lets a child tighten to a stricter mode" do + child = Class.new(ReplaceBase) { white_space :collapse } + + expect(child.facets[:white_space]).to eq(:collapse) + end + + it "raises when a child loosens the inherited mode" do + child = Class.new(ReplaceBase) { white_space :preserve } + + expect { child.facets }.to raise_error(ArgumentError, /cannot widen/) + end + + it "computes each subclass's effective mode independently" do + strict = Class.new(ReplaceBase) { white_space :collapse } + plain = Class.new(ReplaceBase) + + expect(strict.white_space_mode).to eq(:collapse) + expect(plain.white_space_mode).to eq(:replace) + end + end + + describe "memo invalidation when a facet is declared after a cast" do + it "recomputes the effective mode for a cast-before-declare sequence" do + cls = Class.new(Lutaml::Model::Type::String) + expect(cls.cast("a\tb")).to eq("a\tb") + + cls.white_space :collapse + + expect(cls.white_space_mode).to eq(:collapse) + expect(cls.cast(" a\t b ")).to eq("a b") + end + end + + describe "plain :string is unaffected (fast path intact)" do + before do + stub_const("Plain", Class.new(Lutaml::Model::Serializable) do + attribute :s, :string + end) + end + + it "does not normalize a plain string attribute" do + expect(Plain.new(s: " a b ").s).to eq(" a b ") + end + + it "returns the identical object via the EMPTY_OPTIONS fast path" do + raw = " a b " + cast = Lutaml::Model::Type::String.cast( + raw, Lutaml::Model::Type::Value::EMPTY_OPTIONS + ) + + expect(cast).to equal(raw) + end + end +end diff --git a/spec/lutaml/xml/schema/compiler_spec.rb b/spec/lutaml/xml/schema/compiler_spec.rb index 6b96738f..5ffb84ef 100644 --- a/spec/lutaml/xml/schema/compiler_spec.rb +++ b/spec/lutaml/xml/schema/compiler_spec.rb @@ -146,6 +146,308 @@ def strip_xml_comments(xml) end end + context "when compiling constraining facets (issue #191 from_xsd)" do + let!(:facets_dir) { Dir.mktmpdir } + let(:item) { RestrictionFacetsSpec::CTItem } + + before do + described_class.to_models( + File.read("spec/fixtures/xml/restriction_facets.xsd"), + output_dir: facets_dir, + create_files: true, + module_namespace: "RestrictionFacetsSpec", + ) + require File.join(facets_dir, "restrictionfacetsspec_registry.rb") + RestrictionFacetsSpec.register_all + end + + after do + FileUtils.rm_rf(facets_dir) + if defined?(RestrictionFacetsSpec) + Object.send(:remove_const, :RestrictionFacetsSpec) + end + end + + it "validates a fully in-range item with no errors" do + model = item.new(code: "ab", ratio: 50, price: "123.45", + moment: "2020-06-15T12:00:00") + expect(model.validate).to be_empty + end + + it "collects a MinLengthError for a too-short string (minLength)" do + expect(item.new(code: "a").validate.first) + .to be_a(Lutaml::Model::MinLengthError) + end + + it "collects a MaxLengthError for a too-long string (maxLength)" do + expect(item.new(code: "toolongvalue").validate.first) + .to be_a(Lutaml::Model::MaxLengthError) + end + + it "collects a MaxExclusiveError at the exclusive upper bound" do + expect(item.new(ratio: 100).validate.first) + .to be_a(Lutaml::Model::MaxExclusiveError) + end + + it "collects a MinExclusiveError at the exclusive lower bound" do + expect(item.new(ratio: 0).validate.first) + .to be_a(Lutaml::Model::MinExclusiveError) + end + + it "collects a TotalDigitsError beyond the total-digit cap" do + expect(item.new(price: "1234.56").validate.first) + .to be_a(Lutaml::Model::TotalDigitsError) + end + + it "collects a FractionDigitsError beyond the fraction-digit cap" do + expect(item.new(price: "1.234").validate.first) + .to be_a(Lutaml::Model::FractionDigitsError) + end + + it "collects a MinInclusiveError before the temporal lower bound" do + expect(item.new(moment: "2019-12-31T00:00:00").validate.first) + .to be_a(Lutaml::Model::MinInclusiveError) + end + + it "accepts a boolean value allowed by a cast enumeration" do + expect(item.new(flag: true).validate).to be_empty + end + + it "collects an InvalidValueError for a disallowed boolean enum value" do + expect(item.new(flag: false).validate.first) + .to be_a(Lutaml::Model::InvalidValueError) + end + + it "normalizes whitespace at cast time (whiteSpace collapse)" do + expect(item.new(label: " a b ").label).to eq("a b") + end + + it "carries the error attribute name" do + error = item.new(code: "a").validate.first + expect(error.to_s).to include("code") + end + end + + context "when compiling a built-in interpolated pattern (issue #191 anyURI)" do + let!(:uri_dir) { Dir.mktmpdir } + let(:link) { AnyUriPatternSpec::CTLink } + let(:uri_schema) do + <<~XSD + + + + + + + + XSD + end + + before do + described_class.to_models( + uri_schema, + output_dir: uri_dir, + create_files: true, + module_namespace: "AnyUriPatternSpec", + ) + require File.join(uri_dir, "anyuripatternspec_registry.rb") + AnyUriPatternSpec.register_all + end + + after do + FileUtils.rm_rf(uri_dir) + if defined?(AnyUriPatternSpec) + Object.send(:remove_const, :AnyUriPatternSpec) + end + end + + # Regression: the lazy pattern facet must keep the built-in anyURI + # pattern's live `#{...}` interpolation, else every valid URI is + # falsely rejected at validate time. + it "accepts a valid URI through the lazy interpolated pattern facet" do + expect(link.new(href: "http://example.com/a?b=1").validate) + .to be_empty + end + + it "rejects an invalid URI at cast time" do + expect { link.new(href: "has spaces here") } + .to raise_error(Lutaml::Model::Type::PatternNotMatchedError) + end + end + + context "when a restriction repeats a bound (issue #191 tightest-wins)" do + let(:tight_schema) do + <<~XSD + + + + + + + + + + + + XSD + end + + # Exclusive bounds must tighten like the inclusive ones: the largest + # lower bound and smallest upper bound win, so no out-of-range value + # slips through the generated facets. + it "carries the tightest exclusive bounds" do + code = described_class.to_models(tight_schema).fetch("Tight") + expect(code).to include("exclusive min: 10, max: 90") + end + + it "orders repeated decimal bounds numerically, not lexically" do + schema = <<~XSD + + + + + + + + + + XSD + code = described_class.to_models(schema).fetch("D") + expect(code).to include('inclusive min: BigDecimal("10.5")') + end + end + + context "when a restriction derives from a user-defined atomic type " \ + "(issue #191 chained base)" do + # Deriving a tighter type from a named numeric/decimal/temporal type is + # valid, common XSD. The further restriction's base is the user type + # (not a built-in), so its bound/enumeration must render through the + # parent's `cast` — a bare string would crash `<=>`/never equal the + # cast value at validation. + let(:chained_schema) do + <<~XSD + + + + + + + + + + + + + + XSD + end + + it "renders the chained bound through the parent type's cast" do + code = described_class.to_models(chained_schema).fetch("SmallAmount") + expect(code).to include('inclusive max: Amount.cast("100")') + end + + it "renders a chained enumeration through the parent type's cast" do + schema = <<~XSD + + + + + + + + + + + + + + + XSD + code = described_class.to_models(schema).fetch("Choice") + expect(code).to include("enumeration(Amount.cast(\"5\"), Amount.cast(\"10\"))") + end + end + + context "when compiling non-integer atomic bases (issue #191)" do + let!(:atomic_dir) { Dir.mktmpdir } + let(:atomic_schema) do + <<~XSD + + + + + + + + + + + + + + + + + + + + + + + + + + + XSD + end + let(:rec) { AtomicBasesSpec::CTRec } + + before do + described_class.to_models( + atomic_schema, + output_dir: atomic_dir, + create_files: true, + module_namespace: "AtomicBasesSpec", + ) + require File.join(atomic_dir, "atomicbasesspec_registry.rb") + AtomicBasesSpec.register_all + end + + after do + FileUtils.rm_rf(atomic_dir) + if defined?(AtomicBasesSpec) + Object.send(:remove_const, :AtomicBasesSpec) + end + end + + # Regression: decimal facet values must keep their exact lexical form + # (the exclusive facets used to parse `value` as an integer, truncating + # 1.5 to 1). At the exact bound 1.5 the eager inclusive cast passes and + # the lazy exclusive facet rejects — proving the bound is 1.5, not 1. + it "preserves decimal precision on exclusive bounds" do + expect(rec.new(money: "5.0").validate).to be_empty + expect(rec.new(money: "1.5").validate.first) + .to be_a(Lutaml::Model::MinExclusiveError) + end + + # Regression: xsd:float/xsd:date were absent from the supported-type map, + # so a faceted restriction generated a class inheriting Ruby-core + # Float/Date and failed to load. They now map to the Lutaml types. A + # below-range float is rejected by the eager numeric cast. + it "compiles a float restriction that loads and enforces bounds" do + expect(rec.new(rate: 5.0).validate).to be_empty + expect { rec.new(rate: 0.5) } + .to raise_error(Lutaml::Model::Type::MinBoundError) + end + + it "compiles a date restriction that loads and enforces bounds" do + expect(rec.new(day: "2020-06-01").validate).to be_empty + expect(rec.new(day: "2019-06-01").validate.first) + .to be_a(Lutaml::Model::MinInclusiveError) + end + end + context "when processing example from lutaml-model#260" do let!(:address_dir) { Dir.mktmpdir } let(:address) do diff --git a/spec/lutaml/xml/schema/facet_round_trip_spec.rb b/spec/lutaml/xml/schema/facet_round_trip_spec.rb new file mode 100644 index 00000000..43408972 --- /dev/null +++ b/spec/lutaml/xml/schema/facet_round_trip_spec.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +require "spec_helper" +require "lutaml/model/schema" +require "bigdecimal" +require "tmpdir" +require "fileutils" + +# End-to-end round-trip for issue #191 constraining facets: a constrained type +# survives model -> XSD -> model (its facet macro is regenerated) and an XSD's +# facets survive XSD -> model -> XSD (each facet element reappears). +RSpec.describe "Issue #191 facet round-trip" do + # Generate XSD from a one-attribute model of the constrained type, compile it + # back, and return the regenerated source of the facet-bearing ST_* type. + def round_trip_source(constrained_type) + model = Class.new(Lutaml::Model::Serializable) do + attribute :value, constrained_type + xml do + root "R" + map_element "value", to: :value + end + end + xsd = Lutaml::Xml::Schema::XsdSchema.generate(model, pretty: true) + compiled = Lutaml::Model::Schema::XmlCompiler.to_models(xsd) + compiled.fetch(compiled.keys.grep(/\AST_/).first) + end + + describe "model -> XSD -> model regenerates each facet macro" do + { + "integer inclusive bounds" => [ + -> { Class.new(Lutaml::Model::Type::Integer) { inclusive min: 0, max: 100 } }, + "inclusive min: 0, max: 100", + ], + "integer exclusive bounds" => [ + -> { Class.new(Lutaml::Model::Type::Integer) { exclusive min: 0, max: 100 } }, + "exclusive min: 0, max: 100", + ], + "exact length" => [ + -> { Class.new(Lutaml::Model::Type::String) { length 5 } }, + "length 5", + ], + "length range" => [ + -> { Class.new(Lutaml::Model::Type::String) { length min: 2, max: 8 } }, + "length min: 2, max: 8", + ], + "pattern" => [ + -> { Class.new(Lutaml::Model::Type::String) { pattern(/[A-Z]+/) } }, + "pattern(%r{[A-Z]+})", + ], + "enumeration" => [ + -> { Class.new(Lutaml::Model::Type::String) { enumeration("AB", "CD") } }, + 'enumeration("AB", "CD")', + ], + "whiteSpace" => [ + -> { Class.new(Lutaml::Model::Type::String) { white_space :collapse } }, + "white_space :collapse", + ], + "total and fraction digits" => [ + lambda { + Class.new(Lutaml::Model::Type::Decimal) do + total_digits 5 + fraction_digits 2 + end + }, + ["total_digits 5", "fraction_digits 2"], + ], + "decimal inclusive bounds" => [ + -> { Class.new(Lutaml::Model::Type::Decimal) { inclusive min: BigDecimal("1.5") } }, + 'inclusive min: BigDecimal("1.5")', + ], + "float inclusive bounds" => [ + -> { Class.new(Lutaml::Model::Type::Float) { inclusive min: 1.25, max: 9.75 } }, + 'inclusive min: Lutaml::Model::Type::Float.cast("1.25")', + ], + "date inclusive bound" => [ + lambda { + Class.new(Lutaml::Model::Type::Date) do + inclusive min: Lutaml::Model::Type::Date.cast("2020-01-01") + end + }, + 'inclusive min: Lutaml::Model::Type::Date.cast("2020-01-01")', + ], + "boolean enumeration" => [ + -> { Class.new(Lutaml::Model::Type::Boolean) { enumeration(true) } }, + 'enumeration(Lutaml::Model::Type::Boolean.cast("true"))', + ], + }.each do |facet, (build, macros)| + it "regenerates #{facet}" do + source = round_trip_source(build.call) + Array(macros).each { |macro| expect(source).to include(macro) } + end + end + + # Layer-1 attribute facets (issue #191 F3) must also survive the round-trip: + # `min`/`max` on the attribute emit an xs:restriction that recompiles to the + # canonical facet macro. + it "regenerates Layer-1 attribute min/max bounds" do + model = Class.new(Lutaml::Model::Serializable) do + attribute :value, :integer, min: 0, max: 100 + xml do + root "R" + map_element "value", to: :value + end + end + xsd = Lutaml::Xml::Schema::XsdSchema.generate(model, pretty: true) + compiled = Lutaml::Model::Schema::XmlCompiler.to_models(xsd) + source = compiled.fetch(compiled.keys.grep(/\AST_/).first) + + expect(source).to include("inclusive min: 0, max: 100") + end + end + + describe "XSD -> model -> XSD regenerates each facet element" do + let!(:dir) { Dir.mktmpdir } + + let(:regenerated) do + Lutaml::Model::Schema::XmlCompiler.to_models( + File.read("spec/fixtures/xml/restriction_facets.xsd"), + output_dir: dir, create_files: true, module_namespace: "FacetRtSpec", + ) + require File.join(dir, "facetrtspec_registry.rb") + FacetRtSpec.register_all + Lutaml::Xml::Schema::XsdSchema.generate(FacetRtSpec::CTItem, pretty: true) + end + + after do + FileUtils.rm_rf(dir) + # The compiled fixture must load as a real module for register_all; drop it + # so repeated runs do not accumulate constants. + # rubocop:disable RSpec/RemoveConst + Object.send(:remove_const, :FacetRtSpec) if defined?(FacetRtSpec) + # rubocop:enable RSpec/RemoveConst + end + + { + "minLength/maxLength" => + ['', ''], + "min/maxExclusive" => + ['', ''], + "total/fractionDigits" => + ['', ''], + "whiteSpace" => [''], + "min/maxInclusive" => [ + '', + '', + ], + "enumeration" => [''], + }.each do |facet, fragments| + it "regenerates #{facet}" do + fragments.each { |fragment| expect(regenerated).to include(fragment) } + end + end + end +end