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.
- checksums.yaml +7 -0
- data/.github/workflows/main.yml +27 -0
- data/.gitignore +12 -0
- data/.rspec +3 -0
- data/.rubocop.yml +10 -0
- data/.rubocop_todo.yml +450 -0
- data/CLAUDE.md +57 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/CORRECTED_HTTP_CACHE_IMPLEMENTATION.md +209 -0
- data/CORRECTED_HTTP_CACHE_PLAN.md +164 -0
- data/Gemfile +15 -0
- data/Gemfile.lock +220 -0
- data/README.adoc +1430 -0
- data/Rakefile +12 -0
- data/TODO.impl/0-lutaml-store-self-quality.md +112 -0
- data/TODO.impl/1-lutaml-hal-migration.md +60 -0
- data/TODO.impl/2-glossarist-migration.md +359 -0
- data/TODO.impl/3-lutaml-jsonschema-migration.md +273 -0
- data/bin/console +11 -0
- data/bin/setup +8 -0
- data/demo/Gemfile +15 -0
- data/demo/Gemfile.lock +61 -0
- data/demo/README.adoc +301 -0
- data/demo/data/vcards/co/contact_10_thompson.data +1 -0
- data/demo/data/vcards/co/contact_10_thompson.meta +1 -0
- data/demo/data/vcards/co/contact_1_doe.data +1 -0
- data/demo/data/vcards/co/contact_1_doe.meta +1 -0
- data/demo/data/vcards/co/contact_2_smith.data +1 -0
- data/demo/data/vcards/co/contact_2_smith.meta +1 -0
- data/demo/data/vcards/co/contact_3_johnson.data +1 -0
- data/demo/data/vcards/co/contact_3_johnson.meta +1 -0
- data/demo/data/vcards/co/contact_4_garcia.data +1 -0
- data/demo/data/vcards/co/contact_4_garcia.meta +1 -0
- data/demo/data/vcards/co/contact_5_wilson.data +1 -0
- data/demo/data/vcards/co/contact_5_wilson.meta +1 -0
- data/demo/data/vcards/co/contact_6_brown.data +1 -0
- data/demo/data/vcards/co/contact_6_brown.meta +1 -0
- data/demo/data/vcards/co/contact_7_davis.data +1 -0
- data/demo/data/vcards/co/contact_7_davis.meta +1 -0
- data/demo/data/vcards/co/contact_8_anderson.data +1 -0
- data/demo/data/vcards/co/contact_8_anderson.meta +1 -0
- data/demo/data/vcards/co/contact_9_taylor.data +1 -0
- data/demo/data/vcards/co/contact_9_taylor.meta +1 -0
- data/demo/data/vcards.db +0 -0
- data/demo/pottery_class_demo.rb +164 -0
- data/demo/vcard_models.rb +140 -0
- data/demo/vcard_store_demo.rb +526 -0
- data/lib/lutaml/store/adapter/base.rb +65 -0
- data/lib/lutaml/store/adapter/filesystem.rb +288 -0
- data/lib/lutaml/store/adapter/memory.rb +225 -0
- data/lib/lutaml/store/adapter/sqlite.rb +193 -0
- data/lib/lutaml/store/adapter.rb +12 -0
- data/lib/lutaml/store/attribute_updater.rb +198 -0
- data/lib/lutaml/store/basic_store.rb +190 -0
- data/lib/lutaml/store/cache.rb +108 -0
- data/lib/lutaml/store/cache_store.rb +282 -0
- data/lib/lutaml/store/composite_model_handler.rb +169 -0
- data/lib/lutaml/store/compression.rb +137 -0
- data/lib/lutaml/store/config.rb +178 -0
- data/lib/lutaml/store/database_store.rb +425 -0
- data/lib/lutaml/store/events.rb +92 -0
- data/lib/lutaml/store/format/base.rb +33 -0
- data/lib/lutaml/store/format/json.rb +25 -0
- data/lib/lutaml/store/format/jsonl.rb +37 -0
- data/lib/lutaml/store/format/marshal_format.rb +37 -0
- data/lib/lutaml/store/format/yaml.rb +29 -0
- data/lib/lutaml/store/format/yamls.rb +35 -0
- data/lib/lutaml/store/format.rb +33 -0
- data/lib/lutaml/store/http_cache.rb +279 -0
- data/lib/lutaml/store/http_cache_config.rb +53 -0
- data/lib/lutaml/store/http_cache_entry.rb +69 -0
- data/lib/lutaml/store/http_header_processor.rb +175 -0
- data/lib/lutaml/store/integrity.rb +102 -0
- data/lib/lutaml/store/model_registration.rb +75 -0
- data/lib/lutaml/store/model_registry.rb +123 -0
- data/lib/lutaml/store/model_serializer.rb +69 -0
- data/lib/lutaml/store/monitor.rb +192 -0
- data/lib/lutaml/store/storage_key.rb +40 -0
- data/lib/lutaml/store/version.rb +7 -0
- data/lib/lutaml/store.rb +41 -0
- data/lutaml-store.gemspec +35 -0
- data/plan.adoc +606 -0
- data/sig/lutaml/store.rbs +6 -0
- data/spec/lutaml/store/adapter_interface_spec.rb +89 -0
- data/spec/lutaml/store/anti_pattern_guard_spec.rb +35 -0
- data/spec/lutaml/store/anti_pattern_spec.rb +78 -0
- data/spec/lutaml/store/autoload_spec.rb +34 -0
- data/spec/lutaml/store/cache_store_spec.rb +271 -0
- data/spec/lutaml/store/compression_spec.rb +78 -0
- data/spec/lutaml/store/config_enhanced_spec.rb +158 -0
- data/spec/lutaml/store/corrected_http_cache_integration_spec.rb +336 -0
- data/spec/lutaml/store/custom_serializer_spec.rb +108 -0
- data/spec/lutaml/store/database_store_spec.rb +279 -0
- data/spec/lutaml/store/file_io_spec.rb +219 -0
- data/spec/lutaml/store/format_round_trip_spec.rb +110 -0
- data/spec/lutaml/store/format_spec.rb +70 -0
- data/spec/lutaml/store/http_cache_entry_spec.rb +203 -0
- data/spec/lutaml/store/http_cache_hal_integration_spec.rb +404 -0
- data/spec/lutaml/store/http_cache_spec.rb +422 -0
- data/spec/lutaml/store/http_header_processor_spec.rb +290 -0
- data/spec/lutaml/store/import_spec.rb +90 -0
- data/spec/lutaml/store/integrity_spec.rb +157 -0
- data/spec/lutaml/store/key_collision_serializer_spec.rb +98 -0
- data/spec/lutaml/store/load_save_spec.rb +107 -0
- data/spec/lutaml/store/lutaml_model_integration_spec.rb +291 -0
- data/spec/lutaml/store/model_serializer_spec.rb +140 -0
- data/spec/lutaml/store/store_spec.rb +182 -0
- data/spec/lutaml/store_spec.rb +21 -0
- data/spec/spec_helper.rb +16 -0
- 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
|