lutaml-store 0.1.1

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 (110) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/main.yml +27 -0
  3. data/.gitignore +12 -0
  4. data/.rspec +3 -0
  5. data/.rubocop.yml +10 -0
  6. data/.rubocop_todo.yml +450 -0
  7. data/CLAUDE.md +57 -0
  8. data/CODE_OF_CONDUCT.md +132 -0
  9. data/CORRECTED_HTTP_CACHE_IMPLEMENTATION.md +209 -0
  10. data/CORRECTED_HTTP_CACHE_PLAN.md +164 -0
  11. data/Gemfile +15 -0
  12. data/Gemfile.lock +220 -0
  13. data/README.adoc +1430 -0
  14. data/Rakefile +12 -0
  15. data/TODO.impl/0-lutaml-store-self-quality.md +112 -0
  16. data/TODO.impl/1-lutaml-hal-migration.md +60 -0
  17. data/TODO.impl/2-glossarist-migration.md +359 -0
  18. data/TODO.impl/3-lutaml-jsonschema-migration.md +273 -0
  19. data/bin/console +11 -0
  20. data/bin/setup +8 -0
  21. data/demo/Gemfile +15 -0
  22. data/demo/Gemfile.lock +61 -0
  23. data/demo/README.adoc +301 -0
  24. data/demo/data/vcards/co/contact_10_thompson.data +1 -0
  25. data/demo/data/vcards/co/contact_10_thompson.meta +1 -0
  26. data/demo/data/vcards/co/contact_1_doe.data +1 -0
  27. data/demo/data/vcards/co/contact_1_doe.meta +1 -0
  28. data/demo/data/vcards/co/contact_2_smith.data +1 -0
  29. data/demo/data/vcards/co/contact_2_smith.meta +1 -0
  30. data/demo/data/vcards/co/contact_3_johnson.data +1 -0
  31. data/demo/data/vcards/co/contact_3_johnson.meta +1 -0
  32. data/demo/data/vcards/co/contact_4_garcia.data +1 -0
  33. data/demo/data/vcards/co/contact_4_garcia.meta +1 -0
  34. data/demo/data/vcards/co/contact_5_wilson.data +1 -0
  35. data/demo/data/vcards/co/contact_5_wilson.meta +1 -0
  36. data/demo/data/vcards/co/contact_6_brown.data +1 -0
  37. data/demo/data/vcards/co/contact_6_brown.meta +1 -0
  38. data/demo/data/vcards/co/contact_7_davis.data +1 -0
  39. data/demo/data/vcards/co/contact_7_davis.meta +1 -0
  40. data/demo/data/vcards/co/contact_8_anderson.data +1 -0
  41. data/demo/data/vcards/co/contact_8_anderson.meta +1 -0
  42. data/demo/data/vcards/co/contact_9_taylor.data +1 -0
  43. data/demo/data/vcards/co/contact_9_taylor.meta +1 -0
  44. data/demo/data/vcards.db +0 -0
  45. data/demo/pottery_class_demo.rb +164 -0
  46. data/demo/vcard_models.rb +140 -0
  47. data/demo/vcard_store_demo.rb +526 -0
  48. data/lib/lutaml/store/adapter/base.rb +65 -0
  49. data/lib/lutaml/store/adapter/filesystem.rb +288 -0
  50. data/lib/lutaml/store/adapter/memory.rb +225 -0
  51. data/lib/lutaml/store/adapter/sqlite.rb +193 -0
  52. data/lib/lutaml/store/adapter.rb +12 -0
  53. data/lib/lutaml/store/attribute_updater.rb +198 -0
  54. data/lib/lutaml/store/basic_store.rb +190 -0
  55. data/lib/lutaml/store/cache.rb +108 -0
  56. data/lib/lutaml/store/cache_store.rb +282 -0
  57. data/lib/lutaml/store/composite_model_handler.rb +169 -0
  58. data/lib/lutaml/store/compression.rb +137 -0
  59. data/lib/lutaml/store/config.rb +178 -0
  60. data/lib/lutaml/store/database_store.rb +425 -0
  61. data/lib/lutaml/store/events.rb +92 -0
  62. data/lib/lutaml/store/format/base.rb +33 -0
  63. data/lib/lutaml/store/format/json.rb +25 -0
  64. data/lib/lutaml/store/format/jsonl.rb +37 -0
  65. data/lib/lutaml/store/format/marshal_format.rb +37 -0
  66. data/lib/lutaml/store/format/yaml.rb +29 -0
  67. data/lib/lutaml/store/format/yamls.rb +35 -0
  68. data/lib/lutaml/store/format.rb +33 -0
  69. data/lib/lutaml/store/http_cache.rb +279 -0
  70. data/lib/lutaml/store/http_cache_config.rb +53 -0
  71. data/lib/lutaml/store/http_cache_entry.rb +69 -0
  72. data/lib/lutaml/store/http_header_processor.rb +175 -0
  73. data/lib/lutaml/store/integrity.rb +102 -0
  74. data/lib/lutaml/store/model_registration.rb +75 -0
  75. data/lib/lutaml/store/model_registry.rb +123 -0
  76. data/lib/lutaml/store/model_serializer.rb +69 -0
  77. data/lib/lutaml/store/monitor.rb +192 -0
  78. data/lib/lutaml/store/storage_key.rb +40 -0
  79. data/lib/lutaml/store/version.rb +7 -0
  80. data/lib/lutaml/store.rb +41 -0
  81. data/lutaml-store.gemspec +35 -0
  82. data/plan.adoc +606 -0
  83. data/sig/lutaml/store.rbs +6 -0
  84. data/spec/lutaml/store/adapter_interface_spec.rb +89 -0
  85. data/spec/lutaml/store/anti_pattern_guard_spec.rb +35 -0
  86. data/spec/lutaml/store/anti_pattern_spec.rb +78 -0
  87. data/spec/lutaml/store/autoload_spec.rb +34 -0
  88. data/spec/lutaml/store/cache_store_spec.rb +271 -0
  89. data/spec/lutaml/store/compression_spec.rb +78 -0
  90. data/spec/lutaml/store/config_enhanced_spec.rb +158 -0
  91. data/spec/lutaml/store/corrected_http_cache_integration_spec.rb +336 -0
  92. data/spec/lutaml/store/custom_serializer_spec.rb +108 -0
  93. data/spec/lutaml/store/database_store_spec.rb +279 -0
  94. data/spec/lutaml/store/file_io_spec.rb +219 -0
  95. data/spec/lutaml/store/format_round_trip_spec.rb +110 -0
  96. data/spec/lutaml/store/format_spec.rb +70 -0
  97. data/spec/lutaml/store/http_cache_entry_spec.rb +203 -0
  98. data/spec/lutaml/store/http_cache_hal_integration_spec.rb +404 -0
  99. data/spec/lutaml/store/http_cache_spec.rb +422 -0
  100. data/spec/lutaml/store/http_header_processor_spec.rb +290 -0
  101. data/spec/lutaml/store/import_spec.rb +90 -0
  102. data/spec/lutaml/store/integrity_spec.rb +157 -0
  103. data/spec/lutaml/store/key_collision_serializer_spec.rb +98 -0
  104. data/spec/lutaml/store/load_save_spec.rb +107 -0
  105. data/spec/lutaml/store/lutaml_model_integration_spec.rb +291 -0
  106. data/spec/lutaml/store/model_serializer_spec.rb +140 -0
  107. data/spec/lutaml/store/store_spec.rb +182 -0
  108. data/spec/lutaml/store_spec.rb +21 -0
  109. data/spec/spec_helper.rb +16 -0
  110. metadata +166 -0
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "digest"
5
+ require "time"
6
+
7
+ module Lutaml
8
+ module Store
9
+ class HttpHeaderProcessor
10
+ # Parse Cache-Control header according to RFC 7234
11
+ def self.parse_cache_control(header_value)
12
+ return {} unless header_value
13
+
14
+ directives = {}
15
+ header_value.split(",").each do |directive|
16
+ key, value = directive.strip.split("=", 2)
17
+ key = key.strip.downcase
18
+
19
+ if value
20
+ # Handle quoted values
21
+ value = value.strip.gsub(/^"(.*)"$/, '\1')
22
+ directives[key] = value.match?(/^\d+$/) ? value.to_i : value
23
+ else
24
+ directives[key] = true
25
+ end
26
+ end
27
+ directives
28
+ end
29
+
30
+ # Calculate expiry time based on HTTP headers
31
+ def self.calculate_expiry(response_headers, cached_at, default_ttl)
32
+ cache_control = parse_cache_control(response_headers["cache-control"])
33
+
34
+ # Check for explicit expiry
35
+ if (expires_header = response_headers["expires"])
36
+ begin
37
+ return Time.parse(expires_header)
38
+ rescue StandardError
39
+ nil
40
+ end
41
+ end
42
+
43
+ # Check for max-age
44
+ if (max_age = cache_control["max-age"])
45
+ return cached_at + max_age
46
+ end
47
+
48
+ # Fall back to default TTL
49
+ cached_at + default_ttl
50
+ end
51
+
52
+ # Determine if response should be cached
53
+ def self.should_cache_response?(status_code, response_headers)
54
+ return false if status_code < 200 || status_code >= 400
55
+
56
+ cache_control = parse_cache_control(response_headers["cache-control"])
57
+ return false if cache_control["no-store"]
58
+ return false if cache_control["private"] # Unless explicitly allowing private
59
+
60
+ true
61
+ end
62
+
63
+ # Generate cache key from request details
64
+ def self.generate_cache_key(method, url, vary_headers = {}, ignore_params = [])
65
+ uri = URI.parse(url)
66
+
67
+ # Normalize query parameters
68
+ if uri.query
69
+ params = URI.decode_www_form(uri.query)
70
+ # Filter out ignored parameters
71
+ params = params.reject { |key, _| ignore_params.include?(key) }
72
+ params = params.sort
73
+ uri.query = params.empty? ? nil : URI.encode_www_form(params)
74
+ end
75
+
76
+ base_key = "#{method.upcase}:#{uri}"
77
+
78
+ # Add vary headers to key if present
79
+ if vary_headers.any?
80
+ vary_suffix = vary_headers.sort.map { |k, v| "#{k}:#{v}" }.join("|")
81
+ base_key += "|#{Digest::SHA256.hexdigest(vary_suffix)[0..8]}"
82
+ end
83
+
84
+ base_key
85
+ end
86
+
87
+ # Parse Vary header
88
+ def self.parse_vary_header(vary_header)
89
+ return [] unless vary_header
90
+
91
+ vary_header.split(",").map(&:strip).map(&:downcase)
92
+ end
93
+
94
+ # Extract vary headers from request headers
95
+ def self.extract_vary_headers(request_headers, vary_header_names)
96
+ return {} if vary_header_names.empty?
97
+
98
+ vary_headers = {}
99
+ vary_header_names.each do |header_name|
100
+ normalized_name = header_name.downcase
101
+ # Find header with case-insensitive matching
102
+ actual_header = request_headers.find { |k, _| k.downcase == normalized_name }
103
+ vary_headers[normalized_name] = actual_header[1] if actual_header
104
+ end
105
+ vary_headers
106
+ end
107
+
108
+ # Check if response is fresh based on age
109
+ def self.fresh?(cached_at, max_age, expires_at)
110
+ return false if expires_at && Time.now > expires_at
111
+ return false if max_age && (Time.now - cached_at) > max_age
112
+
113
+ true
114
+ end
115
+
116
+ # Build conditional request headers
117
+ def self.build_conditional_headers(cache_entry, original_headers)
118
+ conditional_headers = original_headers.dup
119
+
120
+ conditional_headers["If-None-Match"] = cache_entry.etag if cache_entry.etag
121
+
122
+ conditional_headers["If-Modified-Since"] = cache_entry.last_modified.httpdate if cache_entry.last_modified
123
+
124
+ conditional_headers
125
+ end
126
+
127
+ # Parse Last-Modified header
128
+ def self.parse_last_modified(header_value)
129
+ return nil unless header_value
130
+
131
+ begin
132
+ Time.parse(header_value)
133
+ rescue StandardError
134
+ nil
135
+ end
136
+ end
137
+
138
+ # Check if cache entry matches request (considering Vary)
139
+ def self.cache_entry_matches?(cache_entry, request_headers, config)
140
+ return true if cache_entry.vary_headers.empty?
141
+
142
+ # Extract vary headers from current request
143
+ current_vary = extract_vary_headers(request_headers, cache_entry.vary_headers)
144
+
145
+ # Compare with cached vary headers
146
+ cache_entry.vary_headers.each do |header_name|
147
+ cached_value = cache_entry.request_headers[header_name.downcase]
148
+ current_value = current_vary[header_name.downcase]
149
+
150
+ # Skip comparison for ignored headers
151
+ next if config.should_ignore_vary_header?(header_name)
152
+
153
+ return false if cached_value != current_value
154
+ end
155
+
156
+ true
157
+ end
158
+
159
+ # Normalize header name for consistent storage
160
+ def self.normalize_header_name(name)
161
+ name.to_s.downcase.strip
162
+ end
163
+
164
+ # Check if response has caching directives
165
+ def self.has_caching_directives?(response_headers)
166
+ return true if response_headers["cache-control"]
167
+ return true if response_headers["expires"]
168
+ return true if response_headers["etag"]
169
+ return true if response_headers["last-modified"]
170
+
171
+ false
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Lutaml
6
+ module Store
7
+ class Integrity
8
+ class IntegrityError < StandardError; end
9
+ class CorruptionError < IntegrityError; end
10
+ class ChecksumMismatchError < IntegrityError; end
11
+
12
+ def self.calculate_checksum(data, algorithm = "sha256")
13
+ # Convert data to string if it's not already
14
+ string_data = data.is_a?(String) ? data : data.to_s
15
+
16
+ case algorithm.to_s.downcase
17
+ when "md5"
18
+ Digest::MD5.hexdigest(string_data)
19
+ when "sha1"
20
+ Digest::SHA1.hexdigest(string_data)
21
+ when "sha256"
22
+ Digest::SHA256.hexdigest(string_data)
23
+ when "sha512"
24
+ Digest::SHA512.hexdigest(string_data)
25
+ else
26
+ raise ArgumentError, "Unsupported checksum algorithm: #{algorithm}"
27
+ end
28
+ end
29
+
30
+ def self.verify_checksum(data, expected_checksum, algorithm = "sha256")
31
+ actual_checksum = calculate_checksum(data, algorithm)
32
+ unless actual_checksum == expected_checksum
33
+ raise ChecksumMismatchError,
34
+ "Checksum mismatch: expected #{expected_checksum}, got #{actual_checksum}"
35
+ end
36
+ true
37
+ end
38
+
39
+ def self.create_integrity_metadata(data, algorithm = "sha256")
40
+ string_data = data.is_a?(String) ? data : data.to_s
41
+ {
42
+ checksum: calculate_checksum(data, algorithm),
43
+ algorithm: algorithm,
44
+ size: string_data.bytesize,
45
+ created_at: Time.now.utc.iso8601,
46
+ version: "1.0"
47
+ }
48
+ end
49
+
50
+ def self.verify_integrity_metadata(data, metadata)
51
+ string_data = data.is_a?(String) ? data : data.to_s
52
+
53
+ # Verify size
54
+ if metadata[:size] && string_data.bytesize != metadata[:size]
55
+ raise CorruptionError,
56
+ "Size mismatch: expected #{metadata[:size]}, got #{string_data.bytesize}"
57
+ end
58
+
59
+ # Verify checksum
60
+ verify_checksum(data, metadata[:checksum], metadata[:algorithm]) if metadata[:checksum] && metadata[:algorithm]
61
+
62
+ true
63
+ end
64
+
65
+ def self.repair_data(corrupted_data, backup_data = nil)
66
+ # Basic repair attempt - in a real implementation, this could be more sophisticated
67
+ return backup_data if backup_data && valid_data?(backup_data)
68
+
69
+ # Try to clean up common corruption patterns
70
+ cleaned_data = corrupted_data.dup
71
+
72
+ # Remove null bytes that might have been introduced
73
+ cleaned_data.gsub!("\x00", "")
74
+
75
+ # Try to fix truncated JSON/YAML
76
+ if cleaned_data.strip.start_with?("{") && !cleaned_data.strip.end_with?("}")
77
+ cleaned_data += "}"
78
+ elsif cleaned_data.strip.start_with?("[") && !cleaned_data.strip.end_with?("]")
79
+ cleaned_data += "]"
80
+ end
81
+
82
+ cleaned_data
83
+ end
84
+
85
+ def self.valid_data?(data)
86
+ return false if data.nil? || data.empty?
87
+ return false if data.include?("\x00") # Contains null bytes
88
+
89
+ # Basic validation - data should be valid UTF-8 or binary
90
+ begin
91
+ data.encode("UTF-8")
92
+ true
93
+ rescue Encoding::UndefinedConversionError
94
+ # Might be binary data, which is also valid
95
+ true
96
+ rescue StandardError
97
+ false
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Store
5
+ # Represents a single model registration with its metadata
6
+ class ModelRegistration
7
+ attr_reader :model_class, :key_field, :polymorphic_class_key, :serializer, :dir
8
+
9
+ def initialize(model_class, key_field, polymorphic_class_key: nil, serializer: nil, dir: nil)
10
+ @model_class = model_class
11
+ @key_field = key_field.to_sym
12
+ @polymorphic_class_key = polymorphic_class_key&.to_sym
13
+ @serializer = serializer
14
+ @dir = dir
15
+ validate!
16
+ end
17
+
18
+ # Extract key value from model instance
19
+ def extract_key(model)
20
+ key_value = model.public_send(@key_field)
21
+ raise InvalidKeyError, "Key field '#{@key_field}' is nil for #{@model_class}" if key_value.nil?
22
+
23
+ key_value.to_s
24
+ end
25
+
26
+ # Check if registration supports polymorphism
27
+ def polymorphic?
28
+ !@polymorphic_class_key.nil?
29
+ end
30
+
31
+ # Extract polymorphic class from model instance
32
+ def extract_polymorphic_class(model)
33
+ return @model_class.name unless polymorphic?
34
+
35
+ polymorphic_value = model.public_send(@polymorphic_class_key)
36
+ polymorphic_value || @model_class.name
37
+ end
38
+
39
+ # Generate storage key for model
40
+ def generate_storage_key(model)
41
+ StorageKey.new(model.class.name, extract_key(model))
42
+ end
43
+
44
+ # Generate storage key from key value and optional polymorphic class
45
+ def generate_storage_key_from_value(key_value, polymorphic_class = nil)
46
+ class_name = polymorphic_class || @model_class.name
47
+ StorageKey.new(class_name, key_value)
48
+ end
49
+
50
+ # Check if model class matches this registration (including inheritance)
51
+ def matches_model?(model_class)
52
+ model_class <= @model_class
53
+ end
54
+
55
+ private
56
+
57
+ def validate!
58
+ # Check if key field exists on model
59
+ unless @model_class.method_defined?(@key_field) ||
60
+ @model_class.private_method_defined?(@key_field)
61
+ raise ConfigurationError,
62
+ "Key field '#{@key_field}' does not exist on #{@model_class}"
63
+ end
64
+
65
+ # Check if polymorphic class key exists when specified
66
+ if polymorphic? &&
67
+ !@model_class.method_defined?(@polymorphic_class_key) &&
68
+ !@model_class.private_method_defined?(@polymorphic_class_key)
69
+ raise ConfigurationError,
70
+ "Polymorphic class key '#{@polymorphic_class_key}' does not exist on #{@model_class}"
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Store
5
+ # Manages registered models with their key fields and polymorphic configurations
6
+ class ModelRegistry
7
+ def initialize(model_configs = [])
8
+ @registrations = {}
9
+ register_models(model_configs)
10
+ end
11
+
12
+ # Register a single model
13
+ def register(model_class, key_field, **options)
14
+ registration = ModelRegistration.new(model_class, key_field, **options)
15
+ @registrations[model_class] = registration
16
+ registration
17
+ end
18
+
19
+ # Register multiple models from configuration array
20
+ def register_models(model_configs)
21
+ model_configs.each do |config|
22
+ raise ConfigurationError, "Invalid model configuration: #{config}" unless config.is_a?(Hash)
23
+
24
+ model_class = config[:model]
25
+ key_field = config[:key]
26
+ register(model_class, key_field, **config.except(:model, :key))
27
+ end
28
+ end
29
+
30
+ # Find registration for model class or its superclass
31
+ def find_registration(model_class)
32
+ # First try exact match
33
+ return @registrations[model_class] if @registrations[model_class]
34
+
35
+ # Then try inheritance chain
36
+ @registrations.each_value do |registration|
37
+ return registration if registration.matches_model?(model_class)
38
+ end
39
+
40
+ nil
41
+ end
42
+
43
+ # Check if model is registered
44
+ def registered?(model_class)
45
+ !find_registration(model_class).nil?
46
+ end
47
+
48
+ # Get registration for model class (raises error if not found)
49
+ def registration_for(model_class)
50
+ registration = find_registration(model_class)
51
+ unless registration
52
+ raise ModelNotRegisteredError,
53
+ "Model #{model_class} is not registered. " \
54
+ "Register it with: models: [{ model: #{model_class}, key: :key_field }]"
55
+ end
56
+ registration
57
+ end
58
+
59
+ # Get all registered model classes
60
+ def registered_models
61
+ @registrations.keys
62
+ end
63
+
64
+ # Get all registrations
65
+ def registrations
66
+ @registrations.values
67
+ end
68
+
69
+ # Check if any models are registered
70
+ def empty?
71
+ @registrations.empty?
72
+ end
73
+
74
+ # Get count of registered models
75
+ def count
76
+ @registrations.size
77
+ end
78
+
79
+ # Clear all registrations
80
+ def clear
81
+ @registrations.clear
82
+ end
83
+
84
+ # Find models that are registered and nested within other models
85
+ def find_composite_models(model)
86
+ composite_models = {}
87
+
88
+ model.class.attributes.each_key do |attr_name|
89
+ attr_value = model.public_send(attr_name)
90
+ next if attr_value.nil?
91
+
92
+ add_composite_entry(composite_models, attr_name, attr_value) if attr_value.is_a?(Object) && registered?(attr_value.class)
93
+
94
+ next unless attr_value.is_a?(Array)
95
+
96
+ attr_value.each_with_index do |item, index|
97
+ next unless item.is_a?(Object) && registered?(item.class)
98
+
99
+ add_composite_entry(composite_models, "#{attr_name}.#{index}", item)
100
+ end
101
+ end
102
+
103
+ composite_models
104
+ rescue NoMethodError
105
+ {}
106
+ end
107
+
108
+ private
109
+
110
+ def add_composite_entry(composite_models, attr_path, model_instance)
111
+ registration = find_registration(model_instance.class)
112
+ key_value = model_instance.public_send(registration.key_field)
113
+ return if key_value.nil?
114
+
115
+ composite_models[attr_path] = {
116
+ model: model_instance,
117
+ registration: registration,
118
+ key_value: key_value.to_s
119
+ }
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Store
5
+ # Single point of serialization/deserialization for Lutaml::Model objects.
6
+ # All registered models are Lutaml::Model::Serializable, so they uniformly
7
+ # support to_hash / from_hash. No duck-typing needed.
8
+ class ModelSerializer
9
+ METADATA_KEY = "_class"
10
+ COMPOSITE_KEY = "_composite_models"
11
+
12
+ def serialize(model, registration = nil)
13
+ hash_data = if registration&.serializer
14
+ registration.serializer.serialize(model)
15
+ else
16
+ extract_hash(model)
17
+ end
18
+ hash_data.merge(METADATA_KEY => model.class.name)
19
+ end
20
+
21
+ def deserialize(data, expected_class, registration = nil)
22
+ validate_data!(data, expected_class)
23
+
24
+ model_class = resolve_class(data[METADATA_KEY])
25
+ validate_polymorphic_compatibility!(model_class, expected_class)
26
+
27
+ model_data = data.except(METADATA_KEY, COMPOSITE_KEY)
28
+ if registration&.serializer
29
+ registration.serializer.deserialize(model_data, model_class)
30
+ else
31
+ build_model(model_class, model_data)
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def extract_hash(model)
38
+ model.to_hash
39
+ rescue NoMethodError
40
+ model.to_h
41
+ end
42
+
43
+ def build_model(model_class, data)
44
+ model_class.from_hash(data)
45
+ rescue NoMethodError
46
+ model_class.from_h(data)
47
+ end
48
+
49
+ def validate_data!(data, expected_class)
50
+ return if data.is_a?(Hash) && data[METADATA_KEY]
51
+
52
+ raise CompositeModelError, "Invalid serialized data for #{expected_class}"
53
+ end
54
+
55
+ def resolve_class(class_name)
56
+ Object.const_get(class_name)
57
+ rescue NameError
58
+ raise CompositeModelError, "Cannot resolve class #{class_name}"
59
+ end
60
+
61
+ def validate_polymorphic_compatibility!(model_class, expected_class)
62
+ return if model_class <= expected_class
63
+
64
+ raise PolymorphicUpdateError,
65
+ "Stored #{model_class} is not compatible with #{expected_class}"
66
+ end
67
+ end
68
+ end
69
+ end