tina4ruby 3.13.94 → 3.13.96
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 +194 -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 +243 -29
- 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/database.rb
CHANGED
|
@@ -145,6 +145,111 @@ module Tina4
|
|
|
145
145
|
stripped
|
|
146
146
|
end
|
|
147
147
|
|
|
148
|
+
# Blank out string literals, quoted identifiers and BOTH comment forms so a
|
|
149
|
+
# keyword search sees only real SQL.
|
|
150
|
+
#
|
|
151
|
+
# Blanks are spaces of the SAME LENGTH (newlines preserved), so offsets and
|
|
152
|
+
# line structure still line up with the original — the scrubbed copy is only
|
|
153
|
+
# ever used to LOOK at the SQL, never to execute it.
|
|
154
|
+
#
|
|
155
|
+
# MEASURED 2026-08-01 on a real 150-row SQLite table with the 100-row cap in
|
|
156
|
+
# force. The old detector was
|
|
157
|
+
# `sql.upcase.split("--")[0].include?("LIMIT")`
|
|
158
|
+
# and each of these returned ALL 150 ROWS instead of 100:
|
|
159
|
+
#
|
|
160
|
+
# SELECT * FROM t WHERE label != 'LIMIT' ORDER BY id literal
|
|
161
|
+
# SELECT * FROM t ORDER BY id -- LIMIT 5 line comment
|
|
162
|
+
# SELECT * FROM t ORDER BY id /* LIMIT 5 */ block comment
|
|
163
|
+
# SELECT id, label AS rate_limit FROM t identifier
|
|
164
|
+
#
|
|
165
|
+
# A silently uncapped read of a whole table is the production incident the
|
|
166
|
+
# cap exists to prevent, and an ordinary column name was enough to cause it.
|
|
167
|
+
# Ported from the Python/Node master (same design, same answers).
|
|
168
|
+
def self.scrub_sql_text(sql)
|
|
169
|
+
return sql if sql.nil? || sql.empty?
|
|
170
|
+
|
|
171
|
+
out = +""
|
|
172
|
+
i = 0
|
|
173
|
+
n = sql.length
|
|
174
|
+
while i < n
|
|
175
|
+
ch = sql[i]
|
|
176
|
+
nxt = i + 1 < n ? sql[i + 1] : ""
|
|
177
|
+
|
|
178
|
+
if ch == "'" || ch == '"'
|
|
179
|
+
quote = ch
|
|
180
|
+
out << " "
|
|
181
|
+
i += 1
|
|
182
|
+
while i < n
|
|
183
|
+
if sql[i] == quote
|
|
184
|
+
# A doubled quote ('' / "") is an ESCAPED quote inside the
|
|
185
|
+
# literal, not its end — blank both and keep going.
|
|
186
|
+
if i + 1 < n && sql[i + 1] == quote
|
|
187
|
+
out << " "
|
|
188
|
+
i += 2
|
|
189
|
+
next
|
|
190
|
+
end
|
|
191
|
+
out << " "
|
|
192
|
+
i += 1
|
|
193
|
+
break
|
|
194
|
+
end
|
|
195
|
+
out << (sql[i] == "\n" ? "\n" : " ")
|
|
196
|
+
i += 1
|
|
197
|
+
end
|
|
198
|
+
next
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
if ch == "-" && nxt == "-"
|
|
202
|
+
while i < n && sql[i] != "\n"
|
|
203
|
+
out << " "
|
|
204
|
+
i += 1
|
|
205
|
+
end
|
|
206
|
+
next
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
if ch == "/" && nxt == "*"
|
|
210
|
+
out << " "
|
|
211
|
+
i += 2
|
|
212
|
+
while i < n && !(sql[i] == "*" && i + 1 < n && sql[i + 1] == "/")
|
|
213
|
+
out << (sql[i] == "\n" ? "\n" : " ")
|
|
214
|
+
i += 1
|
|
215
|
+
end
|
|
216
|
+
if i < n
|
|
217
|
+
out << " "
|
|
218
|
+
i += 2
|
|
219
|
+
end
|
|
220
|
+
next
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
out << ch
|
|
224
|
+
i += 1
|
|
225
|
+
end
|
|
226
|
+
out
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# True when the statement ENDS with its own LIMIT clause.
|
|
230
|
+
#
|
|
231
|
+
# Anchored to the END on purpose: a bare "contains LIMIT" test also matches a
|
|
232
|
+
# LIMIT inside a SUBQUERY, where the OUTER statement still needs its cap.
|
|
233
|
+
# This is tina4-php's SqlNormalizerTrait::hasTrailingLimit regex, ported
|
|
234
|
+
# verbatim so all four frameworks answer identically. It accepts a numeric
|
|
235
|
+
# value, ?/$1/:name/%s placeholders, MySQL's `LIMIT a, b` comma form, and a
|
|
236
|
+
# trailing OFFSET.
|
|
237
|
+
#
|
|
238
|
+
# Literals, quoted identifiers and comments are scrubbed before matching —
|
|
239
|
+
# see .scrub_sql_text for the measured failures that motivate it.
|
|
240
|
+
VALUE_TOKEN = '(?:\d+|\?|\$\d+|:\w+|%s)'
|
|
241
|
+
TRAILING_LIMIT_RE = /
|
|
242
|
+
\bLIMIT\s+#{VALUE_TOKEN}
|
|
243
|
+
(?:\s*,\s*#{VALUE_TOKEN})?
|
|
244
|
+
(?:\s+OFFSET\s+#{VALUE_TOKEN})?
|
|
245
|
+
\s*;?\s*\z
|
|
246
|
+
/ix.freeze
|
|
247
|
+
|
|
248
|
+
def self.has_trailing_limit?(sql)
|
|
249
|
+
return false if sql.nil? || sql.empty?
|
|
250
|
+
TRAILING_LIMIT_RE.match?(scrub_sql_text(sql))
|
|
251
|
+
end
|
|
252
|
+
|
|
148
253
|
# Static factory — cross-framework consistency: Database.create(url)
|
|
149
254
|
def self.create(url, username: "", password: "", pool: nil)
|
|
150
255
|
new(url, username: username.empty? ? nil : username,
|
|
@@ -310,7 +415,7 @@ module Tina4
|
|
|
310
415
|
# native toggle (default ON — see @autocommit in #initialize). The
|
|
311
416
|
# framework-level commit in #autocommit_standalone_write covers drivers
|
|
312
417
|
# that have no native setter.
|
|
313
|
-
@driver.autocommit = @autocommit if @driver
|
|
418
|
+
@driver.autocommit = @autocommit if driver_implements?(@driver, :autocommit=)
|
|
314
419
|
|
|
315
420
|
Tina4::Log.info("Database connected: #{@driver_name}")
|
|
316
421
|
@connect_error = nil
|
|
@@ -370,10 +475,18 @@ module Tina4
|
|
|
370
475
|
|
|
371
476
|
# The connection string with any password stripped, so a raised error can name
|
|
372
477
|
# the target without leaking a credential into a log or an HTTP 500 body.
|
|
478
|
+
#
|
|
479
|
+
# This used to carry its own regex, which was a SECOND redaction
|
|
480
|
+
# implementation next to DatabaseUrl#to_safe_string - and it leaked on two
|
|
481
|
+
# shapes, both measured 2026-08-02:
|
|
482
|
+
# postgres://u:p@ss@h:5432/db -> postgres://u:***@ss@h:5432/db
|
|
483
|
+
# because `([^:/@]+):[^@]*@` stops at the FIRST "@", so the tail of a
|
|
484
|
+
# password containing an unencoded "@" survived into the message.
|
|
485
|
+
# odbc:///DRIVER=...;PWD=<secret> -> returned VERBATIM, straight into
|
|
486
|
+
# DatabaseConnectionError.
|
|
487
|
+
# There is now ONE redaction primitive. Do not add a second.
|
|
373
488
|
def safe_connection_target
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
@connection_string.sub(%r{://([^:/@]+):[^@]*@}, '://\1:***@')
|
|
489
|
+
Tina4::DatabaseUrl.redact(@connection_string)
|
|
377
490
|
end
|
|
378
491
|
|
|
379
492
|
# ── Query Cache ──────────────────────────────────────────────
|
|
@@ -470,10 +583,43 @@ module Tina4
|
|
|
470
583
|
sql = Tina4::Database.strip_trailing_semicolons(sql)
|
|
471
584
|
|
|
472
585
|
effective_sql = sql
|
|
473
|
-
# Skip appending LIMIT if
|
|
474
|
-
|
|
586
|
+
# Skip appending LIMIT if the statement already ENDS with its own.
|
|
587
|
+
#
|
|
588
|
+
# .has_trailing_limit? scrubs literals/identifiers/comments and anchors to
|
|
589
|
+
# the end. The old test was `sql.upcase.split("--")[0].include?("LIMIT")`,
|
|
590
|
+
# so `WHERE label != 'LIMIT'`, a `/* LIMIT 5 */` comment, or a column named
|
|
591
|
+
# rate_limit all read as "the caller supplied their own" and the cap was
|
|
592
|
+
# silently dropped — a full-table read (MEASURED: 150 of 150 rows).
|
|
593
|
+
has_limit = Tina4::Database.has_trailing_limit?(sql)
|
|
594
|
+
applied_limit = 0
|
|
595
|
+
# `limit: 0` means "no cap, give me everything" - the same reading as the
|
|
596
|
+
# Python master's `limit <= 0`. Ruby treats 0 as TRUTHY, so this used to
|
|
597
|
+
# fall through and append a literal `LIMIT 0`, returning ZERO rows where
|
|
598
|
+
# Python returned all of them for the identical call. fetch_all passes nil
|
|
599
|
+
# and was never affected, which is why it went unnoticed.
|
|
600
|
+
limit = nil if limit.is_a?(Numeric) && limit <= 0
|
|
475
601
|
if limit && !has_limit
|
|
476
602
|
effective_sql = drv.apply_limit(effective_sql, limit, offset)
|
|
603
|
+
applied_limit = limit
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
# COUNT PROBE: `count` is the TRUE total for the filter, not the number of
|
|
607
|
+
# rows this page returned.
|
|
608
|
+
#
|
|
609
|
+
# Ruby and Node used to populate count with records.length while Python
|
|
610
|
+
# and PHP populated it from a probe, so `db.fetch(sql).count` answered 20
|
|
611
|
+
# here and 250 there for one query against one table - and every paginated
|
|
612
|
+
# response built on it under-reported. MEASURED 2026-08-05 on a 250-row
|
|
613
|
+
# table read with limit=20: Ruby reported total 20 and 1 page against
|
|
614
|
+
# Python's 250 and 13.
|
|
615
|
+
#
|
|
616
|
+
# Only probed when WE appended the pagination. If no limit was applied
|
|
617
|
+
# (fetch_all, or the caller supplied their own LIMIT) the rows returned
|
|
618
|
+
# ARE the whole answer for this SQL, so records.length is already the true
|
|
619
|
+
# total and a second round-trip would buy nothing.
|
|
620
|
+
total = nil
|
|
621
|
+
if applied_limit.positive?
|
|
622
|
+
total = count_probe(drv, sql, params)
|
|
477
623
|
end
|
|
478
624
|
|
|
479
625
|
if @cache_enabled && !no_cache
|
|
@@ -486,13 +632,13 @@ module Tina4
|
|
|
486
632
|
# fetch_direct RAISES on a SQL error (and captures @last_error), so a
|
|
487
633
|
# failed read never reaches cache_set below — we never cache an empty
|
|
488
634
|
# result produced by a buried failure.
|
|
489
|
-
result = fetch_direct(drv, effective_sql, params)
|
|
635
|
+
result = fetch_direct(drv, effective_sql, params, limit: applied_limit, offset: offset, total: total)
|
|
490
636
|
cache_set(key, result)
|
|
491
637
|
@cache_mutex.synchronize { @cache_misses += 1 }
|
|
492
638
|
return result
|
|
493
639
|
end
|
|
494
640
|
|
|
495
|
-
fetch_direct(drv, effective_sql, params)
|
|
641
|
+
fetch_direct(drv, effective_sql, params, limit: applied_limit, offset: offset, total: total)
|
|
496
642
|
end
|
|
497
643
|
|
|
498
644
|
# Fetch a single row (or nil).
|
|
@@ -559,7 +705,7 @@ module Tina4
|
|
|
559
705
|
# — instead of probing a session sequence (lastval()) that returns nil or
|
|
560
706
|
# a stale wrong id for a UUID table. Other engines (SQLite/MySQL/MSSQL/
|
|
561
707
|
# Firebird) keep the generic build-then-last_insert_id path below.
|
|
562
|
-
if
|
|
708
|
+
if driver_implements?(drv, :insert)
|
|
563
709
|
result = drv.insert(table, data)
|
|
564
710
|
autocommit_standalone_write(drv)
|
|
565
711
|
# A driver that owns its insert (PostgreSQL, via RETURNING *) returns a
|
|
@@ -583,7 +729,7 @@ module Tina4
|
|
|
583
729
|
# insert). Mirrors the Python master + PHP, whose write DatabaseResult carries
|
|
584
730
|
# affected_rows/affectedRows; best-effort on drivers without a native count.
|
|
585
731
|
def write_affected(drv, default = 0)
|
|
586
|
-
|
|
732
|
+
driver_implements?(drv, :affected_rows) ? drv.affected_rows.to_i : default
|
|
587
733
|
end
|
|
588
734
|
private :write_affected
|
|
589
735
|
|
|
@@ -633,12 +779,36 @@ module Tina4
|
|
|
633
779
|
|
|
634
780
|
if where_sql.empty?
|
|
635
781
|
pk_columns = primary_key(table)
|
|
636
|
-
# Resolve each key column to whichever form the caller used
|
|
637
|
-
# Symbol
|
|
782
|
+
# Resolve each key column to whichever form the caller used - String or
|
|
783
|
+
# Symbol, and in whatever CASE they typed; nil marks one that is absent.
|
|
784
|
+
#
|
|
785
|
+
# The engines disagree about identifier case BY DESIGN and always will:
|
|
786
|
+
# Firebird folds an unquoted identifier to UPPER, PostgreSQL folds it to
|
|
787
|
+
# LOWER, MySQL and SQLite preserve what was typed. Introspection hands
|
|
788
|
+
# back the ENGINE's spelling while `data` carries the caller's, so an
|
|
789
|
+
# exact match failed on whichever engine folds the other way. That is a
|
|
790
|
+
# case-sensitivity bug, not a Firebird quirk - Firebird merely made it
|
|
791
|
+
# visible first, because the shared write-path contract writes lower-case
|
|
792
|
+
# keys and Firebird reports upper-case ones.
|
|
793
|
+
#
|
|
794
|
+
# Deliberately does NOT downcase what introspection returns: that would
|
|
795
|
+
# special-case one engine and break a genuinely quoted mixed-case table.
|
|
796
|
+
# The WHERE below is still built from the ENGINE's column name (pk_columns);
|
|
797
|
+
# only the VALUE is looked up by the caller's key.
|
|
638
798
|
pk_keys = pk_columns.map do |col|
|
|
639
|
-
|
|
640
|
-
|
|
799
|
+
folded = col.to_s.downcase
|
|
800
|
+
matches = data.keys.select { |k| k.to_s.downcase == folded }
|
|
801
|
+
if matches.length > 1
|
|
802
|
+
# Ambiguity is refused, never guessed - choosing wrong here writes
|
|
803
|
+
# the WHERE clause of an UPDATE.
|
|
804
|
+
raise ArgumentError,
|
|
805
|
+
"update was given more than one key for the primary-key column " \
|
|
806
|
+
"#{col.inspect}: #{matches.map(&:to_s).sort.inspect} " \
|
|
807
|
+
"(table=#{table.inspect}). These differ only by case, so which one " \
|
|
808
|
+
"identifies the row is ambiguous - pass exactly one, or pass an " \
|
|
809
|
+
"explicit filter."
|
|
641
810
|
end
|
|
811
|
+
matches.first
|
|
642
812
|
end
|
|
643
813
|
missing = pk_columns.each_with_index.reject { |_, i| pk_keys[i] }.map(&:first)
|
|
644
814
|
|
|
@@ -795,7 +965,19 @@ module Tina4
|
|
|
795
965
|
begin
|
|
796
966
|
drv.begin_transaction unless already_pinned
|
|
797
967
|
begin
|
|
798
|
-
|
|
968
|
+
# ONE round-trip per CHUNK instead of one per ROW. Looping execute()
|
|
969
|
+
# here pays a full network round-trip for every row: 500 rows took
|
|
970
|
+
# 9848ms on PostgreSQL against 15.8ms as a single multi-row VALUES
|
|
971
|
+
# (625x), MySQL 216x, MSSQL 121x. build_batch_inserts returns an empty
|
|
972
|
+
# array for anything it cannot collapse safely - RETURNING, upserts,
|
|
973
|
+
# non-INSERT statements, ragged rows, Firebird - and the row-at-a-time
|
|
974
|
+
# loop then runs unchanged.
|
|
975
|
+
batched = SQLTranslator.build_batch_inserts(sql, params_list, get_database_type)
|
|
976
|
+
if batched.empty?
|
|
977
|
+
params_list.each { |params| drv.execute(sql, params) }
|
|
978
|
+
else
|
|
979
|
+
batched.each { |chunk_sql, chunk_params| drv.execute(chunk_sql, chunk_params) }
|
|
980
|
+
end
|
|
799
981
|
drv.commit unless already_pinned
|
|
800
982
|
rescue => e
|
|
801
983
|
drv.rollback unless already_pinned
|
|
@@ -812,6 +994,11 @@ module Tina4
|
|
|
812
994
|
nil
|
|
813
995
|
end
|
|
814
996
|
|
|
997
|
+
# last_id stays the LAST inserted row's id. MySQL reports the FIRST id of
|
|
998
|
+
# a multi-row INSERT; its DRIVER normalises that at write time (the only
|
|
999
|
+
# place that knows both the first id and the row count), so get_last_id
|
|
1000
|
+
# and this result always agree. Normalising here instead would
|
|
1001
|
+
# double-apply.
|
|
815
1002
|
Tina4::DatabaseResult.new(
|
|
816
1003
|
[],
|
|
817
1004
|
affected_rows: params_list.length,
|
|
@@ -930,7 +1117,7 @@ module Tina4
|
|
|
930
1117
|
# v3.13.14 (#48): drivers that can resolve a schema/catalog-qualified
|
|
931
1118
|
# name ("gift_cards.gift_card", "dbo.widget", "attached.table") answer
|
|
932
1119
|
# directly; the rest fall back to a case-insensitive scan of tables.
|
|
933
|
-
return drv.table_exists?(table_name) if
|
|
1120
|
+
return drv.table_exists?(table_name) if driver_implements?(drv, :table_exists?)
|
|
934
1121
|
|
|
935
1122
|
tables.any? { |t| t.downcase == table_name.to_s.downcase }
|
|
936
1123
|
end
|
|
@@ -1048,10 +1235,52 @@ module Tina4
|
|
|
1048
1235
|
# main query propagates (same contract as #execute). The cause is captured on
|
|
1049
1236
|
# @last_error for #get_error before the re-raise — preferring the driver's own
|
|
1050
1237
|
# last_error (when it exposes one, e.g. postgres) over the exception message.
|
|
1051
|
-
|
|
1238
|
+
# `limit:`/`offset:` are the pagination ACTUALLY APPLIED to this statement —
|
|
1239
|
+
# `limit: 0` means "no cap was appended" (an explicit no-limit read, or SQL
|
|
1240
|
+
# that carries its own trailing LIMIT). They were never passed before, so
|
|
1241
|
+
# DatabaseResult#limit fell through to its constructor default and reported
|
|
1242
|
+
# 10 on EVERY fetch whatever limit ran — the same stale v2 number the buried
|
|
1243
|
+
# PHP row-cap test asserted as the cap, sitting one field away from the read
|
|
1244
|
+
# path it fails to describe.
|
|
1245
|
+
# The true row count for `sql`, ignoring the pagination we appended.
|
|
1246
|
+
#
|
|
1247
|
+
# BEST EFFORT BY DESIGN, and it must never mask a real failure: it runs
|
|
1248
|
+
# BEFORE the main query and returns nil on any error, so the main query
|
|
1249
|
+
# still runs and still raises loudly on bad SQL. A probe that swallowed the
|
|
1250
|
+
# error AND the main query's would turn a typo into "no rows".
|
|
1251
|
+
#
|
|
1252
|
+
# nil (not 0) is the miss value. DatabaseResult then falls back to
|
|
1253
|
+
# records.length, which is a true lower bound. Reporting 0 alongside 100
|
|
1254
|
+
# real records - as a failed probe would - is simply false, and it is the
|
|
1255
|
+
# same "envelope states a wrong number authoritatively" defect this whole
|
|
1256
|
+
# change exists to remove.
|
|
1257
|
+
#
|
|
1258
|
+
# The closing paren goes on its OWN LINE. Appended inline, a trailing
|
|
1259
|
+
# `-- comment` in the caller's SQL comments the paren out and the probe dies
|
|
1260
|
+
# with "incomplete input". Postgres, MySQL, MSSQL and ODBC additionally
|
|
1261
|
+
# require a name for the derived table; SQLite and Firebird do not, and
|
|
1262
|
+
# Firebird rejects the `AS` keyword there - so the alias comes from the
|
|
1263
|
+
# driver rather than being assumed.
|
|
1264
|
+
def count_probe(drv, sql, params)
|
|
1265
|
+
return nil unless driver_implements?(drv, :execute_query)
|
|
1266
|
+
|
|
1267
|
+
alias_name = driver_implements?(drv, :count_subquery_alias) ? drv.count_subquery_alias.to_s : ""
|
|
1268
|
+
suffix = alias_name.empty? ? "" : " AS #{alias_name}"
|
|
1269
|
+
rows = drv.execute_query("SELECT COUNT(*) AS tina4_total FROM (#{sql}\n)#{suffix}", params)
|
|
1270
|
+
row = rows.is_a?(Array) ? rows.first : nil
|
|
1271
|
+
return nil unless row.is_a?(Hash)
|
|
1272
|
+
|
|
1273
|
+
value = row["tina4_total"] || row[:tina4_total] || row["TINA4_TOTAL"] || row.values.first
|
|
1274
|
+
value.nil? ? nil : value.to_i
|
|
1275
|
+
rescue StandardError
|
|
1276
|
+
nil
|
|
1277
|
+
end
|
|
1278
|
+
|
|
1279
|
+
def fetch_direct(drv, effective_sql, params, limit: 0, offset: 0, total: nil)
|
|
1052
1280
|
result = drv.execute_query(effective_sql, params)
|
|
1053
1281
|
@last_error = nil
|
|
1054
|
-
Tina4::DatabaseResult.new(result, sql: effective_sql, db: self
|
|
1282
|
+
Tina4::DatabaseResult.new(result, sql: effective_sql, db: self,
|
|
1283
|
+
limit: limit, offset: offset, count: total)
|
|
1055
1284
|
rescue => e
|
|
1056
1285
|
@last_error = driver_error_message(drv, e)
|
|
1057
1286
|
raise
|
|
@@ -1075,7 +1304,7 @@ module Tina4
|
|
|
1075
1304
|
# Prefer the driver's own last_error (postgres sets one) over str(e), so the
|
|
1076
1305
|
# captured message matches what each engine surfaces; never blank.
|
|
1077
1306
|
def driver_error_message(drv, error)
|
|
1078
|
-
drv_err =
|
|
1307
|
+
drv_err = driver_implements?(drv, :last_error) ? drv.last_error : nil
|
|
1079
1308
|
msg = drv_err || error.message
|
|
1080
1309
|
msg = error.message if msg.nil? || msg.to_s.empty?
|
|
1081
1310
|
msg || @last_error
|
|
@@ -1167,7 +1396,7 @@ module Tina4
|
|
|
1167
1396
|
# lock (NOT via ensure_sequence_table, which would re-enter current_driver/
|
|
1168
1397
|
# table_exists? and risk a nested touch).
|
|
1169
1398
|
def sequence_next_sqlite(drv, seq_name, table, pk_column)
|
|
1170
|
-
conn =
|
|
1399
|
+
conn = driver_implements?(drv, :connection) ? drv.connection : nil
|
|
1171
1400
|
raise "get_next_id: SQLite driver has no live connection" if conn.nil?
|
|
1172
1401
|
|
|
1173
1402
|
Tina4::Drivers::SqliteDriver.write_lock.synchronize do
|
|
@@ -1349,8 +1578,46 @@ module Tina4
|
|
|
1349
1578
|
end
|
|
1350
1579
|
end
|
|
1351
1580
|
|
|
1581
|
+
# Stable identity of the DATABASE a cache entry came from.
|
|
1582
|
+
#
|
|
1583
|
+
# engine://host:port/database - and deliberately NOTHING else.
|
|
1584
|
+
#
|
|
1585
|
+
# WHY IT EXISTS: the key used to be sha256(sql + params) with nothing naming
|
|
1586
|
+
# the connection, so on any SHARED backend two databases cross-served each
|
|
1587
|
+
# other's rows. Two apps pointed at one Redis, or one app with a primary and
|
|
1588
|
+
# an analytics connection, silently read each other's data. Identical SQL
|
|
1589
|
+
# text across tenants is the COMMON case, not an edge case, so the collision
|
|
1590
|
+
# was the normal outcome.
|
|
1591
|
+
#
|
|
1592
|
+
# WHY NO CREDENTIALS: a password in the key means every rotation silently
|
|
1593
|
+
# cold-starts the cache, and a shared backend's key namespace is visible to
|
|
1594
|
+
# every tenant of that backend - a secret must never be folded into it. The
|
|
1595
|
+
# username is out for the same reason plus a second: two connections
|
|
1596
|
+
# differing only by role read the SAME rows and should share the entry.
|
|
1597
|
+
#
|
|
1598
|
+
# WHY NOTHING PER-PROCESS: no pid, no object_id, no salt. Those would
|
|
1599
|
+
# isolate the databases by accident and destroy the point of a shared cache,
|
|
1600
|
+
# because no instance would ever hit another instance's entry.
|
|
1601
|
+
def self.cache_identity(url)
|
|
1602
|
+
parsed = Tina4::DatabaseUrl.new(url.to_s)
|
|
1603
|
+
"#{parsed.engine}://#{parsed.host}:#{parsed.port}/#{parsed.database}"
|
|
1604
|
+
rescue StandardError
|
|
1605
|
+
# An unparseable connection string still needs a STABLE identity, and
|
|
1606
|
+
# falling back to a constant would silently restore the cross-serving bug.
|
|
1607
|
+
# The raw string is stable and distinct; it is only reached for a string
|
|
1608
|
+
# the connection layer is about to reject anyway.
|
|
1609
|
+
url.to_s
|
|
1610
|
+
end
|
|
1611
|
+
private_class_method :cache_identity
|
|
1612
|
+
|
|
1613
|
+
# Generate a cache key from DATABASE IDENTITY + SQL + params.
|
|
1614
|
+
#
|
|
1615
|
+
# The NUL separators keep the three parts from running together, so a table
|
|
1616
|
+
# named after the tail of a database name cannot forge another database's
|
|
1617
|
+
# key.
|
|
1352
1618
|
def cache_key(sql, params)
|
|
1353
|
-
|
|
1619
|
+
raw = "#{self.class.send(:cache_identity, @connection_string)}\x00#{sql}\x00#{params || []}"
|
|
1620
|
+
Digest::SHA256.hexdigest(raw)
|
|
1354
1621
|
end
|
|
1355
1622
|
|
|
1356
1623
|
def cache_get(key)
|
|
@@ -1440,8 +1707,41 @@ module Tina4
|
|
|
1440
1707
|
row.each_with_object({}) { |(k, v), h| h[k.to_sym] = v }
|
|
1441
1708
|
end
|
|
1442
1709
|
|
|
1710
|
+
# Did the driver actually OVERRIDE this, or is it inheriting the raising
|
|
1711
|
+
# stub from Tina4::DatabaseAdapter?
|
|
1712
|
+
#
|
|
1713
|
+
# These call sites used to ask `respond_to?`. Including the contract module
|
|
1714
|
+
# makes every driver respond to every contract method, so `respond_to?` can
|
|
1715
|
+
# no longer tell "implemented" from "declared and not yet written" - and
|
|
1716
|
+
# answering it wrongly turns a working path into a NotImplementedError.
|
|
1717
|
+
#
|
|
1718
|
+
# Methods outside the contract (affected_rows, last_error, connection) are
|
|
1719
|
+
# not on the module at all, so this degrades to a plain respond_to? for them.
|
|
1720
|
+
def driver_implements?(drv, name)
|
|
1721
|
+
return false if drv.nil?
|
|
1722
|
+
return Tina4::DatabaseAdapter.implemented_by?(drv, name) if
|
|
1723
|
+
Tina4::DatabaseAdapter::CONTRACT.include?(name.to_s.chomp("=").chomp("?").to_sym)
|
|
1724
|
+
|
|
1725
|
+
drv.respond_to?(name)
|
|
1726
|
+
end
|
|
1727
|
+
|
|
1443
1728
|
def detect_driver(conn)
|
|
1444
|
-
|
|
1729
|
+
# A real URL is decided by its SCHEME, through DatabaseUrl, which resolves
|
|
1730
|
+
# aliases once and RAISES on a scheme it does not know.
|
|
1731
|
+
#
|
|
1732
|
+
# The substring matching below is kept only for connection strings that
|
|
1733
|
+
# are not URLs at all (a bare "app.db" path). It used to handle
|
|
1734
|
+
# everything, and its `else "sqlite"` meant an unrecognised URL did not
|
|
1735
|
+
# fail - it quietly became SQLite. The app boots, writes to a local file,
|
|
1736
|
+
# and nobody learns the real database was never reached. It also matched
|
|
1737
|
+
# on substrings, so a postgres database named "mysqldata" could be
|
|
1738
|
+
# detected as MySQL.
|
|
1739
|
+
text = conn.to_s
|
|
1740
|
+
if text.match?(/\A[a-zA-Z][a-zA-Z0-9+.-]*:/)
|
|
1741
|
+
return Tina4::DatabaseUrl.new(text).engine
|
|
1742
|
+
end
|
|
1743
|
+
|
|
1744
|
+
case text.downcase
|
|
1445
1745
|
when /\.db$/, /\.sqlite/, /sqlite/
|
|
1446
1746
|
"sqlite"
|
|
1447
1747
|
when /postgres/, /^pg:/
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tina4
|
|
4
|
+
# The contract every database driver must satisfy.
|
|
5
|
+
#
|
|
6
|
+
# Feature 3 of the feature audit. Ruby had NO adapter interface: `Database`
|
|
7
|
+
# called four things on a driver and guarded the rest behind six `respond_to?`
|
|
8
|
+
# checks. The consequences, in order of severity:
|
|
9
|
+
#
|
|
10
|
+
# - A driver missing a method was discovered at runtime, on whichever engine
|
|
11
|
+
# nobody exercised, and the guards meant the failure was often a SILENT SKIP
|
|
12
|
+
# rather than an exception - which is worse than a crash.
|
|
13
|
+
# - Nothing told a contributor writing an eighth driver what to implement. The
|
|
14
|
+
# answer was "read database.rb and infer", and it is 828 lines.
|
|
15
|
+
# - The audit could not compare Ruby's contract to the other three, because
|
|
16
|
+
# Ruby did not have one. That was the finding.
|
|
17
|
+
#
|
|
18
|
+
# Measured against the shared contract (spec/fixtures/adapter_contract.json,
|
|
19
|
+
# byte-identical in all four), the seven drivers scored 9, 10 and 11 out of 20
|
|
20
|
+
# - three different levels of completeness, because each implemented whatever
|
|
21
|
+
# its facade path happened to need.
|
|
22
|
+
#
|
|
23
|
+
# Every method here raises. A driver that does not override one fails LOUDLY,
|
|
24
|
+
# at the point of the call, naming itself and the method - instead of being
|
|
25
|
+
# quietly skipped.
|
|
26
|
+
#
|
|
27
|
+
# == Migration in progress
|
|
28
|
+
#
|
|
29
|
+
# The owner's decision (2026-07-30) is that CRUD lives on the ADAPTER, matching
|
|
30
|
+
# PHP, Python and Node. Today Ruby's facade builds the SQL for fetch / insert /
|
|
31
|
+
# update / delete and calls the driver's +execute+, consulting +drv.insert+
|
|
32
|
+
# only when a driver chooses to own it (PostgreSQL does, via RETURNING *).
|
|
33
|
+
# Those methods are declared here so the gap is visible and countable; they are
|
|
34
|
+
# migrated driver by driver, each with its own test, rather than in one sweep.
|
|
35
|
+
#
|
|
36
|
+
# Until a driver overrides them, +Database+ keeps using its own path - see
|
|
37
|
+
# +Database#driver_implements?+, which asks whether the driver actually
|
|
38
|
+
# OVERRODE a method rather than whether it merely responds to it. That
|
|
39
|
+
# distinction is the whole point: including this module makes every driver
|
|
40
|
+
# respond to everything, so +respond_to?+ stopped being able to tell the
|
|
41
|
+
# difference.
|
|
42
|
+
module DatabaseAdapter
|
|
43
|
+
# Methods a driver MUST override. Kept as data so the conformance spec can
|
|
44
|
+
# read it instead of maintaining a second copy of the list.
|
|
45
|
+
# The REDESIGNED contract: only what genuinely differs per engine.
|
|
46
|
+
#
|
|
47
|
+
# CRUD (insert/update/delete), executeMany, fetchOne and DDL
|
|
48
|
+
# (create_table/add_column) are NOT here. They are composable above the
|
|
49
|
+
# adapter from execute + fetch + get_database_type, and Ruby was already
|
|
50
|
+
# doing exactly that in the facade - which is why Ruby's driver layer is
|
|
51
|
+
# 1335 LOC against PHP's 5823 for the same job. The first contract this row
|
|
52
|
+
# produced would have made Ruby write those seven more times; this one keeps
|
|
53
|
+
# the shape Ruby already had and asks the other three to adopt it.
|
|
54
|
+
CONTRACT = %i[
|
|
55
|
+
open close get_database_type
|
|
56
|
+
execute fetch
|
|
57
|
+
start_transaction commit rollback autocommit
|
|
58
|
+
get_tables get_columns table_exists
|
|
59
|
+
last_insert_id error
|
|
60
|
+
].freeze
|
|
61
|
+
|
|
62
|
+
CONTRACT.each do |name|
|
|
63
|
+
define_method(name) do |*_args, **_kwargs, &_block|
|
|
64
|
+
raise NotImplementedError,
|
|
65
|
+
"#{self.class} does not implement ##{name}, which the Tina4 " \
|
|
66
|
+
"database adapter contract requires. See Tina4::DatabaseAdapter."
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# == Bounding the connect
|
|
71
|
+
#
|
|
72
|
+
# A connect that can block forever hangs the whole application with NO log,
|
|
73
|
+
# no error and no signal. MEASURED here on Ruby 3.2.3 / Ubuntu 24.04.4
|
|
74
|
+
# against a real TCPServer that accepts the TCP connection and then never
|
|
75
|
+
# replies: pg, mysql2, tiny_tds AND fb all sat past 20 seconds and needed
|
|
76
|
+
# SIGKILL - `timeout`'s SIGTERM could not even be delivered, because the
|
|
77
|
+
# blocking work happens inside a C client that never yields to the
|
|
78
|
+
# interpreter. (A CLOSED port is a different thing entirely: it refuses in
|
|
79
|
+
# 0.00s and tests nothing.)
|
|
80
|
+
#
|
|
81
|
+
# ONE variable governs every driver whose connect crosses a network:
|
|
82
|
+
#
|
|
83
|
+
# TINA4_DATABASE_CONNECT_TIMEOUT seconds, default 10; <= 0 disables the
|
|
84
|
+
# bound (unbounded, the old behaviour);
|
|
85
|
+
# a non-number warns and falls back to 10
|
|
86
|
+
#
|
|
87
|
+
# Each driver applies it through its OWN native option - libpq
|
|
88
|
+
# connect_timeout, mysql2 connect_timeout, FreeTDS login_timeout, mongo
|
|
89
|
+
# connect_timeout - because only the C client can interrupt its own blocking
|
|
90
|
+
# socket work. Ruby's Timeout.timeout and Thread#join CANNOT: see
|
|
91
|
+
# Tina4::Drivers::FirebirdDriver.bound_reachability! for the measurement.
|
|
92
|
+
#
|
|
93
|
+
# There is deliberately NO outer Ruby timeout racing the native one. The
|
|
94
|
+
# native option is the ONLY timer; bounding_connect below merely TRANSLATES
|
|
95
|
+
# whatever the client raises into the one contract message, so the operator
|
|
96
|
+
# is never left holding a driver-worded error that names no variable. Where
|
|
97
|
+
# a driver cannot produce that message at all, its own file says so at the
|
|
98
|
+
# point of exclusion:
|
|
99
|
+
#
|
|
100
|
+
# postgres bounded + contract message libpq connect_timeout
|
|
101
|
+
# mysql bounded + contract message mysql2 connect_timeout
|
|
102
|
+
# mssql bounded + contract message FreeTDS login_timeout
|
|
103
|
+
# firebird bounded to REACHABILITY only stdlib socket; the attach itself
|
|
104
|
+
# cannot be bounded from Ruby
|
|
105
|
+
# mongodb bounded, NO message possible Client.new never fails
|
|
106
|
+
# sqlite n/a local file, no network peer
|
|
107
|
+
# odbc NOT bounded gem untestable here; see its file
|
|
108
|
+
CONNECT_TIMEOUT_VAR = "TINA4_DATABASE_CONNECT_TIMEOUT"
|
|
109
|
+
DEFAULT_CONNECT_TIMEOUT_SECONDS = 10
|
|
110
|
+
|
|
111
|
+
# Clock slack when deciding whether a failed connect was OUR bound expiring.
|
|
112
|
+
# A native bound of 10s is measured back as 9.998s often enough to matter,
|
|
113
|
+
# and without the slack the contract error would degrade into the raw driver
|
|
114
|
+
# error at random.
|
|
115
|
+
CONNECT_TIMEOUT_SLACK_SECONDS = 0.25
|
|
116
|
+
|
|
117
|
+
# Seconds to bound a connect by, or nil when the operator disabled the bound.
|
|
118
|
+
def self.connect_timeout_seconds
|
|
119
|
+
seconds = Tina4::Env.float(CONNECT_TIMEOUT_VAR, default: DEFAULT_CONNECT_TIMEOUT_SECONDS)
|
|
120
|
+
seconds.positive? ? seconds : nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Whole seconds for the native options that accept only an integer (libpq,
|
|
124
|
+
# libmysqlclient, FreeTDS). Rounds UP and never below 1: libpq reads
|
|
125
|
+
# connect_timeout=0 as "wait forever", so rounding 0.4 DOWN to 0 would
|
|
126
|
+
# silently disable the very bound being set.
|
|
127
|
+
def self.connect_timeout_whole_seconds
|
|
128
|
+
seconds = connect_timeout_seconds
|
|
129
|
+
seconds && [seconds.ceil, 1].max
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# The one error a timed-out connect raises: it names the host, the port, the
|
|
133
|
+
# seconds actually spent, and the variable that tunes it.
|
|
134
|
+
def self.connect_timed_out!(host, port, elapsed_seconds, cause = nil)
|
|
135
|
+
detail = cause ? " Driver reported: #{cause.message.to_s.gsub(/\s+/, " ").strip}" : ""
|
|
136
|
+
raise Tina4::DatabaseConnectionError,
|
|
137
|
+
"Database connect to #{host}:#{port} timed out after " \
|
|
138
|
+
"#{format("%.1f", elapsed_seconds)}s (#{CONNECT_TIMEOUT_VAR}=" \
|
|
139
|
+
"#{connect_timeout_seconds} seconds; set it to 0 to wait " \
|
|
140
|
+
"indefinitely).#{detail}"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Run a driver's natively-bounded connect and translate an expiry into the
|
|
144
|
+
# contract error above. The NATIVE option does the bounding; this only names
|
|
145
|
+
# it. Whether the bound expired is decided by ELAPSED TIME, not by matching
|
|
146
|
+
# driver error text - the four clients word it four different ways
|
|
147
|
+
# ("timeout expired", "waiting for initial communication packet", "TDS
|
|
148
|
+
# server connection timed out", "Connection timed out"), and a marker table
|
|
149
|
+
# is one more thing to drift and MISS. A missed timeout is the whole defect.
|
|
150
|
+
def self.bounding_connect(host, port)
|
|
151
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
152
|
+
yield
|
|
153
|
+
rescue StandardError => error
|
|
154
|
+
bound = connect_timeout_seconds
|
|
155
|
+
raise if bound.nil?
|
|
156
|
+
|
|
157
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
|
|
158
|
+
raise if elapsed < bound - CONNECT_TIMEOUT_SLACK_SECONDS
|
|
159
|
+
|
|
160
|
+
connect_timed_out!(host, port, elapsed, error)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Did this driver actually OVERRIDE the contract method, or is it inheriting
|
|
164
|
+
# the raising stub? `respond_to?` cannot answer that once the module is
|
|
165
|
+
# included, and answering it wrongly turns a working silent-skip path into a
|
|
166
|
+
# NotImplementedError at runtime.
|
|
167
|
+
def self.implemented_by?(object, name)
|
|
168
|
+
return false unless object.respond_to?(name)
|
|
169
|
+
|
|
170
|
+
owner = begin
|
|
171
|
+
object.class.instance_method(name).owner
|
|
172
|
+
rescue NameError
|
|
173
|
+
nil
|
|
174
|
+
end
|
|
175
|
+
!owner.nil? && owner != self
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|