tina4ruby 3.13.93 → 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 (69) 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 +35 -43
  13. data/lib/tina4/cors.rb +186 -30
  14. data/lib/tina4/database/sqlite3_adapter.rb +4 -1
  15. data/lib/tina4/database.rb +458 -48
  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/dev_mailbox.rb +5 -1
  22. data/lib/tina4/dispatch_pipeline.rb +605 -0
  23. data/lib/tina4/docstore.rb +274 -60
  24. data/lib/tina4/drivers/firebird_driver.rb +118 -4
  25. data/lib/tina4/drivers/mongodb_driver.rb +19 -4
  26. data/lib/tina4/drivers/mssql_driver.rb +73 -10
  27. data/lib/tina4/drivers/mysql_driver.rb +71 -4
  28. data/lib/tina4/drivers/odbc_driver.rb +40 -4
  29. data/lib/tina4/drivers/postgres_driver.rb +97 -10
  30. data/lib/tina4/drivers/sqlite_driver.rb +25 -3
  31. data/lib/tina4/env.rb +176 -34
  32. data/lib/tina4/field_types.rb +12 -0
  33. data/lib/tina4/frond.rb +102 -10
  34. data/lib/tina4/health.rb +30 -14
  35. data/lib/tina4/job.rb +15 -5
  36. data/lib/tina4/log.rb +236 -32
  37. data/lib/tina4/mcp.rb +11 -5
  38. data/lib/tina4/messenger.rb +317 -82
  39. data/lib/tina4/metrics.rb +179 -891
  40. data/lib/tina4/middleware.rb +191 -56
  41. data/lib/tina4/migration.rb +17 -1
  42. data/lib/tina4/orm.rb +114 -17
  43. data/lib/tina4/public/css/tina4.min.css +1 -1
  44. data/lib/tina4/queue.rb +154 -9
  45. data/lib/tina4/queue_backends/kafka_backend.rb +191 -2
  46. data/lib/tina4/queue_backends/lite_backend.rb +121 -25
  47. data/lib/tina4/queue_backends/mongo_backend.rb +146 -10
  48. data/lib/tina4/queue_backends/rabbitmq_backend.rb +194 -1
  49. data/lib/tina4/rack_app.rb +94 -316
  50. data/lib/tina4/request.rb +48 -8
  51. data/lib/tina4/response.rb +42 -1
  52. data/lib/tina4/response_cache.rb +142 -24
  53. data/lib/tina4/router.rb +141 -12
  54. data/lib/tina4/session.rb +243 -29
  55. data/lib/tina4/session_handlers/database_handler.rb +185 -20
  56. data/lib/tina4/session_handlers/file_handler.rb +113 -21
  57. data/lib/tina4/session_handlers/memcached_handler.rb +183 -0
  58. data/lib/tina4/session_handlers/mongo_handler.rb +232 -15
  59. data/lib/tina4/session_handlers/mongo_wire_client.rb +300 -0
  60. data/lib/tina4/session_handlers/redis_handler.rb +20 -6
  61. data/lib/tina4/session_handlers/valkey_handler.rb +18 -4
  62. data/lib/tina4/shutdown.rb +180 -30
  63. data/lib/tina4/sql_translator.rb +110 -0
  64. data/lib/tina4/swagger.rb +50 -18
  65. data/lib/tina4/version.rb +1 -1
  66. data/lib/tina4/webserver.rb +28 -6
  67. data/lib/tina4.rb +301 -38
  68. metadata +35 -17
  69. data/lib/tina4/scss_compiler.rb +0 -349
@@ -5,6 +5,15 @@ require "digest"
5
5
  require "weakref"
6
6
 
7
7
  module Tina4
8
+ # Raised at the first USE of a database whose connect failed.
9
+ #
10
+ # Connecting is deliberately non-fatal at boot (log loud, then degrade - the same
11
+ # policy the session backends follow), so the failure has to resurface somewhere.
12
+ # It used to resurface as a nil dereference inside the driver
13
+ # ("NoMethodError: private method `exec' called for nil:NilClass"), which named
14
+ # neither the database nor the reason. This carries both.
15
+ class DatabaseConnectionError < StandardError; end
16
+
8
17
  # Thread-safe connection pool with round-robin rotation.
9
18
  # Connections are created lazily on first use.
10
19
  class ConnectionPool
@@ -136,6 +145,111 @@ module Tina4
136
145
  stripped
137
146
  end
138
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
+
139
253
  # Static factory — cross-framework consistency: Database.create(url)
140
254
  def self.create(url, username: "", password: "", pool: nil)
141
255
  new(url, username: username.empty? ? nil : username,
@@ -191,6 +305,9 @@ module Tina4
191
305
  pool
192
306
  end
193
307
  @connected = false
308
+ # Set by #connect when connecting fails; re-raised with context by
309
+ # #current_driver so the first real call says what went wrong.
310
+ @connect_error = nil
194
311
 
195
312
  # Per-instance thread-local key for the transaction adapter pin.
196
313
  # Without this pin, every Database method call rotates to a different
@@ -298,12 +415,24 @@ module Tina4
298
415
  # native toggle (default ON — see @autocommit in #initialize). The
299
416
  # framework-level commit in #autocommit_standalone_write covers drivers
300
417
  # that have no native setter.
301
- @driver.autocommit = @autocommit if @driver.respond_to?(:autocommit=)
418
+ @driver.autocommit = @autocommit if driver_implements?(@driver, :autocommit=)
302
419
 
303
420
  Tina4::Log.info("Database connected: #{@driver_name}")
421
+ @connect_error = nil
304
422
  rescue => e
305
423
  Tina4::Log.error("Database connection failed: #{e.message}")
306
424
  @connected = false
425
+ # REMEMBER the cause. Logging alone is not enough: the driver's own
426
+ # connection stays nil, so the next call used to die inside the driver as
427
+ # NoMethodError: private method `exec' called for nil:NilClass
428
+ # which names neither the database it failed to reach nor why. That cost
429
+ # real debugging time when a missing test database looked like a driver
430
+ # bug. current_driver re-raises this with context instead.
431
+ #
432
+ # Boot still does NOT crash on an unreachable database (deliberate, and
433
+ # the same log-loud-then-degrade policy the session backends use) - the
434
+ # error surfaces at the first actual USE.
435
+ @connect_error = e
307
436
  end
308
437
 
309
438
  def close
@@ -324,6 +453,11 @@ module Tina4
324
453
  def current_driver
325
454
  pinned = Thread.current[@tx_pin_key]
326
455
  return pinned if pinned
456
+
457
+ # Fail with the REAL reason, at the point of use. Without this the caller
458
+ # gets a nil dereference from deep inside the driver and has to guess.
459
+ raise Tina4::DatabaseConnectionError, connect_error_message if @connect_error
460
+
327
461
  if @pool
328
462
  @pool.checkout
329
463
  else
@@ -331,6 +465,30 @@ module Tina4
331
465
  end
332
466
  end
333
467
 
468
+ # A message that says which database, on which driver, and why - the three
469
+ # things the old nil-dereference told you nothing about.
470
+ def connect_error_message
471
+ target = safe_connection_target
472
+ "Database not connected (#{@driver_name}#{target.empty? ? '' : " -> #{target}"}): " \
473
+ "#{@connect_error.class}: #{@connect_error.message}"
474
+ end
475
+
476
+ # The connection string with any password stripped, so a raised error can name
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.
488
+ def safe_connection_target
489
+ Tina4::DatabaseUrl.redact(@connection_string)
490
+ end
491
+
334
492
  # ── Query Cache ──────────────────────────────────────────────
335
493
 
336
494
  def cache_stats
@@ -425,10 +583,43 @@ module Tina4
425
583
  sql = Tina4::Database.strip_trailing_semicolons(sql)
426
584
 
427
585
  effective_sql = sql
428
- # Skip appending LIMIT if SQL already has one
429
- 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
430
601
  if limit && !has_limit
431
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)
432
623
  end
433
624
 
434
625
  if @cache_enabled && !no_cache
@@ -441,13 +632,13 @@ module Tina4
441
632
  # fetch_direct RAISES on a SQL error (and captures @last_error), so a
442
633
  # failed read never reaches cache_set below — we never cache an empty
443
634
  # result produced by a buried failure.
444
- result = fetch_direct(drv, effective_sql, params)
635
+ result = fetch_direct(drv, effective_sql, params, limit: applied_limit, offset: offset, total: total)
445
636
  cache_set(key, result)
446
637
  @cache_mutex.synchronize { @cache_misses += 1 }
447
638
  return result
448
639
  end
449
640
 
450
- fetch_direct(drv, effective_sql, params)
641
+ fetch_direct(drv, effective_sql, params, limit: applied_limit, offset: offset, total: total)
451
642
  end
452
643
 
453
644
  # Fetch a single row (or nil).
@@ -514,7 +705,7 @@ module Tina4
514
705
  # — instead of probing a session sequence (lastval()) that returns nil or
515
706
  # a stale wrong id for a UUID table. Other engines (SQLite/MySQL/MSSQL/
516
707
  # Firebird) keep the generic build-then-last_insert_id path below.
517
- if drv.respond_to?(:insert)
708
+ if driver_implements?(drv, :insert)
518
709
  result = drv.insert(table, data)
519
710
  autocommit_standalone_write(drv)
520
711
  # A driver that owns its insert (PostgreSQL, via RETURNING *) returns a
@@ -538,61 +729,150 @@ module Tina4
538
729
  # insert). Mirrors the Python master + PHP, whose write DatabaseResult carries
539
730
  # affected_rows/affectedRows; best-effort on drivers without a native count.
540
731
  def write_affected(drv, default = 0)
541
- drv.respond_to?(:affected_rows) ? drv.affected_rows.to_i : default
732
+ driver_implements?(drv, :affected_rows) ? drv.affected_rows.to_i : default
542
733
  end
543
734
  private :write_affected
544
735
 
736
+ # The table's primary-key columns, introspected once and cached.
737
+ #
738
+ # Returns an ARRAY because a primary key may span several columns. A
739
+ # composite key is still one primary key; it just has more than one column.
740
+ # Returns [] when the table has no primary key or cannot be introspected.
741
+ #
742
+ # Uses the cross-engine columns() contract (v3.13.14, #48), which reports
743
+ # :primary_key per column on every driver.
744
+ def primary_key(table)
745
+ @pk_cache ||= {}
746
+ unless @pk_cache.key?(table)
747
+ @pk_cache[table] = begin
748
+ columns(table).select { |c| c[:primary_key] }.map { |c| c[:name].to_s }
749
+ rescue StandardError
750
+ []
751
+ end
752
+ end
753
+ @pk_cache[table]
754
+ end
755
+
756
+ # Normalise a filter to [sql, params], accepting a Hash or a String.
757
+ def as_where(filter, params)
758
+ return ["", []] if filter.nil?
759
+
760
+ if filter.is_a?(Hash)
761
+ return ["", []] if filter.empty?
762
+
763
+ drv = current_driver
764
+ [filter.keys.map { |k| "#{k} = #{drv.placeholder}" }.join(" AND "), filter.values]
765
+ else
766
+ [filter.to_s, Array(params)]
767
+ end
768
+ end
769
+ private :as_where
770
+
771
+ # Update rows. A write with no filter is an error, not a full-table write.
772
+ #
773
+ # With no explicit filter the primary key is taken out of `data` and used as
774
+ # the WHERE clause. With neither a filter nor a primary key in `data` this
775
+ # raises rather than overwriting every row (audit feature 4, P1).
545
776
  def update(table, data, filter = {}, params = nil)
546
- cache_invalidate if @cache_enabled
547
- drv = current_driver
777
+ where_sql, where_params = as_where(filter, params)
778
+ data = data.dup
779
+
780
+ if where_sql.empty?
781
+ pk_columns = primary_key(table)
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.
798
+ pk_keys = pk_columns.map do |col|
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."
810
+ end
811
+ matches.first
812
+ end
813
+ missing = pk_columns.each_with_index.reject { |_, i| pk_keys[i] }.map(&:first)
814
+
815
+ if pk_columns.empty? || !missing.empty?
816
+ raise ArgumentError,
817
+ "update requires a filter or the complete primary key in the data; " \
818
+ "pass a filter explicitly to update multiple rows " \
819
+ "(table=#{table.inspect}, primary key=#{pk_columns.inspect}, " \
820
+ "missing from data=#{missing.inspect}). " \
821
+ "To empty a table use truncate(#{table.inspect})."
822
+ end
548
823
 
549
- # String filter with explicit params array
550
- if filter.is_a?(String) && !params.nil?
551
- set_parts = data.keys.map { |k| "#{k} = #{drv.placeholder}" }
552
- sql = "UPDATE #{table} SET #{set_parts.join(', ')}"
553
- sql += " WHERE #{filter}" unless filter.empty?
554
- drv.execute(sql, data.values + Array(params))
555
- autocommit_standalone_write(drv)
556
- return Tina4::DatabaseResult.new([], affected_rows: write_affected(drv))
824
+ # EVERY key column goes into the WHERE. A composite key built from only
825
+ # its first column would match every row sharing that value - the
826
+ # data-loss bug this method exists to prevent, reintroduced.
827
+ where_params = pk_keys.map { |k| data.delete(k) }
828
+ if data.empty?
829
+ raise ArgumentError,
830
+ "update was given only the primary key #{pk_columns.inspect} and no " \
831
+ "columns to set (table=#{table.inspect})"
832
+ end
833
+
834
+ where_sql = pk_columns.map { |c| "#{c} = #{current_driver.placeholder}" }.join(" AND ")
557
835
  end
558
836
 
837
+ cache_invalidate if @cache_enabled
838
+ drv = current_driver
559
839
  set_parts = data.keys.map { |k| "#{k} = #{drv.placeholder}" }
560
- where_parts = filter.keys.map { |k| "#{k} = #{drv.placeholder}" }
561
- sql = "UPDATE #{table} SET #{set_parts.join(', ')}"
562
- sql += " WHERE #{where_parts.join(' AND ')}" unless filter.empty?
563
- values = data.values + filter.values
564
- drv.execute(sql, values)
840
+ sql = "UPDATE #{table} SET #{set_parts.join(', ')} WHERE #{where_sql}"
841
+ drv.execute(sql, data.values + where_params)
565
842
  autocommit_standalone_write(drv)
566
- Tina4::DatabaseResult.new([], affected_rows: write_affected(drv))
843
+ Tina4::DatabaseResult.new([], affected_rows: write_affected(drv), last_id: nil)
567
844
  end
568
845
 
846
+ # Delete rows. A filterless delete raises; use truncate() to empty a table.
569
847
  def delete(table, filter = {}, params = nil)
570
- cache_invalidate if @cache_enabled
571
- drv = current_driver
572
-
573
848
  # List of hashes — delete each row
574
849
  if filter.is_a?(Array)
575
850
  total = 0
576
851
  filter.each { |row| total += delete(table, row).affected_rows }
577
- return Tina4::DatabaseResult.new([], affected_rows: total)
852
+ return Tina4::DatabaseResult.new([], affected_rows: total, last_id: nil)
578
853
  end
579
854
 
580
- # String filter — raw WHERE clause with optional params
581
- if filter.is_a?(String)
582
- sql = "DELETE FROM #{table}"
583
- sql += " WHERE #{filter}" unless filter.empty?
584
- drv.execute(sql, Array(params))
585
- autocommit_standalone_write(drv)
586
- return Tina4::DatabaseResult.new([], affected_rows: write_affected(drv))
855
+ where_sql, where_params = as_where(filter, params)
856
+ if where_sql.empty?
857
+ raise ArgumentError,
858
+ "delete requires a filter (table=#{table.inspect}). " \
859
+ "To remove every row use truncate(#{table.inspect})."
587
860
  end
588
861
 
589
- # Hash filter — build WHERE from keys
590
- where_parts = filter.keys.map { |k| "#{k} = #{drv.placeholder}" }
591
- sql = "DELETE FROM #{table}"
592
- sql += " WHERE #{where_parts.join(' AND ')}" unless filter.empty?
593
- drv.execute(sql, filter.values)
862
+ cache_invalidate if @cache_enabled
863
+ drv = current_driver
864
+ drv.execute("DELETE FROM #{table} WHERE #{where_sql}", where_params)
865
+ autocommit_standalone_write(drv)
866
+ Tina4::DatabaseResult.new([], affected_rows: write_affected(drv), last_id: nil)
867
+ end
868
+
869
+ # Remove every row. The explicit spelling of a whole-table delete.
870
+ def truncate(table)
871
+ cache_invalidate if @cache_enabled
872
+ drv = current_driver
873
+ drv.execute("DELETE FROM #{table} WHERE 1 = 1", [])
594
874
  autocommit_standalone_write(drv)
595
- Tina4::DatabaseResult.new([], affected_rows: write_affected(drv))
875
+ Tina4::DatabaseResult.new([], affected_rows: write_affected(drv), last_id: nil)
596
876
  end
597
877
 
598
878
  # Return the last execute() error message, or nil.
@@ -685,7 +965,19 @@ module Tina4
685
965
  begin
686
966
  drv.begin_transaction unless already_pinned
687
967
  begin
688
- 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
689
981
  drv.commit unless already_pinned
690
982
  rescue => e
691
983
  drv.rollback unless already_pinned
@@ -702,6 +994,11 @@ module Tina4
702
994
  nil
703
995
  end
704
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.
705
1002
  Tina4::DatabaseResult.new(
706
1003
  [],
707
1004
  affected_rows: params_list.length,
@@ -820,7 +1117,7 @@ module Tina4
820
1117
  # v3.13.14 (#48): drivers that can resolve a schema/catalog-qualified
821
1118
  # name ("gift_cards.gift_card", "dbo.widget", "attached.table") answer
822
1119
  # directly; the rest fall back to a case-insensitive scan of tables.
823
- 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?)
824
1121
 
825
1122
  tables.any? { |t| t.downcase == table_name.to_s.downcase }
826
1123
  end
@@ -938,10 +1235,52 @@ module Tina4
938
1235
  # main query propagates (same contract as #execute). The cause is captured on
939
1236
  # @last_error for #get_error before the re-raise — preferring the driver's own
940
1237
  # last_error (when it exposes one, e.g. postgres) over the exception message.
941
- 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)
942
1280
  result = drv.execute_query(effective_sql, params)
943
1281
  @last_error = nil
944
- 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)
945
1284
  rescue => e
946
1285
  @last_error = driver_error_message(drv, e)
947
1286
  raise
@@ -965,7 +1304,7 @@ module Tina4
965
1304
  # Prefer the driver's own last_error (postgres sets one) over str(e), so the
966
1305
  # captured message matches what each engine surfaces; never blank.
967
1306
  def driver_error_message(drv, error)
968
- drv_err = drv.respond_to?(:last_error) ? drv.last_error : nil
1307
+ drv_err = driver_implements?(drv, :last_error) ? drv.last_error : nil
969
1308
  msg = drv_err || error.message
970
1309
  msg = error.message if msg.nil? || msg.to_s.empty?
971
1310
  msg || @last_error
@@ -1057,7 +1396,7 @@ module Tina4
1057
1396
  # lock (NOT via ensure_sequence_table, which would re-enter current_driver/
1058
1397
  # table_exists? and risk a nested touch).
1059
1398
  def sequence_next_sqlite(drv, seq_name, table, pk_column)
1060
- conn = drv.respond_to?(:connection) ? drv.connection : nil
1399
+ conn = driver_implements?(drv, :connection) ? drv.connection : nil
1061
1400
  raise "get_next_id: SQLite driver has no live connection" if conn.nil?
1062
1401
 
1063
1402
  Tina4::Drivers::SqliteDriver.write_lock.synchronize do
@@ -1239,8 +1578,46 @@ module Tina4
1239
1578
  end
1240
1579
  end
1241
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.
1242
1618
  def cache_key(sql, params)
1243
- 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)
1244
1621
  end
1245
1622
 
1246
1623
  def cache_get(key)
@@ -1330,8 +1707,41 @@ module Tina4
1330
1707
  row.each_with_object({}) { |(k, v), h| h[k.to_sym] = v }
1331
1708
  end
1332
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
+
1333
1728
  def detect_driver(conn)
1334
- 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
1335
1745
  when /\.db$/, /\.sqlite/, /sqlite/
1336
1746
  "sqlite"
1337
1747
  when /postgres/, /^pg:/