tina4ruby 3.13.39 → 3.13.40
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/lib/tina4/auth.rb +5 -1
- data/lib/tina4/dev_admin.rb +79 -12
- data/lib/tina4/docstore.rb +753 -0
- data/lib/tina4/env.rb +7 -2
- data/lib/tina4/mcp.rb +92 -20
- data/lib/tina4/rack_app.rb +16 -4
- data/lib/tina4/swagger.rb +166 -32
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4.rb +1 -0
- metadata +3 -2
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Tina4 DocStore - pymongo-style document storage with a zero-config SQLite fallback.
|
|
4
|
+
#
|
|
5
|
+
# A document store with the everyday Mongo collection API, backed by SQLite's
|
|
6
|
+
# JSON1 extension when no MongoDB server is configured.
|
|
7
|
+
#
|
|
8
|
+
# require "tina4"
|
|
9
|
+
#
|
|
10
|
+
# orders = Tina4::DocStore.get_collection("orders") # SqliteCollection when no Mongo
|
|
11
|
+
# oid = orders.insert_one({ "customer_id" => 1, "total" => 9.99 }).inserted_id
|
|
12
|
+
# orders.find({ "customer_id" => { "$in" => [1, 2] } }).sort("created_at", -1).limit(10).each do |o|
|
|
13
|
+
# # ...
|
|
14
|
+
# end
|
|
15
|
+
# orders.update_one({ "_id" => oid }, { "$set" => { "status" => "shipped" } })
|
|
16
|
+
#
|
|
17
|
+
# get_collection(name) returns a real Mongo driver collection when a Mongo URI
|
|
18
|
+
# is configured (TINA4_MONGO_URI, else TINA4_SESSION_MONGO_URI - the same env
|
|
19
|
+
# vars the queue/session Mongo backends read), and otherwise a SqliteCollection
|
|
20
|
+
# backed by a local SQLite file. This mirrors the file-based fallbacks the
|
|
21
|
+
# queue, cache, and session subsystems already provide: an app that talks to
|
|
22
|
+
# Mongo in production runs serverless in local dev with no code change - only
|
|
23
|
+
# the backend differs.
|
|
24
|
+
#
|
|
25
|
+
# Design (the SQLite backend):
|
|
26
|
+
# - Each collection is a table (_id TEXT PRIMARY KEY, doc TEXT); doc is JSON.
|
|
27
|
+
# - Query filters are pushed down to SQL over json_extract(doc, '$.field')
|
|
28
|
+
# (indexed + lazy, not a full in-memory scan), supporting equality, $in/$nin,
|
|
29
|
+
# $gt/$gte/$lt/$lte, $ne, $exists, $regex, and implicit-AND / $or / $and.
|
|
30
|
+
# - Updates: $set, $unset, $inc, and full-document replace.
|
|
31
|
+
# - Cursors: sort / limit / skip / projection.
|
|
32
|
+
# - IDs are a built-in 12-byte ObjectId (zero-dependency; interchangeable with
|
|
33
|
+
# a Mongo ObjectId as a 24-hex string).
|
|
34
|
+
#
|
|
35
|
+
# Type round-trip is by value, not by wrapper object, so json_extract stays
|
|
36
|
+
# queryable and sortable: a Time is stored as an ISO-8601 UTC string and an
|
|
37
|
+
# ObjectId as its 24-hex string, and reads rehydrate a strict-ISO string back to
|
|
38
|
+
# Time and a 24-hex string back to ObjectId. That keeps range queries and sorts
|
|
39
|
+
# working on date and id fields - the trade-off (a plain 24-hex / ISO string
|
|
40
|
+
# becomes an ObjectId / Time on read) is acceptable for the local dev store.
|
|
41
|
+
#
|
|
42
|
+
# Deliberate non-goals: aggregation pipeline, $elemMatch, geo. This is the
|
|
43
|
+
# everyday CRUD + filter subset, not full Mongo parity. The operator set matches
|
|
44
|
+
# the Python master exactly.
|
|
45
|
+
|
|
46
|
+
require "json"
|
|
47
|
+
require "time"
|
|
48
|
+
require "securerandom"
|
|
49
|
+
|
|
50
|
+
module Tina4
|
|
51
|
+
module DocStore
|
|
52
|
+
# Raised when a value cannot be parsed as an ObjectId.
|
|
53
|
+
class InvalidId < ArgumentError; end
|
|
54
|
+
|
|
55
|
+
OID_RE = /\A[0-9a-fA-F]{24}\z/.freeze
|
|
56
|
+
ISO_RE = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?\z/.freeze
|
|
57
|
+
|
|
58
|
+
# A 12-byte MongoDB-style ObjectId, with no external dependency.
|
|
59
|
+
#
|
|
60
|
+
# Layout: 4-byte big-endian seconds since epoch, 5-byte per-process random,
|
|
61
|
+
# 3-byte big-endian counter. Renders as a 24-char hex string, so it is
|
|
62
|
+
# interchangeable with a Mongo ObjectId wherever the string form is used.
|
|
63
|
+
class ObjectId
|
|
64
|
+
@counter = SecureRandom.random_number(0xFFFFFF)
|
|
65
|
+
@process = SecureRandom.random_bytes(5)
|
|
66
|
+
@lock = Mutex.new
|
|
67
|
+
|
|
68
|
+
class << self
|
|
69
|
+
attr_reader :lock
|
|
70
|
+
|
|
71
|
+
def next_counter
|
|
72
|
+
@lock.synchronize { @counter = (@counter + 1) & 0xFFFFFF }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def process_bytes = @process
|
|
76
|
+
|
|
77
|
+
def valid?(value)
|
|
78
|
+
new(value)
|
|
79
|
+
true
|
|
80
|
+
rescue InvalidId, TypeError
|
|
81
|
+
false
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
attr_reader :bytes
|
|
86
|
+
|
|
87
|
+
def initialize(oid = nil)
|
|
88
|
+
case oid
|
|
89
|
+
when nil
|
|
90
|
+
@bytes = generate
|
|
91
|
+
when ObjectId
|
|
92
|
+
@bytes = oid.bytes
|
|
93
|
+
when String
|
|
94
|
+
if oid.bytesize == 12 && oid.encoding == Encoding::ASCII_8BIT && !oid.match?(OID_RE)
|
|
95
|
+
@bytes = oid.dup
|
|
96
|
+
elsif oid.match?(OID_RE)
|
|
97
|
+
@bytes = [oid].pack("H*")
|
|
98
|
+
else
|
|
99
|
+
raise InvalidId, "#{oid.inspect} is not a valid 24-character hex ObjectId"
|
|
100
|
+
end
|
|
101
|
+
else
|
|
102
|
+
raise InvalidId, "cannot make an ObjectId from #{oid.class}"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# 4-byte time + 5-byte random + 3-byte counter.
|
|
107
|
+
def generate
|
|
108
|
+
ts = [Time.now.to_i].pack("N") # 4-byte big-endian
|
|
109
|
+
counter = [self.class.next_counter].pack("N")[1, 3] # low 3 bytes big-endian
|
|
110
|
+
ts + self.class.process_bytes + counter
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def generation_time
|
|
114
|
+
ts = @bytes[0, 4].unpack1("N")
|
|
115
|
+
Time.at(ts).utc
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def to_s = @bytes.unpack1("H*")
|
|
119
|
+
|
|
120
|
+
def inspect = "ObjectId('#{self}')"
|
|
121
|
+
|
|
122
|
+
def ==(other)
|
|
123
|
+
other.is_a?(ObjectId) && other.bytes == @bytes
|
|
124
|
+
end
|
|
125
|
+
alias eql? ==
|
|
126
|
+
|
|
127
|
+
def hash = @bytes.hash
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
module_function
|
|
131
|
+
|
|
132
|
+
# -- Value encoding: keep scalars queryable, rehydrate types on read --------
|
|
133
|
+
|
|
134
|
+
def iso(time)
|
|
135
|
+
time.getutc.strftime("%Y-%m-%dT%H:%M:%S") +
|
|
136
|
+
(time.getutc.subsec.zero? ? "" : format(".%06d", time.getutc.usec)) + "Z"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Ruby value -> JSON-serialisable, sortable scalar form (for storage/queries).
|
|
140
|
+
def encode_value(value)
|
|
141
|
+
case value
|
|
142
|
+
when ObjectId then value.to_s
|
|
143
|
+
when Time then iso(value)
|
|
144
|
+
when Hash then value.each_with_object({}) { |(k, v), h| h[k.to_s] = encode_value(v) }
|
|
145
|
+
when Array then value.map { |v| encode_value(v) }
|
|
146
|
+
else value
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Stored JSON value -> Ruby, rehydrating ObjectId (24-hex) and Time (ISO).
|
|
151
|
+
def decode_value(value)
|
|
152
|
+
case value
|
|
153
|
+
when String
|
|
154
|
+
if value.match?(OID_RE)
|
|
155
|
+
ObjectId.new(value)
|
|
156
|
+
elsif value.match?(ISO_RE)
|
|
157
|
+
begin
|
|
158
|
+
Time.parse(value)
|
|
159
|
+
rescue ArgumentError
|
|
160
|
+
value
|
|
161
|
+
end
|
|
162
|
+
else
|
|
163
|
+
value
|
|
164
|
+
end
|
|
165
|
+
when Hash then value.each_with_object({}) { |(k, v), h| h[k] = decode_value(v) }
|
|
166
|
+
when Array then value.map { |v| decode_value(v) }
|
|
167
|
+
else value
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Canonical string key for the _id column.
|
|
172
|
+
def id_key(value)
|
|
173
|
+
case value
|
|
174
|
+
when ObjectId then value.to_s
|
|
175
|
+
when Time then iso(value)
|
|
176
|
+
else value.to_s
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# -- Query translation: Mongo filter Hash -> SQL WHERE over json_extract ----
|
|
181
|
+
|
|
182
|
+
COMPARATORS = { "$gt" => ">", "$gte" => ">=", "$lt" => "<", "$lte" => "<=" }.freeze
|
|
183
|
+
|
|
184
|
+
# Field name -> a JSON path. Dotted names address nested keys.
|
|
185
|
+
def json_path(field)
|
|
186
|
+
segments = field.to_s.split(".").map do |s|
|
|
187
|
+
s.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/) ? s : "\"#{s}\""
|
|
188
|
+
end
|
|
189
|
+
"$." + segments.join(".")
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def extract(field) = "json_extract(doc, '#{json_path(field)}')"
|
|
193
|
+
|
|
194
|
+
def json_type(field) = "json_type(doc, '#{json_path(field)}')"
|
|
195
|
+
|
|
196
|
+
# Compile a Mongo-style filter Hash into [sql_fragment, params].
|
|
197
|
+
# Returns ["1=1", []] for an empty filter. Supports implicit AND across keys,
|
|
198
|
+
# $or / $and, and the per-field operator set.
|
|
199
|
+
def compile_filter(query)
|
|
200
|
+
return ["1=1", []] if query.nil? || query.empty?
|
|
201
|
+
|
|
202
|
+
clauses = []
|
|
203
|
+
params = []
|
|
204
|
+
query.each do |key, value|
|
|
205
|
+
key = key.to_s
|
|
206
|
+
if key == "$or" || key == "$and"
|
|
207
|
+
joiner = key == "$or" ? " OR " : " AND "
|
|
208
|
+
subs = []
|
|
209
|
+
Array(value).each do |sub|
|
|
210
|
+
frag, p = compile_filter(sub)
|
|
211
|
+
subs << "(#{frag})"
|
|
212
|
+
params.concat(p)
|
|
213
|
+
end
|
|
214
|
+
clauses << "(#{subs.join(joiner)})" unless subs.empty?
|
|
215
|
+
next
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
if value.is_a?(Hash) && !value.empty? && value.keys.all? { |k| k.to_s.start_with?("$") }
|
|
219
|
+
value.each do |op, operand|
|
|
220
|
+
frag, p = compile_op(key, op.to_s, operand)
|
|
221
|
+
clauses << frag
|
|
222
|
+
params.concat(p)
|
|
223
|
+
end
|
|
224
|
+
elsif value.nil?
|
|
225
|
+
clauses << "#{extract(key)} IS NULL"
|
|
226
|
+
else
|
|
227
|
+
clauses << "#{extract(key)} = ?"
|
|
228
|
+
params << bind(value)
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
[clauses.empty? ? "1=1" : clauses.join(" AND "), params]
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def compile_op(field, op, operand)
|
|
236
|
+
ex = extract(field)
|
|
237
|
+
if COMPARATORS.key?(op)
|
|
238
|
+
return ["#{ex} #{COMPARATORS[op]} ?", [bind(operand)]]
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
case op
|
|
242
|
+
when "$eq"
|
|
243
|
+
return ["#{ex} IS NULL", []] if operand.nil?
|
|
244
|
+
|
|
245
|
+
["#{ex} = ?", [bind(operand)]]
|
|
246
|
+
when "$ne"
|
|
247
|
+
return ["#{ex} IS NOT NULL", []] if operand.nil?
|
|
248
|
+
|
|
249
|
+
["(#{ex} <> ? OR #{ex} IS NULL)", [bind(operand)]]
|
|
250
|
+
when "$in"
|
|
251
|
+
items = Array(operand)
|
|
252
|
+
return ["0", []] if items.empty?
|
|
253
|
+
|
|
254
|
+
placeholders = (["?"] * items.length).join(",")
|
|
255
|
+
["#{ex} IN (#{placeholders})", items.map { |v| bind(v) }]
|
|
256
|
+
when "$nin"
|
|
257
|
+
items = Array(operand)
|
|
258
|
+
return ["1", []] if items.empty?
|
|
259
|
+
|
|
260
|
+
placeholders = (["?"] * items.length).join(",")
|
|
261
|
+
["(#{ex} NOT IN (#{placeholders}) OR #{ex} IS NULL)", items.map { |v| bind(v) }]
|
|
262
|
+
when "$exists"
|
|
263
|
+
# json_type is NULL when the path is absent; present-but-null still has a type.
|
|
264
|
+
[operand ? "#{json_type(field)} IS NOT NULL" : "#{json_type(field)} IS NULL", []]
|
|
265
|
+
when "$regex"
|
|
266
|
+
pattern = operand.is_a?(Hash) ? operand["$regex"].to_s : operand.to_s
|
|
267
|
+
["#{ex} REGEXP ?", [pattern]]
|
|
268
|
+
else
|
|
269
|
+
raise ArgumentError, "DocStore: unsupported query operator #{op.inspect}"
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Bind a Ruby value for comparison against json_extract output.
|
|
274
|
+
def bind(value)
|
|
275
|
+
case value
|
|
276
|
+
when true then 1
|
|
277
|
+
when false then 0
|
|
278
|
+
when ObjectId, Time then encode_value(value)
|
|
279
|
+
when Integer, Float, String, nil then value
|
|
280
|
+
else JSON.generate(encode_value(value))
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# REGEXP user function body, registered on the SQLite connection.
|
|
285
|
+
def regexp_match(pattern, value)
|
|
286
|
+
return 0 if value.nil?
|
|
287
|
+
|
|
288
|
+
begin
|
|
289
|
+
Regexp.new(pattern.to_s).match?(value.to_s) ? 1 : 0
|
|
290
|
+
rescue RegexpError
|
|
291
|
+
0
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# -- Projection / update helpers --------------------------------------------
|
|
296
|
+
|
|
297
|
+
def project(doc, projection)
|
|
298
|
+
return doc if projection.nil? || projection.empty?
|
|
299
|
+
|
|
300
|
+
proj = projection.transform_keys(&:to_s)
|
|
301
|
+
include_keys = proj.select { |k, v| truthy?(v) && k != "_id" }.keys
|
|
302
|
+
exclude_keys = proj.reject { |_k, v| truthy?(v) }.keys
|
|
303
|
+
|
|
304
|
+
unless include_keys.empty?
|
|
305
|
+
out = {}
|
|
306
|
+
include_keys.each { |k| out[k] = doc[k] if doc.key?(k) }
|
|
307
|
+
out["_id"] = doc["_id"] if proj.fetch("_id", 1) && doc.key?("_id") && truthy?(proj.fetch("_id", 1))
|
|
308
|
+
return out
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
doc.reject { |k, _v| exclude_keys.include?(k) }
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def truthy?(value)
|
|
315
|
+
!(value.nil? || value == false || value == 0)
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def apply_update(doc, update)
|
|
319
|
+
update = update.transform_keys(&:to_s)
|
|
320
|
+
unless update.keys.any? { |k| k.start_with?("$") }
|
|
321
|
+
# Full-document replace (keep the existing _id).
|
|
322
|
+
new_doc = deep_string_keys(update)
|
|
323
|
+
new_doc["_id"] = doc["_id"] unless new_doc.key?("_id")
|
|
324
|
+
return new_doc
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
new_doc = doc.dup
|
|
328
|
+
update.each do |op, fields|
|
|
329
|
+
case op
|
|
330
|
+
when "$set"
|
|
331
|
+
fields.each { |k, v| set_path(new_doc, k.to_s, v) }
|
|
332
|
+
when "$unset"
|
|
333
|
+
fields.each_key { |k| unset_path(new_doc, k.to_s) }
|
|
334
|
+
when "$inc"
|
|
335
|
+
fields.each { |k, v| set_path(new_doc, k.to_s, (get_path(new_doc, k.to_s) || 0) + v) }
|
|
336
|
+
else
|
|
337
|
+
raise ArgumentError, "DocStore: unsupported update operator #{op.inspect}"
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
new_doc
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def deep_string_keys(value)
|
|
344
|
+
case value
|
|
345
|
+
when Hash then value.each_with_object({}) { |(k, v), h| h[k.to_s] = deep_string_keys(v) }
|
|
346
|
+
when Array then value.map { |v| deep_string_keys(v) }
|
|
347
|
+
else value
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def set_path(doc, dotted, value)
|
|
352
|
+
parts = dotted.split(".")
|
|
353
|
+
node = doc
|
|
354
|
+
parts[0...-1].each do |p|
|
|
355
|
+
node[p] = {} unless node[p].is_a?(Hash)
|
|
356
|
+
node = node[p]
|
|
357
|
+
end
|
|
358
|
+
node[parts[-1]] = value
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def unset_path(doc, dotted)
|
|
362
|
+
parts = dotted.split(".")
|
|
363
|
+
node = doc
|
|
364
|
+
parts[0...-1].each do |p|
|
|
365
|
+
node = node[p]
|
|
366
|
+
return unless node.is_a?(Hash)
|
|
367
|
+
end
|
|
368
|
+
node.delete(parts[-1]) if node.is_a?(Hash)
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def get_path(doc, dotted)
|
|
372
|
+
parts = dotted.split(".")
|
|
373
|
+
node = doc
|
|
374
|
+
parts.each do |p|
|
|
375
|
+
return nil unless node.is_a?(Hash)
|
|
376
|
+
|
|
377
|
+
node = node[p]
|
|
378
|
+
end
|
|
379
|
+
node
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# -- Result wrappers ---------------------------------------------------------
|
|
383
|
+
|
|
384
|
+
InsertOneResult = Struct.new(:inserted_id)
|
|
385
|
+
InsertManyResult = Struct.new(:inserted_ids)
|
|
386
|
+
UpdateResult = Struct.new(:matched_count, :modified_count, :upserted_id)
|
|
387
|
+
DeleteResult = Struct.new(:deleted_count)
|
|
388
|
+
|
|
389
|
+
# -- Cursor ----------------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
# Lazy result cursor. Builds and runs SQL only when iterated.
|
|
392
|
+
class Cursor
|
|
393
|
+
include Enumerable
|
|
394
|
+
|
|
395
|
+
def initialize(collection, where, params, projection = nil)
|
|
396
|
+
@collection = collection
|
|
397
|
+
@where = where
|
|
398
|
+
@params = params
|
|
399
|
+
@projection = projection
|
|
400
|
+
@sort = []
|
|
401
|
+
@limit = nil
|
|
402
|
+
@skip = 0
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def sort(key_or_list, direction = 1)
|
|
406
|
+
if key_or_list.is_a?(String) || key_or_list.is_a?(Symbol)
|
|
407
|
+
@sort << [key_or_list.to_s, direction]
|
|
408
|
+
else
|
|
409
|
+
key_or_list.each { |k, d| @sort << [k.to_s, d] }
|
|
410
|
+
end
|
|
411
|
+
self
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def limit(num)
|
|
415
|
+
@limit = num.to_i
|
|
416
|
+
self
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def skip(num)
|
|
420
|
+
@skip = num.to_i
|
|
421
|
+
self
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def build_sql
|
|
425
|
+
sql = "SELECT doc FROM #{@collection.quoted_name} WHERE #{@where}"
|
|
426
|
+
unless @sort.empty?
|
|
427
|
+
order = @sort.map { |k, d| "#{DocStore.extract(k)} #{d.to_i.negative? ? 'DESC' : 'ASC'}" }.join(", ")
|
|
428
|
+
sql += " ORDER BY #{order}"
|
|
429
|
+
end
|
|
430
|
+
if @limit
|
|
431
|
+
sql += " LIMIT #{@limit.to_i}"
|
|
432
|
+
sql += " OFFSET #{@skip.to_i}" if @skip.positive?
|
|
433
|
+
elsif @skip.positive?
|
|
434
|
+
sql += " LIMIT -1 OFFSET #{@skip.to_i}"
|
|
435
|
+
end
|
|
436
|
+
sql
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def each
|
|
440
|
+
return enum_for(:each) unless block_given?
|
|
441
|
+
|
|
442
|
+
@collection.connection.execute(build_sql, @params).each do |row|
|
|
443
|
+
doc_text = row.is_a?(Hash) ? (row["doc"] || row[:doc] || row.values.first) : row.first
|
|
444
|
+
yield @collection.load_doc(doc_text, @projection)
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def to_a
|
|
449
|
+
out = []
|
|
450
|
+
each { |doc| out << doc }
|
|
451
|
+
out
|
|
452
|
+
end
|
|
453
|
+
alias to_list to_a
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
# -- Collection -------------------------------------------------------------
|
|
457
|
+
|
|
458
|
+
# A SQLite-backed collection exposing the everyday Mongo API.
|
|
459
|
+
class SqliteCollection
|
|
460
|
+
attr_reader :connection
|
|
461
|
+
|
|
462
|
+
def initialize(conn, name)
|
|
463
|
+
@connection = conn
|
|
464
|
+
@name = name.to_s
|
|
465
|
+
raise ArgumentError, "DocStore: invalid collection name #{name.inspect}" unless @name.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
|
|
466
|
+
|
|
467
|
+
@quoted_name = "\"#{@name}\""
|
|
468
|
+
@connection.execute(
|
|
469
|
+
"CREATE TABLE IF NOT EXISTS #{@quoted_name} (_id TEXT PRIMARY KEY, doc TEXT NOT NULL)"
|
|
470
|
+
)
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def quoted_name = @quoted_name
|
|
474
|
+
|
|
475
|
+
# -- helpers --
|
|
476
|
+
def dump(document) = JSON.generate(DocStore.encode_value(document))
|
|
477
|
+
|
|
478
|
+
def load_doc(doc_text, projection = nil)
|
|
479
|
+
doc = DocStore.decode_value(JSON.parse(doc_text))
|
|
480
|
+
projection ? DocStore.project(doc, projection) : doc
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
# -- writes --
|
|
484
|
+
def insert_one(document)
|
|
485
|
+
doc = stringify(document)
|
|
486
|
+
doc["_id"] = ObjectId.new unless doc.key?("_id")
|
|
487
|
+
@connection.execute(
|
|
488
|
+
"INSERT INTO #{@quoted_name} (_id, doc) VALUES (?, ?)",
|
|
489
|
+
[DocStore.id_key(doc["_id"]), dump(doc)]
|
|
490
|
+
)
|
|
491
|
+
InsertOneResult.new(doc["_id"])
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def insert_many(documents)
|
|
495
|
+
ids = []
|
|
496
|
+
documents.each do |document|
|
|
497
|
+
doc = stringify(document)
|
|
498
|
+
doc["_id"] = ObjectId.new unless doc.key?("_id")
|
|
499
|
+
ids << doc["_id"]
|
|
500
|
+
@connection.execute(
|
|
501
|
+
"INSERT INTO #{@quoted_name} (_id, doc) VALUES (?, ?)",
|
|
502
|
+
[DocStore.id_key(doc["_id"]), dump(doc)]
|
|
503
|
+
)
|
|
504
|
+
end
|
|
505
|
+
InsertManyResult.new(ids)
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
# -- reads --
|
|
509
|
+
def find(filter = nil, projection = nil)
|
|
510
|
+
where, params = DocStore.compile_filter(filter || {})
|
|
511
|
+
Cursor.new(self, where, params, projection)
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def find_one(filter = nil, projection = nil)
|
|
515
|
+
find(filter, projection).limit(1).to_a.first
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
def count_documents(filter = nil)
|
|
519
|
+
where, params = DocStore.compile_filter(filter || {})
|
|
520
|
+
row = @connection.execute("SELECT count(*) AS c FROM #{@quoted_name} WHERE #{where}", params).first
|
|
521
|
+
scalar(row).to_i
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
def estimated_document_count
|
|
525
|
+
row = @connection.execute("SELECT count(*) AS c FROM #{@quoted_name}").first
|
|
526
|
+
scalar(row).to_i
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
def distinct(key, filter = nil)
|
|
530
|
+
seen = []
|
|
531
|
+
find(filter).each do |doc|
|
|
532
|
+
v = doc[key.to_s]
|
|
533
|
+
seen << v unless seen.include?(v)
|
|
534
|
+
end
|
|
535
|
+
seen
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
# -- updates (filter pushed to SQL; mutation applied per matched doc) --
|
|
539
|
+
def update_one(filter, update, upsert: false)
|
|
540
|
+
rows = matching_rows(filter, limit: 1)
|
|
541
|
+
if rows.empty?
|
|
542
|
+
return do_upsert(filter, update) if upsert
|
|
543
|
+
|
|
544
|
+
return UpdateResult.new(0, 0, nil)
|
|
545
|
+
end
|
|
546
|
+
old_id, doc_text = rows.first
|
|
547
|
+
doc = DocStore.decode_value(JSON.parse(doc_text))
|
|
548
|
+
new_doc = DocStore.apply_update(doc, update)
|
|
549
|
+
modified = write_back(old_id, new_doc)
|
|
550
|
+
UpdateResult.new(1, modified ? 1 : 0, nil)
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
def update_many(filter, update, upsert: false)
|
|
554
|
+
rows = matching_rows(filter)
|
|
555
|
+
return do_upsert(filter, update) if rows.empty? && upsert
|
|
556
|
+
|
|
557
|
+
matched = 0
|
|
558
|
+
modified = 0
|
|
559
|
+
rows.each do |old_id, doc_text|
|
|
560
|
+
matched += 1
|
|
561
|
+
doc = DocStore.decode_value(JSON.parse(doc_text))
|
|
562
|
+
new_doc = DocStore.apply_update(doc, update)
|
|
563
|
+
modified += 1 if write_back(old_id, new_doc)
|
|
564
|
+
end
|
|
565
|
+
UpdateResult.new(matched, modified, nil)
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
def replace_one(filter, replacement, upsert: false)
|
|
569
|
+
rows = matching_rows(filter, limit: 1)
|
|
570
|
+
if rows.empty?
|
|
571
|
+
if upsert
|
|
572
|
+
doc = stringify(replacement)
|
|
573
|
+
doc["_id"] = ObjectId.new unless doc.key?("_id")
|
|
574
|
+
insert_one(doc)
|
|
575
|
+
return UpdateResult.new(0, 0, doc["_id"])
|
|
576
|
+
end
|
|
577
|
+
return UpdateResult.new(0, 0, nil)
|
|
578
|
+
end
|
|
579
|
+
old_id, doc_text = rows.first
|
|
580
|
+
doc = stringify(replacement)
|
|
581
|
+
doc["_id"] = DocStore.decode_value(JSON.parse(doc_text))["_id"] unless doc.key?("_id")
|
|
582
|
+
write_back(old_id, doc)
|
|
583
|
+
UpdateResult.new(1, 1, nil)
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
# -- deletes --
|
|
587
|
+
def delete_one(filter)
|
|
588
|
+
rows = matching_rows(filter, limit: 1)
|
|
589
|
+
return DeleteResult.new(0) if rows.empty?
|
|
590
|
+
|
|
591
|
+
@connection.execute("DELETE FROM #{@quoted_name} WHERE _id = ?", [rows.first.first])
|
|
592
|
+
DeleteResult.new(1)
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
def delete_many(filter)
|
|
596
|
+
where, params = DocStore.compile_filter(filter || {})
|
|
597
|
+
before = count_documents(filter)
|
|
598
|
+
@connection.execute("DELETE FROM #{@quoted_name} WHERE #{where}", params)
|
|
599
|
+
DeleteResult.new(before)
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
def drop
|
|
603
|
+
@connection.execute("DROP TABLE IF EXISTS #{@quoted_name}")
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
private
|
|
607
|
+
|
|
608
|
+
def stringify(document)
|
|
609
|
+
document.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
def matching_rows(filter, limit: nil)
|
|
613
|
+
where, params = DocStore.compile_filter(filter || {})
|
|
614
|
+
sql = "SELECT _id, doc FROM #{@quoted_name} WHERE #{where}"
|
|
615
|
+
sql += " LIMIT #{limit.to_i}" if limit
|
|
616
|
+
@connection.execute(sql, params).map do |row|
|
|
617
|
+
if row.is_a?(Hash)
|
|
618
|
+
[row["_id"] || row[:_id], row["doc"] || row[:doc]]
|
|
619
|
+
else
|
|
620
|
+
[row[0], row[1]]
|
|
621
|
+
end
|
|
622
|
+
end
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
def write_back(old_id, new_doc)
|
|
626
|
+
new_key = DocStore.id_key(new_doc["_id"])
|
|
627
|
+
@connection.execute(
|
|
628
|
+
"UPDATE #{@quoted_name} SET _id = ?, doc = ? WHERE _id = ?",
|
|
629
|
+
[new_key, dump(new_doc), old_id]
|
|
630
|
+
)
|
|
631
|
+
true
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
def do_upsert(filter, update)
|
|
635
|
+
# Seed a document from the filter's equality terms, then apply the update.
|
|
636
|
+
seed = {}
|
|
637
|
+
(filter || {}).each do |k, v|
|
|
638
|
+
k = k.to_s
|
|
639
|
+
seed[k] = v unless k.start_with?("$") || v.is_a?(Hash)
|
|
640
|
+
end
|
|
641
|
+
doc = DocStore.apply_update(seed, update)
|
|
642
|
+
doc["_id"] = ObjectId.new unless doc.key?("_id")
|
|
643
|
+
insert_one(doc)
|
|
644
|
+
UpdateResult.new(0, 0, doc["_id"])
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
def scalar(row)
|
|
648
|
+
return 0 if row.nil?
|
|
649
|
+
return (row["c"] || row[:c] || row.values.first) if row.is_a?(Hash)
|
|
650
|
+
|
|
651
|
+
row.first
|
|
652
|
+
end
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
# -- Database + selection -----------------------------------------------------
|
|
656
|
+
|
|
657
|
+
# A SQLite-backed document database (a file of collection tables).
|
|
658
|
+
class SqliteDatabase
|
|
659
|
+
attr_reader :path
|
|
660
|
+
|
|
661
|
+
def initialize(path = nil)
|
|
662
|
+
@path = path || ENV["TINA4_DOC_STORE_PATH"] || "data/tina4_docstore.db"
|
|
663
|
+
require "sqlite3"
|
|
664
|
+
if @path != ":memory:"
|
|
665
|
+
dir = File.dirname(@path)
|
|
666
|
+
require "fileutils"
|
|
667
|
+
FileUtils.mkdir_p(dir) unless dir.empty? || dir == "." || File.directory?(dir)
|
|
668
|
+
end
|
|
669
|
+
@conn = SQLite3::Database.new(@path)
|
|
670
|
+
# Register the REGEXP user function for the $regex operator.
|
|
671
|
+
@conn.create_function("regexp", 2) do |func, pattern, value|
|
|
672
|
+
func.result = DocStore.regexp_match(pattern, value)
|
|
673
|
+
end
|
|
674
|
+
@collections = {}
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
def connection = @conn
|
|
678
|
+
|
|
679
|
+
def get_collection(name)
|
|
680
|
+
@collections[name.to_s] ||= SqliteCollection.new(@conn, name)
|
|
681
|
+
end
|
|
682
|
+
alias [] get_collection
|
|
683
|
+
|
|
684
|
+
def list_collection_names
|
|
685
|
+
@conn.execute(
|
|
686
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
|
687
|
+
).map { |row| row.is_a?(Hash) ? (row["name"] || row[:name] || row.values.first) : row.first }
|
|
688
|
+
end
|
|
689
|
+
|
|
690
|
+
def close
|
|
691
|
+
@conn.close
|
|
692
|
+
end
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
module_function
|
|
696
|
+
|
|
697
|
+
# The configured Mongo URI, reusing the app-wide queue/session env vars.
|
|
698
|
+
# Canonical TINA4_SESSION_MONGO_URI; TINA4_SESSION_MONGO_URL is a legacy alias.
|
|
699
|
+
def mongo_uri
|
|
700
|
+
(ENV["TINA4_MONGO_URI"] ||
|
|
701
|
+
ENV["TINA4_SESSION_MONGO_URI"] ||
|
|
702
|
+
ENV["TINA4_SESSION_MONGO_URL"] ||
|
|
703
|
+
"").strip
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
# True when no Mongo is configured, so the SQLite fallback is in effect.
|
|
707
|
+
def serverless?
|
|
708
|
+
return true if mongo_uri.empty?
|
|
709
|
+
|
|
710
|
+
begin
|
|
711
|
+
require "mongo"
|
|
712
|
+
false
|
|
713
|
+
rescue LoadError
|
|
714
|
+
# A URI is set but the driver is absent: degrade to the local store
|
|
715
|
+
# rather than crash.
|
|
716
|
+
true
|
|
717
|
+
end
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
@default_db = nil
|
|
721
|
+
@default_lock = Mutex.new
|
|
722
|
+
|
|
723
|
+
def default_db
|
|
724
|
+
return @default_db if @default_db
|
|
725
|
+
|
|
726
|
+
@default_lock.synchronize do
|
|
727
|
+
@default_db ||= SqliteDatabase.new
|
|
728
|
+
end
|
|
729
|
+
end
|
|
730
|
+
|
|
731
|
+
# Return a collection for `name`.
|
|
732
|
+
#
|
|
733
|
+
# A real Mongo driver Collection when a Mongo URI is configured (and the
|
|
734
|
+
# mongo gem is installed); otherwise a SqliteCollection backed by the local
|
|
735
|
+
# SQLite file. Same call sites either way - only the backend differs.
|
|
736
|
+
def get_collection(name)
|
|
737
|
+
return default_db.get_collection(name) if serverless?
|
|
738
|
+
|
|
739
|
+
require "mongo"
|
|
740
|
+
db_name = ENV["TINA4_MONGO_DB"] || ENV["TINA4_SESSION_MONGO_DB"] || "tina4"
|
|
741
|
+
client = Mongo::Client.new(mongo_uri, database: db_name)
|
|
742
|
+
client[name]
|
|
743
|
+
end
|
|
744
|
+
|
|
745
|
+
# Drop the cached default SQLite store (test helper).
|
|
746
|
+
def reset_default_store
|
|
747
|
+
@default_lock.synchronize do
|
|
748
|
+
@default_db&.close
|
|
749
|
+
@default_db = nil
|
|
750
|
+
end
|
|
751
|
+
end
|
|
752
|
+
end
|
|
753
|
+
end
|