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::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

Methods included from Tina4::DatabaseAdapter

bounding_connect, connect_timed_out!, connect_timeout_seconds, connect_timeout_whole_seconds, implemented_by?

Instance Attribute Details

#connectionObject (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 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)


245
246
247
248
249
250
# File 'lib/tina4/drivers/firebird_driver.rb', line 245

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

#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).



268
269
270
# File 'lib/tina4/drivers/firebird_driver.rb', line 268

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.



291
292
293
294
# File 'lib/tina4/drivers/firebird_driver.rb', line 291

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



326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
# File 'lib/tina4/drivers/firebird_driver.rb', line 326

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.



310
311
312
313
# File 'lib/tina4/drivers/firebird_driver.rb', line 310

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
# File 'lib/tina4/drivers/firebird_driver.rb', line 233

def execute(sql, params = [])
  with_reconnect do
    if params.empty?
      @connection.execute(sql)
    else
      @connection.execute(sql, *params)
    end
  end
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(stringify_keys(row)) }
end

#last_insert_idObject



252
253
254
# File 'lib/tina4/drivers/firebird_driver.rb', line 252

def last_insert_id
  nil
end

#placeholderObject



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

def placeholder
  "?"
end

#placeholders(count) ⇒ Object



260
261
262
# File 'lib/tina4/drivers/firebird_driver.rb', line 260

def placeholders(count)
  (["?"] * count).join(", ")
end

#rollbackObject



315
316
317
318
# File 'lib/tina4/drivers/firebird_driver.rb', line 315

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

#tablesObject



320
321
322
323
324
# File 'lib/tina4/drivers/firebird_driver.rb', line 320

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