lutaml-store 0.2.2 → 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,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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Store
5
- VERSION = "0.2.2"
5
+ VERSION = "0.2.4"
6
6
  end
7
7
  end
data/lib/lutaml/store.rb CHANGED
@@ -32,6 +32,8 @@ module Lutaml
32
32
  autoload :Adapter, "lutaml/store/adapter"
33
33
  autoload :StorageKey, "lutaml/store/storage_key"
34
34
  autoload :Format, "lutaml/store/format"
35
+ autoload :Query, "lutaml/store/query"
36
+ autoload :Predicate, "lutaml/store/predicate"
35
37
 
36
38
  autoload :HttpCache, "lutaml/store/http_cache"
37
39
  autoload :HttpCacheConfig, "lutaml/store/http_cache_config"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lutaml-store
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.2.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ronald Tse
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-06-06 00:00:00.000000000 Z
11
+ date: 2026-09-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: lutaml-model
@@ -28,16 +28,22 @@ dependencies:
28
28
  name: rubyzip
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
- - - "~>"
31
+ - - ">="
32
32
  - !ruby/object:Gem::Version
33
33
  version: '2.3'
34
+ - - "<"
35
+ - !ruby/object:Gem::Version
36
+ version: '4'
34
37
  type: :runtime
35
38
  prerelease: false
36
39
  version_requirements: !ruby/object:Gem::Requirement
37
40
  requirements:
38
- - - "~>"
41
+ - - ">="
39
42
  - !ruby/object:Gem::Version
40
43
  version: '2.3'
44
+ - - "<"
45
+ - !ruby/object:Gem::Version
46
+ version: '4'
41
47
  description: |
42
48
  Provides a unified store interface for Lutaml::Model objects with model registry,
43
49
  polymorphic support, composite relationships, and multiple storage backends
@@ -94,6 +100,8 @@ files:
94
100
  - lib/lutaml/store/package_transport/base.rb
95
101
  - lib/lutaml/store/package_transport/directory_transport.rb
96
102
  - lib/lutaml/store/package_transport/zip_transport.rb
103
+ - lib/lutaml/store/predicate.rb
104
+ - lib/lutaml/store/query.rb
97
105
  - lib/lutaml/store/storage_key.rb
98
106
  - lib/lutaml/store/version.rb
99
107
  - sig/lutaml/store.rbs