tina4ruby 3.13.94 → 3.13.97
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/CHANGELOG.md +883 -0
- data/README.md +1 -1
- data/lib/tina4/auth.rb +166 -87
- data/lib/tina4/auto_crud.rb +29 -32
- data/lib/tina4/cache_backends/base_backend.rb +19 -0
- data/lib/tina4/cache_backends/database_backend.rb +29 -0
- data/lib/tina4/cache_backends/memcached_backend.rb +124 -13
- data/lib/tina4/cache_backends/memory_backend.rb +15 -0
- data/lib/tina4/cache_backends/redis_backend.rb +173 -52
- data/lib/tina4/cache_backends.rb +10 -1
- data/lib/tina4/cli.rb +23 -39
- data/lib/tina4/cors.rb +186 -30
- data/lib/tina4/database/sqlite3_adapter.rb +4 -1
- data/lib/tina4/database.rb +322 -22
- data/lib/tina4/database_adapter.rb +178 -0
- data/lib/tina4/database_result.rb +63 -17
- data/lib/tina4/database_url.rb +363 -0
- data/lib/tina4/dev.rb +0 -1
- data/lib/tina4/dev_admin.rb +118 -20
- data/lib/tina4/dispatch_pipeline.rb +605 -0
- data/lib/tina4/docstore.rb +274 -60
- data/lib/tina4/drivers/firebird_driver.rb +118 -4
- data/lib/tina4/drivers/mongodb_driver.rb +19 -4
- data/lib/tina4/drivers/mssql_driver.rb +73 -10
- data/lib/tina4/drivers/mysql_driver.rb +71 -4
- data/lib/tina4/drivers/odbc_driver.rb +40 -4
- data/lib/tina4/drivers/postgres_driver.rb +97 -10
- data/lib/tina4/drivers/sqlite_driver.rb +21 -2
- data/lib/tina4/env.rb +176 -34
- data/lib/tina4/field_types.rb +12 -0
- data/lib/tina4/health.rb +30 -14
- data/lib/tina4/job.rb +15 -5
- data/lib/tina4/log.rb +236 -32
- data/lib/tina4/mcp.rb +11 -5
- data/lib/tina4/messenger.rb +248 -36
- data/lib/tina4/metrics.rb +179 -891
- data/lib/tina4/middleware.rb +191 -56
- data/lib/tina4/migration.rb +17 -1
- data/lib/tina4/orm.rb +114 -17
- data/lib/tina4/public/css/tina4.min.css +1 -1
- data/lib/tina4/queue.rb +154 -9
- data/lib/tina4/queue_backends/kafka_backend.rb +191 -2
- data/lib/tina4/queue_backends/lite_backend.rb +121 -25
- data/lib/tina4/queue_backends/mongo_backend.rb +146 -10
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +208 -1
- data/lib/tina4/rack_app.rb +94 -316
- data/lib/tina4/request.rb +48 -8
- data/lib/tina4/response.rb +42 -1
- data/lib/tina4/response_cache.rb +142 -24
- data/lib/tina4/router.rb +141 -12
- data/lib/tina4/session.rb +256 -33
- data/lib/tina4/session_handlers/database_handler.rb +185 -20
- data/lib/tina4/session_handlers/file_handler.rb +113 -21
- data/lib/tina4/session_handlers/memcached_handler.rb +183 -0
- data/lib/tina4/session_handlers/mongo_handler.rb +232 -15
- data/lib/tina4/session_handlers/mongo_wire_client.rb +300 -0
- data/lib/tina4/session_handlers/redis_handler.rb +20 -6
- data/lib/tina4/session_handlers/valkey_handler.rb +18 -4
- data/lib/tina4/shutdown.rb +180 -30
- data/lib/tina4/sql_translator.rb +110 -0
- data/lib/tina4/swagger.rb +50 -18
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +28 -6
- data/lib/tina4.rb +289 -37
- metadata +35 -17
- data/lib/tina4/scss_compiler.rb +0 -349
data/lib/tina4/docstore.rb
CHANGED
|
@@ -46,12 +46,21 @@
|
|
|
46
46
|
require "json"
|
|
47
47
|
require "time"
|
|
48
48
|
require "securerandom"
|
|
49
|
+
require "delegate"
|
|
49
50
|
|
|
50
51
|
module Tina4
|
|
51
52
|
module DocStore
|
|
52
53
|
# Raised when a value cannot be parsed as an ObjectId.
|
|
53
54
|
class InvalidId < ArgumentError; end
|
|
54
55
|
|
|
56
|
+
# A Mongo URI is configured but the MongoDB driver is not installed.
|
|
57
|
+
#
|
|
58
|
+
# ADR-0024 rule 3, settled for DocStore by ADR-0033: a provider that cannot
|
|
59
|
+
# honour an operation must RAISE, naming the provider and what is missing.
|
|
60
|
+
# Falling back to the local SQLite store here would send production writes
|
|
61
|
+
# to a container-local file nobody reads.
|
|
62
|
+
class DocStoreDriverMissing < StandardError; end
|
|
63
|
+
|
|
55
64
|
OID_RE = /\A[0-9a-fA-F]{24}\z/.freeze
|
|
56
65
|
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
66
|
|
|
@@ -193,6 +202,45 @@ module Tina4
|
|
|
193
202
|
|
|
194
203
|
def json_type(field) = "json_type(doc, '#{json_path(field)}')"
|
|
195
204
|
|
|
205
|
+
# A rowset over the field: one row per element of an array, one for a scalar.
|
|
206
|
+
def json_each(field) = "json_each(doc, '#{json_path(field)}')"
|
|
207
|
+
|
|
208
|
+
# True when the field is an ARRAY and any element satisfies +condition+.
|
|
209
|
+
#
|
|
210
|
+
# MongoDB's rule for an array-valued field is that a condition matches when
|
|
211
|
+
# ANY ELEMENT matches it. json_each yields one row per element, so EXISTS is
|
|
212
|
+
# the direct translation.
|
|
213
|
+
#
|
|
214
|
+
# The `= 'array'` guard is load-bearing: json_each over an OBJECT iterates
|
|
215
|
+
# its VALUES, and Mongo never matches an object field against one of its
|
|
216
|
+
# values - {"obj" => "x"} must NOT match {"obj" => {"city" => "x"}}.
|
|
217
|
+
def any_element(field, condition)
|
|
218
|
+
"(#{json_type(field)} = 'array' AND EXISTS (SELECT 1 FROM #{json_each(field)} WHERE #{condition}))"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Compile `field == operand` under Mongo's array rule -> [sql, params].
|
|
222
|
+
def equality(field, operand)
|
|
223
|
+
ex = extract(field)
|
|
224
|
+
return ["#{ex} IS NULL", []] if operand.nil?
|
|
225
|
+
|
|
226
|
+
# An Array or Hash operand compares against the WHOLE value, never
|
|
227
|
+
# element-wise: {"tags" => ["x","y"]} is exact-array equality.
|
|
228
|
+
return ["#{ex} = ?", [bind(operand)]] if operand.is_a?(Array) || operand.is_a?(Hash)
|
|
229
|
+
|
|
230
|
+
["(#{ex} = ? OR #{any_element(field, "value = ?")})", [bind(operand), bind(operand)]]
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Compile `field OP operand` under Mongo's array rule -> [sql, params].
|
|
234
|
+
#
|
|
235
|
+
# The `<> 'array'` guard on the scalar branch removes a measured FALSE
|
|
236
|
+
# POSITIVE: json_extract of an array returns its JSON TEXT, and SQLite sorts
|
|
237
|
+
# any text above any number, so {"nums" => {"$gt" => 9}} matched [1,2,3].
|
|
238
|
+
def compare(field, sql_op, operand)
|
|
239
|
+
ex = extract(field)
|
|
240
|
+
["((#{json_type(field)} <> 'array' AND #{ex} #{sql_op} ?) OR #{any_element(field, "value #{sql_op} ?")})",
|
|
241
|
+
[bind(operand), bind(operand)]]
|
|
242
|
+
end
|
|
243
|
+
|
|
196
244
|
# Compile a Mongo-style filter Hash into [sql_fragment, params].
|
|
197
245
|
# Returns ["1=1", []] for an empty filter. Supports implicit AND across keys,
|
|
198
246
|
# $or / $and, and the per-field operator set.
|
|
@@ -221,11 +269,12 @@ module Tina4
|
|
|
221
269
|
clauses << frag
|
|
222
270
|
params.concat(p)
|
|
223
271
|
end
|
|
224
|
-
elsif value.nil?
|
|
225
|
-
clauses << "#{extract(key)} IS NULL"
|
|
226
272
|
else
|
|
227
|
-
|
|
228
|
-
|
|
273
|
+
# equality - the same helper $eq uses, so the array rule applies
|
|
274
|
+
# whether the filter reads {"tags" => "x"} or {"tags" => {"$eq" => "x"}}
|
|
275
|
+
frag, p = equality(key, value)
|
|
276
|
+
clauses << frag
|
|
277
|
+
params.concat(p)
|
|
229
278
|
end
|
|
230
279
|
end
|
|
231
280
|
|
|
@@ -234,37 +283,40 @@ module Tina4
|
|
|
234
283
|
|
|
235
284
|
def compile_op(field, op, operand)
|
|
236
285
|
ex = extract(field)
|
|
237
|
-
if COMPARATORS.key?(op)
|
|
238
|
-
return ["#{ex} #{COMPARATORS[op]} ?", [bind(operand)]]
|
|
239
|
-
end
|
|
286
|
+
return compare(field, COMPARATORS[op], operand) if COMPARATORS.key?(op)
|
|
240
287
|
|
|
241
288
|
case op
|
|
242
289
|
when "$eq"
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
["#{ex} = ?", [bind(operand)]]
|
|
290
|
+
equality(field, operand)
|
|
246
291
|
when "$ne"
|
|
247
292
|
return ["#{ex} IS NOT NULL", []] if operand.nil?
|
|
248
293
|
|
|
249
|
-
|
|
294
|
+
sql, p = equality(field, operand)
|
|
295
|
+
# A MISSING field satisfies $ne in Mongo, and SQL's NOT(NULL) is NULL
|
|
296
|
+
# rather than true - so the IS NULL arm is required, not decoration.
|
|
297
|
+
["(NOT (#{sql}) OR #{ex} IS NULL)", p]
|
|
250
298
|
when "$in"
|
|
251
299
|
items = Array(operand)
|
|
252
300
|
return ["0", []] if items.empty?
|
|
253
301
|
|
|
254
302
|
placeholders = (["?"] * items.length).join(",")
|
|
255
|
-
|
|
303
|
+
bound = items.map { |v| bind(v) }
|
|
304
|
+
["(#{ex} IN (#{placeholders}) OR #{any_element(field, "value IN (#{placeholders})")})", bound + bound]
|
|
256
305
|
when "$nin"
|
|
257
306
|
items = Array(operand)
|
|
258
307
|
return ["1", []] if items.empty?
|
|
259
308
|
|
|
260
309
|
placeholders = (["?"] * items.length).join(",")
|
|
261
|
-
|
|
310
|
+
bound = items.map { |v| bind(v) }
|
|
311
|
+
["(NOT (#{ex} IN (#{placeholders}) OR #{any_element(field, "value IN (#{placeholders})")}) OR #{ex} IS NULL)",
|
|
312
|
+
bound + bound]
|
|
262
313
|
when "$exists"
|
|
263
314
|
# json_type is NULL when the path is absent; present-but-null still has a type.
|
|
264
315
|
[operand ? "#{json_type(field)} IS NOT NULL" : "#{json_type(field)} IS NULL", []]
|
|
265
316
|
when "$regex"
|
|
266
317
|
pattern = operand.is_a?(Hash) ? operand["$regex"].to_s : operand.to_s
|
|
267
|
-
["#{ex} REGEXP ?",
|
|
318
|
+
["((#{json_type(field)} <> 'array' AND #{ex} REGEXP ?) OR #{any_element(field, "value REGEXP ?")})",
|
|
319
|
+
[pattern, pattern]]
|
|
268
320
|
else
|
|
269
321
|
raise ArgumentError, "DocStore: unsupported query operator #{op.inspect}"
|
|
270
322
|
end
|
|
@@ -292,8 +344,37 @@ module Tina4
|
|
|
292
344
|
end
|
|
293
345
|
end
|
|
294
346
|
|
|
347
|
+
# Normalise the driver's three sort spellings to an array of [key, direction].
|
|
348
|
+
#
|
|
349
|
+
# ADR-0036. Mongo::Collection::View#sort takes ONE spec document; the
|
|
350
|
+
# framework documents sort(key, direction); and a list of pairs is the third
|
|
351
|
+
# form pymongo and the Node driver both take. Measured 2026-08-04 against a
|
|
352
|
+
# real MongoDB, ALL THREE had to work on both providers before one spelling
|
|
353
|
+
# could be called portable: the two-argument form raised ArgumentError on
|
|
354
|
+
# the Ruby driver, and the pairs form reached the server as an ARRAY and
|
|
355
|
+
# came back "[14:TypeMismatch]: Expected field sort to be of type object".
|
|
356
|
+
def sort_spec(key_or_list, direction = 1)
|
|
357
|
+
case key_or_list
|
|
358
|
+
when String, Symbol then [[key_or_list.to_s, direction]]
|
|
359
|
+
else key_or_list.map { |key, sort_direction| [key.to_s, sort_direction] }
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
295
363
|
# -- Projection / update helpers --------------------------------------------
|
|
296
364
|
|
|
365
|
+
# Decode a stored JSON document, rehydrating ObjectId and Time values.
|
|
366
|
+
#
|
|
367
|
+
# Module-level because it is a PURE function of its inputs. It was a public
|
|
368
|
+
# collection method only so the Cursor could reach it, which ADR-0025
|
|
369
|
+
# corollary 1 forbids.
|
|
370
|
+
def load_doc(doc_text, projection = nil)
|
|
371
|
+
doc = decode_value(JSON.parse(doc_text))
|
|
372
|
+
projection ? project(doc, projection) : doc
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# Encode a document for storage. Pure, for the same reason as load_doc.
|
|
376
|
+
def dump_doc(document) = JSON.generate(encode_value(document))
|
|
377
|
+
|
|
297
378
|
def project(doc, projection)
|
|
298
379
|
return doc if projection.nil? || projection.empty?
|
|
299
380
|
|
|
@@ -392,8 +473,15 @@ module Tina4
|
|
|
392
473
|
class Cursor
|
|
393
474
|
include Enumerable
|
|
394
475
|
|
|
395
|
-
|
|
396
|
-
|
|
476
|
+
# The cursor receives WHAT IT NEEDS, not the collection it came from.
|
|
477
|
+
#
|
|
478
|
+
# It used to hold the collection and reach back into it for quoted_name,
|
|
479
|
+
# connection and load_doc - which was the ONLY reason those three were
|
|
480
|
+
# public. ADR-0025 corollary 1: anything the fallback needs internally is
|
|
481
|
+
# private, and a real Mongo::Collection::View exposes none of them.
|
|
482
|
+
def initialize(conn, quoted_name, where, params, projection = nil)
|
|
483
|
+
@conn = conn
|
|
484
|
+
@quoted_name = quoted_name
|
|
397
485
|
@where = where
|
|
398
486
|
@params = params
|
|
399
487
|
@projection = projection
|
|
@@ -403,11 +491,7 @@ module Tina4
|
|
|
403
491
|
end
|
|
404
492
|
|
|
405
493
|
def sort(key_or_list, direction = 1)
|
|
406
|
-
|
|
407
|
-
@sort << [key_or_list.to_s, direction]
|
|
408
|
-
else
|
|
409
|
-
key_or_list.each { |k, d| @sort << [k.to_s, d] }
|
|
410
|
-
end
|
|
494
|
+
@sort.concat(DocStore.sort_spec(key_or_list, direction))
|
|
411
495
|
self
|
|
412
496
|
end
|
|
413
497
|
|
|
@@ -422,7 +506,7 @@ module Tina4
|
|
|
422
506
|
end
|
|
423
507
|
|
|
424
508
|
def build_sql
|
|
425
|
-
sql = "SELECT doc FROM #{@
|
|
509
|
+
sql = "SELECT doc FROM #{@quoted_name} WHERE #{@where}"
|
|
426
510
|
unless @sort.empty?
|
|
427
511
|
order = @sort.map { |k, d| "#{DocStore.extract(k)} #{d.to_i.negative? ? 'DESC' : 'ASC'}" }.join(", ")
|
|
428
512
|
sql += " ORDER BY #{order}"
|
|
@@ -439,9 +523,9 @@ module Tina4
|
|
|
439
523
|
def each
|
|
440
524
|
return enum_for(:each) unless block_given?
|
|
441
525
|
|
|
442
|
-
@
|
|
526
|
+
@conn.execute(build_sql, @params).each do |row|
|
|
443
527
|
doc_text = row.is_a?(Hash) ? (row["doc"] || row[:doc] || row.values.first) : row.first
|
|
444
|
-
yield
|
|
528
|
+
yield DocStore.load_doc(doc_text, @projection)
|
|
445
529
|
end
|
|
446
530
|
end
|
|
447
531
|
|
|
@@ -450,15 +534,26 @@ module Tina4
|
|
|
450
534
|
each { |doc| out << doc }
|
|
451
535
|
out
|
|
452
536
|
end
|
|
453
|
-
|
|
537
|
+
|
|
538
|
+
# The uniform Tina4 spelling, ADDITIVE to to_a (ADR-0035).
|
|
539
|
+
#
|
|
540
|
+
# ADR-0025 corollary 1 removed this because Mongo::Collection::View has no
|
|
541
|
+
# to_list. ADR-0035 amends that corollary: a method may exist here when it
|
|
542
|
+
# also exists on WHAT get_collection RETURNS for the real provider, and
|
|
543
|
+
# MongoView supplies it there. to_a stays - it is the driver's spelling
|
|
544
|
+
# and the Ruby idiom - so both work on both providers.
|
|
545
|
+
def to_list = to_a
|
|
546
|
+
|
|
547
|
+
# The generated SQL is an implementation detail, not part of the cursor
|
|
548
|
+
# API. Declared here rather than with a `private` section because build_sql
|
|
549
|
+
# is defined above and a trailing `private` would not reach it.
|
|
550
|
+
private :build_sql
|
|
454
551
|
end
|
|
455
552
|
|
|
456
553
|
# -- Collection -------------------------------------------------------------
|
|
457
554
|
|
|
458
555
|
# A SQLite-backed collection exposing the everyday Mongo API.
|
|
459
556
|
class SqliteCollection
|
|
460
|
-
attr_reader :connection
|
|
461
|
-
|
|
462
557
|
def initialize(conn, name)
|
|
463
558
|
@connection = conn
|
|
464
559
|
@name = name.to_s
|
|
@@ -470,23 +565,13 @@ module Tina4
|
|
|
470
565
|
)
|
|
471
566
|
end
|
|
472
567
|
|
|
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
568
|
# -- writes --
|
|
484
569
|
def insert_one(document)
|
|
485
570
|
doc = stringify(document)
|
|
486
571
|
doc["_id"] = ObjectId.new unless doc.key?("_id")
|
|
487
572
|
@connection.execute(
|
|
488
573
|
"INSERT INTO #{@quoted_name} (_id, doc) VALUES (?, ?)",
|
|
489
|
-
[DocStore.id_key(doc["_id"]),
|
|
574
|
+
[DocStore.id_key(doc["_id"]), DocStore.dump_doc(doc)]
|
|
490
575
|
)
|
|
491
576
|
InsertOneResult.new(doc["_id"])
|
|
492
577
|
end
|
|
@@ -499,7 +584,7 @@ module Tina4
|
|
|
499
584
|
ids << doc["_id"]
|
|
500
585
|
@connection.execute(
|
|
501
586
|
"INSERT INTO #{@quoted_name} (_id, doc) VALUES (?, ?)",
|
|
502
|
-
[DocStore.id_key(doc["_id"]),
|
|
587
|
+
[DocStore.id_key(doc["_id"]), DocStore.dump_doc(doc)]
|
|
503
588
|
)
|
|
504
589
|
end
|
|
505
590
|
InsertManyResult.new(ids)
|
|
@@ -508,11 +593,18 @@ module Tina4
|
|
|
508
593
|
# -- reads --
|
|
509
594
|
def find(filter = nil, projection = nil)
|
|
510
595
|
where, params = DocStore.compile_filter(filter || {})
|
|
511
|
-
Cursor.new(
|
|
596
|
+
Cursor.new(@connection, @quoted_name, where, params, projection)
|
|
512
597
|
end
|
|
513
598
|
|
|
599
|
+
# The uniform Tina4 spelling, ADDITIVE to find(filter).first (ADR-0035).
|
|
600
|
+
#
|
|
601
|
+
# Mongo::Collection has no find_one, which is why ADR-0025 corollary 1
|
|
602
|
+
# deleted this. ADR-0035 amends the means, not the goal: MongoCollection
|
|
603
|
+
# supplies find_one on the driver side, so the method now exists with the
|
|
604
|
+
# same meaning on BOTH halves of the swap. The signature is pymongo's,
|
|
605
|
+
# which is what the Python master already ships.
|
|
514
606
|
def find_one(filter = nil, projection = nil)
|
|
515
|
-
find(filter, projection).
|
|
607
|
+
find(filter, projection).first
|
|
516
608
|
end
|
|
517
609
|
|
|
518
610
|
def count_documents(filter = nil)
|
|
@@ -626,7 +718,7 @@ module Tina4
|
|
|
626
718
|
new_key = DocStore.id_key(new_doc["_id"])
|
|
627
719
|
@connection.execute(
|
|
628
720
|
"UPDATE #{@quoted_name} SET _id = ?, doc = ? WHERE _id = ?",
|
|
629
|
-
[new_key,
|
|
721
|
+
[new_key, DocStore.dump_doc(new_doc), old_id]
|
|
630
722
|
)
|
|
631
723
|
true
|
|
632
724
|
end
|
|
@@ -652,6 +744,67 @@ module Tina4
|
|
|
652
744
|
end
|
|
653
745
|
end
|
|
654
746
|
|
|
747
|
+
# -- The driver delegators (ADR-0035) -----------------------------------------
|
|
748
|
+
#
|
|
749
|
+
# ADR-0025 costed "wrap the driver" as a hand-written FACADE over 12-14
|
|
750
|
+
# methods, and rejected it because that makes aggregate, bulk_write,
|
|
751
|
+
# indexes, watch, sessions and transactions unreachable. That objection is
|
|
752
|
+
# fatal to a facade and irrelevant to a DELEGATOR: SimpleDelegator forwards
|
|
753
|
+
# EVERYTHING untouched, so the entire driver surface stays reachable and we
|
|
754
|
+
# add exactly one method per class.
|
|
755
|
+
#
|
|
756
|
+
# These exist so the uniform Tina4 spelling works on BOTH providers rather
|
|
757
|
+
# than on the fallback alone - which is the thing ADR-0025 corollary 1 was
|
|
758
|
+
# right to forbid, and ADR-0035 supplies instead of deleting.
|
|
759
|
+
|
|
760
|
+
# A Mongo::Collection::View that also answers to_list, and accepts every
|
|
761
|
+
# sort spelling the fallback Cursor accepts.
|
|
762
|
+
class MongoView < SimpleDelegator
|
|
763
|
+
# to_a is the driver's spelling and stays authoritative; to_list is the
|
|
764
|
+
# uniform Tina4 spelling the SQLite Cursor also answers.
|
|
765
|
+
def to_list = __getobj__.to_a
|
|
766
|
+
|
|
767
|
+
# ADR-0036: ONE sort contract on both providers.
|
|
768
|
+
#
|
|
769
|
+
# Mongo::Collection::View#sort takes a single spec document, so
|
|
770
|
+
# sort("total", -1) raised ArgumentError and sort([["total", -1]]) reached
|
|
771
|
+
# the server as an array and came back TypeMismatch. Normalising here means
|
|
772
|
+
# all three spellings work on the driver exactly as they do on the
|
|
773
|
+
# fallback - which is the point of the swap.
|
|
774
|
+
def sort(key_or_list, direction = 1)
|
|
775
|
+
MongoView.new(__getobj__.sort(DocStore.sort_spec(key_or_list, direction).to_h))
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
# A View is IMMUTABLE - sort/limit/skip/projection each return a NEW View.
|
|
779
|
+
# Without re-wrapping, the first chained call would hand back a bare View
|
|
780
|
+
# and to_list would vanish mid-chain: exactly the "works until you sort
|
|
781
|
+
# it" surprise this ADR exists to stop.
|
|
782
|
+
def method_missing(name, *args, &block)
|
|
783
|
+
result = super
|
|
784
|
+
result.is_a?(::Mongo::Collection::View) ? MongoView.new(result) : result
|
|
785
|
+
end
|
|
786
|
+
ruby2_keywords :method_missing
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
# A Mongo::Collection that also answers find_one, and whose find returns a
|
|
790
|
+
# cursor answering to_list.
|
|
791
|
+
class MongoCollection < SimpleDelegator
|
|
792
|
+
# Pure pass-through plus wrapping. The argument list is NOT re-declared,
|
|
793
|
+
# so the driver keeps deciding what find accepts.
|
|
794
|
+
def find(*args, &block)
|
|
795
|
+
MongoView.new(__getobj__.find(*args, &block))
|
|
796
|
+
end
|
|
797
|
+
ruby2_keywords :find
|
|
798
|
+
|
|
799
|
+
# pymongo's signature, which the Python master already ships. On the
|
|
800
|
+
# driver a projection is an OPTION rather than a positional, so it is
|
|
801
|
+
# spelled the driver's way here; the meaning is identical.
|
|
802
|
+
def find_one(filter = nil, projection = nil)
|
|
803
|
+
view = projection ? __getobj__.find(filter || {}, projection: projection) : __getobj__.find(filter || {})
|
|
804
|
+
view.first
|
|
805
|
+
end
|
|
806
|
+
end
|
|
807
|
+
|
|
655
808
|
# -- Database + selection -----------------------------------------------------
|
|
656
809
|
|
|
657
810
|
# A SQLite-backed document database (a file of collection tables).
|
|
@@ -694,27 +847,35 @@ module Tina4
|
|
|
694
847
|
|
|
695
848
|
module_function
|
|
696
849
|
|
|
850
|
+
# The env var that supplied the URI, or nil when none did.
|
|
851
|
+
#
|
|
852
|
+
# Named separately so an error can tell the operator WHICH variable to
|
|
853
|
+
# unset without ever printing its value - a Mongo URI routinely carries
|
|
854
|
+
# `user:password@`.
|
|
855
|
+
#
|
|
856
|
+
# Canonical TINA4_MONGO_URI, then the session-layer TINA4_SESSION_MONGO_URI;
|
|
857
|
+
# TINA4_SESSION_MONGO_URL is a legacy alias.
|
|
858
|
+
def mongo_uri_source
|
|
859
|
+
%w[TINA4_MONGO_URI TINA4_SESSION_MONGO_URI TINA4_SESSION_MONGO_URL]
|
|
860
|
+
.find { |name| !(ENV[name] || "").strip.empty? }
|
|
861
|
+
end
|
|
862
|
+
|
|
697
863
|
# 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
864
|
def mongo_uri
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
ENV["TINA4_SESSION_MONGO_URL"] ||
|
|
703
|
-
"").strip
|
|
865
|
+
source = mongo_uri_source
|
|
866
|
+
source ? ENV[source].strip : ""
|
|
704
867
|
end
|
|
705
868
|
|
|
706
869
|
# True when no Mongo is configured, so the SQLite fallback is in effect.
|
|
870
|
+
#
|
|
871
|
+
# CONFIGURATION ONLY. Before 3.13.95 this also returned true when a URI was
|
|
872
|
+
# set but the mongo gem was absent, and that is precisely what made
|
|
873
|
+
# get_collection hand back the local SQLite store while the operator
|
|
874
|
+
# believed they were on Mongo. A missing driver is now an error (ADR-0033),
|
|
875
|
+
# not a second way to be serverless - otherwise an app branching on this
|
|
876
|
+
# would take the local path and never reach the raise.
|
|
707
877
|
def serverless?
|
|
708
|
-
|
|
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
|
|
878
|
+
mongo_uri.empty?
|
|
718
879
|
end
|
|
719
880
|
|
|
720
881
|
@default_db = nil
|
|
@@ -733,13 +894,66 @@ module Tina4
|
|
|
733
894
|
# A real Mongo driver Collection when a Mongo URI is configured (and the
|
|
734
895
|
# mongo gem is installed); otherwise a SqliteCollection backed by the local
|
|
735
896
|
# SQLite file. Same call sites either way - only the backend differs.
|
|
897
|
+
#
|
|
898
|
+
# The Mongo collection is handed back through MongoCollection, a
|
|
899
|
+
# SimpleDelegator that adds the uniform Tina4 spellings and forwards
|
|
900
|
+
# everything else untouched (ADR-0035). What this method RETURNS is the
|
|
901
|
+
# surface a call site sees, so it is the surface the contract compares.
|
|
736
902
|
def get_collection(name)
|
|
737
903
|
return default_db.get_collection(name) if serverless?
|
|
738
904
|
|
|
739
|
-
require "mongo"
|
|
740
905
|
db_name = ENV["TINA4_MONGO_DB"] || ENV["TINA4_SESSION_MONGO_DB"] || "tina4"
|
|
741
|
-
|
|
742
|
-
|
|
906
|
+
MongoCollection.new(mongo_client(mongo_uri, db_name)[name])
|
|
907
|
+
end
|
|
908
|
+
|
|
909
|
+
@mongo_clients = {}
|
|
910
|
+
|
|
911
|
+
# Return the shared Mongo client for this (uri, database), connecting once.
|
|
912
|
+
#
|
|
913
|
+
# MEASURED 2026-08-03 against a real MongoDB: get_collection used to build a
|
|
914
|
+
# new Mongo::Client on EVERY call and never close it, so 20 calls left 60
|
|
915
|
+
# server connections open and the count grew without bound. It was invisible
|
|
916
|
+
# in development because the SQLite fallback has no connections at all - a
|
|
917
|
+
# resource leak that only exists AFTER the swap to the real provider.
|
|
918
|
+
#
|
|
919
|
+
# A Mongo::Client is thread-safe and pools internally, so one per
|
|
920
|
+
# (uri, database) is the shape the driver itself expects. The double-checked
|
|
921
|
+
# lock matches default_db above: without it two threads racing the first call
|
|
922
|
+
# both build a client and one is orphaned - the same leak, just rarer.
|
|
923
|
+
#
|
|
924
|
+
# A missing gem raises here, at provider RESOLUTION, before any socket is
|
|
925
|
+
# opened: it is a static fact and needs no network to establish.
|
|
926
|
+
def mongo_client(uri, db_name)
|
|
927
|
+
begin
|
|
928
|
+
require "mongo"
|
|
929
|
+
rescue LoadError
|
|
930
|
+
source = mongo_uri_source || "TINA4_MONGO_URI"
|
|
931
|
+
raise DocStoreDriverMissing,
|
|
932
|
+
"Tina4 DocStore: #{source} is set, so the MongoDB provider is selected, but " \
|
|
933
|
+
"its driver is not installed (gem 'mongo'). Install it with `gem install mongo`, " \
|
|
934
|
+
"or unset #{source} to use the local SQLite store."
|
|
935
|
+
end
|
|
936
|
+
key = [uri, db_name]
|
|
937
|
+
client = @mongo_clients[key]
|
|
938
|
+
return client if client
|
|
939
|
+
|
|
940
|
+
@default_lock.synchronize do
|
|
941
|
+
@mongo_clients[key] ||= Mongo::Client.new(uri, database: db_name)
|
|
942
|
+
end
|
|
943
|
+
end
|
|
944
|
+
|
|
945
|
+
# Close every DocStore connection: the SQLite store and all Mongo clients.
|
|
946
|
+
def close_doc_store
|
|
947
|
+
@default_lock.synchronize do
|
|
948
|
+
@mongo_clients.each_value do |client|
|
|
949
|
+
client.close
|
|
950
|
+
rescue StandardError
|
|
951
|
+
nil # a close failure must not mask the caller's work
|
|
952
|
+
end
|
|
953
|
+
@mongo_clients.clear
|
|
954
|
+
@default_db&.close
|
|
955
|
+
@default_db = nil
|
|
956
|
+
end
|
|
743
957
|
end
|
|
744
958
|
|
|
745
959
|
# Drop the cached default SQLite store (test helper).
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
require "set"
|
|
1
2
|
# frozen_string_literal: true
|
|
2
3
|
|
|
3
4
|
module Tina4
|
|
4
5
|
module Drivers
|
|
5
6
|
class FirebirdDriver
|
|
7
|
+
include Tina4::DatabaseAdapter
|
|
6
8
|
attr_reader :connection
|
|
7
9
|
|
|
8
10
|
# Substring markers (lowercased) that identify a dead-socket Firebird
|
|
@@ -111,6 +113,55 @@ module Tina4
|
|
|
111
113
|
url_charset || kwarg || env || "UTF8"
|
|
112
114
|
end
|
|
113
115
|
|
|
116
|
+
# Bound the REACH to a Firebird server before libfbclient is handed the
|
|
117
|
+
# attach. Raises the shared connect-timeout error when the host does not
|
|
118
|
+
# answer within TINA4_DATABASE_CONNECT_TIMEOUT.
|
|
119
|
+
#
|
|
120
|
+
# Why a socket probe and not a timeout around the attach - MEASURED on
|
|
121
|
+
# Ruby 3.2.3 / Ubuntu 24.04.4 / fb 0.10.0 against a REAL TCPServer that
|
|
122
|
+
# accepts the connection and then never replies:
|
|
123
|
+
#
|
|
124
|
+
# fb attach, no bound WEDGED past 20s, SIGKILL needed
|
|
125
|
+
# fb attach, Timeout.timeout(3) WEDGED past 20s - the timeout NEVER fired
|
|
126
|
+
# fb attach, Thread#join(3) WEDGED past 20s - join never returned
|
|
127
|
+
#
|
|
128
|
+
# The gem calls isc_attach_database (fb.c:3002) WITHOUT releasing the GVL
|
|
129
|
+
# and without an unblocking function, so no Ruby thread runs to deliver
|
|
130
|
+
# the interrupt - `timeout`'s SIGTERM could not even be delivered. It also
|
|
131
|
+
# builds its DPB from a fixed four-item set (user, password, lc_ctype,
|
|
132
|
+
# role) with no connect-timeout item. There is therefore NO in-process way
|
|
133
|
+
# to bound the attach itself, and a Timeout.timeout here would be WORSE
|
|
134
|
+
# than nothing because it would look like protection and silently not fire.
|
|
135
|
+
#
|
|
136
|
+
# What IS boundable is reaching the host at all, which is the hang
|
|
137
|
+
# operators actually hit: a dead or firewalled server swallows the SYN and
|
|
138
|
+
# the process sits there. A plain stdlib socket bounds that exactly.
|
|
139
|
+
#
|
|
140
|
+
# RESIDUAL GAP, stated plainly so this is not mistaken for full cover: if
|
|
141
|
+
# the TCP handshake SUCCEEDS and the server then never speaks the Firebird
|
|
142
|
+
# protocol, the attach is still UNBOUNDED. Ruby cannot fix that from
|
|
143
|
+
# inside this process - it needs a connect timeout in the fb gem itself.
|
|
144
|
+
#
|
|
145
|
+
# ONLY a timeout is converted. A refused connection, an unknown host or
|
|
146
|
+
# any other socket error is swallowed so libfbclient still produces its
|
|
147
|
+
# own (better) diagnosis: this probe adds a bound, it does not take over
|
|
148
|
+
# error reporting.
|
|
149
|
+
def self.bound_reachability!(host, port)
|
|
150
|
+
seconds = Tina4::DatabaseAdapter.connect_timeout_seconds
|
|
151
|
+
return if seconds.nil? || host.to_s.empty?
|
|
152
|
+
|
|
153
|
+
require "socket"
|
|
154
|
+
begin
|
|
155
|
+
Tina4::DatabaseAdapter.bounding_connect(host, port) do
|
|
156
|
+
Socket.tcp(host, port, connect_timeout: seconds, &:close)
|
|
157
|
+
end
|
|
158
|
+
rescue Tina4::DatabaseConnectionError
|
|
159
|
+
raise
|
|
160
|
+
rescue StandardError
|
|
161
|
+
nil
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
114
165
|
def connect(connection_string, username: nil, password: nil, charset: nil)
|
|
115
166
|
require "fb"
|
|
116
167
|
require "uri"
|
|
@@ -155,6 +206,7 @@ module Tina4
|
|
|
155
206
|
# with the gem's default charset (double-encoding). Defaults to UTF8.
|
|
156
207
|
@connect_opts[:charset] = self.class.resolve_charset(connection_string, charset)
|
|
157
208
|
|
|
209
|
+
self.class.bound_reachability!(host, port)
|
|
158
210
|
open_connection
|
|
159
211
|
rescue LoadError
|
|
160
212
|
raise LoadError,
|
|
@@ -209,8 +261,12 @@ module Tina4
|
|
|
209
261
|
(["?"] * count).join(", ")
|
|
210
262
|
end
|
|
211
263
|
|
|
264
|
+
# The closing paren goes on a NEW LINE. Inline, a trailing `-- comment` in
|
|
265
|
+
# the caller's SQL comments the paren out and the whole wrapped statement
|
|
266
|
+
# is a syntax error (the same class of bug as the LIMIT append site — see
|
|
267
|
+
# the note on Drivers::SqliteDriver#apply_limit).
|
|
212
268
|
def apply_limit(sql, limit, offset = 0)
|
|
213
|
-
"SELECT FIRST #{limit} SKIP #{offset} * FROM (#{sql})"
|
|
269
|
+
"SELECT FIRST #{limit} SKIP #{offset} * FROM (#{sql}\n)"
|
|
214
270
|
end
|
|
215
271
|
|
|
216
272
|
# Transaction handling — mirrors the Python master's connection-level
|
|
@@ -237,6 +293,20 @@ module Tina4
|
|
|
237
293
|
@in_transaction = true
|
|
238
294
|
end
|
|
239
295
|
|
|
296
|
+
# Guarded on the CONNECTION only, deliberately - no active-transaction check
|
|
297
|
+
# is needed here.
|
|
298
|
+
#
|
|
299
|
+
# Python's adapter has the same shape and it IS a bug there: firebird-driver
|
|
300
|
+
# delegates Connection#commit to main_transaction, whose handle is nil until
|
|
301
|
+
# a statement opens one, so committing with nothing open raises
|
|
302
|
+
# "AttributeError: 'NoneType' object has no attribute 'commit'". Measured
|
|
303
|
+
# 2026-08-04 against the lab's real Firebird 5.0.4, the `fb` gem does NOT
|
|
304
|
+
# behave that way: both #commit and #rollback on a fresh connection with no
|
|
305
|
+
# open transaction return cleanly, because the driver opens one implicitly.
|
|
306
|
+
#
|
|
307
|
+
# So do not "port" Python's is_active? guard here on the strength of the
|
|
308
|
+
# shape matching - it would be dead code guarding a condition this driver
|
|
309
|
+
# cannot reach.
|
|
240
310
|
def commit
|
|
241
311
|
@connection&.commit
|
|
242
312
|
@in_transaction = false
|
|
@@ -259,13 +329,33 @@ module Tina4
|
|
|
259
329
|
"JOIN RDB\$FIELDS F ON RF.RDB\$FIELD_SOURCE = F.RDB\$FIELD_NAME " \
|
|
260
330
|
"WHERE RF.RDB\$RELATION_NAME = ?"
|
|
261
331
|
rows = execute_query(sql, [table_name.upcase])
|
|
332
|
+
|
|
333
|
+
# The primary key comes from the constraint catalogue. This used to be
|
|
334
|
+
# hardcoded `false` for every column, so primary_key(table) always
|
|
335
|
+
# answered [] on Firebird -- which silently breaks anything that
|
|
336
|
+
# introspects the key, including the filterless-write guard that lifts
|
|
337
|
+
# the PK out of `data`. Same bug the Python master carried.
|
|
338
|
+
pk_sql = "SELECT SG.RDB\$FIELD_NAME FROM RDB\$INDEX_SEGMENTS SG " \
|
|
339
|
+
"JOIN RDB\$RELATION_CONSTRAINTS RC ON SG.RDB\$INDEX_NAME = RC.RDB\$INDEX_NAME " \
|
|
340
|
+
"WHERE RC.RDB\$CONSTRAINT_TYPE = 'PRIMARY KEY' AND RC.RDB\$RELATION_NAME = ? " \
|
|
341
|
+
"ORDER BY SG.RDB\$FIELD_POSITION"
|
|
342
|
+
pk_names = begin
|
|
343
|
+
execute_query(pk_sql, [table_name.upcase]).map do |r|
|
|
344
|
+
(r["RDB\$FIELD_NAME"] || r["rdb\$field_name"] || "").strip.upcase
|
|
345
|
+
end.reject(&:empty?).to_set
|
|
346
|
+
rescue StandardError
|
|
347
|
+
# A table with no primary key is not an error.
|
|
348
|
+
Set.new
|
|
349
|
+
end
|
|
350
|
+
|
|
262
351
|
rows.map do |r|
|
|
352
|
+
field_name = (r["RDB\$FIELD_NAME"] || r["rdb\$field_name"] || "").strip
|
|
263
353
|
{
|
|
264
|
-
name:
|
|
354
|
+
name: field_name,
|
|
265
355
|
type: r["RDB\$FIELD_TYPE"] || r["rdb\$field_type"],
|
|
266
356
|
nullable: (r["RDB\$NULL_FLAG"] || r["rdb\$null_flag"]).nil?,
|
|
267
357
|
default: r["RDB\$DEFAULT_SOURCE"] || r["rdb\$default_source"],
|
|
268
|
-
primary_key:
|
|
358
|
+
primary_key: pk_names.include?(field_name.upcase)
|
|
269
359
|
}
|
|
270
360
|
end
|
|
271
361
|
end
|
|
@@ -301,7 +391,31 @@ module Tina4
|
|
|
301
391
|
end
|
|
302
392
|
|
|
303
393
|
def stringify_keys(hash)
|
|
304
|
-
hash.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
|
|
394
|
+
hash.each_with_object({}) { |(k, v), h| h[column_name(k.to_s)] = v }
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# Firebird's stored column name, folded back only when it was folded.
|
|
398
|
+
#
|
|
399
|
+
# Firebird's identifier folding is ASYMMETRIC. An unquoted `AS x` is
|
|
400
|
+
# stored UPPERCASE, so the driver hands back "X" where every other engine
|
|
401
|
+
# Tina4 supports gives "x" -- PostgreSQL folds to lower, and MySQL, SQLite
|
|
402
|
+
# and MSSQL preserve what you wrote. Portable code reading row["x"] broke
|
|
403
|
+
# on Firebird alone; the live URL examples in this repo asserted row["x"]
|
|
404
|
+
# and could never have passed once they actually reached a server.
|
|
405
|
+
#
|
|
406
|
+
# A QUOTED `AS "MyCol"` is stored exactly as written, and that case is
|
|
407
|
+
# deliberate -- the caller asked for it -- so it is left alone. Folding
|
|
408
|
+
# unconditionally makes a mixed-case key unreachable, the same asymmetric
|
|
409
|
+
# trap that made table_exists? miss quoted tables.
|
|
410
|
+
#
|
|
411
|
+
# So: fold back only a name carrying no lowercase letter, the only thing
|
|
412
|
+
# unquoted folding can produce. A quoted ALL-CAPS name is genuinely
|
|
413
|
+
# indistinguishable from a folded one and is lowercased too; that
|
|
414
|
+
# ambiguity is Firebird's, and it is the one spelling this cannot
|
|
415
|
+
# round-trip.
|
|
416
|
+
def column_name(raw)
|
|
417
|
+
name = raw.strip
|
|
418
|
+
name == name.upcase ? name.downcase : name
|
|
305
419
|
end
|
|
306
420
|
|
|
307
421
|
# Ensure Firebird BLOB columns are proper byte strings.
|