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,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Lutaml
|
|
6
|
+
module Store
|
|
7
|
+
class Config
|
|
8
|
+
attr_reader :adapter_type, :adapter_options, :cache_enabled, :cache_max_size,
|
|
9
|
+
:cache_ttl, :monitoring_enabled, :async_events, :compression_enabled,
|
|
10
|
+
:compression_algorithm, :compression_level, :serialization_formats,
|
|
11
|
+
:validate_on_write
|
|
12
|
+
|
|
13
|
+
def initialize(adapter_type: :memory, adapter_options: {},
|
|
14
|
+
cache: {}, monitoring: {}, events: {},
|
|
15
|
+
compression: {}, serialization: {}, **)
|
|
16
|
+
@adapter_type = normalize_adapter_type(adapter_type)
|
|
17
|
+
@adapter_options = symbolize_keys(adapter_options)
|
|
18
|
+
|
|
19
|
+
cache_config = symbolize_keys(cache)
|
|
20
|
+
@cache_enabled = cache_config.fetch(:enabled, true)
|
|
21
|
+
@cache_max_size = cache_config.fetch(:max_size, 1000)
|
|
22
|
+
@cache_ttl = cache_config.fetch(:ttl, nil)
|
|
23
|
+
|
|
24
|
+
monitoring_config = symbolize_keys(monitoring)
|
|
25
|
+
@monitoring_enabled = monitoring_config.fetch(:enabled, false)
|
|
26
|
+
|
|
27
|
+
events_config = symbolize_keys(events)
|
|
28
|
+
@async_events = events_config.fetch(:async, false)
|
|
29
|
+
|
|
30
|
+
compression_config = symbolize_keys(compression)
|
|
31
|
+
@compression_enabled = compression_config.fetch(:enabled, false)
|
|
32
|
+
@compression_algorithm = compression_config.fetch(:algorithm, "gzip")
|
|
33
|
+
@compression_level = compression_config.fetch(:level, 6)
|
|
34
|
+
|
|
35
|
+
serialization_config = symbolize_keys(serialization)
|
|
36
|
+
@serialization_formats = serialization_config.fetch(:formats, %w[marshal hash json yaml xml toml])
|
|
37
|
+
@validate_on_write = serialization_config.fetch(:validate_on_write, false)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.from_file(file_path)
|
|
41
|
+
config_data = YAML.load_file(file_path)
|
|
42
|
+
lutaml_config = config_data["lutaml_store"] || config_data
|
|
43
|
+
from_hash(lutaml_config)
|
|
44
|
+
rescue Errno::ENOENT
|
|
45
|
+
raise ConfigurationError, "Configuration file not found: #{file_path}"
|
|
46
|
+
rescue Psych::SyntaxError => e
|
|
47
|
+
raise ConfigurationError, "Invalid YAML in configuration file: #{e.message}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.from_yaml(yaml_string)
|
|
51
|
+
config_data = YAML.safe_load(yaml_string)
|
|
52
|
+
lutaml_config = config_data["lutaml_store"] || config_data
|
|
53
|
+
from_hash(lutaml_config)
|
|
54
|
+
rescue Psych::SyntaxError => e
|
|
55
|
+
raise ConfigurationError, "Invalid YAML configuration: #{e.message}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def cache_enabled?
|
|
59
|
+
@cache_enabled
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def monitoring_enabled?
|
|
63
|
+
@monitoring_enabled
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def async_events?
|
|
67
|
+
@async_events
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def compression_enabled?
|
|
71
|
+
@compression_enabled
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def validate_on_write?
|
|
75
|
+
@validate_on_write
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def validate!
|
|
79
|
+
validate_adapter_config
|
|
80
|
+
validate_cache_config
|
|
81
|
+
validate_monitoring_config
|
|
82
|
+
validate_events_config
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def to_h
|
|
86
|
+
{
|
|
87
|
+
adapter: { type: @adapter_type, options: @adapter_options },
|
|
88
|
+
cache: { enabled: @cache_enabled, max_size: @cache_max_size, ttl: @cache_ttl },
|
|
89
|
+
monitoring: { enabled: @monitoring_enabled },
|
|
90
|
+
events: { async: @async_events },
|
|
91
|
+
compression: { enabled: @compression_enabled, algorithm: @compression_algorithm, level: @compression_level },
|
|
92
|
+
serialization: { formats: @serialization_formats, validate_on_write: @validate_on_write }
|
|
93
|
+
}
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
class << self
|
|
97
|
+
def from_hash(hash)
|
|
98
|
+
symbolized = symbolize_keys(hash)
|
|
99
|
+
adapter_config = symbolized[:adapter] || {}
|
|
100
|
+
new(
|
|
101
|
+
adapter_type: adapter_config[:type],
|
|
102
|
+
adapter_options: adapter_config[:options] || {},
|
|
103
|
+
cache: symbolized[:cache] || {},
|
|
104
|
+
monitoring: symbolized[:monitoring] || {},
|
|
105
|
+
events: symbolized[:events] || {},
|
|
106
|
+
compression: symbolized[:compression] || {},
|
|
107
|
+
serialization: symbolized[:serialization] || {}
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private :from_hash
|
|
112
|
+
|
|
113
|
+
def symbolize_keys(hash)
|
|
114
|
+
return hash unless hash.is_a?(Hash)
|
|
115
|
+
|
|
116
|
+
hash.each_with_object({}) do |(key, value), result|
|
|
117
|
+
new_key = key.to_sym
|
|
118
|
+
new_value = value.is_a?(Hash) ? symbolize_keys(value) : value
|
|
119
|
+
result[new_key] = new_value
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
def normalize_adapter_type(type)
|
|
127
|
+
case type
|
|
128
|
+
when Symbol then type
|
|
129
|
+
when String then type.to_sym
|
|
130
|
+
when Hash
|
|
131
|
+
type[:type]&.to_sym || type["type"]&.to_sym || :memory
|
|
132
|
+
else
|
|
133
|
+
:memory
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def validate_adapter_config
|
|
138
|
+
valid_adapters = %i[memory filesystem sqlite]
|
|
139
|
+
unless valid_adapters.include?(@adapter_type)
|
|
140
|
+
raise ConfigurationError,
|
|
141
|
+
"Invalid adapter type: #{@adapter_type}. " \
|
|
142
|
+
"Valid types: #{valid_adapters.join(", ")}"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
case @adapter_type
|
|
146
|
+
when :filesystem
|
|
147
|
+
raise ConfigurationError, "FileSystem adapter requires 'path' option" unless @adapter_options[:path]
|
|
148
|
+
when :sqlite
|
|
149
|
+
raise ConfigurationError, "SQLite adapter requires 'path' option" unless @adapter_options[:path]
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def validate_cache_config
|
|
154
|
+
raise ConfigurationError, "Cache max_size must be positive" if @cache_max_size && @cache_max_size <= 0
|
|
155
|
+
|
|
156
|
+
return unless @cache_ttl && @cache_ttl <= 0
|
|
157
|
+
|
|
158
|
+
raise ConfigurationError, "Cache TTL must be positive"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def validate_monitoring_config
|
|
162
|
+
return if [true, false].include?(@monitoring_enabled)
|
|
163
|
+
|
|
164
|
+
raise ConfigurationError, "Monitoring enabled must be boolean"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def validate_events_config
|
|
168
|
+
return if [true, false].include?(@async_events)
|
|
169
|
+
|
|
170
|
+
raise ConfigurationError, "Events async must be boolean"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def symbolize_keys(hash)
|
|
174
|
+
self.class.symbolize_keys(hash)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Store
|
|
5
|
+
# Store-centric API with model registry and database-style operations
|
|
6
|
+
class DatabaseStore
|
|
7
|
+
attr_reader :store, :registry, :composite_handler, :attribute_updater
|
|
8
|
+
|
|
9
|
+
def initialize(adapter:, models: [], **options)
|
|
10
|
+
@store = BasicStore.new(adapter_type: adapter, **options)
|
|
11
|
+
@registry = ModelRegistry.new(models)
|
|
12
|
+
@serializer = ModelSerializer.new
|
|
13
|
+
@composite_handler = CompositeModelHandler.new(@registry, @store, self, serializer: @serializer)
|
|
14
|
+
@attribute_updater = AttributeUpdater.new(@registry, @composite_handler)
|
|
15
|
+
|
|
16
|
+
validate_configuration!
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Save single model or array of models
|
|
20
|
+
def save(models)
|
|
21
|
+
models_array = Array(models)
|
|
22
|
+
saved_models = models_array.map { |model| save_single_model(model) }
|
|
23
|
+
|
|
24
|
+
@store.emit_event(:model_save, models: saved_models, count: saved_models.size)
|
|
25
|
+
models.is_a?(Array) ? saved_models : saved_models.first
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Fetch model by class and key field
|
|
29
|
+
def fetch(model:, **key_params)
|
|
30
|
+
registration = @registry.registration_for(model)
|
|
31
|
+
key_field = registration.key_field
|
|
32
|
+
key_value = key_params[key_field]
|
|
33
|
+
raise InvalidKeyError, "Key field '#{key_field}' not provided" if key_value.nil?
|
|
34
|
+
|
|
35
|
+
stored_data = find_stored_data(registration, model, key_value)
|
|
36
|
+
return nil unless stored_data
|
|
37
|
+
|
|
38
|
+
model_instance = @serializer.deserialize(stored_data, model, registration)
|
|
39
|
+
|
|
40
|
+
composite_references = stored_data["_composite_models"]
|
|
41
|
+
if composite_references
|
|
42
|
+
model_instance = @composite_handler.restore_composite_models(
|
|
43
|
+
model_instance, composite_references
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
@store.emit_event(:model_fetch, model: model_instance, key: key_value, source: :backend)
|
|
48
|
+
model_instance
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Update model with attributes array or block
|
|
52
|
+
def update(model:, attributes: nil, **key_params, &block)
|
|
53
|
+
current_model = fetch(model: model, **key_params)
|
|
54
|
+
raise ModelNotRegisteredError, "Model not found" unless current_model
|
|
55
|
+
|
|
56
|
+
updated_model = if block_given?
|
|
57
|
+
@attribute_updater.update_with_block(current_model, &block)
|
|
58
|
+
elsif attributes
|
|
59
|
+
if attributes.is_a?(Hash)
|
|
60
|
+
@attribute_updater.update_with_hash(current_model, attributes)
|
|
61
|
+
else
|
|
62
|
+
@attribute_updater.update_with_attributes(current_model, attributes)
|
|
63
|
+
end
|
|
64
|
+
else
|
|
65
|
+
raise ArgumentError, "Either attributes or block must be provided"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
save(updated_model)
|
|
69
|
+
|
|
70
|
+
@store.emit_event(:model_update,
|
|
71
|
+
model: updated_model,
|
|
72
|
+
key: key_params,
|
|
73
|
+
changes: extract_changes(current_model, updated_model))
|
|
74
|
+
|
|
75
|
+
updated_model
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Destroy model by class and key field
|
|
79
|
+
def destroy(model:, **key_params)
|
|
80
|
+
registration = @registry.registration_for(model)
|
|
81
|
+
key_field = registration.key_field
|
|
82
|
+
key_value = key_params[key_field]
|
|
83
|
+
raise InvalidKeyError, "Key field '#{key_field}' not provided" if key_value.nil?
|
|
84
|
+
|
|
85
|
+
model_instance = fetch(model: model, **key_params)
|
|
86
|
+
return false unless model_instance
|
|
87
|
+
|
|
88
|
+
@composite_handler.delete_composite_models(model_instance)
|
|
89
|
+
|
|
90
|
+
storage_key = registration.generate_storage_key_from_value(key_value)
|
|
91
|
+
deleted = @store.delete(storage_key)
|
|
92
|
+
|
|
93
|
+
@store.emit_event(:model_destroy, model: model, key: key_value, deleted: deleted)
|
|
94
|
+
deleted
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Query operations
|
|
98
|
+
def where(model:, **conditions)
|
|
99
|
+
all(model: model).select do |model_instance|
|
|
100
|
+
conditions.all? { |field, value| model_instance.public_send(field) == value }
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Get all models of a specific type
|
|
105
|
+
def all(model:)
|
|
106
|
+
registration = @registry.registration_for(model)
|
|
107
|
+
|
|
108
|
+
models = []
|
|
109
|
+
@store.keys.each do |storage_key|
|
|
110
|
+
parsed = StorageKey.parse(storage_key.to_s)
|
|
111
|
+
next unless parsed.class_name == model.name
|
|
112
|
+
|
|
113
|
+
stored_data = @store.get(storage_key)
|
|
114
|
+
next unless stored_data
|
|
115
|
+
|
|
116
|
+
begin
|
|
117
|
+
model_instance = @serializer.deserialize(stored_data, model, registration)
|
|
118
|
+
|
|
119
|
+
composite_references = stored_data["_composite_models"]
|
|
120
|
+
if composite_references
|
|
121
|
+
model_instance = @composite_handler.restore_composite_models(
|
|
122
|
+
model_instance, composite_references
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
models << model_instance
|
|
127
|
+
rescue StandardError => e
|
|
128
|
+
@store.emit_event(:deserialization_error, key: storage_key, error: e)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
models
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def exists?(model:, **key_params)
|
|
136
|
+
!fetch(model: model, **key_params).nil?
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def count(model:)
|
|
140
|
+
all(model: model).size
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Load all models of a type from a directory using format-specific serialization.
|
|
144
|
+
# Bypasses the key-value layer and reads files directly using the format handler.
|
|
145
|
+
def load_all(model_class, path: nil, format: :yaml, layout: :separate)
|
|
146
|
+
fmt = Format.resolve(format)
|
|
147
|
+
dir = resolve_model_dir(model_class, path)
|
|
148
|
+
|
|
149
|
+
raise BackendError, "No directory specified for load_all" unless dir
|
|
150
|
+
raise BackendError, "Directory not found: #{dir}" unless Dir.exist?(dir)
|
|
151
|
+
|
|
152
|
+
case layout
|
|
153
|
+
when :separate
|
|
154
|
+
load_separate(dir, model_class, fmt)
|
|
155
|
+
when :grouped
|
|
156
|
+
load_grouped(dir, model_class, fmt)
|
|
157
|
+
when :flat
|
|
158
|
+
load_flat(dir, model_class, fmt)
|
|
159
|
+
else
|
|
160
|
+
raise ConfigurationError, "Unknown layout: #{layout}"
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Load from directory and store into the key-value backend.
|
|
165
|
+
# Returns the loaded models and makes them available via fetch/where/all.
|
|
166
|
+
def import_all(model_class, path: nil, format: :yaml, layout: :separate)
|
|
167
|
+
models = load_all(model_class, path: path, format: format, layout: layout)
|
|
168
|
+
models.each { |model| save(model) }
|
|
169
|
+
@store.emit_event(:model_import, count: models.size, path: path)
|
|
170
|
+
models
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Save all models to a directory using format-specific serialization.
|
|
174
|
+
def save_all(models, path: nil, format: :yaml, layout: :separate)
|
|
175
|
+
fmt = Format.resolve(format)
|
|
176
|
+
models_array = Array(models)
|
|
177
|
+
return [] if models_array.empty?
|
|
178
|
+
|
|
179
|
+
model_class = models_array.first.class
|
|
180
|
+
dir = resolve_model_dir(model_class, path)
|
|
181
|
+
|
|
182
|
+
raise BackendError, "No directory specified for save_all" unless dir
|
|
183
|
+
|
|
184
|
+
FileUtils.mkdir_p(dir)
|
|
185
|
+
|
|
186
|
+
saved = case layout
|
|
187
|
+
when :separate
|
|
188
|
+
save_separate(models_array, dir, fmt)
|
|
189
|
+
when :grouped
|
|
190
|
+
save_grouped(models_array, dir, fmt, model_class)
|
|
191
|
+
when :flat
|
|
192
|
+
save_flat(models_array, dir, fmt)
|
|
193
|
+
else
|
|
194
|
+
raise ConfigurationError, "Unknown layout: #{layout}"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
@store.emit_event(:model_save_all, models: saved, count: saved.size, path: dir)
|
|
198
|
+
saved
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Export models to a single file or directory.
|
|
202
|
+
def export(models, path:, format: :yaml)
|
|
203
|
+
fmt = Format.resolve(format)
|
|
204
|
+
models_array = Array(models)
|
|
205
|
+
|
|
206
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
207
|
+
|
|
208
|
+
content = fmt.serialize_many(models_array)
|
|
209
|
+
|
|
210
|
+
File.write(path, content, encoding: "utf-8")
|
|
211
|
+
@store.emit_event(:model_export, count: models_array.size, path: path)
|
|
212
|
+
path
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def on(event, &block)
|
|
216
|
+
@store.on(event, &block)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def off(event, listener)
|
|
220
|
+
@store.off(event, listener)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def stats
|
|
224
|
+
base_stats = @store.stats
|
|
225
|
+
base_stats.merge(
|
|
226
|
+
models_registered: @registry.count,
|
|
227
|
+
registered_models: @registry.registered_models.map(&:name),
|
|
228
|
+
total_models: total_model_count
|
|
229
|
+
)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def close
|
|
233
|
+
@store.close
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
private
|
|
237
|
+
|
|
238
|
+
def find_stored_data(registration, model, key_value)
|
|
239
|
+
if registration.polymorphic?
|
|
240
|
+
find_polymorphic_data(model, key_value)
|
|
241
|
+
else
|
|
242
|
+
storage_key = registration.generate_storage_key_from_value(key_value)
|
|
243
|
+
@store.get(storage_key)
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def find_polymorphic_data(model, key_value)
|
|
248
|
+
candidates = @store.keys
|
|
249
|
+
.filter_map do |key|
|
|
250
|
+
parsed = StorageKey.parse(key.to_s)
|
|
251
|
+
next unless parsed.key_value == key_value.to_s
|
|
252
|
+
|
|
253
|
+
data = @store.get(key)
|
|
254
|
+
next unless data&.key?("_class")
|
|
255
|
+
|
|
256
|
+
stored_class = Object.const_get(data["_class"])
|
|
257
|
+
next unless stored_class <= model
|
|
258
|
+
|
|
259
|
+
{ key: key, data: data, klass: stored_class }
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
candidates.max_by { |c| polymorphic_depth(c[:klass], model) }&.dig(:data)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def polymorphic_depth(klass, base)
|
|
266
|
+
depth = 0
|
|
267
|
+
current = klass
|
|
268
|
+
while current < base && current.superclass
|
|
269
|
+
current = current.superclass
|
|
270
|
+
depth += 1
|
|
271
|
+
end
|
|
272
|
+
depth
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def save_single_model(model)
|
|
276
|
+
registration = @registry.registration_for(model.class)
|
|
277
|
+
composite_references = @composite_handler.process_composite_models(model)
|
|
278
|
+
serialized_data = @serializer.serialize(model, registration)
|
|
279
|
+
|
|
280
|
+
serialized_data["_composite_models"] = composite_references unless composite_references.empty?
|
|
281
|
+
|
|
282
|
+
storage_key = registration.generate_storage_key(model)
|
|
283
|
+
@store.set(storage_key, serialized_data)
|
|
284
|
+
|
|
285
|
+
unless composite_references.empty?
|
|
286
|
+
@store.emit_event(:composite_model_stored,
|
|
287
|
+
model: model,
|
|
288
|
+
composite_count: composite_references.size)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
model
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def extract_changes(old_model, new_model)
|
|
295
|
+
registration = @registry.registration_for(old_model.class)
|
|
296
|
+
old_attrs = @serializer.serialize(old_model, registration)
|
|
297
|
+
new_attrs = @serializer.serialize(new_model, registration)
|
|
298
|
+
|
|
299
|
+
new_attrs.each_with_object({}) do |(key, new_value), changes|
|
|
300
|
+
old_value = old_attrs[key]
|
|
301
|
+
changes[key] = { from: old_value, to: new_value } if old_value != new_value
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def total_model_count
|
|
306
|
+
@registry.registered_models.sum { |model_class| count(model: model_class) }
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def validate_configuration!
|
|
310
|
+
return unless @registry.empty?
|
|
311
|
+
|
|
312
|
+
raise ConfigurationError,
|
|
313
|
+
"No models registered. Provide models: [{ model: YourModel, key: :key_field }]"
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# ── Layout helpers ──
|
|
317
|
+
|
|
318
|
+
def resolve_model_dir(model_class, base_path)
|
|
319
|
+
registration = @registry.find_registration(model_class)
|
|
320
|
+
return base_path unless registration
|
|
321
|
+
|
|
322
|
+
dir = registration.dir
|
|
323
|
+
return base_path unless dir
|
|
324
|
+
|
|
325
|
+
base_path ? File.join(base_path, dir) : nil
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def load_separate(dir, model_class, fmt)
|
|
329
|
+
models = []
|
|
330
|
+
glob = File.join(dir, fmt.glob_pattern)
|
|
331
|
+
Dir.glob(glob).sort.each do |file_path|
|
|
332
|
+
next unless File.file?(file_path)
|
|
333
|
+
|
|
334
|
+
raw = File.read(file_path, encoding: "utf-8")
|
|
335
|
+
next if raw.strip.empty?
|
|
336
|
+
|
|
337
|
+
begin
|
|
338
|
+
model = fmt.deserialize(raw, model_class)
|
|
339
|
+
set_model_key_from_filename(model, file_path, fmt)
|
|
340
|
+
models << model
|
|
341
|
+
rescue StandardError => e
|
|
342
|
+
@store.emit_event(:load_error, file: file_path, error: e)
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
models
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def load_grouped(dir, model_class, fmt)
|
|
349
|
+
models = []
|
|
350
|
+
glob = File.join(dir, fmt.glob_pattern)
|
|
351
|
+
Dir.glob(glob).sort.each do |file_path|
|
|
352
|
+
next unless File.file?(file_path)
|
|
353
|
+
|
|
354
|
+
raw = File.read(file_path, encoding: "utf-8")
|
|
355
|
+
next if raw.strip.empty?
|
|
356
|
+
|
|
357
|
+
begin
|
|
358
|
+
loaded = fmt.deserialize_many(raw, model_class)
|
|
359
|
+
loaded = [loaded] unless loaded.is_a?(Array)
|
|
360
|
+
loaded.each { |m| set_model_key_from_filename(m, file_path, fmt) }
|
|
361
|
+
models.concat(loaded)
|
|
362
|
+
rescue StandardError => e
|
|
363
|
+
@store.emit_event(:load_error, file: file_path, error: e)
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
models
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def load_flat(dir, model_class, fmt)
|
|
370
|
+
load_separate(dir, model_class, fmt)
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def save_separate(models, dir, fmt)
|
|
374
|
+
models.map do |model|
|
|
375
|
+
key = extract_model_key(model)
|
|
376
|
+
filename = key || model.class.name.to_s.gsub("::", "_")
|
|
377
|
+
file_path = File.join(dir, "#{filename}#{fmt.extension}")
|
|
378
|
+
content = fmt.serialize(model)
|
|
379
|
+
File.write(file_path, content, encoding: "utf-8")
|
|
380
|
+
model
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def save_grouped(models, dir, fmt, _model_class)
|
|
385
|
+
grouped = {}
|
|
386
|
+
models.each do |model|
|
|
387
|
+
key = extract_model_key(model) || model.class.name.to_s.gsub("::", "_")
|
|
388
|
+
grouped[key] ||= []
|
|
389
|
+
grouped[key] << model
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
grouped.map do |key, group|
|
|
393
|
+
file_path = File.join(dir, "#{key}#{fmt.extension}")
|
|
394
|
+
content = fmt.serialize_many(group)
|
|
395
|
+
File.write(file_path, content, encoding: "utf-8")
|
|
396
|
+
group
|
|
397
|
+
end.flatten
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def save_flat(models, dir, fmt)
|
|
401
|
+
save_separate(models, dir, fmt)
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def extract_model_key(model)
|
|
405
|
+
registration = @registry.find_registration(model.class)
|
|
406
|
+
return nil unless registration
|
|
407
|
+
|
|
408
|
+
key_value = model.public_send(registration.key_field)
|
|
409
|
+
key_value&.to_s
|
|
410
|
+
rescue StandardError
|
|
411
|
+
nil
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def set_model_key_from_filename(model, file_path, _fmt)
|
|
415
|
+
return if extract_model_key(model)
|
|
416
|
+
|
|
417
|
+
registration = @registry.find_registration(model.class)
|
|
418
|
+
return unless registration
|
|
419
|
+
|
|
420
|
+
basename = File.basename(file_path, ".*")
|
|
421
|
+
model.public_send(:"#{registration.key_field}=", basename)
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Store
|
|
5
|
+
class Events
|
|
6
|
+
def initialize(async: false)
|
|
7
|
+
@listeners = Hash.new { |h, k| h[k] = [] }
|
|
8
|
+
@mutex = Mutex.new
|
|
9
|
+
@async = async
|
|
10
|
+
@queue = async ? Queue.new : nil
|
|
11
|
+
@worker_thread = async ? start_worker_thread : nil
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def on(event, callable = nil, &block)
|
|
15
|
+
listener = callable || block
|
|
16
|
+
raise ArgumentError, "No listener provided" unless listener
|
|
17
|
+
raise ArgumentError, "Event must be a Symbol" unless event.is_a?(Symbol)
|
|
18
|
+
|
|
19
|
+
@mutex.synchronize do
|
|
20
|
+
@listeners[event] << listener
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def off(event, listener)
|
|
25
|
+
@mutex.synchronize do
|
|
26
|
+
@listeners[event].delete(listener)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def emit(event, data = {})
|
|
31
|
+
listeners = @mutex.synchronize { @listeners[event].dup }
|
|
32
|
+
return if listeners.empty?
|
|
33
|
+
|
|
34
|
+
event_data = {
|
|
35
|
+
event: event,
|
|
36
|
+
timestamp: Time.now,
|
|
37
|
+
**data
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if @async
|
|
41
|
+
@queue << [listeners, event_data]
|
|
42
|
+
else
|
|
43
|
+
notify_listeners(listeners, event_data)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def clear_listeners(event = nil)
|
|
48
|
+
@mutex.synchronize do
|
|
49
|
+
if event
|
|
50
|
+
@listeners[event].clear
|
|
51
|
+
else
|
|
52
|
+
@listeners.clear
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def listener_count(event)
|
|
58
|
+
@mutex.synchronize { @listeners[event].size }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def stop
|
|
62
|
+
return unless @async && @worker_thread
|
|
63
|
+
|
|
64
|
+
@queue << :stop
|
|
65
|
+
@worker_thread.join
|
|
66
|
+
@worker_thread = nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def notify_listeners(listeners, event_data)
|
|
72
|
+
listeners.each do |listener|
|
|
73
|
+
listener.call(event_data)
|
|
74
|
+
rescue StandardError => e
|
|
75
|
+
warn "Event listener error: #{e.message}"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def start_worker_thread
|
|
80
|
+
Thread.new do
|
|
81
|
+
loop do
|
|
82
|
+
item = @queue.pop
|
|
83
|
+
break if item == :stop
|
|
84
|
+
|
|
85
|
+
listeners, event_data = item
|
|
86
|
+
notify_listeners(listeners, event_data)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|