Module: Tina4::DatabaseAdapter
- Included in:
- Tina4::Drivers::FirebirdDriver, Tina4::Drivers::MongodbDriver, Tina4::Drivers::MssqlDriver, Tina4::Drivers::MysqlDriver, Tina4::Drivers::OdbcDriver, Tina4::Drivers::PostgresDriver, Tina4::Drivers::SqliteDriver
- Defined in:
- lib/tina4/database_adapter.rb
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 10Each 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
Class Method Summary collapse
-
.bound_reached?(elapsed_monotonic, elapsed_realtime, seconds) ⇒ Boolean
Did a connect that FAILED take at least the configured bound?.
-
.bounding_connect(host, port) ⇒ Object
Run a driver's natively-bounded connect and translate an expiry into the contract error above.
-
.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.
-
.connect_timeout_seconds ⇒ Object
Seconds to bound a connect by, or nil when the operator disabled the bound.
-
.connect_timeout_whole_seconds ⇒ Object
Whole seconds for the native options that accept only an integer (libpq, libmysqlclient, FreeTDS).
-
.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. -
.validate!(adapter_object, name = nil) ⇒ Object
Fail loud when a class does not declare every required capability.
Instance Method Summary collapse
-
#autocommit ⇒ Object
ADR-0044: readable and writable native boolean, defaulting true.
- #autocommit=(value) ⇒ Object
-
#execute_many(sql, params_list = []) ⇒ Object
ADR-0044: one aggregate result for the whole batch - last_id:.
-
#fetch(sql, params = []) ⇒ Object
ADR-0044: the adapter-level read-many primitive.
-
#fetch_one(sql, params = []) ⇒ Object
ADR-0044: one native record or nil.
-
#open(*args, **kwargs) ⇒ Object
openis the pre-3.14 spelling every Ruby driver's constructor already calls;connectis the ADR-0044 canonical lifecycle name. -
#start_transaction(*args, **kwargs) ⇒ Object
begin_transactionis every existing driver's real spelling;start_transactionis the ADR-0044 canonical name (matches Python/PHP/ Node). -
#supports_atomic_batch ⇒ Object
ADR-0044 / DBA-P02: whether this adapter's deployment can guarantee an atomic multi-row batch write.
- #supports_atomic_batch=(value) ⇒ Object
-
#table_exists?(name) ⇒ Boolean
ADR-0044: table_exists? is required on the adapter.
Class Method Details
.bound_reached?(elapsed_monotonic, elapsed_realtime, seconds) ⇒ Boolean
Did a connect that FAILED take at least the configured bound?
Two readings, because the framework and the driver do not share a clock. bounding_connect times on CLOCK_MONOTONIC; libpq times its own connect_timeout on gettimeofday - CLOCK_REALTIME (libmysqlclient and FreeTDS likewise measure against the wall clock). NTP slews and steps realtime and never touches monotonic, so a forward step or slew can make the driver abort while a monotonic reading over the very same connect is still short of the bound - and then the driver's own message, which names no tunable, reaches the caller.
Taking the LARGER of the two readings covers both directions: the realtime reading catches a forward jump, and keeping the monotonic reading means a BACKWARD jump cannot hide a timeout that really did happen. Pure, so the decision is testable without faking a clock.
314 315 316 317 318 |
# File 'lib/tina4/database_adapter.rb', line 314 def self.bound_reached?(elapsed_monotonic, elapsed_realtime, seconds) return false if seconds.nil? [elapsed_monotonic, elapsed_realtime].max >= seconds end |
.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.
The elapsed time is read on BOTH clocks and compared through bound_reached?, because the driver measured its own deadline on the wall clock while we started ours on the monotonic one - see that method. The strictly-greater native option (connect_timeout_whole_seconds) leaves the driver's deadline after ours, so on an undisturbed clock the larger reading comfortably reaches the bound with no slack to tune.
334 335 336 337 338 339 340 341 342 343 344 345 346 347 |
# File 'lib/tina4/database_adapter.rb', line 334 def self.bounding_connect(host, port) started_monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC) started_realtime = Process.clock_gettime(Process::CLOCK_REALTIME) yield rescue StandardError => error bound = connect_timeout_seconds raise if bound.nil? elapsed_monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_monotonic elapsed_realtime = Process.clock_gettime(Process::CLOCK_REALTIME) - started_realtime raise unless bound_reached?(elapsed_monotonic, elapsed_realtime, bound) connect_timed_out!(host, port, [elapsed_monotonic, elapsed_realtime].max, 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.
290 291 292 293 294 295 296 297 |
# File 'lib/tina4/database_adapter.rb', line 290 def self.connect_timed_out!(host, port, elapsed_seconds, cause = nil) detail = cause ? " Driver reported: #{cause..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_seconds ⇒ Object
Seconds to bound a connect by, or nil when the operator disabled the bound.
266 267 268 269 |
# File 'lib/tina4/database_adapter.rb', line 266 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_seconds ⇒ Object
Whole seconds for the native options that accept only an integer (libpq, libmysqlclient, FreeTDS). STRICTLY greater than the bound, never below 1.
floor(s) + 1, not ceil(s): the native option must land AFTER our bound so the driver's own timer fires first and bounding_connect gets to translate its failure. ceil(N) == N for a whole-second bound - and the shipped default of 10 is whole - which put the driver's deadline ON our bound instead of after it, so which message reached the caller came down to which clock ticked first. floor(s) + 1 is strictly greater for every input, whole or fractional, at a cost of at most one extra second on a path that has already failed. (libpq also reads connect_timeout=0 as "wait forever", so the +1 doubles as the guard against rounding a sub-second bound to 0.)
283 284 285 286 |
# File 'lib/tina4/database_adapter.rb', line 283 def self.connect_timeout_whole_seconds seconds = connect_timeout_seconds seconds && [seconds.floor + 1, 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.
353 354 355 356 357 358 359 360 361 362 |
# File 'lib/tina4/database_adapter.rb', line 353 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
#autocommit ⇒ Object
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_batch ⇒ Object
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.
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 |