Class: Tina4::Drivers::FirebirdDriver

Inherits:
Object
  • Object
show all
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

Class Method Summary collapse

Instance Method Summary collapse

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 returned

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

Returns:

  • (Boolean)


253
254
255
256
257
258
# File 'lib/tina4/drivers/firebird_driver.rb', line 253

def self.dead_connection?(error_or_message)
  msg = error_or_message.respond_to?(:message) ? error_or_message.message : error_or_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.fdb

But 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 fb gem, 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_rowsObject

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 -- comment in 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_transactionObject

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 fb gem the transaction lives ON the connection: Fb::Connection#transaction (no block) STARTS a transaction and returns true (a boolean — NOT a transaction object), and Fb::Connection#commit / #rollback end it. The old code stored that boolean in @transaction and 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_transaction boolean (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#commit is 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

#closeObject



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

#commitObject

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 fb gem 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_typeObject

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_idObject

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-id primary 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

#placeholderObject



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

#rollbackObject



341
342
343
344
# File 'lib/tina4/drivers/firebird_driver.rb', line 341

def rollback
  @connection&.rollback
  @in_transaction = false
end

#tablesObject



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