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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +22 -13
  3. data/README.adoc +56 -0
  4. data/docs/astro.config.mjs +12 -0
  5. data/docs/package-lock.json +6098 -0
  6. data/docs/package.json +18 -0
  7. data/docs/public/favicon.svg +1 -0
  8. data/docs/public/lutaml-logo_logo-full-dark.svg +1 -0
  9. data/docs/public/lutaml-logo_logo-full-light.svg +1 -0
  10. data/docs/src/layouts/Base.astro +44 -0
  11. data/docs/src/layouts/Docs.astro +41 -0
  12. data/docs/src/pages/adapters.md +46 -0
  13. data/docs/src/pages/architecture.md +68 -0
  14. data/docs/src/pages/cloud-contract.md +48 -0
  15. data/docs/src/pages/formats.md +54 -0
  16. data/docs/src/pages/http-cache.md +43 -0
  17. data/docs/src/pages/index.md +82 -0
  18. data/docs/src/pages/quick-start.md +109 -0
  19. data/docs/src/pages/sources.md +83 -0
  20. data/docs/src/pages/stores.md +110 -0
  21. data/docs/src/styles/global.css +10 -0
  22. data/lib/lutaml/store/adapter/base.rb +28 -0
  23. data/lib/lutaml/store/adapter/memory.rb +213 -121
  24. data/lib/lutaml/store/adapter/sqlite.rb +181 -7
  25. data/lib/lutaml/store/database_store.rb +302 -49
  26. data/lib/lutaml/store/format.rb +13 -0
  27. data/lib/lutaml/store/manifest.rb +85 -0
  28. data/lib/lutaml/store/mirror.rb +94 -0
  29. data/lib/lutaml/store/predicate.rb +248 -0
  30. data/lib/lutaml/store/query.rb +259 -0
  31. data/lib/lutaml/store/repository.rb +178 -0
  32. data/lib/lutaml/store/source/base.rb +137 -0
  33. data/lib/lutaml/store/source/directory.rb +49 -0
  34. data/lib/lutaml/store/source/https.rb +109 -0
  35. data/lib/lutaml/store/source/rest.rb +61 -0
  36. data/lib/lutaml/store/source/zip.rb +52 -0
  37. data/lib/lutaml/store/source.rb +55 -0
  38. data/lib/lutaml/store/version.rb +1 -1
  39. data/lib/lutaml/store.rb +7 -0
  40. 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