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.
@@ -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