Class: Tina4::Database

Inherits:
Object
  • Object
show all
Defined in:
lib/tina4/database.rb

Constant Summary collapse

DRIVERS =
{
  "sqlite" => "Tina4::Drivers::SqliteDriver",
  "sqlite3" => "Tina4::Drivers::SqliteDriver",
  "postgres" => "Tina4::Drivers::PostgresDriver",
  "postgresql" => "Tina4::Drivers::PostgresDriver",
  "pgsql" => "Tina4::Drivers::PostgresDriver",
  "mysql" => "Tina4::Drivers::MysqlDriver",
  "mssql" => "Tina4::Drivers::MssqlDriver",
  "sqlserver" => "Tina4::Drivers::MssqlDriver",
  "firebird" => "Tina4::Drivers::FirebirdDriver",
  "mongodb" => "Tina4::Drivers::MongodbDriver",
  "mongo" => "Tina4::Drivers::MongodbDriver",
  "odbc" => "Tina4::Drivers::OdbcDriver"
}.freeze
VALUE_TOKEN =

True when the statement ENDS with its own LIMIT clause.

Anchored to the END on purpose: a bare "contains LIMIT" test also matches a LIMIT inside a SUBQUERY, where the OUTER statement still needs its cap. This is tina4-php's SqlNormalizerTrait::hasTrailingLimit regex, ported verbatim so all four frameworks answer identically. It accepts a numeric value, ?/$1/:name/%s placeholders, MySQL's LIMIT a, b comma form, and a trailing OFFSET.

Literals, quoted identifiers and comments are scrubbed before matching — see .scrub_sql_text for the measured failures that motivate it.

'(?:\d+|\?|\$\d+|:\w+|%s)'
TRAILING_LIMIT_RE =
/
  \bLIMIT\s+#{VALUE_TOKEN}
  (?:\s*,\s*#{VALUE_TOKEN})?
  (?:\s+OFFSET\s+#{VALUE_TOKEN})?
  \s*;?\s*\z
/ix.freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(connection_string = nil, username: nil, password: nil, driver_name: nil, pool: nil) ⇒ Database

Returns a new instance of Database.



295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
# File 'lib/tina4/database.rb', line 295

def initialize(connection_string = nil, username: nil, password: nil, driver_name: nil, pool: nil)
  @connection_string = connection_string || ENV["TINA4_DATABASE_URL"]
  @username = username || ENV["TINA4_DATABASE_USERNAME"]
  @password = password || ENV["TINA4_DATABASE_PASSWORD"]
  @driver_name = driver_name || detect_driver(@connection_string)
  # TINA4_DB_POOL falls back when caller doesn't pass `pool:` explicitly.
  # Default 0 = single connection, N>0 = N pooled connections (round-robin).
  @pool_size = if pool.nil?
                 (ENV["TINA4_DB_POOL"] || "0").to_i
               else
                 pool
               end
  @connected = false
  # Set by #connect when connecting fails; re-raised with context by
  # #current_driver so the first real call says what went wrong.
  @connect_error = nil

  # Per-instance thread-local key for the transaction adapter pin.
  # Without this pin, every Database method call rotates to a different
  # pooled connection. Inside a transaction this silently breaks atomicity:
  # start_transaction begins on adapter A, executes autocommit on B/C, and
  # commit/rollback land on D — a no-op. start_transaction sets the pin,
  # commit/rollback clear it. While pinned, current_driver returns the same
  # driver for every call so the whole transaction runs on one connection.
  @tx_pin_key = :"tina4_pinned_adapter_#{object_id}"
  # Per-thread nested-transaction depth counter (DB-contract C, v3.13.37).
  # A second start_transaction on a thread that already holds the pin is a
  # double-begin: most engines silently commit or no-op the inner BEGIN,
  # leaving the connection mid-transaction. We warn + increment depth instead
  # of re-beginning; the inner commit just decrements; the outer commit/any
  # rollback releases the pin.
  @tx_depth_key = :"tina4_tx_depth_#{object_id}"

  # Query cache. One store, two layers (parity with Python connection.py).
  # BOTH layers are OPT-IN — the DEFAULT is OFF.
  #
  # A request-scoped cache that defaults ON is a footgun: a SELECT MAX(id)
  # (or generator read) right before an INSERT in the SAME request returns a
  # cached pre-write value → duplicate primary keys, and any read-after-write
  # in one request shows stale state. So both layers default OFF:
  #   • request-scoped (opt-in, TINA4_AUTO_CACHING=true) — dedupes identical
  #     SELECTs to protect the DB from rapid repeat reads on read-heavy
  #     endpoints. Cleared at the START of every HTTP request (so it never
  #     serves rows across requests) AND on any write, with a short safety
  #     TTL (5s) for non-request contexts (scripts/workers).
  #   • persistent (opt-in, TINA4_DB_CACHE=true) — cross-request TTL cache
  #     that is NOT cleared per request; entries expire by TINA4_DB_CACHE_TTL.
  @cache_persistent = truthy?(ENV["TINA4_DB_CACHE"])
  # Default OFF; honour the same truthy semantics the framework uses
  # (mirrors Python's is_truthy(get("TINA4_AUTO_CACHING", "false"))).
  @cache_request_scoped = truthy?(ENV["TINA4_AUTO_CACHING"] || "false")
  @cache_enabled = @cache_persistent || @cache_request_scoped
  @cache_ttl = if @cache_persistent
                 (ENV["TINA4_DB_CACHE_TTL"] || "30").to_i
               else
                 (ENV["TINA4_AUTO_CACHING_TTL"] || "5").to_i
               end
  @query_cache = {}  # key => { expires_at:, value: }
  @cache_hits = 0
  @cache_misses = 0
  @cache_mutex = Mutex.new

  # Persistent mode may route through the unified CacheBackend (redis/
  # valkey/memcached/mongodb/database via TINA4_DB_CACHE_BACKEND) so
  # multiple instances can share one cache with global write-invalidation.
  # Request-scoped mode always stays in-process (the @query_cache dict).
  # The DatabaseResult is serialized to a JSON-friendly Hash before storing
  # and reconstructed on read so shared backends work cross-instance.
  @cache_backend = nil
  if @cache_persistent
    begin
      @cache_backend = Tina4::CacheBackends.create_backend(
        backend: ENV["TINA4_DB_CACHE_BACKEND"] || "memory",
        url: ENV["TINA4_DB_CACHE_URL"],
        max_entries: 1000
      )
    rescue StandardError
      @cache_backend = nil # fall back to the in-process dict
    end
  end

  # Autocommit is ON by default — parity with Python/PHP/Node. A standalone
  # write (execute/insert/update/delete made OUTSIDE an explicit
  # start_transaction()/commit() block) commits on its own connection before
  # returning, so a write actually persists. An UNSET TINA4_AUTOCOMMIT is
  # treated as TRUE; set TINA4_AUTOCOMMIT=false for strict manual mode (every
  # write needs an explicit commit). Inside an explicit transaction the
  # framework-issued commit is suppressed (gated on the thread tx-pin), so
  # explicit transactions stay atomic. Mirrors Python's
  # DatabaseAdapter._autocommit ("true"/"1"/"yes", default "true").
  @autocommit = truthy?(ENV.fetch("TINA4_AUTOCOMMIT", "true"))

  # Register this connection so Tina4::Database.reset_request_caches can
  # clear its request-scoped entries at the start of every HTTP request.
  Tina4::Database.register_instance(self)

  if @pool_size > 0
    # Pooled mode — create a ConnectionPool with lazy driver creation
    @pool = ConnectionPool.new(
      @pool_size,
      driver_factory: method(:create_driver),
      connection_string: @connection_string,
      username: @username,
      password: @password
    )
    @driver = nil
    @connected = true
  else
    # Single-connection mode — current behavior
    @pool = nil
    @driver = create_driver
    connect
  end
end

Instance Attribute Details

#connectedObject (readonly)

Returns the value of attribute connected.



79
80
81
# File 'lib/tina4/database.rb', line 79

def connected
  @connected
end

#driverObject (readonly)

Returns the value of attribute driver.



79
80
81
# File 'lib/tina4/database.rb', line 79

def driver
  @driver
end

#driver_nameObject (readonly)

Returns the value of attribute driver_name.



79
80
81
# File 'lib/tina4/database.rb', line 79

def driver_name
  @driver_name
end

#poolObject (readonly)

Returns the value of attribute pool.



79
80
81
# File 'lib/tina4/database.rb', line 79

def pool
  @pool
end

Class Method Details

.create(url, username: "", password: "", pool: nil) ⇒ Object

Static factory — cross-framework consistency: Database.create(url)



254
255
256
257
258
# File 'lib/tina4/database.rb', line 254

def self.create(url, username: "", password: "", pool: nil)
  new(url, username: username.empty? ? nil : username,
           password: password.empty? ? nil : password,
           pool: pool)
end

.from_env(env_key: "TINA4_DATABASE_URL", pool: nil) ⇒ Object

Construct a Database from environment variables. Returns nil if the named env var is not set.



262
263
264
265
266
267
268
269
270
# File 'lib/tina4/database.rb', line 262

def self.from_env(env_key: "TINA4_DATABASE_URL", pool: nil)
  url = ENV[env_key]
  return nil if url.nil? || url.strip.empty?

  new(url,
      username: ENV["TINA4_DATABASE_USERNAME"],
      password: ENV["TINA4_DATABASE_PASSWORD"],
      pool: pool)
end

.get_connection(url_or_env_key = "TINA4_DATABASE_URL", username: nil, password: nil, pool: nil) ⇒ Object

Open a database connection — convention name matching SQLAlchemy engine.connect() and the cross-framework Database.get_connection() surface shipped in 3.13.x.

The first argument may be either a URL (containing :// or sqlite:) or an env-var name. Falls back to in-memory SQLite when no URL resolves — matches Python tina4_python's default behaviour.

db = Tina4::Database.get_connection                     # from TINA4_DATABASE_URL
db = Tina4::Database.get_connection("sqlite::memory:")  # explicit URL
db = Tina4::Database.get_connection("postgres://...", username: "u", password: "p")


283
284
285
286
287
288
289
290
291
292
293
# File 'lib/tina4/database.rb', line 283

def self.get_connection(url_or_env_key = "TINA4_DATABASE_URL", username: nil, password: nil, pool: nil)
  if url_or_env_key.include?("://") || url_or_env_key.start_with?("sqlite:")
    return new(url_or_env_key, username: username, password: password, pool: pool)
  end

  db = from_env(env_key: url_or_env_key, pool: pool)
  return db if db

  # Fallback: in-memory SQLite — matches Python parity.
  new("sqlite::memory:", username: username, password: password, pool: pool)
end

.has_trailing_limit?(sql) ⇒ Boolean

Returns:

  • (Boolean)


248
249
250
251
# File 'lib/tina4/database.rb', line 248

def self.has_trailing_limit?(sql)
  return false if sql.nil? || sql.empty?
  TRAILING_LIMIT_RE.match?(scrub_sql_text(sql))
end

.register_instance(db) ⇒ Object

Register a live connection in the class-level WeakRef registry.



91
92
93
94
95
# File 'lib/tina4/database.rb', line 91

def register_instance(db)
  @instances_mutex.synchronize do
    @instances << WeakRef.new(db)
  end
end

.reset_request_cachesObject

Clear the request-scoped query cache on every live Database instance.

The request dispatcher calls this at the start of each HTTP request so request-scoped caching never serves rows across requests (zero cross-request staleness). Persistent-mode connections are left alone. Dead WeakRefs (closed/GC'd connections) are pruned as we go.



103
104
105
106
107
108
109
110
111
112
113
114
115
# File 'lib/tina4/database.rb', line 103

def reset_request_caches
  @instances_mutex.synchronize do
    @instances.reject! do |ref|
      begin
        inst = ref.__getobj__
        inst.cache_new_request
        false
      rescue WeakRef::RefError, StandardError
        true  # dead reference (or errored) — prune it
      end
    end
  end
end

.scrub_sql_text(sql) ⇒ Object

Blank out string literals, quoted identifiers and BOTH comment forms so a keyword search sees only real SQL.

Blanks are spaces of the SAME LENGTH (newlines preserved), so offsets and line structure still line up with the original — the scrubbed copy is only ever used to LOOK at the SQL, never to execute it.

MEASURED 2026-08-01 on a real 150-row SQLite table with the 100-row cap in force. The old detector was sql.upcase.split("--")[0].include?("LIMIT") and each of these returned ALL 150 ROWS instead of 100:

SELECT * FROM t WHERE label != 'LIMIT' ORDER BY id     literal
SELECT * FROM t ORDER BY id -- LIMIT 5                 line comment
SELECT * FROM t ORDER BY id /* LIMIT 5 */              block comment
SELECT id, label AS rate_limit FROM t                  identifier

A silently uncapped read of a whole table is the production incident the cap exists to prevent, and an ordinary column name was enough to cause it. Ported from the Python/Node master (same design, same answers).



168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
# File 'lib/tina4/database.rb', line 168

def self.scrub_sql_text(sql)
  return sql if sql.nil? || sql.empty?

  out = +""
  i = 0
  n = sql.length
  while i < n
    ch = sql[i]
    nxt = i + 1 < n ? sql[i + 1] : ""

    if ch == "'" || ch == '"'
      quote = ch
      out << " "
      i += 1
      while i < n
        if sql[i] == quote
          # A doubled quote ('' / "") is an ESCAPED quote inside the
          # literal, not its end — blank both and keep going.
          if i + 1 < n && sql[i + 1] == quote
            out << "  "
            i += 2
            next
          end
          out << " "
          i += 1
          break
        end
        out << (sql[i] == "\n" ? "\n" : " ")
        i += 1
      end
      next
    end

    if ch == "-" && nxt == "-"
      while i < n && sql[i] != "\n"
        out << " "
        i += 1
      end
      next
    end

    if ch == "/" && nxt == "*"
      out << "  "
      i += 2
      while i < n && !(sql[i] == "*" && i + 1 < n && sql[i + 1] == "/")
        out << (sql[i] == "\n" ? "\n" : " ")
        i += 1
      end
      if i < n
        out << "  "
        i += 2
      end
      next
    end

    out << ch
    i += 1
  end
  out
end

.strip_trailing_semicolons(sql) ⇒ Object

v3.13.12 — strip trailing ; from user SQL before the framework wraps it with COUNT(*) subqueries or appends LIMIT/OFFSET. Without this, "SELECT * FROM t;" produces "SELECT * FROM t; LIMIT 100 OFFSET 0" — a syntax error on every engine. Internal semicolons (inside string literals, between meaningful statements) are left alone; drivers reject those if multi-statement isn't supported.



139
140
141
142
143
144
145
146
# File 'lib/tina4/database.rb', line 139

def self.strip_trailing_semicolons(sql)
  return sql if sql.nil? || sql.empty?
  stripped = sql.rstrip
  while stripped.end_with?(";")
    stripped = stripped[0..-2].rstrip
  end
  stripped
end

Instance Method Details

#active_countObject

Number of connections currently created (lazy pool connections counted).



1154
1155
1156
1157
1158
1159
1160
# File 'lib/tina4/database.rb', line 1154

def active_count
  if @pool
    @pool.active_count
  else
    @connected ? 1 : 0
  end
end

#cache_clearObject



521
522
523
524
525
526
527
528
# File 'lib/tina4/database.rb', line 521

def cache_clear
  @cache_backend.clear if @cache_backend
  @cache_mutex.synchronize do
    @query_cache.clear
    @cache_hits = 0
    @cache_misses = 0
  end
end

#cache_new_requestObject

Clear the request-scoped query cache at the start of an HTTP request.

No-op in persistent mode (TINA4_DB_CACHE=true) so cross-request entries survive up to their TTL. Cumulative hit/miss counters are preserved.



534
535
536
537
538
# File 'lib/tina4/database.rb', line 534

def cache_new_request
  return unless @cache_request_scoped && !@cache_persistent

  @cache_mutex.synchronize { @query_cache.clear }
end

#cache_statsObject

── Query Cache ──────────────────────────────────────────────



494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
# File 'lib/tina4/database.rb', line 494

def cache_stats
  if @cache_backend
    bs = @cache_backend.stats
    return {
      enabled: @cache_enabled,
      mode: cache_mode,
      hits: @cache_hits,
      misses: @cache_misses,
      size: bs[:size],
      backend: bs[:backend] || @cache_backend.name,
      ttl: @cache_ttl
    }
  end

  @cache_mutex.synchronize do
    {
      enabled: @cache_enabled,
      mode: cache_mode,
      hits: @cache_hits,
      misses: @cache_misses,
      size: @query_cache.size,
      backend: "memory",
      ttl: @cache_ttl
    }
  end
end

#checkin(_driver) ⇒ Object

Return a driver to the pool. No-op for round-robin pool or single connection.



1168
1169
1170
# File 'lib/tina4/database.rb', line 1168

def checkin(_driver)
  # no-op
end

#checkoutObject

Check out a driver from the pool (or return the single driver).



1163
1164
1165
# File 'lib/tina4/database.rb', line 1163

def checkout
  current_driver
end

#closeObject



438
439
440
441
442
443
444
445
# File 'lib/tina4/database.rb', line 438

def close
  if @pool
    @pool.close_all
  elsif @driver && @connected
    @driver.close
  end
  @connected = false
end

#close_allObject

Close all pooled connections (or the single connection).



1173
1174
1175
# File 'lib/tina4/database.rb', line 1173

def close_all
  close
end

#columns(table_name) ⇒ Object Also known as: get_columns



1108
1109
1110
# File 'lib/tina4/database.rb', line 1108

def columns(table_name)
  current_driver.columns(table_name)
end

#commitObject

Commit the current transaction and release the driver pin.

FAILS LOUD (v3.13.37, DB-contract C): if the underlying commit raises, capture @last_error and RE-RAISE — never swallow. On failure the transaction pin is RETAINED so the caller's follow-up #rollback lands on the SAME connection (clearing it would leak a dirty connection back into the pool and route the rollback to a different one). The pin is cleared ONLY on a successful commit. An inner commit of an ignored nested begin (depth > 1) just decrements the depth and returns — the outer commit is the real one.



1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
# File 'lib/tina4/database.rb', line 1066

def commit
  depth = (Thread.current[@tx_depth_key] || 0)
  if depth > 1
    Thread.current[@tx_depth_key] = depth - 1
    return
  end
  current_driver.commit
  @last_error = nil
  # Success — release the pin.
  Thread.current[@tx_pin_key] = nil
  Thread.current[@tx_depth_key] = nil
rescue => e
  # Keep the pin so rollback reaches this same connection.
  @last_error = e.message
  raise
end

#connectObject



410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
# File 'lib/tina4/database.rb', line 410

def connect
  @driver.connect(@connection_string, username: @username, password: @password)
  @connected = true

  # Push the resolved autocommit setting down to the driver when it exposes a
  # native toggle (default ON — see @autocommit in #initialize). The
  # framework-level commit in #autocommit_standalone_write covers drivers
  # that have no native setter.
  @driver.autocommit = @autocommit if driver_implements?(@driver, :autocommit=)

  Tina4::Log.info("Database connected: #{@driver_name}")
  @connect_error = nil
rescue => e
  Tina4::Log.error("Database connection failed: #{e.message}")
  @connected = false
  # REMEMBER the cause. Logging alone is not enough: the driver's own
  # connection stays nil, so the next call used to die inside the driver as
  #   NoMethodError: private method `exec' called for nil:NilClass
  # which names neither the database it failed to reach nor why. That cost
  # real debugging time when a missing test database looked like a driver
  # bug. current_driver re-raises this with context instead.
  #
  # Boot still does NOT crash on an unreachable database (deliberate, and
  # the same log-loud-then-degrade policy the session backends use) - the
  # error surfaces at the first actual USE.
  @connect_error = e
end

#connect_error_messageObject

A message that says which database, on which driver, and why - the three things the old nil-dereference told you nothing about.



470
471
472
473
474
# File 'lib/tina4/database.rb', line 470

def connect_error_message
  target = safe_connection_target
  "Database not connected (#{@driver_name}#{target.empty? ? '' : " -> #{target}"}): " \
    "#{@connect_error.class}: #{@connect_error.message}"
end

#current_driverObject

Get the current driver — from pool (round-robin) or single connection.

Inside a transaction, all calls must land on the SAME driver — otherwise start_transaction, execute, and commit each rotate to a different pooled connection and the transaction is meaningless. start_transaction pins the driver to the calling thread; commit/rollback release it.



453
454
455
456
457
458
459
460
461
462
463
464
465
466
# File 'lib/tina4/database.rb', line 453

def current_driver
  pinned = Thread.current[@tx_pin_key]
  return pinned if pinned

  # Fail with the REAL reason, at the point of use. Without this the caller
  # gets a nil dereference from deep inside the driver and has to guess.
  raise Tina4::DatabaseConnectionError, connect_error_message if @connect_error

  if @pool
    @pool.checkout
  else
    @driver
  end
end

#delete(table, filter = {}, params = nil) ⇒ Object

Delete rows. A filterless delete raises; use truncate() to empty a table.



847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
# File 'lib/tina4/database.rb', line 847

def delete(table, filter = {}, params = nil)
  # List of hashes — delete each row
  if filter.is_a?(Array)
    total = 0
    filter.each { |row| total += delete(table, row).affected_rows }
    return Tina4::DatabaseResult.new([], affected_rows: total, last_id: nil)
  end

  where_sql, where_params = as_where(filter, params)
  if where_sql.empty?
    raise ArgumentError,
          "delete requires a filter (table=#{table.inspect}). " \
          "To remove every row use truncate(#{table.inspect})."
  end

  cache_invalidate if @cache_enabled
  drv = current_driver
  drv.execute("DELETE FROM #{table} WHERE #{where_sql}", where_params)
  autocommit_standalone_write(drv)
  Tina4::DatabaseResult.new([], affected_rows: write_affected(drv), last_id: nil)
end

#execute(sql, params = []) ⇒ Object

Execute a write statement. FAILS LOUD — raises on a SQL error.

On a SQL error (bad SQL, constraint violation, dead/aborted connection, missing driver) the cause is captured on @last_error / #get_error AND the error is re-raised — execute() never silently returns false on failure. Almost no caller checks a boolean after every write, so the old swallow-and-return-false behaviour turned a failed INSERT/UPDATE/DELETE into a silent partial-write footgun. This mirrors fetch()/fetch_one(), which already raise, and the Python master (database.execute).

On SUCCESS the return is unchanged: a DatabaseResult when the SQL contains RETURNING, CALL, EXEC, or SELECT (truthy), otherwise true. Never false.

Higher-level callers that promise a boolean (ORM save/create_table) wrap this in begin/rescue and return false themselves; the migration runner and dev-admin/MCP DB tools catch the raise and surface it as a failed migration or a clean { error: } payload respectively.



919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
# File 'lib/tina4/database.rb', line 919

def execute(sql, params = [])
  cache_invalidate if @cache_enabled
  drv = current_driver
  result = drv.execute(sql, params)
  @last_error = nil
  autocommit_standalone_write(drv)
  sql_upper = sql.strip.upcase
  if sql_upper.include?("RETURNING") || sql_upper.start_with?("CALL ") ||
     sql_upper.start_with?("EXEC ") || sql_upper.start_with?("SELECT ")
    return result
  end
  true
rescue => e
  @last_error = e.message
  raise
end

#execute_many(sql, params_list = []) ⇒ Object

Run one statement once per row in a SINGLE transaction on a SINGLE connection, returning a DatabaseResult.

DB-contract / batch-insert parity (mirrors the Python master's execute_many): the WHOLE batch runs on ONE driver — pinned for the duration — so begin/execute*/commit can never scatter across pooled connections (which made affected_rows / last_id non-deterministic). It is all-or-raise (any row raising rolls the whole batch back), so:

* affected_rows is the ROW COUNT, computed deterministically from the
number of supplied rows — NOT read from a driver rowcount. PostgreSQL's
no-RETURNING INSERT reports cmd_tuples correctly, but other engines'
rowcounts after a batch are unreliable, and a follow-up probe (lastval/
SAVEPOINT) can clobber the rowcount, so the count is the supplied length.
* last_id is read from last_insert_id() on the SAME connection AFTER the
batch, so a SERIAL/AUTOINCREMENT table surfaces the last generated id
(nil for engines/tables with no sequence — Firebird, a no-PK table).

The pin is set here only when no transaction is already open on the thread (an outer start_transaction already pinned the driver — leave it, and let the outer commit/rollback own the lifecycle). When we pin, we own the begin/commit/rollback; when an outer tx owns the pin, we just run the rows and let the outer transaction commit them.



959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
# File 'lib/tina4/database.rb', line 959

def execute_many(sql, params_list = [])
  params_list ||= []
  already_pinned = !Thread.current[@tx_pin_key].nil?
  drv = current_driver
  Thread.current[@tx_pin_key] = drv unless already_pinned

  begin
    drv.begin_transaction unless already_pinned
    begin
      # ONE round-trip per CHUNK instead of one per ROW. Looping execute()
      # here pays a full network round-trip for every row: 500 rows took
      # 9848ms on PostgreSQL against 15.8ms as a single multi-row VALUES
      # (625x), MySQL 216x, MSSQL 121x. build_batch_inserts returns an empty
      # array for anything it cannot collapse safely - RETURNING, upserts,
      # non-INSERT statements, ragged rows, Firebird - and the row-at-a-time
      # loop then runs unchanged.
      batched = SQLTranslator.build_batch_inserts(sql, params_list, get_database_type)
      if batched.empty?
        params_list.each { |params| drv.execute(sql, params) }
      else
        batched.each { |chunk_sql, chunk_params| drv.execute(chunk_sql, chunk_params) }
      end
      drv.commit unless already_pinned
    rescue => e
      drv.rollback unless already_pinned
      @last_error = e.message
      raise e
    end
  ensure
    Thread.current[@tx_pin_key] = nil unless already_pinned
  end

  last_id = begin
    drv.last_insert_id
  rescue StandardError
    nil
  end

  # last_id stays the LAST inserted row's id. MySQL reports the FIRST id of
  # a multi-row INSERT; its DRIVER normalises that at write time (the only
  # place that knows both the first id and the row count), so get_last_id
  # and this result always agree. Normalising here instead would
  # double-apply.
  Tina4::DatabaseResult.new(
    [],
    affected_rows: params_list.length,
    last_id: last_id,
    db: self
  )
end

#fetch(sql, params = [], limit: 100, offset: nil, no_cache: false) ⇒ Object

Fetch rows with pagination, returning a DatabaseResult.

FAILS LOUD (v3.13.37, DB-contract A): a SQL error in the main query propagates — a typo'd / bad SELECT RAISES, it never silently returns an empty result. The cause is captured on @last_error / #get_error before the re-raise (parity with #execute and the Python master), so the public API can read why it failed even for engines whose driver doesn't expose its own last_error. Because the raise happens BEFORE cache_set is reached, a buried failure is never written into the query cache.

Pass no_cache: true to bypass the query cache entirely for this single call — no lookup, no store — and run the query directly against the driver. Works for both the request-scoped auto-cache and the persistent DB cache. The default false preserves the cached behaviour. Parity with Python db.fetch(no_cache=) / PHP / Node.



574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
# File 'lib/tina4/database.rb', line 574

def fetch(sql, params = [], limit: 100, offset: nil, no_cache: false)
  offset ||= 0
  drv = current_driver

  # v3.13.12: strip trailing `;` so the driver's apply_limit
  # (which appends "LIMIT N OFFSET M") doesn't produce
  # "SELECT * FROM t; LIMIT 100 OFFSET 0" — a syntax error
  # on every engine. Also helps any COUNT(*) FROM (sql)
  # subqueries downstream survive a user-supplied semicolon.
  sql = Tina4::Database.strip_trailing_semicolons(sql)

  effective_sql = sql
  # Skip appending LIMIT if the statement already ENDS with its own.
  #
  # .has_trailing_limit? scrubs literals/identifiers/comments and anchors to
  # the end. The old test was `sql.upcase.split("--")[0].include?("LIMIT")`,
  # so `WHERE label != 'LIMIT'`, a `/* LIMIT 5 */` comment, or a column named
  # rate_limit all read as "the caller supplied their own" and the cap was
  # silently dropped — a full-table read (MEASURED: 150 of 150 rows).
  has_limit = Tina4::Database.has_trailing_limit?(sql)
  applied_limit = 0
  # `limit: 0` means "no cap, give me everything" - the same reading as the
  # Python master's `limit <= 0`. Ruby treats 0 as TRUTHY, so this used to
  # fall through and append a literal `LIMIT 0`, returning ZERO rows where
  # Python returned all of them for the identical call. fetch_all passes nil
  # and was never affected, which is why it went unnoticed.
  limit = nil if limit.is_a?(Numeric) && limit <= 0
  if limit && !has_limit
    effective_sql = drv.apply_limit(effective_sql, limit, offset)
    applied_limit = limit
  end

  # COUNT PROBE: `count` is the TRUE total for the filter, not the number of
  # rows this page returned.
  #
  # Ruby and Node used to populate count with records.length while Python
  # and PHP populated it from a probe, so `db.fetch(sql).count` answered 20
  # here and 250 there for one query against one table - and every paginated
  # response built on it under-reported. MEASURED 2026-08-05 on a 250-row
  # table read with limit=20: Ruby reported total 20 and 1 page against
  # Python's 250 and 13.
  #
  # Only probed when WE appended the pagination. If no limit was applied
  # (fetch_all, or the caller supplied their own LIMIT) the rows returned
  # ARE the whole answer for this SQL, so records.length is already the true
  # total and a second round-trip would buy nothing.
  total = nil
  if applied_limit.positive?
    total = count_probe(drv, sql, params)
  end

  if @cache_enabled && !no_cache
    key = cache_key(effective_sql, params)
    cached = cache_get(key)
    if cached
      @cache_mutex.synchronize { @cache_hits += 1 }
      return cached
    end
    # fetch_direct RAISES on a SQL error (and captures @last_error), so a
    # failed read never reaches cache_set below — we never cache an empty
    # result produced by a buried failure.
    result = fetch_direct(drv, effective_sql, params, limit: applied_limit, offset: offset, total: total)
    cache_set(key, result)
    @cache_mutex.synchronize { @cache_misses += 1 }
    return result
  end

  fetch_direct(drv, effective_sql, params, limit: applied_limit, offset: offset, total: total)
end

#fetch_all(sql, params = [], limit: nil, offset: nil, no_cache: false) ⇒ Object

Fetch rows and return the records array directly.

Symmetric with fetch_one. Cross-framework parity with Python db.fetch_all() / PHP $db->fetchAll() / Node db.fetchAll().

rows = db.fetch_all("SELECT * FROM users WHERE active = ?", [1])
rows.each { |row| puts row["name"] }

Returns [] (not nil) when no rows match.

v3.13.12: default limit is nil (no truncation) — the method name says fetch_all, so it returns all matching rows. Pre-v3.13.12 silently truncated to 100. Pass an explicit limit: to cap.

Pass no_cache: true to bypass the query cache for this call (see #fetch).



555
556
557
# File 'lib/tina4/database.rb', line 555

def fetch_all(sql, params = [], limit: nil, offset: nil, no_cache: false)
  fetch(sql, params, limit: limit, offset: offset, no_cache: no_cache).records
end

#fetch_one(sql, params = [], no_cache: false) ⇒ Object

Fetch a single row (or nil).

FAILS LOUD (v3.13.37, DB-contract A): a SQL error RAISES and populates fetch_one ran the query through #fetch but did not separately guarantee the error capture, and a buried failure could be cached as nil. It now routes the uncached path through fetch_one_direct (capture + re-raise) and, on the cached path, only ever stores a value produced by a SUCCESSFUL read.

Pass no_cache: true to bypass the query cache entirely for this call — no lookup, no store — running the query directly. The no_cache flag is propagated to the inner read so the request-scoped/persistent cache is never populated either. Default false preserves cached behaviour.



657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
# File 'lib/tina4/database.rb', line 657

def fetch_one(sql, params = [], no_cache: false)
  sql = Tina4::Database.strip_trailing_semicolons(sql)
  if @cache_enabled && !no_cache
    key = cache_key(sql + ":ONE", params)
    cached = cache_get(key)
    if cached
      @cache_mutex.synchronize { @cache_hits += 1 }
      return cached
    end
    # Raises (and captures @last_error) BEFORE cache_set, so a failed read
    # is never cached as nil.
    value = fetch_one_direct(sql, params)
    cache_set(key, value)
    @cache_mutex.synchronize { @cache_misses += 1 }
    return value
  end

  fetch_one_direct(sql, params)
end

#get_adapterInteger

Pre-generate the next available primary key ID using engine-aware strategies.

Race-safe implementation using a tina4_sequences table for SQLite/MySQL/MSSQL fallback. Each call atomically increments the stored counter, so concurrent callers never receive the same value.

  • Firebird: auto-creates a generator if missing, then increments via GEN_ID.
  • PostgreSQL: tries nextval() on the named sequence, auto-creates it if missing.
  • SQLite/MySQL/MSSQL: atomic UPDATE on tina4_sequences table.
  • Returns 1 if the table is empty or does not exist.

Returns the underlying driver object (pool's current driver or single driver).

Parameters:

  • table (String)

    Table name

  • pk_column (String)

    Primary key column name (default: "id")

  • generator_name (String, nil)

    Override for sequence/generator name

Returns:

  • (Integer)

    The next available ID



1144
1145
1146
# File 'lib/tina4/database.rb', line 1144

def get_adapter
  current_driver
end

#get_database_typeObject

Return the normalised engine name for this connection.

Cross-framework parity with Python/PHP/Node get_database_type(). ORM.create_table needs this to emit engine-correct DDL (SERIAL vs AUTOINCREMENT, BOOLEAN vs INTEGER, TIMESTAMP vs DATETIME). Returns the resolved driver key ("postgres", "mysql", "mssql", "firebird", "sqlite", ...) — the same alias-normalised value used to pick the driver class, so callers don't have to re-parse the connection string.



898
899
900
# File 'lib/tina4/database.rb', line 898

def get_database_type
  @driver_name
end

#get_errorObject

Return the last execute() error message, or nil.



879
880
881
# File 'lib/tina4/database.rb', line 879

def get_error
  @last_error
end

#get_last_idObject

Return the last insert ID from execute() or insert().



884
885
886
887
888
# File 'lib/tina4/database.rb', line 884

def get_last_id
  current_driver.last_insert_id
rescue
  nil
end

#get_next_id(table, pk_column: "id", generator_name: nil) ⇒ Object



1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
# File 'lib/tina4/database.rb', line 1177

def get_next_id(table, pk_column: "id", generator_name: nil)
  drv = current_driver

  # Firebird — use generators
  if @driver_name == "firebird"
    gen_name = generator_name || "GEN_#{table.upcase}_ID"

    # Auto-create the generator if it does not exist
    begin
      drv.execute("CREATE GENERATOR #{gen_name}")
    rescue
      # Generator already exists — ignore
    end

    rows = drv.execute_query("SELECT GEN_ID(#{gen_name}, 1) AS NEXT_ID FROM RDB$DATABASE")
    row = rows.is_a?(Array) ? rows.first : nil
    val = row_value(row, :NEXT_ID) || row_value(row, :next_id)
    return val&.to_i || 1
  end

  # PostgreSQL — try sequence first, auto-create if missing
  if @driver_name == "postgres"
    seq_name = generator_name || "#{table.downcase}_#{pk_column.downcase}_seq"
    begin
      rows = drv.execute_query("SELECT nextval('#{seq_name}') AS next_id")
      row = rows.is_a?(Array) ? rows.first : nil
      val = row_value(row, :next_id) || row_value(row, :nextval)
      return val.to_i if val
    rescue
      # Sequence does not exist — auto-create it seeded from MAX
      begin
        max_rows = drv.execute_query("SELECT COALESCE(MAX(#{pk_column}), 0) AS max_id FROM #{table}")
        max_row = max_rows.is_a?(Array) ? max_rows.first : nil
        max_val = row_value(max_row, :max_id)
        start_val = max_val ? max_val.to_i + 1 : 1
        drv.execute("CREATE SEQUENCE #{seq_name} START WITH #{start_val}")
        drv.commit rescue nil
        rows = drv.execute_query("SELECT nextval('#{seq_name}') AS next_id")
        row = rows.is_a?(Array) ? rows.first : nil
        val = row_value(row, :next_id) || row_value(row, :nextval)
        return val&.to_i || start_val
      rescue
        # Fall through to sequence table fallback
      end
    end
  end

  # SQLite / MySQL / MSSQL / PostgreSQL fallback — atomic sequence table
  seq_key = generator_name || "#{table}.#{pk_column}"
  sequence_next(seq_key, table: table, pk_column: pk_column)
end

#insert(table, data) ⇒ Object



677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
# File 'lib/tina4/database.rb', line 677

def insert(table, data)
  cache_invalidate if @cache_enabled
  drv = current_driver

  # List of hashes — batch insert.
  #
  # Cross-framework parity (mirrors the Python master's DatabaseAdapter.insert
  # → execute_many): build ONE parameterised INSERT and run it once per row
  # inside a SINGLE transaction on a SINGLE connection (see #execute_many),
  # then report a DatabaseResult whose affected_rows == the number of rows
  # (deterministic — the batch is all-or-raise) and a sensible last_id read
  # from that same connection. The per-driver #insert overrides (e.g.
  # PostgreSQL's INSERT ... RETURNING *) call data.keys, so they only ever
  # see a single Hash — the Array is intercepted here and never reaches them,
  # which is exactly the crash Python hit when a list fell through to a
  # keys-only override.
  if data.is_a?(Array)
    return Tina4::DatabaseResult.new([], affected_rows: 0, last_id: nil) if data.empty?
    keys = data.first.keys.map(&:to_s)
    placeholders = drv.placeholders(keys.length)
    sql = "INSERT INTO #{table} (#{keys.join(', ')}) VALUES (#{placeholders})"
    params_list = data.map { |row| keys.map { |k| row[k.to_sym] || row[k] } }
    return execute_many(sql, params_list)
  end

  # Issue #256: a driver that can surface the ACTUAL generated primary key
  # (PostgreSQL, via INSERT ... RETURNING *) owns its own insert so a UUID
  # PK comes back as the real 36-char string and a SERIAL PK as the integer
  # — instead of probing a session sequence (lastval()) that returns nil or
  # a stale wrong id for a UUID table. Other engines (SQLite/MySQL/MSSQL/
  # Firebird) keep the generic build-then-last_insert_id path below.
  if driver_implements?(drv, :insert)
    result = drv.insert(table, data)
    autocommit_standalone_write(drv)
    # A driver that owns its insert (PostgreSQL, via RETURNING *) returns a
    # Hash { success:, last_id: }; normalise it to a DatabaseResult so every
    # engine returns the same write-result shape (parity with Python master).
    last_id = result.is_a?(Hash) ? result[:last_id] : drv.last_insert_id
    return Tina4::DatabaseResult.new([], affected_rows: write_affected(drv, 1), last_id: last_id)
  end

  columns = data.keys.map(&:to_s)
  placeholders = drv.placeholders(columns.length)
  sql = "INSERT INTO #{table} (#{columns.join(', ')}) VALUES (#{placeholders})"
  drv.execute(sql, data.values)
  last_id = drv.last_insert_id
  autocommit_standalone_write(drv)
  Tina4::DatabaseResult.new([], affected_rows: write_affected(drv, 1), last_id: last_id)
end

#pool_sizeObject

Returns the configured pool size, or 1 for single-connection mode.



1149
1150
1151
# File 'lib/tina4/database.rb', line 1149

def pool_size
  @pool_size > 0 ? @pool_size : 1
end

#primary_key(table) ⇒ Object

The table's primary-key columns, introspected once and cached.

Returns an ARRAY because a primary key may span several columns. A composite key is still one primary key; it just has more than one column. Returns [] when the table has no primary key or cannot be introspected.

Uses the cross-engine columns() contract (v3.13.14, #48), which reports :primary_key per column on every driver.



744
745
746
747
748
749
750
751
752
753
754
# File 'lib/tina4/database.rb', line 744

def primary_key(table)
  @pk_cache ||= {}
  unless @pk_cache.key?(table)
    @pk_cache[table] = begin
      columns(table).select { |c| c[:primary_key] }.map { |c| c[:name].to_s }
    rescue StandardError
      []
    end
  end
  @pk_cache[table]
end

#rollbackObject

Roll back the current transaction and release the driver pin.

Rollback is the terminal cleanup of a transaction, so it ALWAYS clears the pin (and the depth counter) — even after a failed commit it routes to the retained pinned connection and cleans it up. If the underlying rollback itself raises, @last_error is captured and the error re-raised, but the pin is still released so a poisoned connection doesn't stay pinned forever.



1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
# File 'lib/tina4/database.rb', line 1090

def rollback
  current_driver.rollback
  @last_error = nil
rescue => e
  @last_error = e.message
  raise
ensure
  Thread.current[@tx_pin_key] = nil
  Thread.current[@tx_depth_key] = nil
end

#safe_connection_targetObject

The connection string with any password stripped, so a raised error can name the target without leaking a credential into a log or an HTTP 500 body.

This used to carry its own regex, which was a SECOND redaction implementation next to DatabaseUrl#to_safe_string - and it leaked on two shapes, both measured 2026-08-02:

postgres://u:p@ss@h:5432/db  ->  postgres://u:***@ss@h:5432/db
because `([^:/@]+):[^@]*@` stops at the FIRST "@", so the tail of a
password containing an unencoded "@" survived into the message.
odbc:///DRIVER=...;PWD=<secret>  ->  returned VERBATIM, straight into
DatabaseConnectionError.

There is now ONE redaction primitive. Do not add a second.



488
489
490
# File 'lib/tina4/database.rb', line 488

def safe_connection_target
  Tina4::DatabaseUrl.redact(@connection_string)
end

#start_transactionObject

Begin a transaction without a block — matches PHP/Python/Node API. Pins the driver to this thread for the whole transaction so executes and the final commit/rollback all run on the same connection.

Nested-begin guard (v3.13.37, DB-contract C): a second start_transaction on a thread that already holds the pin is a double-begin — the inner BEGIN silently commits or no-ops on most engines, leaving the connection mid-transaction with the caller none the wiser. We keep a per-thread depth counter and log a clear warning instead of silently re-beginning. The pin stays on the original driver so commit/rollback still land on the right connection.



1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
# File 'lib/tina4/database.rb', line 1036

def start_transaction
  pinned = Thread.current[@tx_pin_key]
  if pinned
    depth = (Thread.current[@tx_depth_key] || 1)
    Tina4::Log.warning(
      "start_transaction called while a transaction is already open on this " \
      "thread (depth would become #{depth + 1}). Nested transactions are not " \
      "supported — the existing transaction stays open on its pinned " \
      "connection and this nested begin is ignored. Commit or rollback the " \
      "outer transaction first."
    )
    Thread.current[@tx_depth_key] = depth + 1
    return
  end
  drv = current_driver
  Thread.current[@tx_pin_key] = drv
  Thread.current[@tx_depth_key] = 1
  drv.begin_transaction
end

#table_exists?(table_name) ⇒ Boolean Also known as: table_exists

Returns:

  • (Boolean)


1115
1116
1117
1118
1119
1120
1121
1122
1123
# File 'lib/tina4/database.rb', line 1115

def table_exists?(table_name)
  drv = current_driver
  # v3.13.14 (#48): drivers that can resolve a schema/catalog-qualified
  # name ("gift_cards.gift_card", "dbo.widget", "attached.table") answer
  # directly; the rest fall back to a case-insensitive scan of tables.
  return drv.table_exists?(table_name) if driver_implements?(drv, :table_exists?)

  tables.any? { |t| t.downcase == table_name.to_s.downcase }
end

#tablesObject Also known as: get_tables



1101
1102
1103
# File 'lib/tina4/database.rb', line 1101

def tables
  current_driver.tables
end

#transactionObject



1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
# File 'lib/tina4/database.rb', line 1010

def transaction
  drv = current_driver
  Thread.current[@tx_pin_key] = drv
  Thread.current[@tx_depth_key] = 1
  drv.begin_transaction
  yield self
  drv.commit
rescue => e
  drv.rollback if drv
  raise e
ensure
  Thread.current[@tx_pin_key] = nil
  Thread.current[@tx_depth_key] = nil
end

#truncate(table) ⇒ Object

Remove every row. The explicit spelling of a whole-table delete.



870
871
872
873
874
875
876
# File 'lib/tina4/database.rb', line 870

def truncate(table)
  cache_invalidate if @cache_enabled
  drv = current_driver
  drv.execute("DELETE FROM #{table} WHERE 1 = 1", [])
  autocommit_standalone_write(drv)
  Tina4::DatabaseResult.new([], affected_rows: write_affected(drv), last_id: nil)
end

#update(table, data, filter = {}, params = nil) ⇒ Object

Update rows. A write with no filter is an error, not a full-table write.

With no explicit filter the primary key is taken out of data and used as the WHERE clause. With neither a filter nor a primary key in data this raises rather than overwriting every row (audit feature 4, P1).



776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
# File 'lib/tina4/database.rb', line 776

def update(table, data, filter = {}, params = nil)
  where_sql, where_params = as_where(filter, params)
  data = data.dup

  if where_sql.empty?
    pk_columns = primary_key(table)
    # Resolve each key column to whichever form the caller used - String or
    # Symbol, and in whatever CASE they typed; nil marks one that is absent.
    #
    # The engines disagree about identifier case BY DESIGN and always will:
    # Firebird folds an unquoted identifier to UPPER, PostgreSQL folds it to
    # LOWER, MySQL and SQLite preserve what was typed. Introspection hands
    # back the ENGINE's spelling while `data` carries the caller's, so an
    # exact match failed on whichever engine folds the other way. That is a
    # case-sensitivity bug, not a Firebird quirk - Firebird merely made it
    # visible first, because the shared write-path contract writes lower-case
    # keys and Firebird reports upper-case ones.
    #
    # Deliberately does NOT downcase what introspection returns: that would
    # special-case one engine and break a genuinely quoted mixed-case table.
    # The WHERE below is still built from the ENGINE's column name (pk_columns);
    # only the VALUE is looked up by the caller's key.
    pk_keys = pk_columns.map do |col|
      folded = col.to_s.downcase
      matches = data.keys.select { |k| k.to_s.downcase == folded }
      if matches.length > 1
        # Ambiguity is refused, never guessed - choosing wrong here writes
        # the WHERE clause of an UPDATE.
        raise ArgumentError,
              "update was given more than one key for the primary-key column " \
              "#{col.inspect}: #{matches.map(&:to_s).sort.inspect} " \
              "(table=#{table.inspect}). These differ only by case, so which one " \
              "identifies the row is ambiguous - pass exactly one, or pass an " \
              "explicit filter."
      end
      matches.first
    end
    missing = pk_columns.each_with_index.reject { |_, i| pk_keys[i] }.map(&:first)

    if pk_columns.empty? || !missing.empty?
      raise ArgumentError,
            "update requires a filter or the complete primary key in the data; " \
            "pass a filter explicitly to update multiple rows " \
            "(table=#{table.inspect}, primary key=#{pk_columns.inspect}, " \
            "missing from data=#{missing.inspect}). " \
            "To empty a table use truncate(#{table.inspect})."
    end

    # EVERY key column goes into the WHERE. A composite key built from only
    # its first column would match every row sharing that value - the
    # data-loss bug this method exists to prevent, reintroduced.
    where_params = pk_keys.map { |k| data.delete(k) }
    if data.empty?
      raise ArgumentError,
            "update was given only the primary key #{pk_columns.inspect} and no " \
            "columns to set (table=#{table.inspect})"
    end

    where_sql = pk_columns.map { |c| "#{c} = #{current_driver.placeholder}" }.join(" AND ")
  end

  cache_invalidate if @cache_enabled
  drv = current_driver
  set_parts = data.keys.map { |k| "#{k} = #{drv.placeholder}" }
  sql = "UPDATE #{table} SET #{set_parts.join(', ')} WHERE #{where_sql}"
  drv.execute(sql, data.values + where_params)
  autocommit_standalone_write(drv)
  Tina4::DatabaseResult.new([], affected_rows: write_affected(drv), last_id: nil)
end