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- 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
-
.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
.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..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.
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_seconds ⇒ Object
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.
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
#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 |