lutaml-store 0.2.2 → 0.3.0
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/README.adoc +56 -0
- data/docs/astro.config.mjs +12 -0
- data/docs/package-lock.json +6098 -0
- data/docs/package.json +18 -0
- data/docs/public/favicon.svg +1 -0
- data/docs/public/lutaml-logo_logo-full-dark.svg +1 -0
- data/docs/public/lutaml-logo_logo-full-light.svg +1 -0
- data/docs/src/layouts/Base.astro +44 -0
- data/docs/src/layouts/Docs.astro +41 -0
- data/docs/src/pages/adapters.md +46 -0
- data/docs/src/pages/architecture.md +68 -0
- data/docs/src/pages/cloud-contract.md +48 -0
- data/docs/src/pages/formats.md +54 -0
- data/docs/src/pages/http-cache.md +43 -0
- data/docs/src/pages/index.md +82 -0
- data/docs/src/pages/quick-start.md +109 -0
- data/docs/src/pages/sources.md +83 -0
- data/docs/src/pages/stores.md +110 -0
- data/docs/src/styles/global.css +10 -0
- 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/database_store.rb +302 -49
- data/lib/lutaml/store/format.rb +13 -0
- data/lib/lutaml/store/manifest.rb +85 -0
- data/lib/lutaml/store/mirror.rb +94 -0
- data/lib/lutaml/store/predicate.rb +248 -0
- data/lib/lutaml/store/query.rb +259 -0
- data/lib/lutaml/store/repository.rb +178 -0
- data/lib/lutaml/store/source/base.rb +137 -0
- data/lib/lutaml/store/source/directory.rb +49 -0
- data/lib/lutaml/store/source/https.rb +109 -0
- data/lib/lutaml/store/source/rest.rb +61 -0
- data/lib/lutaml/store/source/zip.rb +52 -0
- data/lib/lutaml/store/source.rb +55 -0
- data/lib/lutaml/store/version.rb +1 -1
- data/lib/lutaml/store.rb +7 -0
- metadata +40 -8
|
@@ -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
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "json"
|
|
7
|
+
|
|
8
|
+
module Lutaml
|
|
9
|
+
module Store
|
|
10
|
+
# The uniform consumer facade: one read API whether the repository is a
|
|
11
|
+
# local package, a downloaded distribution, a temp-folder cache, or the
|
|
12
|
+
# cloud API. Backends are chosen by explicit composition — the
|
|
13
|
+
# repository never guesses, never falls back, and never hides which
|
|
14
|
+
# layer answered.
|
|
15
|
+
#
|
|
16
|
+
# # cloud, with an explicit local package cache
|
|
17
|
+
# repo = Repository.new(
|
|
18
|
+
# source: Source.for(:rest, base_url: "https://api.relaton.org",
|
|
19
|
+
# collection: "ietf"),
|
|
20
|
+
# cache: Source.for(:directory, path: "~/.cache/relaton/ietf"),
|
|
21
|
+
# )
|
|
22
|
+
#
|
|
23
|
+
# # a downloaded official package (zip) — identical read API
|
|
24
|
+
# repo = Repository.new(source: Source.for(:zip, path: "ietf.zip"))
|
|
25
|
+
#
|
|
26
|
+
# # a local GCR-style checkout
|
|
27
|
+
# repo = Repository.new(source: Source.for(:directory, path: "~/gcr/ietf"))
|
|
28
|
+
#
|
|
29
|
+
# Modes are explicit:
|
|
30
|
+
# :online — read cache first, then the source (default)
|
|
31
|
+
# :offline — never touch the source; absent keys raise NotFoundError
|
|
32
|
+
class Repository
|
|
33
|
+
MODES = %i[online offline].freeze
|
|
34
|
+
|
|
35
|
+
# @param source [Source::Base] the authoritative repository
|
|
36
|
+
# @param cache [Source::Directory, nil] explicit read-through local
|
|
37
|
+
# package (same layout as Mirror#pull writes); nil disables caching
|
|
38
|
+
# @param mode [Symbol] :online or :offline
|
|
39
|
+
def initialize(source:, cache: nil, mode: :online)
|
|
40
|
+
raise ConfigurationError, "mode must be one of #{MODES.inspect}" unless MODES.include?(mode)
|
|
41
|
+
raise ConfigurationError, "offline mode requires a cache source" if mode == :offline && cache.nil?
|
|
42
|
+
|
|
43
|
+
@source = source
|
|
44
|
+
@cache = cache
|
|
45
|
+
@mode = mode
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Read-through convenience constructors — still explicit about every
|
|
49
|
+
# backend, just shorter.
|
|
50
|
+
def self.for_cloud(base_url:, collection:, cache: nil, **source_options)
|
|
51
|
+
new(source: Source.for(:rest, base_url: base_url, collection: collection,
|
|
52
|
+
**source_options), cache: cache)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.for_package(path, type: :directory, **source_options)
|
|
56
|
+
new(source: Source.for(type, path: path, **source_options))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
attr_reader :source, :cache, :mode
|
|
60
|
+
|
|
61
|
+
# @return [String] the record's bytes
|
|
62
|
+
def read(key)
|
|
63
|
+
if @cache
|
|
64
|
+
begin
|
|
65
|
+
return @cache.read(key)
|
|
66
|
+
rescue NotFoundError
|
|
67
|
+
raise if @mode == :offline
|
|
68
|
+
end
|
|
69
|
+
body = @source.read(key)
|
|
70
|
+
write_cache_entry(key, body)
|
|
71
|
+
return body
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
@source.read(key)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @return the model instance built by the consumer's model class
|
|
78
|
+
def get(key, model_class)
|
|
79
|
+
format_for(key).deserialize(read(key), model_class)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def exist?(key)
|
|
83
|
+
read(key)
|
|
84
|
+
true
|
|
85
|
+
rescue NotFoundError
|
|
86
|
+
false
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def keys
|
|
90
|
+
@source.keys
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def each_key(&block)
|
|
94
|
+
keys.each(&block)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def manifest
|
|
98
|
+
@source.manifest
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Filters the collection's manifest entries by metadata key/values.
|
|
102
|
+
# The store is field-agnostic: it matches the literal metadata hash —
|
|
103
|
+
# the domain (relaton pubid, Glossarist concepts) narrows semantics.
|
|
104
|
+
#
|
|
105
|
+
# repo.search(doctype: "rfc", stream: "IETF")
|
|
106
|
+
# repo.search(docid: "RFC 7231")
|
|
107
|
+
#
|
|
108
|
+
# @param filter [Hash<String=>String>] ALL key/values must match
|
|
109
|
+
# @return [Array<Manifest::Entry>] matching entries (empty if none)
|
|
110
|
+
def search(**filter)
|
|
111
|
+
return [] if filter.empty?
|
|
112
|
+
|
|
113
|
+
manifest.entries.select do |entry|
|
|
114
|
+
filter.all? do |k, v|
|
|
115
|
+
mv = entry.metadata[k.to_s]
|
|
116
|
+
mv.is_a?(String) ? mv.casecmp?(v.to_s) : mv == v
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Mirror the whole source into a local package (the cache layout).
|
|
122
|
+
# Returns a Repository over the written package.
|
|
123
|
+
def pull!(into:, collection: nil, force: false)
|
|
124
|
+
pulled = Mirror.pull(@source, into: into, collection: collection, force: force)
|
|
125
|
+
Repository.new(source: pulled, mode: @mode)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
# The write-through target is a Directory package (the Mirror layout);
|
|
131
|
+
# anything else is a configuration error, not a duck-type guess. The
|
|
132
|
+
# package manifest is maintained on every write so later reads —
|
|
133
|
+
# including reference resolution and offline mode — see the entry.
|
|
134
|
+
def write_cache_entry(key, body)
|
|
135
|
+
unless @cache.is_a?(Source::Directory)
|
|
136
|
+
raise ConfigurationError,
|
|
137
|
+
"cache must be a Source::Directory package, got #{@cache.class}"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
location = "entries/#{Source.encode_key(key)}"
|
|
141
|
+
FileUtils.mkdir_p(::File.join(@cache.package_root, "entries"))
|
|
142
|
+
::File.binwrite(::File.join(@cache.package_root, location), body)
|
|
143
|
+
|
|
144
|
+
manifest = begin
|
|
145
|
+
@cache.manifest
|
|
146
|
+
rescue NotFoundError
|
|
147
|
+
Lutaml::Store::Manifest.build([])
|
|
148
|
+
end
|
|
149
|
+
# The source manifest's metadata (e.g. the domain docid) rides
|
|
150
|
+
# along — offline reference resolution depends on it.
|
|
151
|
+
declared = begin
|
|
152
|
+
@source.manifest.entry_for(key)
|
|
153
|
+
rescue StandardError
|
|
154
|
+
nil
|
|
155
|
+
end
|
|
156
|
+
entry = Manifest::Entry.new(
|
|
157
|
+
key: key, location: location,
|
|
158
|
+
digest: "sha256:#{Digest::SHA256.hexdigest(body)}",
|
|
159
|
+
metadata: declared&.metadata || {}
|
|
160
|
+
)
|
|
161
|
+
entries = manifest.entries.reject { |e| e.key == key } + [entry]
|
|
162
|
+
updated = Manifest.build(entries, shards: manifest.shards)
|
|
163
|
+
::File.write(
|
|
164
|
+
::File.join(@cache.package_root, "manifest.json"),
|
|
165
|
+
JSON.pretty_generate(updated.to_hash)
|
|
166
|
+
)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def format_for(key)
|
|
170
|
+
fmt = Format.for_extension(::File.extname(key.to_s))
|
|
171
|
+
fmt ||= @source.options[:default_format] &&
|
|
172
|
+
Format.resolve(@source.options[:default_format])
|
|
173
|
+
fmt || raise(ConfigurationError,
|
|
174
|
+
"cannot determine the format of #{key.inspect}: pass default_format:")
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|