diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 3991986..a2b87b4 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,27 +1,26 @@ # This configuration was generated by # `rubocop --auto-gen-config` -# on 2024-09-08 11:19:50 UTC using RuboCop version 1.66.1. +# on 2024-09-10 23:53:08 UTC using RuboCop version 1.66.1. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new # versions of RuboCop, may require this file to be generated again. -# Offense count: 1 -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: Severity, Include. -# Include: **/*.gemspec -Gemspec/RequireMFA: - Exclude: - - 'lutaml-model.gemspec' - -# Offense count: 66 +# Offense count: 88 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: Max, AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns. # URISchemes: http, https Layout/LineLength: Enabled: false -# Offense count: 10 +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AllowInHeredoc. +Layout/TrailingWhitespace: + Exclude: + - 'lib/lutaml/model/schema_location.rb' + +# Offense count: 11 # Configuration parameters: AllowedMethods. # AllowedMethods: enums Lint/ConstantDefinitionInBlock: @@ -30,6 +29,7 @@ Lint/ConstantDefinitionInBlock: - 'spec/lutaml/model/schema/relaxng_schema_spec.rb' - 'spec/lutaml/model/schema/xsd_schema_spec.rb' - 'spec/lutaml/model/schema/yaml_schema_spec.rb' + - 'spec/lutaml/model/validation_spec.rb' - 'spec/lutaml/model/xml_adapter/xml_namespace_spec.rb' # Offense count: 1 @@ -100,7 +100,7 @@ RSpec/ContextWording: - 'spec/lutaml/model/xml_adapter/ox_adapter_spec.rb' - 'spec/lutaml/model/xml_adapter/xml_namespace_spec.rb' -# Offense count: 89 +# Offense count: 101 # Configuration parameters: CountAsOne. RSpec/ExampleLength: Max: 57 @@ -111,13 +111,14 @@ RSpec/IndexedLet: Exclude: - 'spec/address_spec.rb' -# Offense count: 18 +# Offense count: 19 RSpec/LeakyConstantDeclaration: Exclude: - 'spec/lutaml/model/schema/json_schema_spec.rb' - 'spec/lutaml/model/schema/relaxng_schema_spec.rb' - 'spec/lutaml/model/schema/xsd_schema_spec.rb' - 'spec/lutaml/model/schema/yaml_schema_spec.rb' + - 'spec/lutaml/model/validation_spec.rb' - 'spec/lutaml/model/xml_adapter/xml_namespace_spec.rb' # Offense count: 4 @@ -128,7 +129,7 @@ RSpec/MultipleDescribes: - 'spec/lutaml/model/xml_adapter/xml_namespace_spec.rb' - 'spec/lutaml/model/xml_adapter_spec.rb' -# Offense count: 75 +# Offense count: 88 RSpec/MultipleExpectations: Max: 11 @@ -137,10 +138,11 @@ RSpec/MultipleExpectations: RSpec/MultipleMemoizedHelpers: Max: 9 -# Offense count: 4 +# Offense count: 7 RSpec/PendingWithoutReason: Exclude: - 'spec/lutaml/model/mixed_content_spec.rb' + - 'spec/lutaml/model/validation_spec.rb' - 'spec/lutaml/model/xml_adapter/oga_adapter_spec.rb' - 'spec/lutaml/model/xml_adapter/xml_namespace_spec.rb' - 'spec/lutaml/model/xml_adapter_spec.rb' @@ -163,6 +165,11 @@ Security/CompoundHash: Exclude: - 'lib/lutaml/model/comparable_model.rb' +# Offense count: 1 +Style/MissingRespondToMissing: + Exclude: + - 'lib/lutaml/model/serialize.rb' + # Offense count: 1 # Configuration parameters: AllowedMethods. # AllowedMethods: respond_to_missing? diff --git a/README.adoc b/README.adoc index 67b2f85..02e60c9 100644 --- a/README.adoc +++ b/README.adoc @@ -1709,6 +1709,101 @@ In this example: ==== +== Validation + +=== General + +Lutaml::Model provides a way to validate data models using the `validate` and +`validate!` methods. + +* The `validate` method sets an `errors` array in the model instance that +contains all the validation errors. This method is used for checking the +validity of the model silently. + +* The `validate!` method raises a `Lutaml::Model::ValidationError` that contains +all the validation errors. This method is used for forceful validation of the +model through raising an error. + +Lutaml::Model supports the following validation methods: + +* `collection`:: Validates collection size range. +* `values`:: Validates the value of an attribute from a set of fixed values. + +[example] +==== +The following class will validate the `degree_settings` attribute to ensure that +it has at least one element and that the `description` attribute is one of the +values in the set `[one, two, three]`. + +[source,ruby] +---- +class Klin < Lutaml::Model::Serializable + attribute :name, :string + attribute :degree_settings, :integer, collection: (1..) + attribute :description, :string, values: %w[one two three] + + xml do + map_element 'name', to: :name + map_attribute 'degree_settings', to: :degree_settings + end +end + +klin = Klin.new(name: "Klin", degree_settings: [100, 200, 300], description: "one") +klin.validate +# => [] + +klin = Klin.new(name: "Klin", degree_settings: [], description: "four") +klin.validate +# => [ +# #, +# # +# ] + +e = klin.validate! +# => Lutaml::Model::ValidationError: [ +# degree_settings must have at least 1 element, +# description must be one of [one, two, three] +# ] +e.errors +# => [ +# #, +# # +# ] +---- +==== + +=== Custom validation + +To add custom validation, override the `validate` method in the model class. +Additional errors should be added to the `errors` array. + +[example] +==== +The following class validates the `degree_settings` attribute when the `type` is +`glass` to ensure that the value is less than 1300. + +[source,ruby] +---- +class Klin < Lutaml::Model::Serializable + attribute :name, :string + attribute :type, :string, values: %w[glass ceramic] + attribute :degree_settings, :integer, collection: (1..) + + def validate + errors = super + if type == "glass" && degree_settings.any? { |d| d > 1300 } + errors << Lutaml::Model::Error.new("Degree settings for glass must be less than 1300") + end + end +end + +klin = Klin.new(name: "Klin", type: "glass", degree_settings: [100, 200, 1400]) +klin.validate +# => [#] +---- +==== + + == Adapters === General diff --git a/lib/lutaml/model/attribute.rb b/lib/lutaml/model/attribute.rb index 9cd8cf4..de11be9 100644 --- a/lib/lutaml/model/attribute.rb +++ b/lib/lutaml/model/attribute.rb @@ -60,22 +60,14 @@ def enum_values # e.g if collection: 0..5 is set then the value greater then 5 # will raise `Lutaml::Model::CollectionCountOutOfRangeError` def validate_value!(value) - # return true if none of the validations are present - return true if enum_values.empty? && singular? - - # Use the default value if the value is nil - value = default if value.nil? - - valid_value!(value) && valid_collection!(value) - end - - def valid_value?(value) - return true unless options[:values] - - options[:values].include?(value) + valid_value!(value) + valid_collection!(value) end def valid_value!(value) + return true if value.nil? && !collection? + return true if enum_values.empty? + unless valid_value?(value) raise Lutaml::Model::InvalidValueError.new(name, value, enum_values) end @@ -83,12 +75,10 @@ def valid_value!(value) true end - def valid_collection?(value) - return true unless collection? - return false unless value.is_a?(Array) - return collection? if [true, false].include?(options[:collection]) + def valid_value?(value) + return true unless options[:values] - options[:collection].include?(value.count) + options[:values].include?(value) end def validate_value!(value) @@ -127,7 +117,12 @@ def validate_collection_range def valid_collection!(value) return true unless collection? - return true if value.nil? # Allow nil values for collections during initialization + + # Allow nil values for collections during initialization + return true if value.nil? + + # Allow any value for unbounded collections + return true if options[:collection] == true unless value.is_a?(Array) raise Lutaml::Model::CollectionCountOutOfRangeError.new( @@ -138,28 +133,23 @@ def valid_collection!(value) end range = options[:collection] - if range.is_a?(Range) - if range.end.nil? - if value.size < range.begin - raise Lutaml::Model::CollectionCountOutOfRangeError.new( - name, - value, - range, - ) - end - elsif !range.cover?(value.size) + return true unless range.is_a?(Range) + + if range.end.nil? + if value.size < range.begin raise Lutaml::Model::CollectionCountOutOfRangeError.new( name, value, range, ) end - elsif range == true && value.empty? - # Allow empty arrays for unbounded collections - return true + elsif !range.cover?(value.size) + raise Lutaml::Model::CollectionCountOutOfRangeError.new( + name, + value, + range, + ) end - - true end def serialize(value, format, options = {}) diff --git a/lib/lutaml/model/error.rb b/lib/lutaml/model/error.rb index 3acdb6e..9ce8654 100644 --- a/lib/lutaml/model/error.rb +++ b/lib/lutaml/model/error.rb @@ -8,3 +8,4 @@ class Error < StandardError require_relative "error/invalid_value_error" require_relative "error/unknown_adapter_type_error" require_relative "error/collection_count_out_of_range_error" +require_relative "error/validation_error" diff --git a/lib/lutaml/model/error/validation_error.rb b/lib/lutaml/model/error/validation_error.rb new file mode 100644 index 0000000..76c0f49 --- /dev/null +++ b/lib/lutaml/model/error/validation_error.rb @@ -0,0 +1,21 @@ +# lib/lutaml/model/error/validation_error.rb +module Lutaml + module Model + class ValidationError < Error + attr_reader :errors + + def initialize(errors) + @errors = errors + super(errors.join(", ")) + end + + def include?(error_class) + errors.any?(error_class) + end + + def error_messages + errors.map(&:message) + end + end + end +end diff --git a/lib/lutaml/model/schema_location.rb b/lib/lutaml/model/schema_location.rb index 1bc0c8b..dd5d444 100644 --- a/lib/lutaml/model/schema_location.rb +++ b/lib/lutaml/model/schema_location.rb @@ -18,7 +18,8 @@ class SchemaLocation attr_reader :namespace, :prefix, :schema_location - def initialize(schema_location:, prefix: "xsi", namespace: DEFAULT_NAMESPACE) + def initialize(schema_location:, prefix: "xsi", +namespace: DEFAULT_NAMESPACE) @original_schema_location = schema_location @schema_location = parsed_schema_locations(schema_location) @prefix = prefix diff --git a/lib/lutaml/model/serialize.rb b/lib/lutaml/model/serialize.rb index a17d458..a909c5d 100644 --- a/lib/lutaml/model/serialize.rb +++ b/lib/lutaml/model/serialize.rb @@ -10,11 +10,13 @@ require_relative "json_adapter" require_relative "comparable_model" require_relative "schema_location" +require_relative "validation" module Lutaml module Model module Serialize include ComparableModel + include Validation def self.included(base) base.extend(ClassMethods) @@ -53,7 +55,7 @@ def attribute(name, type, options = {}) define_method(:"#{name}=") do |value| instance_variable_set(:"@#{name}", value) - validate!(name) + # validate!(name) end end @@ -373,6 +375,8 @@ def ensure_utf8(value) attr_accessor :element_order, :schema_location def initialize(attrs = {}) + @validate_on_set = attrs.delete(:validate_on_set) || false + return unless self.class.attributes if attrs.is_a?(Lutaml::Model::MappingHash) @@ -396,10 +400,25 @@ def initialize(attrs = {}) value = [] end - send(:"#{name}=", self.class.ensure_utf8(value)) + instance_variable_set(:"@#{name}", self.class.ensure_utf8(value)) end + end + + def method_missing(method_name, *args) + if method_name.to_s.end_with?("=") && self.class.attributes.key?(method_name.to_s.chomp("=").to_sym) + define_singleton_method(method_name) do |value| + instance_variable_set(:"@#{method_name.to_s.chomp('=')}", value) + end + send(method_name, *args) + else + super + end + end - validate! + def validate_attribute!(attr_name) + attr = self.class.attributes[attr_name] + value = instance_variable_get(:"@#{attr_name}") + attr.validate_value!(value) end def ordered? @@ -432,20 +451,6 @@ def key_value(hash, key) adapter.new(representation).public_send(:"to_#{format}", options) end end - - def validate!(attr_name = nil) - self.class.attributes.each do |name, attr| - next if attr_name && attr_name != name - - value = send(name) - - # Skip validation for nil values of non-collection attributes - next if value.nil? && !attr.collection? - - # Always validate collections - attr.validate_value!(value) - end - end end end end diff --git a/lib/lutaml/model/validation.rb b/lib/lutaml/model/validation.rb new file mode 100644 index 0000000..a94f7ca --- /dev/null +++ b/lib/lutaml/model/validation.rb @@ -0,0 +1,24 @@ +module Lutaml + module Model + module Validation + def validate + errors = [] + self.class.attributes.each do |name, attr| + value = instance_variable_get(:"@#{name}") + begin + attr.validate_value!(value) + rescue Lutaml::Model::InvalidValueError, + Lutaml::Model::CollectionCountOutOfRangeError => e + errors << e + end + end + errors + end + + def validate! + errors = validate + raise Lutaml::Model::ValidationError.new(errors) if errors.any? + end + end + end +end diff --git a/lutaml-model.gemspec b/lutaml-model.gemspec index 956e579..09a7846 100644 --- a/lutaml-model.gemspec +++ b/lutaml-model.gemspec @@ -32,4 +32,5 @@ Gem::Specification.new do |spec| spec.add_dependency "bigdecimal" spec.add_dependency "thor" + spec.metadata["rubygems_mfa_required"] = "true" end diff --git a/spec/lutaml/model/collection_spec.rb b/spec/lutaml/model/collection_spec.rb index 100ea0b..15e361c 100644 --- a/spec/lutaml/model/collection_spec.rb +++ b/spec/lutaml/model/collection_spec.rb @@ -114,18 +114,24 @@ class Kiln < Lutaml::Model::Serializable attributes.merge(operators: [], sensors: []) end - it "raises CollectionCountOutOfRangeError" do + it "raises ValidationError containing CollectionCountOutOfRangeError for operators" do + kiln = CollectionTests::Kiln.new(invalid_attributes) expect do - CollectionTests::Kiln.new(invalid_attributes) - end.to raise_error(Lutaml::Model::CollectionCountOutOfRangeError, - /operators count is 0, must be at least 1/) + kiln.validate! + end.to raise_error(Lutaml::Model::ValidationError) do |error| + expect(error).to include(Lutaml::Model::CollectionCountOutOfRangeError) + expect(error.error_messages).to include(a_string_matching(/operators count is 0, must be at least 1/)) + end end - it "raises CollectionCountOutOfRangeError for sensors" do + it "raises ValidationError containing CollectionCountOutOfRangeError for sensors" do + kiln = CollectionTests::Kiln.new(attributes.merge(sensors: [])) expect do - CollectionTests::Kiln.new(attributes.merge(sensors: [])) - end.to raise_error(Lutaml::Model::CollectionCountOutOfRangeError, - /sensors count is 0, must be between 1 and 3/) + kiln.validate! + end.to raise_error(Lutaml::Model::ValidationError) do |error| + expect(error).to include(Lutaml::Model::CollectionCountOutOfRangeError) + expect(error.error_messages).to include(a_string_matching(/sensors count is 0, must be between 1 and 3/)) + end end end @@ -135,10 +141,13 @@ class Kiln < Lutaml::Model::Serializable end it "raises CollectionCountOutOfRangeError" do + kiln = CollectionTests::Kiln.new(invalid_attributes) expect do - CollectionTests::Kiln.new(invalid_attributes) - end.to raise_error(Lutaml::Model::CollectionCountOutOfRangeError, - /operators count is 0, must be at least 1/) + kiln.validate! + end.to raise_error(Lutaml::Model::ValidationError) do |error| + expect(error).to include(Lutaml::Model::CollectionCountOutOfRangeError) + expect(error.error_messages).to include(a_string_matching(/operators count is 0, must be at least 1/)) + end end end @@ -175,11 +184,13 @@ class Kiln < Lutaml::Model::Serializable XML end - it "raises CollectionCountOutOfRangeError" do + it "raises ValidationError containing CollectionCountOutOfRangeError" do expect do - CollectionTests::Kiln.from_xml(invalid_xml) - end.to raise_error(Lutaml::Model::CollectionCountOutOfRangeError, - /pots count is 3, must be between 0 and 2/) + CollectionTests::Kiln.from_xml(invalid_xml).validate! + end.to raise_error(Lutaml::Model::ValidationError) do |error| + expect(error).to include(Lutaml::Model::CollectionCountOutOfRangeError) + expect(error.error_messages).to include(a_string_matching(/pots count is 3, must be between 0 and 2/)) + end end end diff --git a/spec/lutaml/model/serializable_spec.rb b/spec/lutaml/model/serializable_spec.rb index d493bde..b8bcdd7 100644 --- a/spec/lutaml/model/serializable_spec.rb +++ b/spec/lutaml/model/serializable_spec.rb @@ -220,17 +220,15 @@ class GlazeTechnique < Lutaml::Model::Serializable describe "String enumeration" do context "when assigning an invalid value" do - it "raises an error after creation" do + it "raises an error after creation after validate" do glaze = GlazeTechnique.new(name: "Celadon") + glaze.name = "Tenmoku" expect do - glaze.name = "Tenmoku" - end.to raise_error(Lutaml::Model::InvalidValueError) - end - - it "raises an error during creation" do - expect do - GlazeTechnique.new(name: "Crystalline") - end.to raise_error(Lutaml::Model::InvalidValueError) + glaze.validate! + end.to raise_error(Lutaml::Model::ValidationError) do |error| + expect(error).to include(Lutaml::Model::InvalidValueError) + expect(error.error_messages).to include("name is `Tenmoku`, must be one of the following [Celadon, Raku, Majolica]") + end end end @@ -250,36 +248,24 @@ class GlazeTechnique < Lutaml::Model::Serializable describe "Serializable object enumeration" do context "when assigning an invalid value" do - it "raises an error after creation" do - collection = CeramicCollection.new( - featured_piece: Ceramic.new(type: "Porcelain", - firing_temperature: 1300), - ) - invalid_ceramic = Ceramic.new(type: "Porcelain", - firing_temperature: 1500) - - expect do - collection.featured_piece = invalid_ceramic - end.to raise_error(Lutaml::Model::InvalidValueError) - end - - it "raises an error during creation" do - invalid_ceramic = Ceramic.new(type: "Porcelain", - firing_temperature: 1500) + it "raises ValidationError containing InvalidValueError after creation" do + glaze = GlazeTechnique.new(name: "Celadon") + glaze.name = "Tenmoku" expect do - CeramicCollection.new(featured_piece: invalid_ceramic) - end.to raise_error(Lutaml::Model::InvalidValueError) + glaze.validate! + end.to raise_error(Lutaml::Model::ValidationError) do |error| + expect(error).to include(Lutaml::Model::InvalidValueError) + expect(error.error_messages).to include(a_string_matching(/name is `Tenmoku`, must be one of the following/)) + end end - it "raises an error when modifying a nested attribute" do - collection = CeramicCollection.new( - featured_piece: Ceramic.new(type: "Porcelain", - firing_temperature: 1300), - ) - collection.featured_piece.firing_temperature = 1400 + it "raises ValidationError containing InvalidValueError during creation" do expect do - collection.validate! - end.to raise_error(Lutaml::Model::InvalidValueError) + GlazeTechnique.new(name: "Crystalline").validate! + end.to raise_error(Lutaml::Model::ValidationError) do |error| + expect(error).to include(Lutaml::Model::InvalidValueError) + expect(error.error_messages).to include(a_string_matching(/name is `Crystalline`, must be one of the following/)) + end end end diff --git a/spec/lutaml/model/validation_spec.rb b/spec/lutaml/model/validation_spec.rb new file mode 100644 index 0000000..4022b29 --- /dev/null +++ b/spec/lutaml/model/validation_spec.rb @@ -0,0 +1,83 @@ +# spec/lutaml/model/validation_spec.rb + +require "spec_helper" + +RSpec.describe Lutaml::Model::Validation do + class ValidationTestClass < Lutaml::Model::Serializable + attribute :name, :string + attribute :age, :integer + attribute :email, :string, values: ["test@example.com", "user@example.com"] + attribute :tags, :string, collection: true + attribute :role, :string, collection: 1..3 + end + + let(:valid_instance) do + ValidationTestClass.new( + name: "John Doe", + age: 30, + email: "test@example.com", + tags: ["tag1", "tag2"], + role: ["admin"], + ) + end + + describe "#validate" do + it "returns an empty array for a valid instance" do + expect(valid_instance.validate).to be_empty + end + + xit "returns errors for invalid integer value" do + instance = ValidationTestClass.new(age: "thirty", role: ["admin"]) + errors = instance.validate + expect(errors).to include("Invalid value for attribute age: thirty") + end + + it "returns errors for value not in allowed set" do + instance = ValidationTestClass.new(email: "invalid@example.com", + role: ["admin"]) + expect do + instance.validate! + end.to raise_error(Lutaml::Model::ValidationError) do |error| + expect(error.error_messages.join("\n")).to include("email is `invalid@example.com`, must be one of the following [test@example.com, user@example.com]") + end + end + + it "returns errors for invalid collection count" do + instance = ValidationTestClass.new(role: ["admin", "user", "manager", + "guest"]) + expect do + instance.validate! + end.to raise_error(Lutaml::Model::ValidationError) do |error| + expect(error.error_messages.join("\n")).to include("role count is 4, must be between 1 and 3") + end + end + + xit "returns multiple errors for multiple invalid attributes" do + instance = ValidationTestClass.new(name: "123", age: "thirty", + email: "invalid@example.com", role: []) + expect do + instance.validate! + end.to raise_error(Lutaml::Model::ValidationError) do |error| + expect(error.error_messages.join("\n")).to include("Invalid value for attribute age: thirty") + expect(error.error_messages.join("\n")).to include("email is `invalid@example.com`, must be one of the following [test@example.com, user@example.com]") + expect(error.error_messages.join("\n")).to include("role count is 0, must be between 1 and 3") + end + end + end + + describe "#validate!" do + it "does not raise an error for a valid instance" do + expect { valid_instance.validate! }.not_to raise_error + end + + xit "raises a ValidationError with all error messages for an invalid instance" do + instance = ValidationTestClass.new(name: "test", age: "thirty") + expect do + instance.validate! + end.to raise_error(Lutaml::Model::ValidationError) do |error| + expect(error.error_messages.join("\n")).to include("role count is 0, must be between 1 and 3") + expect(error.error_messages.join("\n")).to include("Invalid value for attribute age: thirty") + end + end + end +end