Module: Tina4::DatabaseAdapter

Constant Summary collapse

CONTRACT =

The exact fourteen capabilities every driver must provide (ADR-0044, plan/v3/fixtures/adapter_contract.json). None is optional. Kept as data so the conformance spec can read it instead of maintaining a second copy.

CRUD (insert/update/delete) and DDL (create_table/add_column) are NOT here - they are composable above the adapter from execute + get_database_type, and Ruby was already doing exactly that in the facade, which is why Ruby's driver layer is 1335 LOC against PHP's 5823 for the same job. executeMany and fetchOne, by contrast, ARE required adapter primitives under ADR-0044 (superseding the original redesign that placed them above the adapter) - see execute_many/fetch_one below.

%i[
  connect close get_database_type
  execute execute_many fetch fetch_one
  start_transaction commit rollback autocommit
  tables columns table_exists?
].freeze
ABSTRACT_CONTRACT =

The subset with NO usable generic default - a driver MUST override these or the raising stub is inherited verbatim.

%i[
  connect close get_database_type
  execute commit rollback
  tables columns
].freeze
CONNECT_TIMEOUT_VAR =

Bounding the connect

A connect that can block forever hangs the whole application with NO log, no error and no signal. MEASURED here on Ruby 3.2.3 / Ubuntu 24.04.4 against a real TCPServer that accepts the TCP connection and then never replies: pg, mysql2, tiny_tds AND fb all sat past 20 seconds and needed SIGKILL - timeout's SIGTERM could not even be delivered, because the blocking work happens inside a C client that never yields to the interpreter. (A CLOSED port is a different thing entirely: it refuses in 0.00s and tests nothing.)

ONE variable governs every driver whose connect crosses a network:

TINA4_DATABASE_CONNECT_TIMEOUT   seconds, default 10; <= 0 disables the
                               bound (unbounded, the old behaviour);
                               a non-number warns and falls back to 10

Each driver applies it through its OWN native option - libpq connect_timeout, mysql2 connect_timeout, FreeTDS login_timeout, mongo connect_timeout - because only the C client can interrupt its own blocking socket work. Ruby's Timeout.timeout and Thread#join CANNOT: see Tina4::Drivers::FirebirdDriver.bound_reachability! for the measurement.

There is deliberately NO outer Ruby timeout racing the native one. The native option is the ONLY timer; bounding_connect below merely TRANSLATES whatever the client raises into the one contract message, so the operator is never left holding a driver-worded error that names no variable. Where a driver cannot produce that message at all, its own file says so at the point of exclusion:

postgres  bounded + contract message   libpq connect_timeout
mysql     bounded + contract message   mysql2 connect_timeout
mssql     bounded + contract message   FreeTDS login_timeout
firebird  bounded to REACHABILITY only stdlib socket; the attach itself
                                     cannot be bounded from Ruby
mongodb   bounded, NO message possible Client.new never fails
sqlite    n/a                          local file, no network peer
odbc      NOT bounded                  gem untestable here; see its file
"TINA4_DATABASE_CONNECT_TIMEOUT"
DEFAULT_CONNECT_TIMEOUT_SECONDS =
10
CONNECT_TIMEOUT_SLACK_SECONDS =

Clock slack when deciding whether a failed connect was OUR bound expiring. A native bound of 10s is measured back as 9.998s often enough to matter, and without the slack the contract error would degrade into the raw driver error at random.

0.25

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.bounding_connect(host, port) ⇒ Object

Run a driver's natively-bounded connect and translate an expiry into the contract error above. The NATIVE option does the bounding; this only names it. Whether the bound expired is decided by ELAPSED TIME, not by matching driver error text - the four clients word it four different ways ("timeout expired", "waiting for initial communication packet", "TDS server connection timed out", "Connection timed out"), and a marker table is one more thing to drift and MISS. A missed timeout is the whole defect.



304
305
306
307
308
309
310
311
312
313
314
315
# File 'lib/tina4/database_adapter.rb', line 304

def self.bounding_connect(host, port)
  started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
  yield
rescue StandardError => error
  bound = connect_timeout_seconds
  raise if bound.nil?

  elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
  raise if elapsed < bound - CONNECT_TIMEOUT_SLACK_SECONDS

  connect_timed_out!(host, port, elapsed, error)
end

.connect_timed_out!(host, port, elapsed_seconds, cause = nil) ⇒ Object

The one error a timed-out connect raises: it names the host, the port, the seconds actually spent, and the variable that tunes it.



288
289
290
291
292
293
294
295
# File 'lib/tina4/database_adapter.rb', line 288

def self.connect_timed_out!(host, port, elapsed_seconds, cause = nil)
  detail = cause ? " Driver reported: #{cause.message.to_s.gsub(/\s+/, " ").strip}" : ""
  raise Tina4::DatabaseConnectionError,
        "Database connect to #{host}:#{port} timed out after " \
        "#{format("%.1f", elapsed_seconds)}s (#{CONNECT_TIMEOUT_VAR}=" \
        "#{connect_timeout_seconds} seconds; set it to 0 to wait " \
        "indefinitely).#{detail}"
end

.connect_timeout_secondsObject

Seconds to bound a connect by, or nil when the operator disabled the bound.



272
273
274
275
# File 'lib/tina4/database_adapter.rb', line 272

def self.connect_timeout_seconds
  seconds = Tina4::Env.float(CONNECT_TIMEOUT_VAR, default: DEFAULT_CONNECT_TIMEOUT_SECONDS)
  seconds.positive? ? seconds : nil
end

.connect_timeout_whole_secondsObject

Whole seconds for the native options that accept only an integer (libpq, libmysqlclient, FreeTDS). Rounds UP and never below 1: libpq reads connect_timeout=0 as "wait forever", so rounding 0.4 DOWN to 0 would silently disable the very bound being set.



281
282
283
284
# File 'lib/tina4/database_adapter.rb', line 281

def self.connect_timeout_whole_seconds
  seconds = connect_timeout_seconds
  seconds && [seconds.ceil, 1].max
end

.implemented_by?(object, name) ⇒ Boolean

Did this driver actually OVERRIDE the contract method, or is it inheriting the raising stub? respond_to? cannot answer that once the module is included, and answering it wrongly turns a working silent-skip path into a NotImplementedError at runtime.

Returns:

  • (Boolean)


321
322
323
324
325
326
327
328
329
330
# File 'lib/tina4/database_adapter.rb', line 321

def self.implemented_by?(object, name)
  return false unless object.respond_to?(name)

  owner = begin
    object.class.instance_method(name).owner
  rescue NameError
    nil
  end
  !owner.nil? && owner != self
end

.validate!(adapter_object, name = nil) ⇒ Object

Fail loud when a class does not declare every required capability.

For ABSTRACT_CONTRACT this checks the method was actually OVERRIDDEN (implemented_by?, below) rather than merely inherited as the raising stub. For the capabilities with a real generic default (execute_many, fetch, fetch_one, table_exists?, autocommit, supports_atomic_batch), simple presence is sufficient - the module's own default IS a complete, correct implementation.



207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
# File 'lib/tina4/database_adapter.rb', line 207

def self.validate!(adapter_object, name = nil)
  label = name || adapter_object.class.name
  missing = CONTRACT.select do |capability|
    if ABSTRACT_CONTRACT.include?(capability)
      !implemented_by?(adapter_object, capability)
    else
      !adapter_object.respond_to?(capability)
    end
  end
  return if missing.empty?

  raise Tina4::AdapterContractError,
        "adapter #{label.inspect} does not implement the required Tina4 " \
        "database adapter contract capabilities: #{missing.join(', ')} " \
        "(ADR-0044 / plan/v3/fixtures/adapter_contract.json)"
end

Instance Method Details

#autocommitObject

ADR-0044: readable and writable native boolean, defaulting true.



130
131
132
# File 'lib/tina4/database_adapter.rb', line 130

def autocommit
  @tina4_autocommit.nil? ? true : @tina4_autocommit
end

#autocommit=(value) ⇒ Object



134
135
136
# File 'lib/tina4/database_adapter.rb', line 134

def autocommit=(value)
  @tina4_autocommit = value
end

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

ADR-0044: one aggregate result for the whole batch - last_id:. Transaction OWNERSHIP is deliberately NOT here: the facade (Database#execute_many) brackets begin/commit/rollback around exactly one call to this method, exactly as it already does for a standalone start_transaction()/execute()/commit() sequence, so a driver that overrides this for native batching never has to duplicate that policy.



157
158
159
160
161
162
163
164
165
166
167
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
# File 'lib/tina4/database_adapter.rb', line 157

def execute_many(sql, params_list = [])
  rows = params_list || []
  return { affected_rows: 0, last_id: nil } if rows.empty?

  # ADR-0044 (DBA-B05): a ragged parameter set must fail BEFORE any
  # durable partial success. Checked generically (every row's length must
  # match the first) rather than parsing the SQL's own placeholder count,
  # so it holds for every driver without a dialect-specific parser.
  expected = rows.first.length
  rows.each do |params|
    next if params.length == expected

    raise Tina4::BindingCountMismatchError,
          "execute_many binding count mismatch - expected #{expected} " \
          "parameters, got #{params.length}"
  end

  if !supports_atomic_batch && rows.length > 1
    raise Tina4::UnsupportedAtomicBatchError,
          "provider #{get_database_type.inspect} cannot guarantee an atomic " \
          "batch write on this deployment (required deployment capability: " \
          "a transaction-capable configuration) - rejected before the first " \
          "write rather than risking partial durability"
  end

  # ONE round-trip per CHUNK instead of one per ROW - see
  # Tina4::SQLTranslator.build_batch_inserts for the measured 121x-625x.
  batched = Tina4::SQLTranslator.build_batch_inserts(sql, rows, get_database_type)
  if batched.empty?
    rows.each { |params| execute(sql, params) }
  else
    batched.each { |chunk_sql, chunk_params| execute(chunk_sql, chunk_params) }
  end

  last_id = begin
    last_insert_id
  rescue StandardError
    nil
  end
  { affected_rows: rows.length, last_id: last_id }
end

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

ADR-0044: the adapter-level read-many primitive. Returns a native list of records with NO pagination envelope and NO count probe - the facade (Database#fetch) owns pagination and the true-total count.



113
114
115
# File 'lib/tina4/database_adapter.rb', line 113

def fetch(sql, params = [])
  execute_query(sql, params)
end

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

ADR-0044: one native record or nil. No pagination count probe.



118
119
120
# File 'lib/tina4/database_adapter.rb', line 118

def fetch_one(sql, params = [])
  fetch(sql, params).first
end

#open(*args, **kwargs) ⇒ Object

open is the pre-3.14 spelling every Ruby driver's constructor already calls; connect is the ADR-0044 canonical lifecycle name. A temporary forwarding alias, to be removed or explicitly deprecated before 3.14.



94
95
96
# File 'lib/tina4/database_adapter.rb', line 94

def open(*args, **kwargs)
  connect(*args, **kwargs)
end

#start_transaction(*args, **kwargs) ⇒ Object

begin_transaction is every existing driver's real spelling; start_transaction is the ADR-0044 canonical name (matches Python/PHP/ Node). A thin forwarding default, exactly like open above.



101
102
103
# File 'lib/tina4/database_adapter.rb', line 101

def start_transaction(*args, **kwargs)
  begin_transaction(*args, **kwargs)
end

#supports_atomic_batchObject

ADR-0044 / DBA-P02: whether this adapter's deployment can guarantee an atomic multi-row batch write. Every built-in driver defaults to true; a deployment that genuinely cannot (a standalone MongoDB without a replica set is the motivating real case) sets this false so execute_many rejects BEFORE the first write.



143
144
145
# File 'lib/tina4/database_adapter.rb', line 143

def supports_atomic_batch
  @tina4_supports_atomic_batch.nil? ? true : @tina4_supports_atomic_batch
end

#supports_atomic_batch=(value) ⇒ Object



147
148
149
# File 'lib/tina4/database_adapter.rb', line 147

def supports_atomic_batch=(value)
  @tina4_supports_atomic_batch = value
end

#table_exists?(name) ⇒ Boolean

ADR-0044: table_exists? is required on the adapter. Drivers that expose a native, efficient check (SQLite, MySQL, MSSQL) override this; the rest get a correct, if less efficient, default from the required tables.

Returns:

  • (Boolean)


125
126
127
# File 'lib/tina4/database_adapter.rb', line 125

def table_exists?(name)
  tables.any? { |t| t.to_s.downcase == name.to_s.downcase }
end