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",
  "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

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.



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
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
# File 'lib/tina4/database.rb', line 180

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

  # 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}"

  # Query cache. One store, two layers (parity with Python connection.py):
  #   • request-scoped (DEFAULT ON, off-switch TINA4_AUTO_CACHING=false) —
  #     dedupes identical SELECTs to protect the DB from rapid repeat reads.
  #     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 true; honour the same truthy semantics the framework uses
  # (mirrors Python's is_truthy(get("TINA4_AUTO_CACHING", "true"))).
  @cache_request_scoped = truthy?(ENV["TINA4_AUTO_CACHING"] || "true")
  @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

  # 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.



70
71
72
# File 'lib/tina4/database.rb', line 70

def connected
  @connected
end

#driverObject (readonly)

Returns the value of attribute driver.



70
71
72
# File 'lib/tina4/database.rb', line 70

def driver
  @driver
end

#driver_nameObject (readonly)

Returns the value of attribute driver_name.



70
71
72
# File 'lib/tina4/database.rb', line 70

def driver_name
  @driver_name
end

#poolObject (readonly)

Returns the value of attribute pool.



70
71
72
# File 'lib/tina4/database.rb', line 70

def pool
  @pool
end

Class Method Details

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

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



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

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.



147
148
149
150
151
152
153
154
155
# File 'lib/tina4/database.rb', line 147

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")


168
169
170
171
172
173
174
175
176
177
178
# File 'lib/tina4/database.rb', line 168

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

.register_instance(db) ⇒ Object

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



82
83
84
85
86
# File 'lib/tina4/database.rb', line 82

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.



94
95
96
97
98
99
100
101
102
103
104
105
106
# File 'lib/tina4/database.rb', line 94

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

.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.



129
130
131
132
133
134
135
136
# File 'lib/tina4/database.rb', line 129

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).



659
660
661
662
663
664
665
# File 'lib/tina4/database.rb', line 659

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

#cache_clearObject



337
338
339
340
341
342
343
344
# File 'lib/tina4/database.rb', line 337

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.



350
351
352
353
354
# File 'lib/tina4/database.rb', line 350

def cache_new_request
  return unless @cache_request_scoped && !@cache_persistent

  @cache_mutex.synchronize { @query_cache.clear }
end

#cache_statsObject

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



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
# File 'lib/tina4/database.rb', line 310

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.



673
674
675
# File 'lib/tina4/database.rb', line 673

def checkin(_driver)
  # no-op
end

#checkoutObject

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



668
669
670
# File 'lib/tina4/database.rb', line 668

def checkout
  current_driver
end

#closeObject



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

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).



678
679
680
# File 'lib/tina4/database.rb', line 678

def close_all
  close
end

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



613
614
615
# File 'lib/tina4/database.rb', line 613

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

#commitObject

Commit the current transaction and release the driver pin.



593
594
595
596
597
# File 'lib/tina4/database.rb', line 593

def commit
  current_driver.commit
ensure
  Thread.current[@tx_pin_key] = nil
end

#connectObject



268
269
270
271
272
273
274
275
276
277
278
279
280
281
# File 'lib/tina4/database.rb', line 268

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

  # Enable autocommit if TINA4_AUTOCOMMIT env var is set
  if truthy?(ENV["TINA4_AUTOCOMMIT"]) && @driver.respond_to?(:autocommit=)
    @driver.autocommit = true
  end

  Tina4::Log.info("Database connected: #{@driver_name}")
rescue => e
  Tina4::Log.error("Database connection failed: #{e.message}")
  @connected = false
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.



298
299
300
301
302
303
304
305
306
# File 'lib/tina4/database.rb', line 298

def current_driver
  pinned = Thread.current[@tx_pin_key]
  return pinned if pinned
  if @pool
    @pool.checkout
  else
    @driver
  end
end

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



487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
# File 'lib/tina4/database.rb', line 487

def delete(table, filter = {}, params = nil)
  cache_invalidate if @cache_enabled
  drv = current_driver

  # List of hashes — delete each row
  if filter.is_a?(Array)
    filter.each { |row| delete(table, row) }
    return { success: true }
  end

  # String filter — raw WHERE clause with optional params
  if filter.is_a?(String)
    sql = "DELETE FROM #{table}"
    sql += " WHERE #{filter}" unless filter.empty?
    drv.execute(sql, Array(params))
    return { success: true }
  end

  # Hash filter — build WHERE from keys
  where_parts = filter.keys.map { |k| "#{k} = #{drv.placeholder}" }
  sql = "DELETE FROM #{table}"
  sql += " WHERE #{where_parts.join(' AND ')}" unless filter.empty?
  drv.execute(sql, filter.values)
  { success: true }
end

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

Execute a write statement. Returns true/false for simple writes. Returns DatabaseResult if SQL contains RETURNING, CALL, EXEC, or SELECT.



539
540
541
542
543
544
545
546
547
548
549
550
551
552
# File 'lib/tina4/database.rb', line 539

def execute(sql, params = [])
  cache_invalidate if @cache_enabled
  result = current_driver.execute(sql, params)
  @last_error = nil
  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
  false
end

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



554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
# File 'lib/tina4/database.rb', line 554

def execute_many(sql, params_list = [])
  results = []
  drv = current_driver
  drv.begin_transaction
  begin
    params_list.each do |params|
      results << drv.execute(sql, params)
    end
    drv.commit
  rescue => e
    drv.rollback
    raise e
  end
  results
end

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

Fetch rows with pagination, returning a DatabaseResult.

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.



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
409
410
411
412
413
414
415
416
# File 'lib/tina4/database.rb', line 382

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 SQL already has one
  has_limit = sql.upcase.split("--")[0].include?("LIMIT")
  if limit && !has_limit
    effective_sql = drv.apply_limit(effective_sql, limit, offset)
  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
    result = drv.execute_query(effective_sql, params)
    result = Tina4::DatabaseResult.new(result, sql: effective_sql, db: self)
    cache_set(key, result)
    @cache_mutex.synchronize { @cache_misses += 1 }
    return result
  end

  rows = drv.execute_query(effective_sql, params)
  Tina4::DatabaseResult.new(rows, sql: effective_sql, db: self)
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).



371
372
373
# File 'lib/tina4/database.rb', line 371

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).

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 #fetch so the request-scoped/persistent cache is never populated either. Default `false` preserves cached behaviour.



424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
# File 'lib/tina4/database.rb', line 424

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
    result = fetch(sql, params, limit: 1)
    value = result.first
    cache_set(key, value)
    @cache_mutex.synchronize { @cache_misses += 1 }
    return value
  end

  result = fetch(sql, params, limit: 1, no_cache: no_cache)
  result.first
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



649
650
651
# File 'lib/tina4/database.rb', line 649

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.



533
534
535
# File 'lib/tina4/database.rb', line 533

def get_database_type
  @driver_name
end

#get_errorObject

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



514
515
516
# File 'lib/tina4/database.rb', line 514

def get_error
  @last_error
end

#get_last_idObject

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



519
520
521
522
523
# File 'lib/tina4/database.rb', line 519

def get_last_id
  current_driver.last_insert_id
rescue
  nil
end

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



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
726
727
728
729
730
731
732
# File 'lib/tina4/database.rb', line 682

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



444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
# File 'lib/tina4/database.rb', line 444

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

  # List of hashes — batch insert
  if data.is_a?(Array)
    return { success: true, affected_rows: 0 } 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

  columns = data.keys.map(&:to_s)
  placeholders = drv.placeholders(columns.length)
  sql = "INSERT INTO #{table} (#{columns.join(', ')}) VALUES (#{placeholders})"
  drv.execute(sql, data.values)
  { success: true, last_id: drv.last_insert_id }
end

#pool_sizeObject

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



654
655
656
# File 'lib/tina4/database.rb', line 654

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

#rollbackObject

Roll back the current transaction and release the driver pin.



600
601
602
603
604
# File 'lib/tina4/database.rb', line 600

def rollback
  current_driver.rollback
ensure
  Thread.current[@tx_pin_key] = nil
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.



586
587
588
589
590
# File 'lib/tina4/database.rb', line 586

def start_transaction
  drv = current_driver
  Thread.current[@tx_pin_key] = drv
  drv.begin_transaction
end

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

Returns:

  • (Boolean)


620
621
622
623
624
625
626
627
628
# File 'lib/tina4/database.rb', line 620

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 drv.respond_to?(:table_exists?)

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

#tablesObject Also known as: get_tables



606
607
608
# File 'lib/tina4/database.rb', line 606

def tables
  current_driver.tables
end

#transactionObject



570
571
572
573
574
575
576
577
578
579
580
581
# File 'lib/tina4/database.rb', line 570

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

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



465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
# File 'lib/tina4/database.rb', line 465

def update(table, data, filter = {}, params = nil)
  cache_invalidate if @cache_enabled
  drv = current_driver

  # String filter with explicit params array
  if filter.is_a?(String) && !params.nil?
    set_parts = data.keys.map { |k| "#{k} = #{drv.placeholder}" }
    sql = "UPDATE #{table} SET #{set_parts.join(', ')}"
    sql += " WHERE #{filter}" unless filter.empty?
    drv.execute(sql, data.values + Array(params))
    return { success: true }
  end

  set_parts = data.keys.map { |k| "#{k} = #{drv.placeholder}" }
  where_parts = filter.keys.map { |k| "#{k} = #{drv.placeholder}" }
  sql = "UPDATE #{table} SET #{set_parts.join(', ')}"
  sql += " WHERE #{where_parts.join(' AND ')}" unless filter.empty?
  values = data.values + filter.values
  drv.execute(sql, values)
  { success: true }
end