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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +22 -13
- data/CLAUDE.md +22 -9
- data/README.adoc +129 -15
- data/lib/lutaml/store/adapter/base.rb +28 -0
- data/lib/lutaml/store/adapter/memory.rb +213 -121
- data/lib/lutaml/store/adapter/sqlite.rb +181 -7
- data/lib/lutaml/store/adapter.rb +17 -0
- data/lib/lutaml/store/basic_store.rb +1 -10
- data/lib/lutaml/store/cache_store.rb +2 -10
- data/lib/lutaml/store/database_store.rb +321 -58
- data/lib/lutaml/store/format/base.rb +4 -0
- data/lib/lutaml/store/format/marshal_format.rb +4 -0
- data/lib/lutaml/store/format/xml.rb +37 -0
- data/lib/lutaml/store/format/yamls.rb +1 -1
- data/lib/lutaml/store/format.rb +3 -1
- data/lib/lutaml/store/format_serializer.rb +44 -0
- data/lib/lutaml/store/package_transport/base.rb +12 -0
- data/lib/lutaml/store/package_transport/directory_transport.rb +8 -8
- data/lib/lutaml/store/package_transport/zip_transport.rb +11 -6
- data/lib/lutaml/store/predicate.rb +248 -0
- data/lib/lutaml/store/query.rb +259 -0
- data/lib/lutaml/store/storage_key.rb +1 -1
- data/lib/lutaml/store/version.rb +1 -1
- data/lib/lutaml/store.rb +3 -0
- metadata +14 -4
|
@@ -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 =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
data/lib/lutaml/store/version.rb
CHANGED
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"
|