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)


154
155
156
157
158
159
# File 'lib/tina4/drivers/firebird_driver.rb', line 154

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



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

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

#begin_transactionObject



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

def begin_transaction
  @transaction = @connection.transaction
end

#closeObject



127
128
129
# File 'lib/tina4/drivers/firebird_driver.rb', line 127

def close
  @connection&.close
end

#columns(table_name) ⇒ Object



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

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



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

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
123
124
125
# 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 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



142
143
144
145
146
147
148
149
150
# File 'lib/tina4/drivers/firebird_driver.rb', line 142

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



131
132
133
134
135
136
137
138
139
140
# File 'lib/tina4/drivers/firebird_driver.rb', line 131

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



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

def last_insert_id
  nil
end

#placeholderObject



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

def placeholder
  "?"
end

#placeholders(count) ⇒ Object



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

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

#rollbackObject



185
186
187
# File 'lib/tina4/drivers/firebird_driver.rb', line 185

def rollback
  @transaction&.rollback
end

#tablesObject



189
190
191
192
193
# File 'lib/tina4/drivers/firebird_driver.rb', line 189

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