Class: Whodunit::Chronicles::Ledgers::SQLiteLedger

Inherits:
Whodunit::Chronicles::Ledger show all
Defined in:
lib/whodunit/chronicles/ledgers/sqlite_ledger.rb,
sig/whodunit/chronicles/ledgers/sqlite_ledger.rbs

Overview

SQLite-backed embedded durable ledger.

SQLiteLedger is the default solid local book. It can create its table, create indexes, report status, and append immutable ledger entries. The sqlite3 gem is loaded lazily only when a connection is not injected.

Constant Summary collapse

DEFAULT_TABLE =

Default SQLite table for entries.

Returns:

  • (String)
'whodunit_chronicles_entries'

Instance Attribute Summary collapse

Instance Method Summary collapse

Methods inherited from Whodunit::Chronicles::Ledger

#migrate!, #partition_for, #verify

Constructor Details

#initialize(path:, table_name: DEFAULT_TABLE, connection: nil) ⇒ SQLiteLedger

Create a SQLite-backed ledger.

Parameters:

  • path (String)

    path to the SQLite database file

  • table_name (String) (defaults to: DEFAULT_TABLE)

    table receiving entries

  • connection (Object, nil) (defaults to: nil)

    optional SQLite-compatible connection

  • path: (String)
  • table_name: (String) (defaults to: DEFAULT_TABLE)
  • connection: (Object) (defaults to: nil)


31
32
33
34
35
# File 'lib/whodunit/chronicles/ledgers/sqlite_ledger.rb', line 31

def initialize(path:, table_name: DEFAULT_TABLE, connection: nil)
  @path = path.to_s
  @table_name = table_name.to_s
  @connection = connection
end

Instance Attribute Details

#pathString (readonly)

Returns path to the SQLite database file.

Returns:

  • (String)

    path to the SQLite database file



21
22
23
# File 'lib/whodunit/chronicles/ledgers/sqlite_ledger.rb', line 21

def path
  @path
end

#table_nameString (readonly)

Returns table receiving entries.

Returns:

  • (String)

    table receiving entries



24
25
26
# File 'lib/whodunit/chronicles/ledgers/sqlite_ledger.rb', line 24

def table_name
  @table_name
end

Instance Method Details

#append(entry) ⇒ LedgerEntry

Append one ledger entry.

Parameters:

Returns:

Raises:

  • (AppendError)

    when SQLite rejects a duplicate event_id



75
76
77
78
79
80
81
82
83
84
85
86
87
# File 'lib/whodunit/chronicles/ledgers/sqlite_ledger.rb', line 75

def append(entry)
  connection.execute(<<~SQL, bind_values(entry))
    INSERT INTO #{quoted_table_name} (
      event_id, occurred_at, recorded_at, namespace, entity,
      identity, operation, actor, changes, metadata, payload
    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  SQL
  entry
rescue StandardError => e
  raise unless sqlite_constraint_error?(e)

  raise AppendError, "duplicate ledger event_id: #{entry.event_id}"
end

#bind_values(entry) ⇒ Array[untyped]

Build bind values for one entry.

Parameters:

Returns:

  • (Array[untyped])


113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
# File 'lib/whodunit/chronicles/ledgers/sqlite_ledger.rb', line 113

def bind_values(entry)
  [
    entry.event_id,
    serialize_time(entry.occurred_at),
    serialize_time(entry.recorded_at),
    entry.namespace,
    entry.entity,
    JSON.generate(entry.identity),
    entry.operation.to_s,
    JSON.generate(entry.actor),
    JSON.generate(entry.changes),
    JSON.generate(entry.),
    JSON.generate(entry.payload)
  ]
end

#connectionObject

Return the SQLite connection, creating it lazily when required.

Returns:

  • (Object)


105
106
107
108
109
110
# File 'lib/whodunit/chronicles/ledgers/sqlite_ledger.rb', line 105

def connection
  @connection ||= begin
    require 'sqlite3'
    SQLite3::Database.new(path)
  end
end

#count_entriesInteger

Count persisted entries.

Returns:

  • (Integer)


146
147
148
# File 'lib/whodunit/chronicles/ledgers/sqlite_ledger.rb', line 146

def count_entries
  connection.execute("SELECT COUNT(*) FROM #{quoted_table_name}").first.first
end

#ensure_indexes!SQLiteLedger

Create indexes useful for audit lookup and de-duplication.

Returns:



63
64
65
66
67
68
# File 'lib/whodunit/chronicles/ledgers/sqlite_ledger.rb', line 63

def ensure_indexes!
  connection.execute("CREATE UNIQUE INDEX IF NOT EXISTS #{index_name(:event_id)} ON #{quoted_table_name} (event_id)")
  connection.execute("CREATE INDEX IF NOT EXISTS #{index_name(:entity)} ON #{quoted_table_name} (namespace, entity)")
  connection.execute("CREATE INDEX IF NOT EXISTS #{index_name(:occurred_at)} ON #{quoted_table_name} (occurred_at)")
  self
end

#index_name(suffix) ⇒ String

Build a safe index name from the table and suffix.

Parameters:

  • suffix (Symbol)

Returns:

  • (String)


166
167
168
# File 'lib/whodunit/chronicles/ledgers/sqlite_ledger.rb', line 166

def index_name(suffix)
  quote_identifier("#{table_name.gsub(/\W+/, '_')}_#{suffix}_idx")
end

#prepare!SQLiteLedger

Create the entries table if needed.

Returns:



40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
# File 'lib/whodunit/chronicles/ledgers/sqlite_ledger.rb', line 40

def prepare!
  connection.execute(<<~SQL)
    CREATE TABLE IF NOT EXISTS #{quoted_table_name} (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      event_id TEXT NOT NULL,
      occurred_at TEXT NOT NULL,
      recorded_at TEXT NOT NULL,
      namespace TEXT,
      entity TEXT,
      identity TEXT,
      operation TEXT NOT NULL,
      actor TEXT,
      changes TEXT,
      metadata TEXT,
      payload TEXT NOT NULL
    )
  SQL
  self
end

#prepared?Boolean

Determine whether the entries table exists.

Returns:

  • (Boolean)


137
138
139
140
141
142
143
# File 'lib/whodunit/chronicles/ledgers/sqlite_ledger.rb', line 137

def prepared?
  rows = connection.execute(
    "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
    [table_name]
  )
  !rows.empty?
end

#quote_identifier(identifier) ⇒ String

Quote a SQLite identifier.

Parameters:

  • identifier (Object)

Returns:

  • (String)


156
157
158
# File 'lib/whodunit/chronicles/ledgers/sqlite_ledger.rb', line 156

def quote_identifier(identifier)
  %("#{identifier.to_s.gsub('"', '""')}")
end

#quoted_table_nameString

Quote the configured table name.

Returns:

  • (String)


161
162
163
# File 'lib/whodunit/chronicles/ledgers/sqlite_ledger.rb', line 161

def quoted_table_name
  @quoted_table_name ||= table_name.split('.').map { |part| quote_identifier(part) }.join('.')
end

#serialize_time(value) ⇒ String

Serialize time-like objects consistently.

Parameters:

  • value (Object)

Returns:

  • (String)


130
131
132
133
134
# File 'lib/whodunit/chronicles/ledgers/sqlite_ledger.rb', line 130

def serialize_time(value)
  return value.iso8601 if value.respond_to?(:iso8601)

  value.to_s
end

#sqlite_constraint_error?(error) ⇒ Boolean

Return whether an error is SQLite's unique constraint failure.

Parameters:

  • error (StandardError)

Returns:

  • (Boolean)


151
152
153
# File 'lib/whodunit/chronicles/ledgers/sqlite_ledger.rb', line 151

def sqlite_constraint_error?(error)
  !!(defined?(SQLite3::ConstraintException) && error.is_a?(SQLite3::ConstraintException))
end

#statusHash<Symbol, Object>

Return lightweight operational status for this ledger.

Returns:

  • (Hash<Symbol, Object>)

    ledger status



92
93
94
95
96
97
98
99
100
# File 'lib/whodunit/chronicles/ledgers/sqlite_ledger.rb', line 92

def status
  {
    adapter: 'sqlite',
    path: path,
    table_name: table_name,
    prepared: prepared?,
    entries: prepared? ? count_entries : nil
  }
end