Class: Tina4::Drivers::SqliteDriver

Inherits:
Object
  • Object
show all
Includes:
Tina4::DatabaseAdapter, SchemaSplit
Defined in:
lib/tina4/drivers/sqlite_driver.rb

Constant Summary

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

Class Attribute Summary collapse

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from SchemaSplit

#split_schema

Methods included from Tina4::DatabaseAdapter

bounding_connect, connect_timed_out!, connect_timeout_seconds, connect_timeout_whole_seconds, implemented_by?

Class Attribute Details

.write_lockObject (readonly)

Returns the value of attribute write_lock.



21
22
23
# File 'lib/tina4/drivers/sqlite_driver.rb', line 21

def write_lock
  @write_lock
end

Instance Attribute Details

#connectionObject (readonly)

Returns the value of attribute connection.



10
11
12
# File 'lib/tina4/drivers/sqlite_driver.rb', line 10

def connection
  @connection
end

Class Method Details

.resolve_path(connection_string) ⇒ Object

Resolve a SQLite URL / path against the project root (cwd).

Convention (matches tina4-python, tina4-php, tina4-nodejs):

sqlite::memory:              → :memory:
sqlite:///:memory:           → :memory:
sqlite:///app.db             → {cwd}/app.db  (relative)
sqlite:///data/app.db        → {cwd}/data/app.db  (relative; auto-mkdir under cwd)
sqlite:////var/data/app.db   → /var/data/app.db  (absolute; no auto-mkdir)
sqlite:///C:/Users/app.db    → C:/Users/app.db  (Windows absolute)

Never mkdir outside cwd — that was the root cause of the "Read-only file system: '/data'" crash on macOS.



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
80
81
# File 'lib/tina4/drivers/sqlite_driver.rb', line 51

def self.resolve_path(connection_string)
  return ":memory:" if connection_string == "sqlite::memory:" || connection_string == "sqlite:///:memory:"

  # Strip the scheme + up to three slashes, preserving a potential fourth
  # slash (absolute) or drive letter.
  # `sqlite3:` is a documented alias for `sqlite:`. Normalise it FIRST or
  # none of the strips below match and `raw` keeps the whole connection
  # string, so the database file is literally named "sqlite3:app.db".
  # Not merely ugly: a colon is an illegal filename character on Windows,
  # so the documented alias is unusable there. DatabaseUrl already
  # normalises it; this method duplicates the strip instead of calling
  # it, which is how the two drifted.
  normalised = connection_string.sub(/^sqlite3:/, "sqlite:")
  raw = normalised.sub(/^sqlite:\/\/\//, "").sub(/^sqlite:\/\//, "").sub(/^sqlite:/, "")
  return ":memory:" if raw == ":memory:"

  is_windows_abs = raw.match?(/^[A-Za-z]:[\/\\]/)
  is_unix_abs    = raw.start_with?("/")

  if is_windows_abs || is_unix_abs
    # Absolute — trust the user; don't auto-mkdir outside cwd.
    raw
  else
    # Relative — resolve under cwd; auto-mkdir parent dir.
    resolved = File.join(Dir.pwd, raw)
    parent = File.dirname(resolved)
    require "fileutils"
    FileUtils.mkdir_p(parent) unless File.directory?(parent)
    resolved
  end
end

Instance Method Details

#affected_rowsObject

Rows changed by the most recent INSERT/UPDATE/DELETE on this connection. Feeds Database#insert/update/delete's DatabaseResult.affected_rows (parity with the Python master + PHP, which surface affectedRows on writes).



121
122
123
# File 'lib/tina4/drivers/sqlite_driver.rb', line 121

def affected_rows
  @connection.changes
end

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

The clause goes on a NEW LINE. Appended inline it lands INSIDE a trailing -- comment and is silently swallowed by the engine — MEASURED: "SELECT * FROM t ORDER BY id -- LIMIT 5" returned all 150 rows with the 100-row cap in force. That is a bug at the APPEND SITE, independent of the detector (Database.has_trailing_limit?), and it needs its own test.



138
139
140
# File 'lib/tina4/drivers/sqlite_driver.rb', line 138

def apply_limit(sql, limit, offset = 0)
  "#{sql}\nLIMIT #{limit} OFFSET #{offset}"
end

#begin_transactionObject



142
143
144
# File 'lib/tina4/drivers/sqlite_driver.rb', line 142

def begin_transaction
  @connection.execute("BEGIN TRANSACTION")
end

#closeObject



83
84
85
# File 'lib/tina4/drivers/sqlite_driver.rb', line 83

def close
  @connection&.close
end

#coerce_params(params) ⇒ Object

Coerce Ruby values to types the sqlite3 gem can bind. The gem RAISES ("can't prepare TrueClass") on a raw boolean, so map true/false to 1/0 — SQLite stores booleans as INTEGER 0/1. Time/DateTime serialise to ISO-8601 so a datetime field round-trips. Parity with the Python/PHP/Node adapters, which coerce booleans at the same bind boundary.



101
102
103
104
105
106
107
108
109
110
111
112
# File 'lib/tina4/drivers/sqlite_driver.rb', line 101

def coerce_params(params)
  return params unless params.is_a?(Array)

  params.map do |value|
    case value
    when true then 1
    when false then 0
    when Time, DateTime then value.respond_to?(:iso8601) ? value.iso8601 : value.to_s
    else value
    end
  end
end

#columns(table_name) ⇒ Object



180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
# File 'lib/tina4/drivers/sqlite_driver.rb', line 180

def columns(table_name)
  # v3.13.14 (#48): PRAGMA accepts an attached-schema prefix.
  schema, tbl = split_schema(table_name)
  pragma = schema && identifier?(schema) && identifier?(tbl) ? "#{schema}.table_info(#{tbl})" : "table_info(#{table_name})"
  rows = execute_query("PRAGMA #{pragma}")
  rows.map do |r|
    {
      name: r[:name],
      type: r[:type],
      nullable: r[:notnull] == 0,
      default: r[:dflt_value],
      # PRAGMA table_info reports `pk` as the 1-BASED POSITION within the
      # primary key, not a boolean: a composite key gives pk=1, pk=2, ...
      # Testing `== 1` reported only the first column of a composite key.
      primary_key: r[:pk].to_i.positive?
    }
  end
end

#commitObject

Committing/rolling back when no transaction is open is a harmless no-op, NOT a failure — SQLite raises "cannot commit - no transaction is active" in that case. Swallow ONLY that specific condition so a stray commit (e.g. after an autocommit standalone write) doesn't poison the Database-level @last_error. A genuine commit/rollback failure (disk I/O, constraint deferral, locked DB) still propagates so Database#commit can FAIL LOUD per the DB-contract.



153
154
155
156
157
# File 'lib/tina4/drivers/sqlite_driver.rb', line 153

def commit
  @connection.execute("COMMIT")
rescue SQLite3::SQLException => e
  raise unless e.message.to_s.downcase.include?("no transaction is active")
end

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

NOT bounded by TINA4_DATABASE_CONNECT_TIMEOUT, deliberately: this connect opens a LOCAL FILE. There is no network peer, so there is no host and no port for the contract's error message to name, and no handshake that can hang. Every other driver's bound lives in its own connect; this is the one that has nothing to bind.



29
30
31
32
33
34
35
36
37
# File 'lib/tina4/drivers/sqlite_driver.rb', line 29

def connect(connection_string, username: nil, password: nil)
  require "sqlite3"
  db_path = self.class.resolve_path(connection_string)

  @connection = SQLite3::Database.new(db_path)
  @connection.results_as_hash = true
  @connection.execute("PRAGMA journal_mode=WAL")
  @connection.execute("PRAGMA foreign_keys=ON")
end

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



92
93
94
# File 'lib/tina4/drivers/sqlite_driver.rb', line 92

def execute(sql, params = [])
  @connection.execute(sql, coerce_params(params))
end

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



87
88
89
90
# File 'lib/tina4/drivers/sqlite_driver.rb', line 87

def execute_query(sql, params = [])
  results = @connection.execute(sql, coerce_params(params))
  symbolize_rows(results)
end

#last_insert_idObject



114
115
116
# File 'lib/tina4/drivers/sqlite_driver.rb', line 114

def last_insert_id
  @connection.last_insert_row_id
end

#placeholderObject



125
126
127
# File 'lib/tina4/drivers/sqlite_driver.rb', line 125

def placeholder
  "?"
end

#placeholders(count) ⇒ Object



129
130
131
# File 'lib/tina4/drivers/sqlite_driver.rb', line 129

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

#rollbackObject



159
160
161
162
163
# File 'lib/tina4/drivers/sqlite_driver.rb', line 159

def rollback
  @connection.execute("ROLLBACK")
rescue SQLite3::SQLException => e
  raise unless e.message.to_s.downcase.include?("no transaction is active")
end

#table_exists?(name) ⇒ Boolean

v3.13.14 (#48): a SQLite "schema" is an ATTACH alias ("extra.widget"). Query that database's own sqlite_master when the prefix is a plain identifier; otherwise treat the whole string as a bare table name.

Returns:

  • (Boolean)


168
169
170
171
172
173
# File 'lib/tina4/drivers/sqlite_driver.rb', line 168

def table_exists?(name)
  schema, tbl = split_schema(name)
  master = schema && identifier?(schema) ? "#{schema}.sqlite_master" : "sqlite_master"
  rows = execute_query("SELECT 1 FROM #{master} WHERE type='table' AND name=?", [tbl])
  !rows.empty?
end

#tablesObject



175
176
177
178
# File 'lib/tina4/drivers/sqlite_driver.rb', line 175

def tables
  rows = execute_query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
  rows.map { |r| r[:name] }
end