lutaml-store 0.2.1 → 0.2.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.
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Store
5
+ # Bridges a Format handler to the ModelSerializer interface.
6
+ # Enables DatabaseStore to use any format (yaml, json, xml, yamls, marshal)
7
+ # for key-value storage instead of the default hash serialization.
8
+ #
9
+ # Usage:
10
+ # serializer = FormatSerializer.new(:yamls)
11
+ # store = DatabaseStore.new(
12
+ # adapter: :sqlite,
13
+ # models: [{ model: ConceptDocument, key: :id, serializer: serializer }]
14
+ # )
15
+ class FormatSerializer
16
+ DATA_KEY = "_data"
17
+ CLASS_KEY = "_class"
18
+
19
+ def initialize(format)
20
+ @format = Format.resolve(format)
21
+ end
22
+
23
+ def serialize(model)
24
+ {
25
+ DATA_KEY => @format.serialize(model),
26
+ CLASS_KEY => model.class.name
27
+ }
28
+ end
29
+
30
+ def deserialize(data, model_class)
31
+ model_class = resolve_class(data[CLASS_KEY]) if data[CLASS_KEY]
32
+ @format.deserialize(data[DATA_KEY], model_class)
33
+ end
34
+
35
+ private
36
+
37
+ def resolve_class(class_name)
38
+ Object.const_get(class_name)
39
+ rescue NameError
40
+ nil
41
+ end
42
+ end
43
+ end
44
+ end
@@ -42,6 +42,18 @@ module Lutaml
42
42
  def sanitize_filename(key)
43
43
  key.gsub(%r{[/:#?]}, "_")
44
44
  end
45
+
46
+ def read_file(file_path, fmt)
47
+ fmt.binary? ? File.binread(file_path) : File.read(file_path, encoding: "utf-8")
48
+ end
49
+
50
+ def write_file(file_path, content, fmt)
51
+ if fmt.binary?
52
+ File.binwrite(file_path, content)
53
+ else
54
+ File.write(file_path, content, encoding: "utf-8")
55
+ end
56
+ end
45
57
  end
46
58
  end
47
59
  end
@@ -47,7 +47,7 @@ module Lutaml
47
47
  return unless File.exist?(file_path)
48
48
 
49
49
  fmt = format_for_file(definition.metadata_file)
50
- raw = File.read(file_path, encoding: "utf-8")
50
+ raw = read_file(file_path, fmt)
51
51
  metadata = fmt.deserialize(raw, definition.metadata_model)
52
52
  package_store.metadata = metadata
53
53
  end
@@ -60,7 +60,7 @@ module Lutaml
60
60
  content = fmt.serialize(package_store.metadata)
61
61
  file_path = File.join(path, definition.metadata_file)
62
62
  FileUtils.mkdir_p(File.dirname(file_path))
63
- File.write(file_path, content, encoding: "utf-8")
63
+ write_file(file_path, content, fmt)
64
64
  end
65
65
 
66
66
  def read_model_entry(base_path, entry, package_store, fmt_name)
@@ -76,7 +76,7 @@ module Lutaml
76
76
  return unless File.exist?(file_path)
77
77
 
78
78
  fmt = resolve_format(fmt_name)
79
- raw = File.read(file_path, encoding: "utf-8")
79
+ raw = read_file(file_path, fmt)
80
80
  model = fmt.deserialize(raw, entry.model)
81
81
  package_store.add_model(model)
82
82
  end
@@ -91,8 +91,8 @@ module Lutaml
91
91
  Dir.glob(glob).sort.each do |file_path|
92
92
  next unless File.file?(file_path)
93
93
 
94
- raw = File.read(file_path, encoding: "utf-8")
95
- next if raw.strip.empty?
94
+ raw = read_file(file_path, fmt)
95
+ next if !fmt.binary? && raw.strip.empty?
96
96
 
97
97
  begin
98
98
  case entry.layout
@@ -127,7 +127,7 @@ module Lutaml
127
127
  content = entry.layout == :grouped ? fmt.serialize_many(models) : fmt.serialize(models.first)
128
128
  file_path = File.join(base_path, entry.file)
129
129
  FileUtils.mkdir_p(File.dirname(file_path))
130
- File.write(file_path, content, encoding: "utf-8")
130
+ write_file(file_path, content, fmt)
131
131
  end
132
132
 
133
133
  def write_directory_models(base_path, entry, models, fmt)
@@ -138,13 +138,13 @@ module Lutaml
138
138
  when :grouped
139
139
  models.group_by { |m| extract_key(m, entry) }.each do |key, group|
140
140
  file_path = File.join(dir, "#{sanitize_filename(key)}#{fmt.extension}")
141
- File.write(file_path, fmt.serialize_many(group), encoding: "utf-8")
141
+ write_file(file_path, fmt.serialize_many(group), fmt)
142
142
  end
143
143
  else
144
144
  models.each do |model|
145
145
  key = extract_key(model, entry)
146
146
  file_path = File.join(dir, "#{sanitize_filename(key)}#{fmt.extension}")
147
- File.write(file_path, fmt.serialize(model), encoding: "utf-8")
147
+ write_file(file_path, fmt.serialize(model), fmt)
148
148
  end
149
149
  end
150
150
  end
@@ -93,14 +93,19 @@ module Lutaml
93
93
  next if zip_entry.name == prefix || zip_entry.name.end_with?("/")
94
94
 
95
95
  raw = zip_entry.get_input_stream.read
96
- next if raw.strip.empty?
96
+ next if !fmt.binary? && raw.strip.empty?
97
97
 
98
98
  begin
99
- loaded = fmt.deserialize_many(raw, entry.model)
100
- loaded = [loaded] unless loaded.is_a?(Array)
101
- loaded.each do |m|
102
- set_key_from_zip_path(m, zip_entry.name, entry, prefix)
103
- package_store.add_model(m)
99
+ case entry.layout
100
+ when :grouped
101
+ fmt.deserialize_many(raw, entry.model).each do |m|
102
+ set_key_from_zip_path(m, zip_entry.name, entry, prefix)
103
+ package_store.add_model(m)
104
+ end
105
+ else
106
+ model = fmt.deserialize(raw, entry.model)
107
+ set_key_from_zip_path(model, zip_entry.name, entry, prefix)
108
+ package_store.add_model(model)
104
109
  end
105
110
  rescue StandardError => e
106
111
  warn "PackageStore: failed to load #{zip_entry.name}: #{e.message}"
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Store
5
+ module Predicate
6
+ class Base
7
+ attr_reader :field, :value
8
+
9
+ def initialize(field, value = nil)
10
+ @field = field.to_sym
11
+ @value = value
12
+ end
13
+
14
+ def match?(_target)
15
+ raise NotImplementedError
16
+ end
17
+
18
+ def negate
19
+ raise NotImplementedError
20
+ end
21
+
22
+ def hash_evaluable?(hash_data)
23
+ hash_data.key?(@field.to_s)
24
+ end
25
+
26
+ private
27
+
28
+ def extract(target)
29
+ target.is_a?(Hash) ? target[@field.to_s] : target.public_send(@field)
30
+ end
31
+ end
32
+
33
+ class Equal < Base
34
+ def match?(target)
35
+ extract(target) == @value
36
+ end
37
+
38
+ def negate
39
+ NotEqual.new(@field, @value)
40
+ end
41
+ end
42
+
43
+ class NotEqual < Base
44
+ def match?(target)
45
+ extract(target) != @value
46
+ end
47
+
48
+ def negate
49
+ Equal.new(@field, @value)
50
+ end
51
+ end
52
+
53
+ class GreaterThan < Base
54
+ def match?(target)
55
+ val = extract(target)
56
+ return false if val.nil?
57
+
58
+ val > @value
59
+ end
60
+
61
+ def negate
62
+ LessThanOrEqual.new(@field, @value)
63
+ end
64
+ end
65
+
66
+ class LessThan < Base
67
+ def match?(target)
68
+ val = extract(target)
69
+ return false if val.nil?
70
+
71
+ val < @value
72
+ end
73
+
74
+ def negate
75
+ GreaterThanOrEqual.new(@field, @value)
76
+ end
77
+ end
78
+
79
+ class GreaterThanOrEqual < Base
80
+ def match?(target)
81
+ val = extract(target)
82
+ return false if val.nil?
83
+
84
+ val >= @value
85
+ end
86
+
87
+ def negate
88
+ LessThan.new(@field, @value)
89
+ end
90
+ end
91
+
92
+ class LessThanOrEqual < Base
93
+ def match?(target)
94
+ val = extract(target)
95
+ return false if val.nil?
96
+
97
+ val <= @value
98
+ end
99
+
100
+ def negate
101
+ GreaterThan.new(@field, @value)
102
+ end
103
+ end
104
+
105
+ class Between < Base
106
+ def match?(target)
107
+ val = extract(target)
108
+ return false if val.nil?
109
+
110
+ @value.cover?(val)
111
+ end
112
+
113
+ def negate
114
+ NotBetween.new(@field, @value)
115
+ end
116
+ end
117
+
118
+ class NotBetween < Base
119
+ def match?(target)
120
+ val = extract(target)
121
+ return false if val.nil?
122
+
123
+ !@value.cover?(val)
124
+ end
125
+
126
+ def negate
127
+ Between.new(@field, @value)
128
+ end
129
+ end
130
+
131
+ class In < Base
132
+ def match?(target)
133
+ val = extract(target)
134
+ return false if val.nil?
135
+
136
+ @value.include?(val)
137
+ end
138
+
139
+ def negate
140
+ NotIn.new(@field, @value)
141
+ end
142
+ end
143
+
144
+ class NotIn < Base
145
+ def match?(target)
146
+ val = extract(target)
147
+ return false if val.nil?
148
+
149
+ !@value.include?(val)
150
+ end
151
+
152
+ def negate
153
+ In.new(@field, @value)
154
+ end
155
+ end
156
+
157
+ class Matches < Base
158
+ def match?(target)
159
+ val = extract(target)
160
+ return false if val.nil?
161
+
162
+ @value.match?(val.to_s)
163
+ end
164
+
165
+ def negate
166
+ NotMatches.new(@field, @value)
167
+ end
168
+ end
169
+
170
+ class NotMatches < Base
171
+ def match?(target)
172
+ val = extract(target)
173
+ return true if val.nil?
174
+
175
+ !@value.match?(val.to_s)
176
+ end
177
+
178
+ def negate
179
+ Matches.new(@field, @value)
180
+ end
181
+ end
182
+
183
+ class Nil < Base
184
+ def match?(target)
185
+ extract(target).nil?
186
+ end
187
+
188
+ def negate
189
+ NotNil.new(@field)
190
+ end
191
+ end
192
+
193
+ class NotNil < Base
194
+ def match?(target)
195
+ !extract(target).nil?
196
+ end
197
+
198
+ def negate
199
+ Nil.new(@field)
200
+ end
201
+ end
202
+
203
+ # ── Factory methods ──
204
+
205
+ def self.gt(field, value)
206
+ GreaterThan.new(field, value)
207
+ end
208
+
209
+ def self.lt(field, value)
210
+ LessThan.new(field, value)
211
+ end
212
+
213
+ def self.gte(field, value)
214
+ GreaterThanOrEqual.new(field, value)
215
+ end
216
+
217
+ def self.lte(field, value)
218
+ LessThanOrEqual.new(field, value)
219
+ end
220
+
221
+ def self.not(field, value)
222
+ NotEqual.new(field, value)
223
+ end
224
+
225
+ def self.matches(field, pattern)
226
+ Matches.new(field, pattern)
227
+ end
228
+
229
+ def self.nil(field)
230
+ Nil.new(field)
231
+ end
232
+
233
+ def self.not_nil(field)
234
+ NotNil.new(field)
235
+ end
236
+
237
+ def self.build_from_hash(conditions)
238
+ conditions.map do |field, value|
239
+ case value
240
+ when Range then Between.new(field, value)
241
+ when Array then In.new(field, value)
242
+ else Equal.new(field, value)
243
+ end
244
+ end
245
+ end
246
+ end
247
+ end
248
+ end
@@ -0,0 +1,259 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Store
5
+ # Lazy, chainable query object — inspired by ActiveRecord::Relation.
6
+ # Collects predicates, sort orders, limit, and offset. Nothing executes
7
+ # until a terminal method is called (to_a, each, first, count, etc.).
8
+ class Query
9
+ include Enumerable
10
+
11
+ attr_reader :model_class, :predicates, :orders, :limit_value, :offset_value
12
+
13
+ def initialize(store, model_class, predicates: [], orders: [],
14
+ limit_value: nil, offset_value: nil)
15
+ @store = store
16
+ @model_class = model_class
17
+ @predicates = predicates.dup.freeze
18
+ @orders = orders.dup.freeze
19
+ @limit_value = limit_value
20
+ @offset_value = offset_value
21
+ end
22
+
23
+ # ── Chainable methods (return new Query) ──
24
+
25
+ def where(*predicates_or_conditions, **kwargs)
26
+ conditions = kwargs
27
+ new_predicates = []
28
+
29
+ predicates_or_conditions.each do |arg|
30
+ if arg.is_a?(Predicate::Base)
31
+ new_predicates << arg
32
+ elsif arg.is_a?(Hash)
33
+ conditions = conditions.merge(arg)
34
+ else
35
+ raise ArgumentError, "where accepts Predicate objects or Hash conditions, got #{arg.class}"
36
+ end
37
+ end
38
+
39
+ new_predicates.concat(Predicate.build_from_hash(conditions)) unless conditions.empty?
40
+ chain(predicates: @predicates + new_predicates)
41
+ end
42
+
43
+ def not(conditions = {}, **kwargs)
44
+ conditions = conditions.merge(kwargs)
45
+ negated = Predicate.build_from_hash(conditions).map(&:negate)
46
+ chain(predicates: @predicates + negated)
47
+ end
48
+
49
+ def order(*specs)
50
+ new_orders = parse_order_specs(specs)
51
+ chain(orders: @orders + new_orders)
52
+ end
53
+
54
+ def limit(count)
55
+ chain(limit_value: count)
56
+ end
57
+
58
+ def offset(count)
59
+ chain(offset_value: count)
60
+ end
61
+
62
+ def reverse_order
63
+ reversed = @orders.map { |o| Order.new(o.field, o.direction == :asc ? :desc : :asc) }
64
+ chain(orders: reversed)
65
+ end
66
+
67
+ # ── Terminal methods (execute the query) ──
68
+
69
+ def to_a
70
+ @to_a_result ||= @store.execute_query(self)
71
+ end
72
+
73
+ def each(&block)
74
+ to_a.each(&block)
75
+ end
76
+
77
+ def first
78
+ self.class.new(@store, @model_class, predicates: @predicates,
79
+ orders: @orders, limit_value: 1,
80
+ offset_value: @offset_value).to_a.first
81
+ end
82
+
83
+ def last
84
+ reversed = @orders.map { |o| Order.new(o.field, o.direction == :asc ? :desc : :asc) }
85
+ self.class.new(@store, @model_class, predicates: @predicates,
86
+ orders: reversed, limit_value: 1,
87
+ offset_value: @offset_value).to_a.first
88
+ end
89
+
90
+ def find_by(**conditions)
91
+ where(**conditions).first
92
+ end
93
+
94
+ def find_by!(**conditions)
95
+ result = find_by(**conditions)
96
+ raise ModelNotRegisteredError, "No #{@model_class} found matching #{conditions}" unless result
97
+
98
+ result
99
+ end
100
+
101
+ def count
102
+ return to_a.size if @limit_value || @offset_value
103
+
104
+ @store.count_query(self)
105
+ end
106
+
107
+ alias size count
108
+ alias length count
109
+
110
+ def exists?
111
+ self.class.new(@store, @model_class, predicates: @predicates,
112
+ orders: [], limit_value: 1, offset_value: nil).to_a.any?
113
+ end
114
+
115
+ def empty?
116
+ !exists?
117
+ end
118
+
119
+ def any?
120
+ exists?
121
+ end
122
+
123
+ def none?
124
+ !exists?
125
+ end
126
+
127
+ def one?
128
+ self.class.new(@store, @model_class, predicates: @predicates,
129
+ orders: [], limit_value: 2, offset_value: nil).to_a.size == 1
130
+ end
131
+
132
+ def many?
133
+ self.class.new(@store, @model_class, predicates: @predicates,
134
+ orders: [], limit_value: 2, offset_value: nil).to_a.size > 1
135
+ end
136
+
137
+ # ── Calculation shortcuts ──
138
+
139
+ def pluck(*fields)
140
+ to_a.map do |model|
141
+ if fields.size == 1
142
+ model.public_send(fields.first)
143
+ else
144
+ fields.map { |f| model.public_send(f) }
145
+ end
146
+ end
147
+ end
148
+
149
+ def distinct(field = nil)
150
+ if field
151
+ to_a.map { |m| m.public_send(field) }.uniq
152
+ else
153
+ to_a.uniq
154
+ end
155
+ end
156
+
157
+ def sum(field)
158
+ to_a.sum { |m| m.public_send(field) }
159
+ end
160
+
161
+ def average(field)
162
+ values = to_a.map { |m| m.public_send(field) }.compact
163
+ return 0.0 if values.empty?
164
+
165
+ values.sum.to_f / values.size
166
+ end
167
+
168
+ def minimum(field)
169
+ to_a.min_by { |m| m.public_send(field) }
170
+ end
171
+
172
+ def maximum(field)
173
+ to_a.max_by { |m| m.public_send(field) }
174
+ end
175
+
176
+ # ── Batch processing ──
177
+
178
+ def find_each(batch_size: 1000, &block)
179
+ raise ArgumentError, "find_each does not support limit/offset" if @limit_value || @offset_value
180
+
181
+ cursor = nil
182
+ loop do
183
+ batch = @store.fetch_batch(self, after: cursor, limit: batch_size)
184
+ break if batch.empty?
185
+
186
+ batch.each(&block)
187
+ cursor = @store.last_storage_key_from(batch, @model_class)
188
+ end
189
+ end
190
+
191
+ def in_batches(of: 1000)
192
+ raise ArgumentError, "in_batches does not support limit/offset" if @limit_value || @offset_value
193
+
194
+ cursor = nil
195
+ loop do
196
+ batch = @store.fetch_batch(self, after: cursor, limit: of)
197
+ break if batch.empty?
198
+
199
+ yield batch
200
+ cursor = @store.last_storage_key_from(batch, @model_class)
201
+ end
202
+ end
203
+
204
+ # ── Scopes ──
205
+
206
+ def apply(scope_name, *args, **kwargs)
207
+ scope_body = @store.scope_for(scope_name)
208
+ instance_exec(*args, **kwargs, &scope_body)
209
+ end
210
+
211
+ def inspect
212
+ parts = [@model_class.to_s]
213
+ parts << "WHERE #{@predicates.map(&:inspect).join(" AND ")}" if @predicates.any?
214
+ parts << "ORDER BY #{@orders.map { |o| "#{o.field} #{o.direction}" }.join(", ")}" if @orders.any?
215
+ parts << "LIMIT #{@limit_value}" if @limit_value
216
+ parts << "OFFSET #{@offset_value}" if @offset_value
217
+ "#<Query #{parts.join(" ")}>"
218
+ end
219
+
220
+ private
221
+
222
+ def chain(**overrides)
223
+ self.class.new(
224
+ @store,
225
+ @model_class,
226
+ predicates: overrides.fetch(:predicates, @predicates),
227
+ orders: overrides.fetch(:orders, @orders),
228
+ limit_value: overrides.fetch(:limit_value, @limit_value),
229
+ offset_value: overrides.fetch(:offset_value, @offset_value)
230
+ )
231
+ end
232
+
233
+ def parse_order_specs(specs)
234
+ orders = []
235
+ i = 0
236
+ while i < specs.size
237
+ item = specs[i]
238
+ case item
239
+ when Hash
240
+ item.each { |field, dir| orders << Order.new(field, dir) }
241
+ when Symbol, String
242
+ next_val = specs[i + 1]
243
+ if next_val.is_a?(Symbol) && %i[asc desc].include?(next_val)
244
+ orders << Order.new(item, next_val)
245
+ i += 1
246
+ else
247
+ orders << Order.new(item, :asc)
248
+ end
249
+ end
250
+ i += 1
251
+ end
252
+ orders
253
+ end
254
+ end
255
+
256
+ # Sort specification value object
257
+ Order = Struct.new(:field, :direction)
258
+ end
259
+ end
@@ -18,7 +18,7 @@ module Lutaml
18
18
 
19
19
  def self.parse(string)
20
20
  str = string.to_s
21
- sep = str.rindex(/(?<!:):(?!:)/)
21
+ sep = str.index(/(?<!:):(?!:)/)
22
22
  return new("", str) unless sep
23
23
 
24
24
  new(str[0...sep], str[sep + 1..])
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Store
5
- VERSION = "0.2.1"
5
+ VERSION = "0.2.4"
6
6
  end
7
7
  end
data/lib/lutaml/store.rb CHANGED
@@ -25,12 +25,15 @@ module Lutaml
25
25
  autoload :CompositeModelHandler, "lutaml/store/composite_model_handler"
26
26
  autoload :AttributeUpdater, "lutaml/store/attribute_updater"
27
27
  autoload :DatabaseStore, "lutaml/store/database_store"
28
+ autoload :FormatSerializer, "lutaml/store/format_serializer"
28
29
  autoload :PackageDefinition, "lutaml/store/package_definition"
29
30
  autoload :PackageStore, "lutaml/store/package_store"
30
31
  autoload :PackageTransport, "lutaml/store/package_transport"
31
32
  autoload :Adapter, "lutaml/store/adapter"
32
33
  autoload :StorageKey, "lutaml/store/storage_key"
33
34
  autoload :Format, "lutaml/store/format"
35
+ autoload :Query, "lutaml/store/query"
36
+ autoload :Predicate, "lutaml/store/predicate"
34
37
 
35
38
  autoload :HttpCache, "lutaml/store/http_cache"
36
39
  autoload :HttpCacheConfig, "lutaml/store/http_cache_config"