lutaml-model 0.8.2 → 0.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +23 -23
  3. data/README.adoc +213 -1
  4. data/docs/_guides/document-validation.adoc +303 -0
  5. data/docs/_guides/index.adoc +1 -0
  6. data/docs/_guides/xml-mapping.adoc +9 -1
  7. data/docs/_guides/xml_mappings/07_best_practices.adoc +36 -0
  8. data/docs/_guides/xml_mappings/08_troubleshooting.adoc +89 -0
  9. data/docs/_tutorials/lutaml-xml-architecture.adoc +6 -1
  10. data/lib/lutaml/model/attribute.rb +19 -1
  11. data/lib/lutaml/model/error/liquid_drop_already_registered_error.rb +11 -0
  12. data/lib/lutaml/model/error/ordered_content_mapping_error.rb +17 -0
  13. data/lib/lutaml/model/global_context.rb +1 -0
  14. data/lib/lutaml/model/liquefiable.rb +12 -15
  15. data/lib/lutaml/model/mapping/mapping_rule.rb +10 -2
  16. data/lib/lutaml/model/mapping_hash.rb +1 -1
  17. data/lib/lutaml/model/services/transformer.rb +67 -32
  18. data/lib/lutaml/model/transform.rb +41 -4
  19. data/lib/lutaml/model/uninitialized_class.rb +11 -5
  20. data/lib/lutaml/model/validation/concerns/has_issues.rb +27 -0
  21. data/lib/lutaml/model/validation/context.rb +36 -0
  22. data/lib/lutaml/model/validation/issue.rb +62 -0
  23. data/lib/lutaml/model/validation/layer_result.rb +34 -0
  24. data/lib/lutaml/model/validation/profile.rb +66 -0
  25. data/lib/lutaml/model/validation/registry.rb +60 -0
  26. data/lib/lutaml/model/validation/remediation.rb +33 -0
  27. data/lib/lutaml/model/validation/remediation_result.rb +20 -0
  28. data/lib/lutaml/model/validation/report.rb +39 -0
  29. data/lib/lutaml/model/validation/rule.rb +59 -0
  30. data/lib/lutaml/model/validation.rb +2 -1
  31. data/lib/lutaml/model/validation_framework.rb +77 -0
  32. data/lib/lutaml/model/version.rb +1 -1
  33. data/lib/lutaml/model.rb +4 -0
  34. data/lib/lutaml/xml/adapter/nokogiri_adapter.rb +9 -2
  35. data/lib/lutaml/xml/adapter/oga_adapter.rb +11 -3
  36. data/lib/lutaml/xml/adapter/ox_adapter.rb +5 -2
  37. data/lib/lutaml/xml/adapter/rexml_adapter.rb +10 -3
  38. data/lib/lutaml/xml/adapter_element.rb +26 -2
  39. data/lib/lutaml/xml/data_model.rb +14 -0
  40. data/lib/lutaml/xml/document.rb +3 -0
  41. data/lib/lutaml/xml/element.rb +8 -2
  42. data/lib/lutaml/xml/mapping.rb +9 -0
  43. data/lib/lutaml/xml/model_transform.rb +42 -0
  44. data/lib/lutaml/xml/schema/xsd/base.rb +4 -1
  45. data/lib/lutaml/xml/schema/xsd/schema_path.rb +6 -0
  46. data/lib/lutaml/xml/serialization/instance_methods.rb +3 -1
  47. data/lib/lutaml/xml/transformation/ordered_applier.rb +46 -2
  48. data/lib/lutaml/xml/transformation.rb +40 -1
  49. data/lib/lutaml/xml/xml_element.rb +8 -7
  50. data/lutaml-model.gemspec +1 -2
  51. data/spec/lutaml/model/attribute_default_cache_spec.rb +58 -0
  52. data/spec/lutaml/model/liquefiable_spec.rb +22 -6
  53. data/spec/lutaml/model/liquid_compatibility_spec.rb +442 -0
  54. data/spec/lutaml/model/ordered_content_spec.rb +5 -5
  55. data/spec/lutaml/model/services/transformer_spec.rb +43 -0
  56. data/spec/lutaml/model/transform_cache_spec.rb +62 -0
  57. data/spec/lutaml/model/transform_dynamic_attributes_spec.rb +41 -0
  58. data/spec/lutaml/model/uninitialized_class_deep_dup_spec.rb +39 -0
  59. data/spec/lutaml/model/uninitialized_class_spec.rb +14 -2
  60. data/spec/lutaml/model/validation/concerns/has_issues_spec.rb +76 -0
  61. data/spec/lutaml/model/validation/context_spec.rb +60 -0
  62. data/spec/lutaml/model/validation/issue_spec.rb +77 -0
  63. data/spec/lutaml/model/validation/layer_result_spec.rb +66 -0
  64. data/spec/lutaml/model/validation/profile_spec.rb +134 -0
  65. data/spec/lutaml/model/validation/registry_spec.rb +94 -0
  66. data/spec/lutaml/model/validation/remediation_result_spec.rb +23 -0
  67. data/spec/lutaml/model/validation/remediation_spec.rb +72 -0
  68. data/spec/lutaml/model/validation/report_spec.rb +58 -0
  69. data/spec/lutaml/model/validation/rule_spec.rb +134 -0
  70. data/spec/lutaml/model/validation/uninitialized_class_validate_spec.rb +29 -0
  71. data/spec/lutaml/model/validation/validation_error_spec.rb +29 -0
  72. data/spec/lutaml/model/validation/validation_framework_spec.rb +110 -0
  73. data/spec/lutaml/xml/content_model_validation_spec.rb +157 -0
  74. data/spec/lutaml/xml/mapping_spec.rb +12 -7
  75. data/spec/lutaml/xml/schema/xsd/glob_spec.rb +12 -0
  76. metadata +46 -21
  77. data/spec/fixtures/liquid_templates/_ceramics.liquid +0 -3
@@ -5,6 +5,37 @@ module Lutaml
5
5
  def call(value, rule, attribute, format: nil)
6
6
  new(rule, attribute, format).call(value)
7
7
  end
8
+
9
+ private
10
+
11
+ def get_transform_static(obj, direction)
12
+ transform = obj&.transform
13
+ return nil if transform.nil? || transform.is_a?(Class)
14
+
15
+ transform.is_a?(::Hash) ? transform[direction] : transform
16
+ end
17
+
18
+ def apply_static(value, sources, direction, format)
19
+ methods = sources.filter_map do |obj|
20
+ get_transform_static(obj, direction)
21
+ end
22
+
23
+ class_transformers = sources.filter_map do |obj|
24
+ next unless obj&.transform.is_a?(Class) &&
25
+ obj.transform < Lutaml::Model::ValueTransformer
26
+
27
+ obj.transform
28
+ end
29
+
30
+ return value if methods.empty? && class_transformers.empty?
31
+
32
+ apply_direction = direction == :import ? :from : :to
33
+ result = class_transformers.reduce(value) do |v, tc|
34
+ tc.public_send(apply_direction, v, format)
35
+ end
36
+
37
+ methods.reduce(result) { |tv, m| m.call(tv) }
38
+ end
8
39
  end
9
40
 
10
41
  attr_reader :rule, :attribute, :format
@@ -16,46 +47,38 @@ module Lutaml
16
47
  end
17
48
 
18
49
  def call(value)
19
- # Collect all class-based and hash/proc-based transformers
20
- # Apply them in the correct order based on transformation_methods
21
-
22
- # Get ordered transformation methods (already in correct precedence)
23
50
  methods = transformation_methods
24
51
 
25
- # Also check for class-based transformers and add them in correct order
26
- class_transformers = []
27
- if instance_of?(ExportTransformer)
28
- # Export order: attribute first, then rule
29
- class_transformers << attribute.transform if attribute&.transform.is_a?(Class) && attribute.transform < Lutaml::Model::ValueTransformer
30
- class_transformers << rule.transform if rule&.transform.is_a?(Class) && rule.transform < Lutaml::Model::ValueTransformer
31
- else
32
- # Import order: rule first, then attribute
33
- class_transformers << rule.transform if rule&.transform.is_a?(Class) && rule.transform < Lutaml::Model::ValueTransformer
34
- class_transformers << attribute.transform if attribute&.transform.is_a?(Class) && attribute.transform < Lutaml::Model::ValueTransformer
52
+ class_transformers = ordered_sources.filter_map do |obj|
53
+ next unless obj&.transform.is_a?(Class) &&
54
+ obj.transform < Lutaml::Model::ValueTransformer
55
+
56
+ obj.transform
35
57
  end
36
58
 
37
- # Apply class transformers first, then hash/proc transformers
38
59
  result = class_transformers.reduce(value) do |v, transformer_class|
39
60
  apply_class_transformer(v, transformer_class, format)
40
61
  end
41
62
 
42
- # Then apply hash/proc transformers
43
63
  methods.reduce(result) do |transformed_value, method|
44
64
  method.call(transformed_value)
45
65
  end
46
66
  end
47
67
 
48
68
  def apply_class_transformer(value, transformer_class, format)
49
- if instance_of?(ExportTransformer)
69
+ if export_direction?
50
70
  transformer_class.to(value, format)
51
71
  else
52
72
  transformer_class.from(value, format)
53
73
  end
54
74
  end
55
75
 
76
+ def export_direction?
77
+ false
78
+ end
79
+
56
80
  def get_transform(obj, direction)
57
81
  transform = obj&.transform
58
-
59
82
  return nil if transform.is_a?(Class)
60
83
 
61
84
  transform.is_a?(::Hash) ? transform[direction] : transform
@@ -63,26 +86,38 @@ module Lutaml
63
86
  end
64
87
 
65
88
  class ImportTransformer < Transformer
66
- # Precedene of transformations:
67
- # 1. Rule transform
68
- # 2. Attribute transform
89
+ class << self
90
+ def call(value, rule, attribute, format: nil)
91
+ apply_static(value, [rule, attribute], :import, format)
92
+ end
93
+ end
94
+
95
+ def ordered_sources
96
+ [rule, attribute]
97
+ end
98
+
69
99
  def transformation_methods
70
- [
71
- get_transform(rule, :import),
72
- get_transform(attribute, :import),
73
- ].compact
100
+ ordered_sources.filter_map { |obj| get_transform(obj, :import) }
74
101
  end
75
102
  end
76
103
 
77
104
  class ExportTransformer < Transformer
78
- # Precedene of transformations (reverse order):
79
- # 1. Attribute transform
80
- # 2. Rule transform
105
+ class << self
106
+ def call(value, rule, attribute, format: nil)
107
+ apply_static(value, [attribute, rule], :export, format)
108
+ end
109
+ end
110
+
111
+ def ordered_sources
112
+ [attribute, rule]
113
+ end
114
+
81
115
  def transformation_methods
82
- [
83
- get_transform(attribute, :export),
84
- get_transform(rule, :export),
85
- ].compact
116
+ ordered_sources.filter_map { |obj| get_transform(obj, :export) }
117
+ end
118
+
119
+ def export_direction?
120
+ true
86
121
  end
87
122
  end
88
123
  end
@@ -3,21 +3,58 @@
3
3
  module Lutaml
4
4
  module Model
5
5
  class Transform
6
+ @transform_cache = {}
7
+
8
+ # Maximum number of cached Transform instances before eviction.
9
+ MAX_CACHE_SIZE = 256
10
+
6
11
  def self.data_to_model(context, data, format, options = {})
7
- new(context, options[:register]).data_to_model(data, format, options)
12
+ register = options[:register] || Lutaml::Model::Config.default_register
13
+ transform = cached_transform(context, register)
14
+ transform.data_to_model(data, format, options)
8
15
  end
9
16
 
10
17
  def self.model_to_data(context, model, format, options = {})
11
18
  register = model.lutaml_register if model.respond_to?(:lutaml_register)
12
- new(context, register).model_to_data(model, format, options)
19
+ register ||= Lutaml::Model::Config.default_register
20
+ transform = cached_transform(context, register)
21
+ transform.model_to_data(model, format, options)
13
22
  end
14
23
 
15
- attr_reader :context, :attributes, :lutaml_register
24
+ def self.cached_transform(context, register)
25
+ @transform_cache ||= {}
26
+ cache_key = [context.object_id, register]
27
+ entry = @transform_cache[cache_key]
28
+ return entry if entry
29
+
30
+ evict_if_needed if @transform_cache.size >= MAX_CACHE_SIZE
31
+ @transform_cache[cache_key] = new(context, register)
32
+ end
33
+
34
+ def self.clear_cache!
35
+ @transform_cache&.clear
36
+ end
37
+
38
+ def self.cache_size
39
+ @transform_cache&.size || 0
40
+ end
41
+
42
+ def self.evict_if_needed
43
+ # Evict oldest half of entries when cache is full
44
+ keys_to_remove = @transform_cache.keys.first(@transform_cache.size / 2)
45
+ keys_to_remove.each { |k| @transform_cache.delete(k) }
46
+ end
47
+ private_class_method :evict_if_needed
48
+
49
+ attr_reader :context, :lutaml_register
16
50
 
17
51
  def initialize(context, register = nil)
18
52
  @context = context
19
53
  @lutaml_register = register || Lutaml::Model::Config.default_register
20
- @attributes = context.attributes(lutaml_register)
54
+ end
55
+
56
+ def attributes
57
+ context.attributes(lutaml_register)
21
58
  end
22
59
 
23
60
  def model_class
@@ -49,16 +49,22 @@ module Lutaml
49
49
  end
50
50
 
51
51
  def method_missing(method, *_args, &)
52
- if method.end_with?("?")
53
- false
54
- else
55
- super
56
- end
52
+ return false if method.end_with?("?")
53
+
54
+ nil
57
55
  end
58
56
 
59
57
  def respond_to_missing?(method_name, _include_private = false)
60
58
  method_name.end_with?("?")
61
59
  end
60
+
61
+ def dup
62
+ self
63
+ end
64
+
65
+ def clone
66
+ self
67
+ end
62
68
  end
63
69
  end
64
70
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Model
5
+ module Validation
6
+ # Shared severity filtering for objects that expose an `issues`
7
+ # collection. Included by LayerResult and Report.
8
+ module HasIssues
9
+ def errors
10
+ issues.select(&:error?)
11
+ end
12
+
13
+ def warnings
14
+ issues.select(&:warning?)
15
+ end
16
+
17
+ def infos
18
+ issues.select(&:info?)
19
+ end
20
+
21
+ def notices
22
+ issues.select(&:notice?)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Model
5
+ module Validation
6
+ # Mutable validation context that accumulates errors and provides
7
+ # per-rule state. Used when validation needs imperative error
8
+ # accumulation (e.g., streaming SAX validation).
9
+ class Context
10
+ attr_reader :errors
11
+
12
+ def initialize
13
+ @errors = []
14
+ @per_rule_state = {}
15
+ end
16
+
17
+ def add_error(issue)
18
+ @errors << issue
19
+ end
20
+
21
+ def add_errors(issues)
22
+ @errors.concat(issues)
23
+ end
24
+
25
+ def rule_state(rule_code)
26
+ @per_rule_state[rule_code] ||= {}
27
+ end
28
+
29
+ def reset!
30
+ @errors.clear
31
+ @per_rule_state.clear
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Model
5
+ module Validation
6
+ # Serializable validation issue with severity, code, location, and
7
+ # suggestion. Used by Rule#check to report problems and aggregated
8
+ # by LayerResult and Report.
9
+ class Issue < Lutaml::Model::Serializable
10
+ # Allowed severity levels for validation issues.
11
+ SEVERITIES = %w[error warning info notice].freeze
12
+
13
+ attribute :severity, :string
14
+ attribute :code, :string
15
+ attribute :message, :string
16
+ attribute :location, :string
17
+ attribute :line, :integer
18
+ attribute :suggestion, :string
19
+
20
+ json do
21
+ map "severity", to: :severity
22
+ map "code", to: :code
23
+ map "message", to: :message
24
+ map "location", to: :location
25
+ map "line", to: :line
26
+ map "suggestion", to: :suggestion
27
+ end
28
+
29
+ def initialize(attributes = {})
30
+ super
31
+ validate_severity!
32
+ end
33
+
34
+ def error?
35
+ severity == "error"
36
+ end
37
+
38
+ def warning?
39
+ severity == "warning"
40
+ end
41
+
42
+ def info?
43
+ severity == "info"
44
+ end
45
+
46
+ def notice?
47
+ severity == "notice"
48
+ end
49
+
50
+ private
51
+
52
+ def validate_severity!
53
+ return if severity.nil? || SEVERITIES.include?(severity)
54
+
55
+ raise ArgumentError,
56
+ "Invalid severity: #{severity}. " \
57
+ "Must be one of: #{SEVERITIES.join(', ')}"
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "issue"
4
+ require_relative "concerns/has_issues"
5
+
6
+ module Lutaml
7
+ module Model
8
+ module Validation
9
+ class LayerResult < Lutaml::Model::Serializable
10
+ include HasIssues
11
+
12
+ attribute :name, :string
13
+ attribute :status, :string
14
+ attribute :duration_ms, :integer
15
+ attribute :issues, Issue, collection: true
16
+
17
+ json do
18
+ map "name", to: :name
19
+ map "status", to: :status
20
+ map "duration_ms", to: :duration_ms
21
+ map "issues", to: :issues
22
+ end
23
+
24
+ def pass?
25
+ status == "pass"
26
+ end
27
+
28
+ def fail?
29
+ status == "fail"
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Lutaml
6
+ module Model
7
+ module Validation
8
+ # Composable validation profile loaded from YAML. Selects which rules
9
+ # run during validation. Profiles can import other profiles for
10
+ # rule reuse across validation levels (e.g., basic → strict).
11
+ class Profile
12
+ attr_reader :name, :description, :rule_names, :imports
13
+
14
+ def initialize(name:, description: nil, rule_names: [], imports: [])
15
+ @name = name
16
+ @description = description
17
+ @rule_names = rule_names
18
+ @imports = imports
19
+ end
20
+
21
+ def self.load(path)
22
+ data = YAML.safe_load_file(path, symbolize_names: false)
23
+ unless data.is_a?(::Hash) && data["name"].is_a?(String)
24
+ raise ArgumentError,
25
+ "Profile YAML must contain a 'name' string key: #{path}"
26
+ end
27
+
28
+ new(
29
+ name: data["name"],
30
+ description: data["description"],
31
+ rule_names: data["rules"] || [],
32
+ imports: data["import"] || [],
33
+ )
34
+ end
35
+
36
+ def resolve(registry, profiles_by_name = {}, visited: Set.new)
37
+ if visited.include?(name)
38
+ raise ArgumentError,
39
+ "Circular profile import detected: #{name}"
40
+ end
41
+
42
+ resolved = resolve_imports(registry, profiles_by_name,
43
+ visited: visited + [name])
44
+ resolved | resolve_names(registry)
45
+ end
46
+
47
+ private
48
+
49
+ def resolve_names(registry)
50
+ rule_names.filter_map do |name|
51
+ registry.all.find { |r| r.class.name == name }
52
+ end
53
+ end
54
+
55
+ def resolve_imports(registry, profiles_by_name, visited: Set.new)
56
+ imports.flat_map do |import_name|
57
+ imported = profiles_by_name[import_name]
58
+ next [] unless imported
59
+
60
+ imported.resolve(registry, profiles_by_name, visited: visited)
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Model
5
+ module Validation
6
+ # Instance-based rule registry. Register rule classes, look up by
7
+ # code or category, and instantiate all registered rules for
8
+ # validation runs.
9
+ class Registry
10
+ def initialize
11
+ @rules = []
12
+ @mutex = Mutex.new
13
+ @all = nil
14
+ end
15
+
16
+ def register(rule_class)
17
+ @mutex.synchronize do
18
+ return if @rules.include?(rule_class)
19
+
20
+ @rules << rule_class
21
+ @all = nil
22
+ end
23
+ end
24
+
25
+ def auto_discover(dir, pattern: "**/*_rule.rb")
26
+ Dir.glob(File.join(dir, pattern)).each { |path| require path }
27
+ end
28
+
29
+ def all
30
+ @mutex.synchronize do
31
+ @all ||= @rules.map(&:new)
32
+ end
33
+ end
34
+
35
+ def for_category(category)
36
+ all.select { |r| r.category == category }
37
+ end
38
+
39
+ def find(code)
40
+ all.find { |r| r.code == code }
41
+ end
42
+
43
+ def reset!
44
+ @mutex.synchronize do
45
+ @rules.clear
46
+ @all = nil
47
+ end
48
+ end
49
+
50
+ def rule_classes
51
+ @rules.dup
52
+ end
53
+
54
+ def size
55
+ @rules.size
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Model
5
+ module Validation
6
+ # Abstract base class for validation remediation. Subclass and
7
+ # override {#id}, {#targets}, {#applicable?}, {#fix}, and
8
+ # {#preview} to implement auto-fix logic for specific issue codes.
9
+ class Remediation
10
+ def id
11
+ nil
12
+ end
13
+
14
+ def targets
15
+ nil
16
+ end
17
+
18
+ def applicable?(_context, _report)
19
+ true
20
+ end
21
+
22
+ def fix(_context, _report)
23
+ raise NotImplementedError,
24
+ "#{self.class}#fix must be implemented by subclass"
25
+ end
26
+
27
+ def preview(_context, _report)
28
+ nil
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Model
5
+ module Validation
6
+ # Result of a remediation fix attempt. Serializable to JSON.
7
+ class RemediationResult < Lutaml::Model::Serializable
8
+ attribute :success, :boolean
9
+ attribute :message, :string
10
+ attribute :fixed_codes, :string, collection: true
11
+
12
+ json do
13
+ map "success", to: :success
14
+ map "message", to: :message
15
+ map "fixed_codes", to: :fixed_codes
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "concerns/has_issues"
5
+ require_relative "issue"
6
+ require_relative "layer_result"
7
+
8
+ module Lutaml
9
+ module Model
10
+ module Validation
11
+ class Report < Lutaml::Model::Serializable
12
+ include HasIssues
13
+
14
+ attribute :source, :string
15
+ attribute :timestamp, :string
16
+ attribute :valid, :boolean
17
+ attribute :duration_ms, :integer
18
+ attribute :layers, LayerResult, collection: true
19
+
20
+ json do
21
+ map "source", to: :source
22
+ map "timestamp", to: :timestamp
23
+ map "valid", to: :valid
24
+ map "duration_ms", to: :duration_ms
25
+ map "layers", to: :layers
26
+ end
27
+
28
+ def initialize(attributes = {})
29
+ super
30
+ self.timestamp ||= Time.now.utc.iso8601
31
+ end
32
+
33
+ def issues
34
+ layers ? layers.flat_map(&:issues) : []
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Model
5
+ module Validation
6
+ # Abstract base class for validation rules. Subclass and override
7
+ # {#code}, {#category}, {#severity}, {#applicable?}, and {#check}
8
+ # to implement domain-specific validation logic.
9
+ #
10
+ # Use the private {#issue} helper inside {#check} to create issues
11
+ # that inherit the rule's severity and code by default.
12
+ class Rule
13
+ def code
14
+ nil
15
+ end
16
+
17
+ def category
18
+ :general
19
+ end
20
+
21
+ def severity
22
+ "error"
23
+ end
24
+
25
+ def applicable?(_context)
26
+ true
27
+ end
28
+
29
+ def check(_context)
30
+ []
31
+ end
32
+
33
+ def needs_deferred?
34
+ false
35
+ end
36
+
37
+ def collect(_element, _context); end
38
+
39
+ def complete(_context)
40
+ []
41
+ end
42
+
43
+ private
44
+
45
+ def issue(message, location: nil, line: nil, suggestion: nil,
46
+ severity: nil, code: nil)
47
+ Issue.new(
48
+ severity: severity || self.severity,
49
+ code: code || self.code,
50
+ message: message,
51
+ location: location,
52
+ line: line,
53
+ suggestion: suggestion,
54
+ )
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -11,7 +11,8 @@ module Lutaml
11
11
 
12
12
  begin
13
13
  if value.respond_to?(:validate)
14
- errors.concat(value.validate)
14
+ sub_errors = value.validate
15
+ errors.concat(sub_errors) if sub_errors.is_a?(Array)
15
16
  else
16
17
  resolver = Lutaml::Model::Services::DefaultValueResolver.new(
17
18
  attr, register, self