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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +883 -0
  3. data/README.md +1 -1
  4. data/lib/tina4/auth.rb +166 -87
  5. data/lib/tina4/auto_crud.rb +29 -32
  6. data/lib/tina4/cache_backends/base_backend.rb +19 -0
  7. data/lib/tina4/cache_backends/database_backend.rb +29 -0
  8. data/lib/tina4/cache_backends/memcached_backend.rb +124 -13
  9. data/lib/tina4/cache_backends/memory_backend.rb +15 -0
  10. data/lib/tina4/cache_backends/redis_backend.rb +173 -52
  11. data/lib/tina4/cache_backends.rb +10 -1
  12. data/lib/tina4/cli.rb +23 -39
  13. data/lib/tina4/cors.rb +186 -30
  14. data/lib/tina4/database/sqlite3_adapter.rb +4 -1
  15. data/lib/tina4/database.rb +322 -22
  16. data/lib/tina4/database_adapter.rb +178 -0
  17. data/lib/tina4/database_result.rb +63 -17
  18. data/lib/tina4/database_url.rb +363 -0
  19. data/lib/tina4/dev.rb +0 -1
  20. data/lib/tina4/dev_admin.rb +118 -20
  21. data/lib/tina4/dispatch_pipeline.rb +605 -0
  22. data/lib/tina4/docstore.rb +274 -60
  23. data/lib/tina4/drivers/firebird_driver.rb +118 -4
  24. data/lib/tina4/drivers/mongodb_driver.rb +19 -4
  25. data/lib/tina4/drivers/mssql_driver.rb +73 -10
  26. data/lib/tina4/drivers/mysql_driver.rb +71 -4
  27. data/lib/tina4/drivers/odbc_driver.rb +40 -4
  28. data/lib/tina4/drivers/postgres_driver.rb +97 -10
  29. data/lib/tina4/drivers/sqlite_driver.rb +21 -2
  30. data/lib/tina4/env.rb +176 -34
  31. data/lib/tina4/field_types.rb +12 -0
  32. data/lib/tina4/health.rb +30 -14
  33. data/lib/tina4/job.rb +15 -5
  34. data/lib/tina4/log.rb +236 -32
  35. data/lib/tina4/mcp.rb +11 -5
  36. data/lib/tina4/messenger.rb +248 -36
  37. data/lib/tina4/metrics.rb +179 -891
  38. data/lib/tina4/middleware.rb +191 -56
  39. data/lib/tina4/migration.rb +17 -1
  40. data/lib/tina4/orm.rb +114 -17
  41. data/lib/tina4/public/css/tina4.min.css +1 -1
  42. data/lib/tina4/queue.rb +154 -9
  43. data/lib/tina4/queue_backends/kafka_backend.rb +191 -2
  44. data/lib/tina4/queue_backends/lite_backend.rb +121 -25
  45. data/lib/tina4/queue_backends/mongo_backend.rb +146 -10
  46. data/lib/tina4/queue_backends/rabbitmq_backend.rb +194 -1
  47. data/lib/tina4/rack_app.rb +94 -316
  48. data/lib/tina4/request.rb +48 -8
  49. data/lib/tina4/response.rb +42 -1
  50. data/lib/tina4/response_cache.rb +142 -24
  51. data/lib/tina4/router.rb +141 -12
  52. data/lib/tina4/session.rb +243 -29
  53. data/lib/tina4/session_handlers/database_handler.rb +185 -20
  54. data/lib/tina4/session_handlers/file_handler.rb +113 -21
  55. data/lib/tina4/session_handlers/memcached_handler.rb +183 -0
  56. data/lib/tina4/session_handlers/mongo_handler.rb +232 -15
  57. data/lib/tina4/session_handlers/mongo_wire_client.rb +300 -0
  58. data/lib/tina4/session_handlers/redis_handler.rb +20 -6
  59. data/lib/tina4/session_handlers/valkey_handler.rb +18 -4
  60. data/lib/tina4/shutdown.rb +180 -30
  61. data/lib/tina4/sql_translator.rb +110 -0
  62. data/lib/tina4/swagger.rb +50 -18
  63. data/lib/tina4/version.rb +1 -1
  64. data/lib/tina4/webserver.rb +28 -6
  65. data/lib/tina4.rb +289 -37
  66. metadata +35 -17
  67. data/lib/tina4/scss_compiler.rb +0 -349
@@ -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.respond_to?(:autocommit=)
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
- return '' if @connection_string.nil? || @connection_string.empty?
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 SQL already has one
474
- has_limit = sql.upcase.split("--")[0].include?("LIMIT")
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 drv.respond_to?(:insert)
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
- drv.respond_to?(:affected_rows) ? drv.affected_rows.to_i : default
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 (String or
637
- # Symbol); nil marks one that is absent from the data.
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
- if data.key?(col) then col
640
- elsif data.key?(col.to_sym) then col.to_sym
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
- params_list.each { |params| drv.execute(sql, params) }
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 drv.respond_to?(:table_exists?)
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
- def fetch_direct(drv, effective_sql, params)
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 = drv.respond_to?(:last_error) ? drv.last_error : nil
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 = drv.respond_to?(:connection) ? drv.connection : nil
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
- Digest::SHA256.hexdigest(sql + params.to_s)
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
- case conn.to_s.downcase
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