Class: Tina4::Drivers::FirebirdDriver

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

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Instance Attribute Details

#connectionObject (readonly)

Returns the value of attribute connection.



6
7
8
# File 'lib/tina4/drivers/firebird_driver.rb', line 6

def connection
  @connection
end

Class Method Details

.dead_connection?(error_or_message) ⇒ Boolean

Public so specs (and curious operators) can verify the matcher behaviour without poking private methods.

Returns:

  • (Boolean)


151
152
153
154
155
156
# File 'lib/tina4/drivers/firebird_driver.rb', line 151

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.



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

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

Instance Method Details

#apply_limit(sql, limit, offset = 0) ⇒ Object



170
171
172
# File 'lib/tina4/drivers/firebird_driver.rb', line 170

def apply_limit(sql, limit, offset = 0)
  "SELECT FIRST #{limit} SKIP #{offset} * FROM (#{sql})"
end

#begin_transactionObject



174
175
176
# File 'lib/tina4/drivers/firebird_driver.rb', line 174

def begin_transaction
  @transaction = @connection.transaction
end

#closeObject



124
125
126
# File 'lib/tina4/drivers/firebird_driver.rb', line 124

def close
  @connection&.close
end

#columns(table_name) ⇒ Object



192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
# File 'lib/tina4/drivers/firebird_driver.rb', line 192

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])
  rows.map do |r|
    {
      name: (r["RDB\$FIELD_NAME"] || r["rdb\$field_name"] || "").strip,
      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: false
    }
  end
end

#commitObject



178
179
180
# File 'lib/tina4/drivers/firebird_driver.rb', line 178

def commit
  @transaction&.commit
end

#connect(connection_string, username: nil, password: nil) ⇒ Object



79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
# File 'lib/tina4/drivers/firebird_driver.rb', line 79

def connect(connection_string, username: nil, password: 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

  open_connection
rescue LoadError
  raise "Firebird driver requires the 'fb' gem. Install it with: gem install fb"
end

#execute(sql, params = []) ⇒ Object



139
140
141
142
143
144
145
146
147
# File 'lib/tina4/drivers/firebird_driver.rb', line 139

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



128
129
130
131
132
133
134
135
136
137
# File 'lib/tina4/drivers/firebird_driver.rb', line 128

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



158
159
160
# File 'lib/tina4/drivers/firebird_driver.rb', line 158

def last_insert_id
  nil
end

#placeholderObject



162
163
164
# File 'lib/tina4/drivers/firebird_driver.rb', line 162

def placeholder
  "?"
end

#placeholders(count) ⇒ Object



166
167
168
# File 'lib/tina4/drivers/firebird_driver.rb', line 166

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

#rollbackObject



182
183
184
# File 'lib/tina4/drivers/firebird_driver.rb', line 182

def rollback
  @transaction&.rollback
end

#tablesObject



186
187
188
189
190
# File 'lib/tina4/drivers/firebird_driver.rb', line 186

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