Class: Tina4::Drivers::FirebirdDriver
- Inherits:
-
Object
- Object
- Tina4::Drivers::FirebirdDriver
- Includes:
- Tina4::DatabaseAdapter
- Defined in:
- lib/tina4/drivers/firebird_driver.rb
Constant Summary collapse
- DEAD_CONN_MARKERS =
Substring markers (lowercased) that identify a dead-socket Firebird error worth reconnecting for. Idle Firebird connections die silently behind NAT timeouts, server-side ConnectionIdleTimeout, or Docker network rotation; without this the next prepare crashes the request.
[ "error writing data to the connection", "error reading data from the connection", "connection shutdown", "connection lost", "network error", "connection is not active", "broken pipe" ].freeze
- WIN_DRIVE_RE =
Detects a Windows drive-letter prefix like "C:/" or "C:". The leading-slash variant ("/C:/...") shows up after URI.parse strips one slash off "firebird://host:port/C:/...".
%r{\A/?[A-Za-z]:[/\\]}.freeze
Constants included from Tina4::DatabaseAdapter
Tina4::DatabaseAdapter::ABSTRACT_CONTRACT, Tina4::DatabaseAdapter::CONNECT_TIMEOUT_SLACK_SECONDS, Tina4::DatabaseAdapter::CONNECT_TIMEOUT_VAR, Tina4::DatabaseAdapter::CONTRACT, Tina4::DatabaseAdapter::DEFAULT_CONNECT_TIMEOUT_SECONDS
Instance Attribute Summary collapse
-
#connection ⇒ Object
readonly
Returns the value of attribute connection.
Class Method Summary collapse
-
.bound_reachability!(host, port) ⇒ Object
Bound the REACH to a Firebird server before libfbclient is handed the attach.
-
.dead_connection?(error_or_message) ⇒ Boolean
Public so specs (and curious operators) can verify the matcher behaviour without poking private methods.
-
.normalize_db_identifier(raw_path) ⇒ Object
Turn the URL path component into a Firebird database identifier.
-
.resolve_charset(connection_string, kwarg_charset = nil) ⇒ Object
Resolve the Firebird connection charset (#160, mirrors php #160 / the Python master's _resolve_firebird_charset).
Instance Method Summary collapse
-
#affected_rows ⇒ Object
Rows affected by the most recent write, read from the fb gem's execute() return (an Integer for DML).
-
#apply_limit(sql, limit, offset = 0) ⇒ Object
The closing paren goes on a NEW LINE.
-
#begin_transaction ⇒ Object
Transaction handling — mirrors the Python master's connection-level contract (tina4_python firebird.py: start_transaction sets a flag, commit/rollback act on the connection).
- #close ⇒ Object
- #columns(table_name) ⇒ Object
-
#commit ⇒ Object
Guarded on the CONNECTION only, deliberately - no active-transaction check is needed here.
- #connect(connection_string, username: nil, password: nil, charset: nil) ⇒ Object
- #execute(sql, params = []) ⇒ Object
- #execute_query(sql, params = []) ⇒ Object
-
#get_database_type ⇒ Object
ADR-0044 required adapter capability.
-
#last_insert_id ⇒ Object
Firebird has no generic last-insert-id, so read the GEN_
_ID generator the row's BEFORE INSERT trigger drew from (FB-LASTID-GAP).
- #placeholder ⇒ Object
- #placeholders(count) ⇒ Object
- #rollback ⇒ Object
- #tables ⇒ Object
Methods included from Tina4::DatabaseAdapter
#autocommit, #autocommit=, bounding_connect, connect_timed_out!, connect_timeout_seconds, connect_timeout_whole_seconds, #execute_many, #fetch, #fetch_one, implemented_by?, #open, #start_transaction, #supports_atomic_batch, #supports_atomic_batch=, #table_exists?, validate!
Instance Attribute Details
#connection ⇒ Object (readonly)
Returns the value of attribute connection.
8 9 10
# File 'lib/tina4/drivers/firebird_driver.rb', line 8 def connection @connection end
Class Method Details
.bound_reachability!(host, port) ⇒ Object
Bound the REACH to a Firebird server before libfbclient is handed the attach. Raises the shared connect-timeout error when the host does not answer within TINA4_DATABASE_CONNECT_TIMEOUT.
Why a socket probe and not a timeout around the attach - MEASURED on Ruby 3.2.3 / Ubuntu 24.04.4 / fb 0.10.0 against a REAL TCPServer that accepts the connection and then never replies:
fb attach, no bound WEDGED past 20s, SIGKILL needed fb attach, Timeout.timeout(3) WEDGED past 20s - the timeout NEVER fired fb attach, Thread#join(3) WEDGED past 20s - join never returnedThe gem calls isc_attach_database (fb.c:3002) WITHOUT releasing the GVL and without an unblocking function, so no Ruby thread runs to deliver the interrupt -
timeout's SIGTERM could not even be delivered. It also builds its DPB from a fixed four-item set (user, password, lc_ctype, role) with no connect-timeout item. There is therefore NO in-process way to bound the attach itself, and a Timeout.timeout here would be WORSE than nothing because it would look like protection and silently not fire.What IS boundable is reaching the host at all, which is the hang operators actually hit: a dead or firewalled server swallows the SYN and the process sits there. A plain stdlib socket bounds that exactly.
RESIDUAL GAP, stated plainly so this is not mistaken for full cover: if the TCP handshake SUCCEEDS and the server then never speaks the Firebird protocol, the attach is still UNBOUNDED. Ruby cannot fix that from inside this process - it needs a connect timeout in the fb gem itself.
ONLY a timeout is converted. A refused connection, an unknown host or any other socket error is swallowed so libfbclient still produces its own (better) diagnosis: this probe adds a bound, it does not take over error reporting.
149 150 151 152 153 154 155 156 157 158 159 160 161 162 163
# File 'lib/tina4/drivers/firebird_driver.rb', line 149 def self.bound_reachability!(host, port) seconds = Tina4::DatabaseAdapter.connect_timeout_seconds return if seconds.nil? || host.to_s.empty? require "socket" begin Tina4::DatabaseAdapter.bounding_connect(host, port) do Socket.tcp(host, port, connect_timeout: seconds, &:close) end rescue Tina4::DatabaseConnectionError raise rescue StandardError nil end end
.dead_connection?(error_or_message) ⇒ Boolean
Public so specs (and curious operators) can verify the matcher behaviour without poking private methods.
253 254 255 256 257 258
# File 'lib/tina4/drivers/firebird_driver.rb', line 253 def self.dead_connection?() msg = .respond_to?(:message) ? . : .to_s return false if msg.nil? || msg.empty? lower = msg.downcase DEAD_CONN_MARKERS.any? { |m| lower.include?(m) } end
.normalize_db_identifier(raw_path) ⇒ Object
Turn the URL path component into a Firebird database identifier.
Firebird is the awkward one — it needs either an absolute file path on the server, a Windows drive-letter path, or an alias name. The classic URI form uses a double-slash to keep the leading "/" of an absolute path through URI.parse:
firebird://host:port//firebird/data/app.fdb → /firebird/data/app.fdbBut that double slash is unintuitive to anyone used to the way postgres / mysql / mssql encode the database name. We accept five equivalent forms and normalise all of them:
- "//abs/path/db.fdb" → "/abs/path/db.fdb" (classic double-slash)
- "/abs/path/db.fdb" → "/abs/path/db.fdb" (single-slash, what most people type)
- "/C:/Data/db.fdb" → "C:/Data/db.fdb" (Windows, leading URL slash dropped)
- "/C%3A/Data/db.fdb" → "C:/Data/db.fdb" (Windows with URL-encoded colon)
- "/employee" → "employee" (alias — single token)
Aliases are detected as the leftover case: a single token with no slashes. Anything path-like is kept as a path.
50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79
# File 'lib/tina4/drivers/firebird_driver.rb', line 50 def self.normalize_db_identifier(raw_path) require "uri" return "" if raw_path.nil? || raw_path.empty? decoded = URI.decode_www_form_component(raw_path) # Classic double-slash form: //abs/path → /abs/path decoded = decoded[1..] if decoded.start_with?("//") # Windows drive-letter — drop the URL-introduced leading slash. # /C:/Data/db.fdb → C:/Data/db.fdb if WIN_DRIVE_RE.match?(decoded) decoded = decoded[1..] if decoded.start_with?("/") return decoded end # Look at the content after stripping the leading slash. If it's a # single token with no separators, it's a Firebird alias — return # WITHOUT the leading slash (the alias name itself is the identifier). body = decoded.start_with?("/") ? decoded[1..] : decoded if !body.empty? && !body.include?("/") && !body.include?("\\") return body end # Otherwise it's a file path. If it already has a leading slash, # keep it. If it's a relative-looking path (slash-separated but no # leading "/") promote it to absolute — Firebird needs absolute paths # and we don't know the server's CWD anyway. decoded.start_with?("/") ? decoded : "/#{decoded}" end
.resolve_charset(connection_string, kwarg_charset = nil) ⇒ Object
Resolve the Firebird connection charset (#160, mirrors php #160 / the Python master's _resolve_firebird_charset).
The driver used to pass NO charset to the
fbgem, leaving it to the gem's own (non-UTF8) default — which double-encodes UTF-8 bytes stored under a legacy NONE database and diverges from the other frameworks. The charset is now resolved from, in precedence order:1. the connection URL query — firebird://host:port/path?charset=NONE 2. an explicit charset: kwarg passed to #connect 3. the TINA4_DATABASE_CHARSET environment variable 4. the UTF8 default (canonical across all four frameworks)Pure config resolution over its inputs (URL string, kwarg, env) — it opens NO connection, so it is unit-testable without a live server. A blank value at any level is treated as absent (matching the Python master's falsy-string semantics) so
?charset=/ an empty env var falls through rather than connecting with an empty charset.99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114
# File 'lib/tina4/drivers/firebird_driver.rb', line 99 def self.resolve_charset(connection_string, kwarg_charset = nil) require "uri" url_charset = nil query = begin URI.parse(connection_string.to_s).query rescue URI::InvalidURIError nil end if query && !query.empty? pair = URI.decode_www_form(query).find { |k, _| k == "charset" } url_charset = pair[1] if pair && !pair[1].to_s.empty? end kwarg = kwarg_charset.to_s.empty? ? nil : kwarg_charset env = ENV["TINA4_DATABASE_CHARSET"].to_s.empty? ? nil : ENV["TINA4_DATABASE_CHARSET"] url_charset || kwarg || env || "UTF8" end
Instance Method Details
#affected_rows ⇒ Object
Rows affected by the most recent write, read from the fb gem's execute() return (an Integer for DML). Best-effort 0 for DDL / a non-count return. The facade's write_affected reads this when the driver implements it.
278 279 280
# File 'lib/tina4/drivers/firebird_driver.rb', line 278 def affected_rows @affected_rows || 0 end
#apply_limit(sql, limit, offset = 0) ⇒ Object
The closing paren goes on a NEW LINE. Inline, a trailing
-- commentin the caller's SQL comments the paren out and the whole wrapped statement is a syntax error (the same class of bug as the LIMIT append site — see the note on Drivers::SqliteDriver#apply_limit).294 295 296
# File 'lib/tina4/drivers/firebird_driver.rb', line 294 def apply_limit(sql, limit, offset = 0) "SELECT FIRST #{limit} SKIP #{offset} * FROM (#{sql}\n)" end
#begin_transaction ⇒ Object
Transaction handling — mirrors the Python master's connection-level contract (tina4_python firebird.py: start_transaction sets a flag, commit/rollback act on the connection).
In the
fbgem the transaction lives ON the connection:Fb::Connection#transaction(no block) STARTS a transaction and returnstrue(a boolean — NOT a transaction object), andFb::Connection#commit/#rollbackend it. The old code stored that boolean in@transactionand called@transaction&.commit/&.rollback, i.e.true.commit/true.rollback— a NoMethodError that broke every explicit-transaction commit AND rollback (a rolled-back write was never undone). We now start the transaction on the connection and commit/rollback the CONNECTION, tracking open-ness with an@in_transactionboolean (parity with Python's_in_transaction).A standalone write auto-commits inside the gem's own
execute, so the framework's autocommit_standalone_write then calls #commit with no txn open —Fb::Connection#commitis a harmless no-op there (returns nil, no raise), so standalone-autocommit is preserved.317 318 319 320
# File 'lib/tina4/drivers/firebird_driver.rb', line 317 def begin_transaction @connection.transaction @in_transaction = true end
#close ⇒ Object
218 219 220
# File 'lib/tina4/drivers/firebird_driver.rb', line 218 def close @connection&.close end
#columns(table_name) ⇒ Object
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
# File 'lib/tina4/drivers/firebird_driver.rb', line 357 def columns(table_name) sql = "SELECT RF.RDB\$FIELD_NAME, F.RDB\$FIELD_TYPE, RF.RDB\$NULL_FLAG, RF.RDB\$DEFAULT_SOURCE " \ "FROM RDB\$RELATION_FIELDS RF " \ "JOIN RDB\$FIELDS F ON RF.RDB\$FIELD_SOURCE = F.RDB\$FIELD_NAME " \ "WHERE RF.RDB\$RELATION_NAME = ?" rows = execute_query(sql, [table_name.upcase]) # The primary key comes from the constraint catalogue. This used to be # hardcoded `false` for every column, so primary_key(table) always # answered [] on Firebird -- which silently breaks anything that # introspects the key, including the filterless-write guard that lifts # the PK out of `data`. Same bug the Python master carried. pk_sql = "SELECT SG.RDB\$FIELD_NAME FROM RDB\$INDEX_SEGMENTS SG " \ "JOIN RDB\$RELATION_CONSTRAINTS RC ON SG.RDB\$INDEX_NAME = RC.RDB\$INDEX_NAME " \ "WHERE RC.RDB\$CONSTRAINT_TYPE = 'PRIMARY KEY' AND RC.RDB\$RELATION_NAME = ? " \ "ORDER BY SG.RDB\$FIELD_POSITION" pk_names = begin execute_query(pk_sql, [table_name.upcase]).map do |r| (r[:"RDB\$FIELD_NAME"] || r[:"rdb\$field_name"] || "").strip.upcase end.reject(&:empty?).to_set rescue StandardError # A table with no primary key is not an error. Set.new end rows.map do |r| field_name = (r[:"RDB\$FIELD_NAME"] || r[:"rdb\$field_name"] || "").strip { name: field_name, type: r[:"RDB\$FIELD_TYPE"] || r[:"rdb\$field_type"], nullable: (r[:"RDB\$NULL_FLAG"] || r[:"rdb\$null_flag"]).nil?, default: r[:"RDB\$DEFAULT_SOURCE"] || r[:"rdb\$default_source"], primary_key: pk_names.include?(field_name.upcase) } end end
#commit ⇒ Object
Guarded on the CONNECTION only, deliberately - no active-transaction check is needed here.
Python's adapter has the same shape and it IS a bug there: firebird-driver delegates Connection#commit to main_transaction, whose handle is nil until a statement opens one, so committing with nothing open raises "AttributeError: 'NoneType' object has no attribute 'commit'". Measured 2026-08-04 against the lab's real Firebird 5.0.4, the
fbgem does NOT behave that way: both #commit and #rollback on a fresh connection with no open transaction return cleanly, because the driver opens one implicitly.So do not "port" Python's is_active? guard here on the strength of the shape matching - it would be dead code guarding a condition this driver cannot reach.
336 337 338 339
# File 'lib/tina4/drivers/firebird_driver.rb', line 336 def commit @connection&.commit @in_transaction = false end
#connect(connection_string, username: nil, password: nil, charset: nil) ⇒ Object
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 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216
# File 'lib/tina4/drivers/firebird_driver.rb', line 165 def connect(connection_string, username: nil, password: nil, charset: nil) require "fb" require "uri" uri = URI.parse(connection_string) host = uri.host port = uri.port || 3050 db_user = username || uri.user db_pass = password || uri.password # Firebird database identifier resolution — two layers: # # 1. TINA4_DATABASE_FIREBIRD_PATH env override wins if set. # Useful for Windows users with raw backslash paths (no URL # encoding required) and for ops setups that keep server URL # and DB location in separate config layers. # 2. Otherwise normalise the URL path component — accepts every # sensible variant (single/double slash, drive letter, alias). env_override = ENV["TINA4_DATABASE_FIREBIRD_PATH"].to_s db_path = if !env_override.empty? env_override else self.class.normalize_db_identifier(uri.path.to_s) end database = if host "#{host}/#{port}:#{db_path}" else # No host → fall back to the raw identifier (or, for # totally non-URL inputs, strip the scheme prefix). return_path = db_path return_path = connection_string.sub(/^firebird:\/\//, "") if return_path.empty? return_path end # Cache for transparent reconnect — never logged, lives only in # driver memory alongside the connection it owns. @connect_opts = { database: database } @connect_opts[:username] = db_user if db_user @connect_opts[:password] = db_pass if db_pass # #160: honour ?charset= in the URL, an explicit charset: kwarg, and # TINA4_DATABASE_CHARSET so a legacy NONE database isn't force-connected # with the gem's default charset (double-encoding). Defaults to UTF8. @connect_opts[:charset] = self.class.resolve_charset(connection_string, charset) self.class.bound_reachability!(host, port) open_connection rescue LoadError raise LoadError, "The 'fb' gem is required for Firebird connections. Install one of:\n" \ " bundle add fb # if your project uses Bundler\n" \ " gem install fb # bare driver" end
#execute(sql, params = []) ⇒ Object
233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249
# File 'lib/tina4/drivers/firebird_driver.rb', line 233 def execute(sql, params = []) result = with_reconnect do if params.empty? @connection.execute(sql) else @connection.execute(sql, *params) end end # The fb gem's Connection#execute RETURNS the affected row count as an # Integer for DML, so capture it for #affected_rows (FB-AFFECTED-FAB -- # the facade used to default it). Remember an INSERT's target table so # #last_insert_id can read its GEN_<TABLE>_ID generator (FB-LASTID-GAP); # a non-INSERT clears it so last_insert_id is nil there. @affected_rows = result.is_a?(Integer) ? result : 0 @last_insert_table = insert_target_table(sql) result end
#execute_query(sql, params = []) ⇒ Object
222 223 224 225 226 227 228 229 230 231
# File 'lib/tina4/drivers/firebird_driver.rb', line 222 def execute_query(sql, params = []) rows = with_reconnect do if params.empty? @connection.query(:hash, sql) else @connection.query(:hash, sql, *params) end end rows.map { |row| decode_blobs(symbolize_keys(row)) } end
#get_database_type ⇒ Object
ADR-0044 required adapter capability.
347 348 349
# File 'lib/tina4/drivers/firebird_driver.rb', line 347 def get_database_type 'firebird' end
#last_insert_id ⇒ Object
Firebird has no generic last-insert-id, so read the GEN_
_ID generator the row's BEFORE INSERT trigger drew from (FB-LASTID-GAP). The generator read is COLUMN-NAME-INDEPENDENT, so it is correct even for a non-
idprimary key. Returns nil when the last write was not an INSERT or the table has no such generator (GEN_ID then raises -> rescued).265 266 267 268 269 270 271 272 273
# File 'lib/tina4/drivers/firebird_driver.rb', line 265 def last_insert_id table = @last_insert_table return nil if table.nil? || table.empty? generator = "GEN_#{table.gsub('"', '').upcase}_ID" row = execute_query("SELECT GEN_ID(#{generator}, 0) AS LID FROM RDB\$DATABASE").first row && (row[:lid] || row[:LID]) rescue StandardError nil end
#placeholder ⇒ Object
282 283 284
# File 'lib/tina4/drivers/firebird_driver.rb', line 282 def placeholder "?" end
#placeholders(count) ⇒ Object
286 287 288
# File 'lib/tina4/drivers/firebird_driver.rb', line 286 def placeholders(count) (["?"] * count).join(", ") end
#rollback ⇒ Object
341 342 343 344
# File 'lib/tina4/drivers/firebird_driver.rb', line 341 def rollback @connection&.rollback @in_transaction = false end
#tables ⇒ Object
351 352 353 354 355
# File 'lib/tina4/drivers/firebird_driver.rb', line 351 def tables sql = "SELECT RDB\$RELATION_NAME FROM RDB\$RELATIONS WHERE RDB\$SYSTEM_FLAG = 0 AND RDB\$VIEW_BLR IS NULL" rows = execute_query(sql) rows.map { |r| (r[:"RDB\$RELATION_NAME"] || r[:"rdb\$relation_name"] || "").strip } end