activerecord-virgodb-adapter 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2506b4fecd647493533cf94951776ebbc4acef508a1bbf1a846001228cfc4bb2
4
- data.tar.gz: 546b407b74267d0fbb6792fcfa26041da4f078f4c241e96919a7b89c3a4525f2
3
+ metadata.gz: a674e66bbf9674502c2c5cb25ec4a1dfeca0f9201fa001a54c5a03f025b09718
4
+ data.tar.gz: 32560701fb69a497e6c539cd71e9de071423523f00d2baa1333b6328973b5da0
5
5
  SHA512:
6
- metadata.gz: 9f8c46262072bd70640666b45f9254902100f6bbde82af984655f5720131227119c768eb1d24bfc8fc7b0435d067e6438ab4b89eb835d5bf7b6664978218bf50
7
- data.tar.gz: 12a8d38269b32b6af45e9dd637a47e1b9c74daa9419dea208a92c9f089bfa95b80630039c670399a9d515d03ca2470d01e7ac07515c0e21950f269025114aac2
6
+ metadata.gz: e9b192ed7e8bc0ad7e3b18a6ecdda847c323a272f70c35b236685718b0aa0b27cca1b63195c4a6df2e403cb49e017ff455f29e8b0a1b932067fcc9cef64c63fc
7
+ data.tar.gz: caeefe677d520080a4db26d04852dbc7adfaa2667c710cc195bd4877307a7ccf452a86b7ff6700d618b67700164217e34676673763c3aad07d056f8d8a4e6693
@@ -0,0 +1,373 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record/connection_adapters/sqlite3_adapter"
4
+ require "bigdecimal"
5
+ require "json"
6
+
7
+ module ActiveRecord
8
+ module ConnectionAdapters
9
+ # ActiveRecord adapter for virgodb migrations -- lets any Rails app
10
+ # define and evolve a virgodb table's schema the same way it already
11
+ # does for SQLite (bin/rails db:migrate): real ActiveRecord::Migration
12
+ # subclasses, raw type strings, picked up by `rails db:migrate`
13
+ # alongside every other database.
14
+ #
15
+ # virgodb's manifest is already a real SQLite file, so subclassing
16
+ # SQLite3Adapter (not building from AbstractAdapter) gets
17
+ # structure_dump/structure_load -- the :sql schema format -- for free,
18
+ # zero custom dumper code. Only create_table/add_column need
19
+ # overriding: call `super` for a real, if never-written-to, SQLite
20
+ # table (SQLite's type-name grammar accepts arbitrary bare-word type
21
+ # declarations -- verified against a real connection, see
22
+ # crates/compact/src/schema.rs's doc comment for the exact grammar and
23
+ # why it has to be `"AnyLast String updated_at"`, not
24
+ # `"AnyLast(String, updated_at)"`), and additionally record the column
25
+ # in a `schema_versions` table matching exactly what
26
+ # `compact::schema_from_columns` on the Rust side expects to parse.
27
+ #
28
+ # One manifest can back several virgodb tables sharing a single
29
+ # `database.yml` connection and migrations path -- the same way a host
30
+ # app's `clickhouse:` connection might already hold all of its ClickHouse
31
+ # tables under one connection. `schema_versions` (and the Rust-side
32
+ # manifest's `run_files`/`inline_batches`/`orphaned_files`/
33
+ # `maintenance_lease` catalog tables) are scoped by `table_name`
34
+ # specifically to make this safe: each logical table's schema history
35
+ # and physical run files stay independent even though they live in the
36
+ # same SQLite file.
37
+ #
38
+ # Additive-only, matching `Manifest::register_schema_version`'s own
39
+ # rule and virgodb's immutable-Parquet philosophy: `remove_column`,
40
+ # `rename_column`, and `change_column` are all disabled outright rather
41
+ # than silently letting the real SQLite table drift out of sync with
42
+ # what `schema_versions` describes.
43
+
44
+ # virgodb's raw "Timestamp" type (crates/compact/src/schema.rs) is plain
45
+ # UNIX seconds -- an Integer, not a real SQL DATETIME/TIMESTAMP string --
46
+ # but it still matches Rails' generic %r(time)i registration (see
47
+ # VirgodbAdapter.initialize_type_map below), landing on the stock
48
+ # ActiveRecord::Type::DateTime, which only knows how to parse a real
49
+ # date/time STRING (or an already-real Time/Date/Hash). Two confirmed,
50
+ # real failure modes from that mismatch, not just cosmetic ones:
51
+ #
52
+ # 1. `Type::DateTime.new.cast("0")` -- "0" being a perfectly valid
53
+ # epoch-seconds default (e.g. a real host app declaring `DEFAULT 0`
54
+ # as a sentinel on a nullable-in-spirit timestamp column)
55
+ # -- returns nil. `Column#default` itself is unaffected (DateTime is
56
+ # `mutable?`, so `Column#initialize` keeps the raw string rather than
57
+ # deserializing it -- see ActiveModel::Type::DateTime#mutable?'s own
58
+ # comment), which is why the schema_versions capture above was never
59
+ # wrong. But `Model.column_defaults` (what a real host app's own
60
+ # schema-annotation tooling reads) DOES deserialize through the cast
61
+ # type, surfacing as a misleading `default(NULL)` in a real model's
62
+ # annotation for a column that has a perfectly real default.
63
+ # 2. `Type::DateTime.new.cast(1753000000)` (a real Integer write-time
64
+ # value) ALSO doesn't produce a real Time object -- DateTime#cast
65
+ # only recognizes String/Time/Date/Hash input, so an Integer just
66
+ # passes through unchanged. Harmless today (nothing in this
67
+ # codebase reads a Timestamp column back through a real
68
+ # ActiveRecord attribute -- see Virgodb::Registry.query, which
69
+ # returns raw parsed JSON, bypassing AR type casting entirely), but
70
+ # a real latent gap the moment anything ever does.
71
+ #
72
+ # Subclassing ActiveRecord::Type::DateTime (not building from scratch)
73
+ # keeps `.type == :datetime` -- the semantically correct label for a
74
+ # timestamp column, unlike the columns String fix above where Rails had
75
+ # no keyword at all -- and keeps every other DateTime behavior (real
76
+ # date strings, Time/Date input, timezone handling) working exactly as
77
+ # before; only the epoch-seconds-as-Integer-or-String case needed a
78
+ # fix. `deserialize` isn't overridden separately -- ActiveModel::Type
79
+ # ::Value#deserialize just calls `cast` by default (confirmed: neither
80
+ # ActiveModel::Type::DateTime nor ActiveRecord::Type::DateTime override
81
+ # it), so fixing `cast` alone is enough for both directions.
82
+ class VirgodbTimestamp < ActiveRecord::Type::DateTime
83
+ def cast(value)
84
+ return value if value.nil? || value.is_a?(::Time) || value.is_a?(::DateTime)
85
+
86
+ seconds = Float(value, exception: false)
87
+ seconds ? ::Time.at(seconds).utc : super
88
+ end
89
+
90
+ def serialize(value)
91
+ value.respond_to?(:to_time) ? value.to_time.to_i : value
92
+ end
93
+ end
94
+
95
+ class VirgodbAdapter < SQLite3Adapter
96
+ ADAPTER_NAME = "Virgodb"
97
+
98
+ # Real bug, found (not assumed) by testing: setting `auto_vacuum =
99
+ # INCREMENTAL` from `create_table` -- even from create_table's very
100
+ # FIRST call on a brand-new manifest, before any table exists -- was
101
+ # already too late. SQLite3Adapter's own `configure_connection` sets
102
+ # `journal_mode = wal` via DEFAULT_PRAGMAS (`super`, below) as part of
103
+ # establishing the connection itself, which happens before this
104
+ # adapter's `create_table` ever runs -- and switching journal_mode to
105
+ # WAL requires SQLite to actually write to the file (creating the -wal
106
+ # file, an implicit checkpoint), which "poisons" the database from
107
+ # auto_vacuum's perspective the same way any other content would.
108
+ # Confirmed directly: a manual `PRAGMA auto_vacuum = INCREMENTAL`
109
+ # issued immediately after `establish_connection`, before ANY table or
110
+ # migration ran, still silently failed. The pragma has to be set
111
+ # before `super` runs here, not merely before the first CREATE TABLE.
112
+ #
113
+ # Second real bug, found the same way: `configure_connection` runs on
114
+ # EVERY new raw connection this adapter's pool ever opens, not just
115
+ # the manifest's first one -- so an unconditional `auto_vacuum =`
116
+ # unconditionally re-issued `PRAGMA auto_vacuum='2'` on every single
117
+ # one of them, forever. Once a manifest is genuinely in INCREMENTAL
118
+ # mode (the common case for any real, already-migrated manifest),
119
+ # re-setting it to that SAME value is not the free no-op re-setting an
120
+ # already-NONE manifest is: confirmed directly with a raw SQLite3
121
+ # connection, re-issuing the SET pragma while a second connection
122
+ # holds an open `BEGIN IMMEDIATE` write transaction (exactly what
123
+ # virgodb's own maintenance thread does while compacting, see
124
+ # crates/compact/src/maintenance.rs) blocks for the full busy_timeout
125
+ # and then raises SQLite3::BusyException -- surfacing in a host app as
126
+ # an ActiveRecord::StatementTimeout on whatever request happened to
127
+ # need a brand-new pooled connection at that moment. A bare read
128
+ # (`PRAGMA auto_vacuum` with no argument) does NOT block the same way,
129
+ # confirmed the same way -- so skip the write once it's already
130
+ # reached the target value instead of paying that contention risk on
131
+ # every connection after the first.
132
+ def configure_connection
133
+ if @raw_connection.respond_to?(:auto_vacuum=) && @raw_connection.auto_vacuum != 2
134
+ @raw_connection.auto_vacuum = "incremental"
135
+ end
136
+ super
137
+ end
138
+
139
+ class << self
140
+ private
141
+
142
+ # Real bug, found (not assumed) by a host app trying to build real
143
+ # ActiveRecord models on top of virgodb tables: `Column#type` (what
144
+ # every host app actually reads -- annotation generators, `cast`,
145
+ # attribute serialization) comes from `TYPE_MAP.lookup(sql_type)`,
146
+ # and Rails' own %r(char)i / %r(int)i / %r(date)i / etc. regexes
147
+ # (registered by AbstractAdapter#initialize_type_map, SQLite3Adapter
148
+ # only adds `int`) exist to recognize real SQL DDL keywords like
149
+ # "varchar" or "datetime" -- never anything from virgodb's own raw
150
+ # type vocabulary (crates/compact/src/schema.rs), since `t.column
151
+ # name, "raw string"` bypasses Rails' normal :string/:datetime/etc
152
+ # -> DDL-keyword translation entirely. Two confirmed failure modes:
153
+ #
154
+ # 1. A bare "String" or "LowCardinality String" column matches NONE
155
+ # of Rails' base regexes (there's no keyword for it -- real
156
+ # SQLite migrations only ever produce "varchar"), so `.type`
157
+ # falls through to `Type.default_value`, an untyped
158
+ # `ActiveModel::Type::Value` whose `.type` is nil.
159
+ # 2. An `AnyLast <type> <order_by_column>` column (the two-scalar-
160
+ # column tuple-workaround pairs a real host app might use to
161
+ # emulate ClickHouse-style last-touch columns) can get a WRONG
162
+ # match instead of no match: Rails'
163
+ # %r(date)i is an unanchored substring regex, and
164
+ # "AnyLast String updated_at" contains the substring "date"
165
+ # (from "upDATEd_at") purely by accident, mis-casting a String
166
+ # column as :date. Confirmed directly: TYPE_MAP.lookup("AnyLast
167
+ # String updated_at").class == ActiveRecord::Type::Date.
168
+ #
169
+ # The fix strips virgodb's own wrapper syntax down to the bare base
170
+ # type and re-enters `m.lookup` for it, rather than trying to write
171
+ # an ever-more-specific whole-string regex per aggregate function x
172
+ # base type combination -- that would still be one accidental
173
+ # trailing-column-name substring match away from the same bug the
174
+ # moment a future order-by column happens to contain "int"/"char"/
175
+ # "time"/etc. `AnyLast` is the only aggregate function that ever
176
+ # carries a trailing order-by column (it's the one that needs to
177
+ # know which row is "last"; Any/Sum/Max/Min/UniqueArrayUnion are
178
+ # simple scalar aggregates with no ordering to track), so it's the
179
+ # only one that needs the extra trailing-token strip.
180
+ def initialize_type_map(m)
181
+ super
182
+
183
+ register_class_with_limit m, /\A(?:LowCardinality\s+)?String\z/i, Type::String
184
+ m.register_type(/\ATimestamp\z/i, VirgodbTimestamp.new)
185
+
186
+ m.register_type(/\AAnyLast\s+/i) do |sql_type|
187
+ base = sql_type.sub(/\AAnyLast\s+/i, "").sub(/\s+\S+\z/, "")
188
+ m.lookup(base)
189
+ end
190
+
191
+ m.register_type(/\A(?:Any|Sum|Max|Min|UniqueArrayUnion)\s+/i) do |sql_type|
192
+ base = sql_type.sub(/\A(?:Any|Sum|Max|Min|UniqueArrayUnion)\s+/i, "")
193
+ m.lookup(base)
194
+ end
195
+ end
196
+ end
197
+
198
+ # `type_map` (AbstractAdapter) reads `self.class::TYPE_MAP` -- a plain
199
+ # constant lookup, not a method call -- so merely overriding
200
+ # `initialize_type_map` above does nothing on its own: without a
201
+ # `TYPE_MAP` constant of ITS OWN, `VirgodbAdapter::TYPE_MAP` constant
202
+ # lookup just walks up to the already-frozen `SQLite3Adapter::TYPE_MAP`
203
+ # (built at THAT class's load time, from the un-overridden method).
204
+ # Confirmed directly: the override above alone left every virgodb
205
+ # column's `.type` unchanged. SQLite3Adapter builds its own `TYPE_MAP`
206
+ # constant the exact same way (see its `TYPE_MAP = Type::TypeMap.new
207
+ # .tap { |m| initialize_type_map(m) }` line) specifically so subclasses
208
+ # can shadow it like this.
209
+ TYPE_MAP = Type::TypeMap.new.tap { |m| initialize_type_map(m) }
210
+
211
+ def create_table(table_name, **options, &block)
212
+ result = super
213
+ record_schema_version!(table_name)
214
+ result
215
+ end
216
+
217
+ def add_column(table_name, column_name, type, **options)
218
+ result = super
219
+ record_added_column!(table_name, column_name, type, options)
220
+ result
221
+ end
222
+
223
+ def remove_column(table_name, column_name, type = nil, **options)
224
+ raise NotImplementedError, "virgodb schemas are additive-only -- columns can be added, never removed (see schema_versions)"
225
+ end
226
+
227
+ def rename_column(table_name, column_name, new_column_name)
228
+ raise NotImplementedError, "virgodb schemas are additive-only -- columns can't be renamed once created"
229
+ end
230
+
231
+ def change_column(table_name, column_name, type, **options)
232
+ raise NotImplementedError, "virgodb schemas are additive-only -- an existing column's type can't change"
233
+ end
234
+
235
+ private
236
+
237
+ # Reads the table back via ActiveRecord's own column introspection --
238
+ # `sql_type` round-trips the raw virgodb type string verbatim, proven
239
+ # against a real SQLite connection immediately after a fresh CREATE
240
+ # TABLE -- and writes a new `schema_versions` row: the full current
241
+ # column list, versioned one higher than whatever was there before FOR
242
+ # THIS table_name. Mirrors Manifest::register_schema_version's shape
243
+ # exactly (same table_name scoping, same column names, same JSON shape
244
+ # for `columns`) so the Rust side can read whatever Ruby wrote with
245
+ # zero translation. Scoping `MAX(version)` by `table_name` is what makes
246
+ # it safe for several logical tables to share one manifest/connection:
247
+ # without it, the second table's first migration would start at
248
+ # version 2 (or worse, silently share version numbers with an
249
+ # unrelated table). Rails' OWN bookkeeping tables (`schema_migrations`,
250
+ # `ar_internal_metadata`) go through this SAME overridden `create_table`
251
+ # the first time either is lazily created -- there's nothing
252
+ # virgodb-specific about them, and recording a schema_versions row for
253
+ # them is pure noise (found while dumping a real structure.sql and
254
+ # seeing them show up as rows next to the real logical tables). Guarded
255
+ # here, not by checking `table_name` against `TABLES` in some caller --
256
+ # this adapter has no such registry, `create_table`'s own argument is
257
+ # the one place that reliably sees every table, virgodb or not.
258
+ #
259
+ # ONLY safe for `create_table` (a brand-new table with no prior
260
+ # history). NOT reused by `add_column` -- see `record_added_column!`'s
261
+ # own doc comment for the real bug this avoids: `columns(table_name)`
262
+ # stops reliably returning the ORIGINAL declared type text for
263
+ # pre-existing columns once the table has been through certain ALTER
264
+ # operations.
265
+ RAILS_INTERNAL_TABLES = %w[schema_migrations ar_internal_metadata].freeze
266
+
267
+ def record_schema_version!(table_name)
268
+ return if RAILS_INTERNAL_TABLES.include?(table_name.to_s)
269
+
270
+ ensure_schema_versions_table!
271
+
272
+ cols = columns(table_name).map do |c|
273
+ { "name" => c.name, "virgodb_type" => c.sql_type, "nullable" => c.null, "default" => stringify_default(c.default) }
274
+ end
275
+
276
+ insert_schema_version!(table_name, cols)
277
+ end
278
+
279
+ # A migration's `default:` (e.g. `t.column :clid, "String", null:
280
+ # false, default: ""`) round-trips through `columns(table_name).first
281
+ # .default` as a real, correctly-typed Ruby value now (String/Integer/
282
+ # BigDecimal) -- only possible since the TYPE_MAP fix above; before it,
283
+ # every virgodb String column's `.type` was nil/wrong and `.default`
284
+ # came back nil regardless of what the migration declared. schema_versions
285
+ # itself only stores plain JSON strings (matching `virgodb_type`'s own
286
+ # convention of storing raw text the Rust side parses, not a typed
287
+ # value) -- the Rust write path (crates/virgodb_ruby's row_convert.rs)
288
+ # re-parses this string against the column's real Arrow type at write
289
+ # time. `BigDecimal#to_s` explicitly forced to fixed-point ("F") rather
290
+ # than its default format, which switches to scientific notation for
291
+ # some magnitudes -- avoids writing e.g. "0.12345e3" into
292
+ # schema_versions where a human reading a real dump would expect
293
+ # "123.45" (Rust's f64 parser would actually accept either form, this
294
+ # is purely for a human reading a real structure.sql/schema_versions
295
+ # dump, not a correctness requirement).
296
+ def stringify_default(value)
297
+ return nil if value.nil?
298
+ value.is_a?(BigDecimal) ? value.to_s("F") : value.to_s
299
+ end
300
+
301
+ # `add_column`'s schema_versions update -- deliberately does NOT
302
+ # re-introspect the whole table via `columns(table_name)` the way
303
+ # `record_schema_version!` does. Real bug, found (not assumed) by
304
+ # testing: `super`'s real `ALTER TABLE ADD COLUMN` -- specifically for
305
+ # a `null: false` column, which SQLite's native ALTER can't add without
306
+ # a default -- makes Rails' SQLite3Adapter fall back to its "rebuild
307
+ # the table" strategy (copy to a temp table, drop, rename). Confirmed
308
+ # directly: introspecting the table immediately afterward, even via a
309
+ # completely fresh connection/process (so not a Rails-process-level
310
+ # schema cache artifact), returns Rails' own GENERIC type names for
311
+ # every PRE-EXISTING column ("INTEGER"/"datetime"/"" instead of
312
+ # "Int64"/"Timestamp"/"LowCardinality String") -- the rebuild
313
+ # reconstructs the physical table's DDL from Rails' normalized column
314
+ # metadata, silently discarding the original literal type text for
315
+ # every column it didn't just add. Re-deriving the full column list
316
+ # via introspection after ANY add_column would therefore corrupt every
317
+ # already-correct entry in `schema_versions`.
318
+ #
319
+ # The fix: never re-derive what's already known to be correct. Read
320
+ # the CURRENT (highest-version) column list straight out of
321
+ # `schema_versions` itself (the one thing guaranteed not to have been
322
+ # touched by the ALTER), and append only the ONE new column -- whose
323
+ # name/type/nullability are already fully known from `add_column`'s own
324
+ # arguments, no introspection needed for it either.
325
+ def record_added_column!(table_name, column_name, type, options)
326
+ return if RAILS_INTERNAL_TABLES.include?(table_name.to_s)
327
+
328
+ ensure_schema_versions_table!
329
+
330
+ current_json = select_value("SELECT columns FROM schema_versions WHERE table_name = #{quote(table_name)} ORDER BY version DESC LIMIT 1")
331
+ current_cols = current_json ? JSON.parse(current_json) : []
332
+
333
+ new_col = { "name" => column_name.to_s, "virgodb_type" => type.to_s, "nullable" => options[:null] != false, "default" => stringify_default(options[:default]) }
334
+ insert_schema_version!(table_name, current_cols + [ new_col ])
335
+ end
336
+
337
+ def insert_schema_version!(table_name, cols)
338
+ next_version = (select_value("SELECT MAX(version) FROM schema_versions WHERE table_name = #{quote(table_name)}") || 0).to_i + 1
339
+
340
+ execute(<<~SQL)
341
+ INSERT INTO schema_versions (table_name, version, columns, applied_at)
342
+ VALUES (#{quote(table_name)}, #{next_version}, #{quote(cols.to_json)}, #{Time.now.to_i})
343
+ SQL
344
+ end
345
+
346
+ # Same DDL as Manifest::open()'s bootstrap on the Rust side (same
347
+ # table/column names, including the `table_name` scoping column) --
348
+ # IF NOT EXISTS makes this safe to call whether virgodb's own Rust
349
+ # code or this Ruby adapter created the manifest file first. Plain
350
+ # INTEGER PRIMARY KEY, not AUTOINCREMENT -- see the matching comment
351
+ # in manifest/src/lib.rs for why (breaks a real structure_dump/
352
+ # structure_load round trip the moment any host app's
353
+ # ActiveRecord::SchemaDumper.ignore_tables is non-empty).
354
+ def ensure_schema_versions_table!
355
+ execute(<<~SQL)
356
+ CREATE TABLE IF NOT EXISTS schema_versions (
357
+ id INTEGER PRIMARY KEY,
358
+ table_name TEXT NOT NULL,
359
+ version INTEGER NOT NULL,
360
+ columns TEXT NOT NULL,
361
+ applied_at INTEGER NOT NULL
362
+ )
363
+ SQL
364
+ end
365
+ end
366
+ end
367
+ end
368
+
369
+ ActiveRecord::ConnectionAdapters.register(
370
+ "virgodb",
371
+ "ActiveRecord::ConnectionAdapters::VirgodbAdapter",
372
+ "activerecord-virgodb-adapter"
373
+ )
@@ -0,0 +1,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record/tasks/sqlite_database_tasks"
4
+ require "json"
5
+ require "sqlite3"
6
+
7
+ module ActiveRecord
8
+ module Tasks
9
+ # `ActiveRecord::Tasks::SQLiteDatabaseTasks#structure_dump` just shells
10
+ # out to the real `sqlite3` CLI (`.schema --nosys`, or a raw `SELECT sql
11
+ # FROM sqlite_master` when a host app has any
12
+ # `ActiveRecord::SchemaDumper.ignore_tables` configured -- e.g. excluding
13
+ # tables owned by a separate replication/backup mechanism) against the
14
+ # configured database file --
15
+ # DDL only. `ActiveRecord::Tasks::DatabaseTasks.dump_schema` then
16
+ # special-cases exactly one table on top of that: if `schema_migrations`
17
+ # exists, it appends that table's real row data (`connection
18
+ # .dump_schema_versions`) to the same dump file, because Rails' own
19
+ # migration bookkeeping needs those rows to survive a `db:schema:load`/
20
+ # `structure_load`, not just the table shape.
21
+ #
22
+ # `schema_versions` (this adapter's own bookkeeping table, plural,
23
+ # distinct from Rails' singular `schema_migrations`) needs exactly the
24
+ # same treatment and doesn't get it for free: it holds real data (the
25
+ # versioned column list `compact::schema_from_columns` on the Rust side
26
+ # depends on), not just structure, and Rails has no way to know that a
27
+ # table it didn't create needs the same row-preserving special case.
28
+ # Confirmed empirically: without this, a fresh database prepared via
29
+ # `structure_load` (which Rails does automatically whenever a `db:migrate`
30
+ # invocation for a later environment/connection finds an existing,
31
+ # version-matching dump already on disk from an earlier one -- not
32
+ # something this adapter can opt out of) ends up with the real
33
+ # host-app table but an EMPTY `schema_versions`, so
34
+ # `Manifest::current_schema_version()` on the Rust side returns `None`
35
+ # even though the SQLite table itself is correct.
36
+ class VirgodbDatabaseTasks < SQLiteDatabaseTasks
37
+ class << self
38
+ # Optional host-app hook: `->(sql_table_name) { {sort_by:, key_column:,
39
+ # partition_column:} or nil }`. Purely a documentation comment written
40
+ # above CREATE TABLE at dump time -- nothing on the Rust or Ruby side
41
+ # ever reads it back. This adapter gem stays host-app-agnostic (see
42
+ # the class doc comment above: "lets any Rails app..."), so it can't
43
+ # hardcode any one host app's sort_by/key_column config (that's
44
+ # app/services/virgodb/registry.rb's job, which already has this
45
+ # exact data for real -- passed to Virgodb.start_maintenance on every
46
+ # boot). A host app that wants it surfaced sets this once, e.g. in an
47
+ # initializer; left nil, structure_dump behaves exactly as before.
48
+ attr_accessor :table_layout_provider
49
+
50
+ # Optional host-app hook: `->(sql_table_name) { path_or_nil }`.
51
+ # Convention over configuration, not opt-in like table_layout_provider
52
+ # above: this gem is Rails-specific (see the class doc comment
53
+ # above -- "Lets any Rails app..."), so it bakes in the same
54
+ # convention Rails' own generators use for SQLite's storage location
55
+ # (config/database.yml's generated `storage/#{Rails.env}.sqlite3`)
56
+ # -- `storage/#{Rails.env}_virgodb_#{sql_table_name}_data`, right
57
+ # next to the manifest. A host app gets working data-dir cleanup
58
+ # with zero setup. One with its own directory convention calls
59
+ # `data_dir_provider =` to override; `= nil` opts every table out
60
+ # entirely, same as plain SQLiteDatabaseTasks -- distinguished from
61
+ # "never touched this accessor" via `defined?` below so an explicit
62
+ # opt-out can't be silently overridden by the default.
63
+ def data_dir_provider
64
+ defined?(@data_dir_provider) ? @data_dir_provider : DEFAULT_DATA_DIR_PROVIDER
65
+ end
66
+ attr_writer :data_dir_provider
67
+ end
68
+
69
+ DEFAULT_DATA_DIR_PROVIDER = lambda do |sql_table_name|
70
+ Rails.root.join("storage", "#{Rails.env}_virgodb_#{sql_table_name}_data").to_s
71
+ end
72
+
73
+ def structure_dump(filename, extra_flags)
74
+ super
75
+ reformat_create_table_statements!(filename)
76
+ annotate_physical_layout!(filename)
77
+ dump_schema_versions!(filename)
78
+ end
79
+
80
+ # Overridden (not just inherited from SQLiteDatabaseTasks) so dropping
81
+ # the manifest also cleans up whatever data_dir_provider says each of
82
+ # its tables' physical files live under. db:drop only ever knew about
83
+ # the ONE file database.yml declares -- left alone, it silently
84
+ # orphans every Parquet run file and Tier 1 inline-batch pointer file
85
+ # forever, since there's no catalog left afterward to even reference
86
+ # them (compact::vacuum, which cleans those up via the manifest's own
87
+ # orphaned_files table, can't reach them either once the manifest's
88
+ # gone).
89
+ #
90
+ # Reads which tables exist via a fresh, read-only, raw SQLite3
91
+ # connection (not `connection`/`ActiveRecord::Base.lease_connection`
92
+ # -- unlike structure_dump, drop doesn't establish one for this
93
+ # specific db_config, and this shouldn't depend on whatever happens
94
+ # to be currently leased) BEFORE calling super, since super deletes
95
+ # the manifest file -- schema_versions goes with it.
96
+ def drop
97
+ provider = self.class.data_dir_provider
98
+ table_names = provider ? table_names_for_data_dir_cleanup : []
99
+ super
100
+ table_names.each do |table_name|
101
+ dir = provider.call(table_name)
102
+ FileUtils.rm_rf(dir) if dir
103
+ end
104
+ end
105
+
106
+ private
107
+
108
+ def table_names_for_data_dir_cleanup
109
+ db_path = db_config.database
110
+ path = File.absolute_path?(db_path) ? db_path : File.join(root, db_path)
111
+ return [] unless File.exist?(path)
112
+
113
+ raw = SQLite3::Database.new(path, readonly: true)
114
+ raw.busy_timeout = 5_000
115
+ raw.execute("SELECT DISTINCT table_name FROM schema_versions").flatten
116
+ rescue SQLite3::SQLException
117
+ # File.exist? isn't proof schema_versions exists -- SQLite
118
+ # auto-creates an empty file the instant something connects to a
119
+ # path that doesn't exist yet, and Rails' parallel-test setup
120
+ # (ActiveRecord::TestDatabases.create_and_load_schema, one file per
121
+ # worker) does exactly that via schema_up_to_date? moments before
122
+ # calling drop. Nothing to clean up in that case.
123
+ []
124
+ ensure
125
+ raw&.close
126
+ end
127
+
128
+ # Only the LATEST schema_versions row per table_name is dumped.
129
+ # `Manifest::current_schema_version` (manifest/src/lib.rs) only ever
130
+ # reads `ORDER BY version DESC LIMIT 1` -- confirmed directly, no Rust
131
+ # or Ruby code anywhere reads an older version row, so keeping full
132
+ # history here isn't a correctness requirement. It also isn't a
133
+ # readability win: each version's `columns` re-lists every column from
134
+ # scratch, so N migrations means N near-duplicate JSON blobs, longer
135
+ # every time. The version count/dates are still worth one line for
136
+ # orientation -- the full column list per version isn't; the
137
+ # migration files themselves (db/migrate_virgodb_*/*.rb, timestamped,
138
+ # under git blame) are already the authoritative "what changed when"
139
+ # record. `register_schema_version`'s additive-only check is
140
+ # unaffected: it only ever compares a new migration's proposed columns
141
+ # against the CURRENT version, which is exactly what's still dumped.
142
+ def dump_schema_versions!(filename)
143
+ return unless connection.data_source_exists?("schema_versions")
144
+
145
+ rows = connection.select_all("SELECT id, table_name, version, columns, applied_at FROM schema_versions ORDER BY table_name, version")
146
+ return if rows.to_a.empty?
147
+
148
+ File.open(filename, "a") do |f|
149
+ f.puts
150
+ rows.to_a.group_by { |r| r["table_name"] }.each do |table_name, versions|
151
+ latest = versions.max_by { |r| r["version"] }
152
+ history = versions.map { |r| "v#{r['version']} (#{Time.at(r['applied_at'].to_i).utc.strftime('%Y-%m-%d')})" }.join(", ")
153
+
154
+ f.puts "-- #{table_name} schema history: #{history} -- only the current version is dumped below; see db/migrate_virgodb_*/ for the rest"
155
+ pretty_columns = JSON.pretty_generate(JSON.parse(latest["columns"]))
156
+ f.puts(
157
+ "INSERT INTO \"schema_versions\" (id, table_name, version, columns, applied_at) VALUES (\n" \
158
+ " #{latest['id']}, #{connection.quote(table_name)}, #{latest['version']},\n" \
159
+ " #{connection.quote(pretty_columns)},\n" \
160
+ " #{latest['applied_at']}\n" \
161
+ ");"
162
+ )
163
+ f.puts
164
+ end
165
+ end
166
+ end
167
+
168
+ # Writes `table_layout_provider`'s sort_by/key_column/partition_column
169
+ # as a comment directly above each table's (already reformatted,
170
+ # one-column-per-line) CREATE TABLE -- the closest a SQLite-flavored
171
+ # dump can get to ClickHouse's own `ORDER BY`/`PARTITION BY` clauses
172
+ # being right there in the DDL. No-op (and no dump format change at
173
+ # all) when no provider is registered.
174
+ def annotate_physical_layout!(filename)
175
+ provider = self.class.table_layout_provider
176
+ return unless provider
177
+
178
+ content = File.read(filename)
179
+ content.gsub!(/^CREATE TABLE (IF NOT EXISTS )?"?(\w+)"? \(/) do
180
+ statement_start = Regexp.last_match(0)
181
+ table_name = Regexp.last_match(2)
182
+ layout = provider.call(table_name)
183
+ next statement_start unless layout
184
+
185
+ comment = [
186
+ "-- Physical layout (app/services/virgodb/registry.rb):",
187
+ "-- sort_by: #{layout[:sort_by].join(', ')}",
188
+ "-- key_column: #{layout[:key_column]} (run file min/max pruning bounds)",
189
+ "-- partition_column: #{layout[:partition_column]}"
190
+ ].join("\n")
191
+
192
+ "#{comment}\n#{statement_start}"
193
+ end
194
+ File.write(filename, content)
195
+ end
196
+
197
+ # `SQLite3Adapter#structure_dump`'s raw `SELECT sql FROM sqlite_master`
198
+ # path (used whenever `ActiveRecord::SchemaDumper.ignore_tables` is
199
+ # non-empty, e.g. excluding tables owned by a separate replication/
200
+ # backup mechanism) reproduces each
201
+ # CREATE TABLE exactly as ActiveRecord originally generated it --
202
+ # one long line, every column crammed together. Fine for SQLite to
203
+ # load back (structure_load doesn't care about whitespace), useless to
204
+ # a human reading the dump next to `db/clickhouse_structure.sql`
205
+ # (ClickHouse's own `SHOW CREATE TABLE` is naturally one-column-per-line,
206
+ # for comparison). Purely cosmetic, safe to do unconditionally: only
207
+ # rewrites the exact same statement with different whitespace, no DDL
208
+ # semantics change. Skips statements that already span multiple lines
209
+ # (e.g. this adapter's own hand-written `schema_versions` DDL, created
210
+ # via a real newline-containing heredoc) -- nothing to reformat there.
211
+ def reformat_create_table_statements!(filename)
212
+ content = File.read(filename)
213
+ content.gsub!(/^CREATE TABLE (IF NOT EXISTS )?("[\w]+"|\w+) \((.*)\);$/) do
214
+ if_not_exists = Regexp.last_match(1)
215
+ table_name = Regexp.last_match(2)
216
+ columns = split_top_level_commas(Regexp.last_match(3))
217
+ next Regexp.last_match(0) if columns.size <= 1
218
+
219
+ "CREATE TABLE #{if_not_exists}#{table_name} (\n" + columns.map { |c| " #{c.strip}" }.join(",\n") + "\n);"
220
+ end
221
+ File.write(filename, content)
222
+ end
223
+
224
+ # Splits a CREATE TABLE column list on commas that are NOT nested
225
+ # inside a type's own parens (e.g. `Decimal(18,2)`) -- a plain
226
+ # `String#split(",")` would wrongly cut `Decimal(18` and `2)` apart.
227
+ def split_top_level_commas(str)
228
+ parts = []
229
+ depth = 0
230
+ current = +""
231
+ str.each_char do |ch|
232
+ case ch
233
+ when "(" then depth += 1; current << ch
234
+ when ")" then depth -= 1; current << ch
235
+ when ","
236
+ depth.zero? ? (parts << current; current = +"") : current << ch
237
+ else
238
+ current << ch
239
+ end
240
+ end
241
+ parts << current unless current.strip.empty?
242
+ parts
243
+ end
244
+ end
245
+ end
246
+ end
247
+
248
+ # Registering our own subclass (not plain SQLiteDatabaseTasks) so
249
+ # schema_versions' row data survives the dump/load round trip -- see the
250
+ # class doc comment above for why a real Rails app can't avoid hitting this.
251
+ ActiveRecord::Tasks::DatabaseTasks.register_task(/virgodb/, "ActiveRecord::Tasks::VirgodbDatabaseTasks")
@@ -1,392 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Lives at lib/activerecord-virgodb-adapter.rb (matching the gem name
4
- # exactly), not the more conventional-looking
5
- # lib/active_record/connection_adapters/virgodb_adapter.rb, so Bundler's
6
- # default require (`require gem_name`, no `require:` option needed in the
7
- # Gemfile) actually finds it. Rails' own ActiveRecord::ConnectionAdapters
8
- # .resolve treats the path passed to .register below as an opaque string,
9
- # not a real convention -- it's only ever exercised as a fallback if this
10
- # file somehow loads without Bundler's default require path having run
11
- # first, so it just points at wherever this file actually lives.
12
- require "active_record/connection_adapters/sqlite3_adapter"
13
- require "json"
14
-
15
- module ActiveRecord
16
- module ConnectionAdapters
17
- # ActiveRecord adapter for virgodb migrations -- lets any Rails app
18
- # define and evolve a virgodb table's schema the same way it already
19
- # does for SQLite (bin/rails db:migrate) and ClickHouse (the
20
- # clickhouse-activerecord gem): real ActiveRecord::Migration subclasses,
21
- # raw type strings, picked up by `rails db:migrate` alongside every
22
- # other database.
23
- #
24
- # virgodb's manifest is already a real SQLite file, so subclassing
25
- # SQLite3Adapter (not building from AbstractAdapter) gets
26
- # structure_dump/structure_load -- the :sql schema format -- for free,
27
- # zero custom dumper code. Only create_table/add_column need
28
- # overriding: call `super` for a real, if never-written-to, SQLite
29
- # table (SQLite's type-name grammar accepts arbitrary bare-word type
30
- # declarations -- verified against a real connection, see
31
- # crates/compact/src/schema.rs's doc comment for the exact grammar and
32
- # why it has to be `"AnyLast String updated_at"`, not
33
- # `"AnyLast(String, updated_at)"`), and additionally record the column
34
- # in a `schema_versions` table matching exactly what
35
- # `compact::schema_from_columns` on the Rust side expects to parse.
36
- #
37
- # One manifest can back several virgodb tables sharing a single
38
- # `database.yml` connection and migrations path -- the same way a host
39
- # app's `clickhouse:` connection might already hold all of its ClickHouse
40
- # tables under one connection. `schema_versions` (and the Rust-side
41
- # manifest's `run_files`/`inline_batches`/`orphaned_files`/
42
- # `maintenance_lease` catalog tables) are scoped by `table_name`
43
- # specifically to make this safe: each logical table's schema history
44
- # and physical run files stay independent even though they live in the
45
- # same SQLite file.
46
- #
47
- # Additive-only, matching `Manifest::register_schema_version`'s own
48
- # rule and virgodb's immutable-Parquet philosophy: `remove_column`,
49
- # `rename_column`, and `change_column` are all disabled outright rather
50
- # than silently letting the real SQLite table drift out of sync with
51
- # what `schema_versions` describes.
52
- class VirgodbAdapter < SQLite3Adapter
53
- ADAPTER_NAME = "Virgodb"
54
-
55
- # Real bug, found (not assumed) by testing: setting `auto_vacuum =
56
- # INCREMENTAL` from `create_table` -- even from create_table's very
57
- # FIRST call on a brand-new manifest, before any table exists -- was
58
- # already too late. SQLite3Adapter's own `configure_connection` sets
59
- # `journal_mode = wal` via DEFAULT_PRAGMAS (`super`, below) as part of
60
- # establishing the connection itself, which happens before this
61
- # adapter's `create_table` ever runs -- and switching journal_mode to
62
- # WAL requires SQLite to actually write to the file (creating the -wal
63
- # file, an implicit checkpoint), which "poisons" the database from
64
- # auto_vacuum's perspective the same way any other content would.
65
- # Confirmed directly: a manual `PRAGMA auto_vacuum = INCREMENTAL`
66
- # issued immediately after `establish_connection`, before ANY table or
67
- # migration ran, still silently failed. The pragma has to be set
68
- # before `super` runs here, not merely before the first CREATE TABLE.
69
- def configure_connection
70
- @raw_connection.auto_vacuum = "incremental" if @raw_connection.respond_to?(:auto_vacuum=)
71
- super
72
- end
73
-
74
- def create_table(table_name, **options, &block)
75
- result = super
76
- record_schema_version!(table_name)
77
- result
78
- end
79
-
80
- def add_column(table_name, column_name, type, **options)
81
- result = super
82
- record_added_column!(table_name, column_name, type, options)
83
- result
84
- end
85
-
86
- def remove_column(table_name, column_name, type = nil, **options)
87
- raise NotImplementedError, "virgodb schemas are additive-only -- columns can be added, never removed (see schema_versions)"
88
- end
89
-
90
- def rename_column(table_name, column_name, new_column_name)
91
- raise NotImplementedError, "virgodb schemas are additive-only -- columns can't be renamed once created"
92
- end
93
-
94
- def change_column(table_name, column_name, type, **options)
95
- raise NotImplementedError, "virgodb schemas are additive-only -- an existing column's type can't change"
96
- end
97
-
98
- private
99
-
100
- # Reads the table back via ActiveRecord's own column introspection --
101
- # `sql_type` round-trips the raw virgodb type string verbatim, proven
102
- # against a real SQLite connection immediately after a fresh CREATE
103
- # TABLE -- and writes a new `schema_versions` row: the full current
104
- # column list, versioned one higher than whatever was there before FOR
105
- # THIS table_name. Mirrors Manifest::register_schema_version's shape
106
- # exactly (same table_name scoping, same column names, same JSON shape
107
- # for `columns`) so the Rust side can read whatever Ruby wrote with
108
- # zero translation. Scoping `MAX(version)` by `table_name` is what makes
109
- # it safe for several logical tables to share one manifest/connection:
110
- # without it, the second table's first migration would start at
111
- # version 2 (or worse, silently share version numbers with an
112
- # unrelated table). Rails' OWN bookkeeping tables (`schema_migrations`,
113
- # `ar_internal_metadata`) go through this SAME overridden `create_table`
114
- # the first time either is lazily created -- there's nothing
115
- # virgodb-specific about them, and recording a schema_versions row for
116
- # them is pure noise (found while dumping a real structure.sql and
117
- # seeing them show up as rows next to the real logical tables). Guarded
118
- # here, not by checking `table_name` against `TABLES` in some caller --
119
- # this adapter has no such registry, `create_table`'s own argument is
120
- # the one place that reliably sees every table, virgodb or not.
121
- #
122
- # ONLY safe for `create_table` (a brand-new table with no prior
123
- # history). NOT reused by `add_column` -- see `record_added_column!`'s
124
- # own doc comment for the real bug this avoids: `columns(table_name)`
125
- # stops reliably returning the ORIGINAL declared type text for
126
- # pre-existing columns once the table has been through certain ALTER
127
- # operations.
128
- RAILS_INTERNAL_TABLES = %w[schema_migrations ar_internal_metadata].freeze
129
-
130
- def record_schema_version!(table_name)
131
- return if RAILS_INTERNAL_TABLES.include?(table_name.to_s)
132
-
133
- ensure_schema_versions_table!
134
-
135
- cols = columns(table_name).map do |c|
136
- { "name" => c.name, "virgodb_type" => c.sql_type, "nullable" => c.null }
137
- end
138
-
139
- insert_schema_version!(table_name, cols)
140
- end
141
-
142
- # `add_column`'s schema_versions update -- deliberately does NOT
143
- # re-introspect the whole table via `columns(table_name)` the way
144
- # `record_schema_version!` does. Real bug, found (not assumed) by
145
- # testing: `super`'s real `ALTER TABLE ADD COLUMN` -- specifically for
146
- # a `null: false` column, which SQLite's native ALTER can't add without
147
- # a default -- makes Rails' SQLite3Adapter fall back to its "rebuild
148
- # the table" strategy (copy to a temp table, drop, rename). Confirmed
149
- # directly: introspecting the table immediately afterward, even via a
150
- # completely fresh connection/process (so not a Rails-process-level
151
- # schema cache artifact), returns Rails' own GENERIC type names for
152
- # every PRE-EXISTING column ("INTEGER"/"datetime"/"" instead of
153
- # "Int64"/"Timestamp"/"LowCardinality String") -- the rebuild
154
- # reconstructs the physical table's DDL from Rails' normalized column
155
- # metadata, silently discarding the original literal type text for
156
- # every column it didn't just add. Re-deriving the full column list
157
- # via introspection after ANY add_column would therefore corrupt every
158
- # already-correct entry in `schema_versions`.
159
- #
160
- # The fix: never re-derive what's already known to be correct. Read
161
- # the CURRENT (highest-version) column list straight out of
162
- # `schema_versions` itself (the one thing guaranteed not to have been
163
- # touched by the ALTER), and append only the ONE new column -- whose
164
- # name/type/nullability are already fully known from `add_column`'s own
165
- # arguments, no introspection needed for it either.
166
- def record_added_column!(table_name, column_name, type, options)
167
- return if RAILS_INTERNAL_TABLES.include?(table_name.to_s)
168
-
169
- ensure_schema_versions_table!
170
-
171
- current_json = select_value("SELECT columns FROM schema_versions WHERE table_name = #{quote(table_name)} ORDER BY version DESC LIMIT 1")
172
- current_cols = current_json ? JSON.parse(current_json) : []
173
-
174
- new_col = { "name" => column_name.to_s, "virgodb_type" => type.to_s, "nullable" => options[:null] != false }
175
- insert_schema_version!(table_name, current_cols + [ new_col ])
176
- end
177
-
178
- def insert_schema_version!(table_name, cols)
179
- next_version = (select_value("SELECT MAX(version) FROM schema_versions WHERE table_name = #{quote(table_name)}") || 0).to_i + 1
180
-
181
- execute(<<~SQL)
182
- INSERT INTO schema_versions (table_name, version, columns, applied_at)
183
- VALUES (#{quote(table_name)}, #{next_version}, #{quote(cols.to_json)}, #{Time.now.to_i})
184
- SQL
185
- end
186
-
187
- # Same DDL as Manifest::open()'s bootstrap on the Rust side (same
188
- # table/column names, including the `table_name` scoping column) --
189
- # IF NOT EXISTS makes this safe to call whether virgodb's own Rust
190
- # code or this Ruby adapter created the manifest file first. Plain
191
- # INTEGER PRIMARY KEY, not AUTOINCREMENT -- see the matching comment
192
- # in manifest/src/lib.rs for why (breaks a real structure_dump/
193
- # structure_load round trip the moment any host app's
194
- # ActiveRecord::SchemaDumper.ignore_tables is non-empty).
195
- def ensure_schema_versions_table!
196
- execute(<<~SQL)
197
- CREATE TABLE IF NOT EXISTS schema_versions (
198
- id INTEGER PRIMARY KEY,
199
- table_name TEXT NOT NULL,
200
- version INTEGER NOT NULL,
201
- columns TEXT NOT NULL,
202
- applied_at INTEGER NOT NULL
203
- )
204
- SQL
205
- end
206
- end
207
- end
208
- end
209
-
210
- ActiveRecord::ConnectionAdapters.register(
211
- "virgodb",
212
- "ActiveRecord::ConnectionAdapters::VirgodbAdapter",
213
- "activerecord-virgodb-adapter"
214
- )
215
-
216
- module ActiveRecord
217
- module Tasks
218
- # `ActiveRecord::Tasks::SQLiteDatabaseTasks#structure_dump` just shells
219
- # out to the real `sqlite3` CLI (`.schema --nosys`, or a raw `SELECT sql
220
- # FROM sqlite_master` when a host app has any
221
- # `ActiveRecord::SchemaDumper.ignore_tables` configured -- e.g. excluding
222
- # tables owned by a separate replication/backup mechanism) against the
223
- # configured database file --
224
- # DDL only. `ActiveRecord::Tasks::DatabaseTasks.dump_schema` then
225
- # special-cases exactly one table on top of that: if `schema_migrations`
226
- # exists, it appends that table's real row data (`connection
227
- # .dump_schema_versions`) to the same dump file, because Rails' own
228
- # migration bookkeeping needs those rows to survive a `db:schema:load`/
229
- # `structure_load`, not just the table shape.
230
- #
231
- # `schema_versions` (this adapter's own bookkeeping table, plural,
232
- # distinct from Rails' singular `schema_migrations`) needs exactly the
233
- # same treatment and doesn't get it for free: it holds real data (the
234
- # versioned column list `compact::schema_from_columns` on the Rust side
235
- # depends on), not just structure, and Rails has no way to know that a
236
- # table it didn't create needs the same row-preserving special case.
237
- # Confirmed empirically: without this, a fresh database prepared via
238
- # `structure_load` (which Rails does automatically whenever a `db:migrate`
239
- # invocation for a later environment/connection finds an existing,
240
- # version-matching dump already on disk from an earlier one -- not
241
- # something this adapter can opt out of) ends up with the real
242
- # host-app table but an EMPTY `schema_versions`, so
243
- # `Manifest::current_schema_version()` on the Rust side returns `None`
244
- # even though the SQLite table itself is correct.
245
- class VirgodbDatabaseTasks < SQLiteDatabaseTasks
246
- class << self
247
- # Optional host-app hook: `->(sql_table_name) { {sort_by:, key_column:,
248
- # partition_column:} or nil }`. Purely a documentation comment written
249
- # above CREATE TABLE at dump time -- nothing on the Rust or Ruby side
250
- # ever reads it back. This adapter gem stays host-app-agnostic (see
251
- # the class doc comment above: "lets any Rails app..."), so it can't
252
- # hardcode any one host app's sort_by/key_column config (that's
253
- # app/services/virgodb/registry.rb's job, which already has this
254
- # exact data for real -- passed to Virgodb.start_maintenance on every
255
- # boot). A host app that wants it surfaced sets this once, e.g. in an
256
- # initializer; left nil, structure_dump behaves exactly as before.
257
- attr_accessor :table_layout_provider
258
- end
259
-
260
- def structure_dump(filename, extra_flags)
261
- super
262
- reformat_create_table_statements!(filename)
263
- annotate_physical_layout!(filename)
264
- dump_schema_versions!(filename)
265
- end
266
-
267
- private
268
-
269
- # Only the LATEST schema_versions row per table_name is dumped.
270
- # `Manifest::current_schema_version` (manifest/src/lib.rs) only ever
271
- # reads `ORDER BY version DESC LIMIT 1` -- confirmed directly, no Rust
272
- # or Ruby code anywhere reads an older version row, so keeping full
273
- # history here isn't a correctness requirement. It also isn't a
274
- # readability win: each version's `columns` re-lists every column from
275
- # scratch, so N migrations means N near-duplicate JSON blobs, longer
276
- # every time. The version count/dates are still worth one line for
277
- # orientation -- the full column list per version isn't; the
278
- # migration files themselves (db/migrate_virgodb_*/*.rb, timestamped,
279
- # under git blame) are already the authoritative "what changed when"
280
- # record. `register_schema_version`'s additive-only check is
281
- # unaffected: it only ever compares a new migration's proposed columns
282
- # against the CURRENT version, which is exactly what's still dumped.
283
- def dump_schema_versions!(filename)
284
- return unless connection.data_source_exists?("schema_versions")
285
-
286
- rows = connection.select_all("SELECT id, table_name, version, columns, applied_at FROM schema_versions ORDER BY table_name, version")
287
- return if rows.to_a.empty?
288
-
289
- File.open(filename, "a") do |f|
290
- f.puts
291
- rows.to_a.group_by { |r| r["table_name"] }.each do |table_name, versions|
292
- latest = versions.max_by { |r| r["version"] }
293
- history = versions.map { |r| "v#{r['version']} (#{Time.at(r['applied_at'].to_i).utc.strftime('%Y-%m-%d')})" }.join(", ")
294
-
295
- f.puts "-- #{table_name} schema history: #{history} -- only the current version is dumped below; see db/migrate_virgodb_*/ for the rest"
296
- pretty_columns = JSON.pretty_generate(JSON.parse(latest["columns"]))
297
- f.puts(
298
- "INSERT INTO \"schema_versions\" (id, table_name, version, columns, applied_at) VALUES (\n" \
299
- " #{latest['id']}, #{connection.quote(table_name)}, #{latest['version']},\n" \
300
- " #{connection.quote(pretty_columns)},\n" \
301
- " #{latest['applied_at']}\n" \
302
- ");"
303
- )
304
- f.puts
305
- end
306
- end
307
- end
308
-
309
- # Writes `table_layout_provider`'s sort_by/key_column/partition_column
310
- # as a comment directly above each table's (already reformatted,
311
- # one-column-per-line) CREATE TABLE -- the closest a SQLite-flavored
312
- # dump can get to ClickHouse's own `ORDER BY`/`PARTITION BY` clauses
313
- # being right there in the DDL. No-op (and no dump format change at
314
- # all) when no provider is registered.
315
- def annotate_physical_layout!(filename)
316
- provider = self.class.table_layout_provider
317
- return unless provider
318
-
319
- content = File.read(filename)
320
- content.gsub!(/^CREATE TABLE (IF NOT EXISTS )?"?(\w+)"? \(/) do
321
- statement_start = Regexp.last_match(0)
322
- table_name = Regexp.last_match(2)
323
- layout = provider.call(table_name)
324
- next statement_start unless layout
325
-
326
- comment = [
327
- "-- Physical layout (app/services/virgodb/registry.rb):",
328
- "-- sort_by: #{layout[:sort_by].join(', ')}",
329
- "-- key_column: #{layout[:key_column]} (run file min/max pruning bounds)",
330
- "-- partition_column: #{layout[:partition_column]}"
331
- ].join("\n")
332
-
333
- "#{comment}\n#{statement_start}"
334
- end
335
- File.write(filename, content)
336
- end
337
-
338
- # `SQLite3Adapter#structure_dump`'s raw `SELECT sql FROM sqlite_master`
339
- # path (used whenever `ActiveRecord::SchemaDumper.ignore_tables` is
340
- # non-empty, e.g. excluding tables owned by a separate replication/
341
- # backup mechanism) reproduces each
342
- # CREATE TABLE exactly as ActiveRecord originally generated it --
343
- # one long line, every column crammed together. Fine for SQLite to
344
- # load back (structure_load doesn't care about whitespace), useless to
345
- # a human reading the dump next to `db/clickhouse_structure.sql`
346
- # (ClickHouse's own `SHOW CREATE TABLE` is naturally one-column-per-line,
347
- # for comparison). Purely cosmetic, safe to do unconditionally: only
348
- # rewrites the exact same statement with different whitespace, no DDL
349
- # semantics change. Skips statements that already span multiple lines
350
- # (e.g. this adapter's own hand-written `schema_versions` DDL, created
351
- # via a real newline-containing heredoc) -- nothing to reformat there.
352
- def reformat_create_table_statements!(filename)
353
- content = File.read(filename)
354
- content.gsub!(/^CREATE TABLE (IF NOT EXISTS )?("[\w]+"|\w+) \((.*)\);$/) do
355
- if_not_exists = Regexp.last_match(1)
356
- table_name = Regexp.last_match(2)
357
- columns = split_top_level_commas(Regexp.last_match(3))
358
- next Regexp.last_match(0) if columns.size <= 1
359
-
360
- "CREATE TABLE #{if_not_exists}#{table_name} (\n" + columns.map { |c| " #{c.strip}" }.join(",\n") + "\n);"
361
- end
362
- File.write(filename, content)
363
- end
364
-
365
- # Splits a CREATE TABLE column list on commas that are NOT nested
366
- # inside a type's own parens (e.g. `Decimal(18,2)`) -- a plain
367
- # `String#split(",")` would wrongly cut `Decimal(18` and `2)` apart.
368
- def split_top_level_commas(str)
369
- parts = []
370
- depth = 0
371
- current = +""
372
- str.each_char do |ch|
373
- case ch
374
- when "(" then depth += 1; current << ch
375
- when ")" then depth -= 1; current << ch
376
- when ","
377
- depth.zero? ? (parts << current; current = +"") : current << ch
378
- else
379
- current << ch
380
- end
381
- end
382
- parts << current unless current.strip.empty?
383
- parts
384
- end
385
- end
386
- end
387
- end
388
-
389
- # Registering our own subclass (not plain SQLiteDatabaseTasks) so
390
- # schema_versions' row data survives the dump/load round trip -- see the
391
- # class doc comment above for why a real Rails app can't avoid hitting this.
392
- ActiveRecord::Tasks::DatabaseTasks.register_task(/virgodb/, "ActiveRecord::Tasks::VirgodbDatabaseTasks")
4
+ # exactly) so Bundler's default require (`require gem_name`, no `require:`
5
+ # option needed in the Gemfile) actually finds it. The real code lives at
6
+ # the conventional-looking paths below (mirroring how Rails organizes its
7
+ # own built-in adapters) -- this file is just Bundler's entrypoint into
8
+ # them. Rails' own ActiveRecord::ConnectionAdapters.register/
9
+ # DatabaseTasks.register_task (bottom of each file below) treat the require
10
+ # path passed to them as an opaque string, not a real convention, so
11
+ # pointing them at this file rather than either individual one is what
12
+ # makes their lazy-load fallback path (loading before Bundler's own
13
+ # implicit require has run) reach both classes, not just one.
14
+ require_relative "active_record/connection_adapters/virgodb_adapter"
15
+ require_relative "active_record/tasks/virgodb_database_tasks"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activerecord-virgodb-adapter
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - virgodb
@@ -23,6 +23,20 @@ dependencies:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: '7.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: railties
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '7.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '7.0'
26
40
  - !ruby/object:Gem::Dependency
27
41
  name: sqlite3
28
42
  requirement: !ruby/object:Gem::Requirement
@@ -39,7 +53,7 @@ dependencies:
39
53
  version: '1.4'
40
54
  description: |
41
55
  Lets any Rails app write real ActiveRecord::Migration subclasses against a virgodb manifest,
42
- picked up by `rails db:migrate` the same way SQLite and ClickHouse migrations already are.
56
+ picked up by `rails db:migrate` the same way SQLite migrations already are.
43
57
  Subclasses ActiveRecord::ConnectionAdapters::SQLite3Adapter (virgodb's manifest is already a
44
58
  real SQLite file), so structure_dump/the :sql schema format work for free.
45
59
  executables: []
@@ -47,6 +61,8 @@ extensions: []
47
61
  extra_rdoc_files: []
48
62
  files:
49
63
  - LICENSE
64
+ - lib/active_record/connection_adapters/virgodb_adapter.rb
65
+ - lib/active_record/tasks/virgodb_database_tasks.rb
50
66
  - lib/activerecord-virgodb-adapter.rb
51
67
  licenses:
52
68
  - Nonstandard