activerecord-virgodb-adapter 0.1.1 → 0.1.2

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: 757fecfe8176d3354cf42df451f013c814c2699b3ac1206a055a3ada8196fd10
4
+ data.tar.gz: 945f9ab23d59f055951dcbe55786545f106d7c0c90a26c23699a9e61a32cbec2
5
5
  SHA512:
6
- metadata.gz: 9f8c46262072bd70640666b45f9254902100f6bbde82af984655f5720131227119c768eb1d24bfc8fc7b0435d067e6438ab4b89eb835d5bf7b6664978218bf50
7
- data.tar.gz: 12a8d38269b32b6af45e9dd637a47e1b9c74daa9419dea208a92c9f089bfa95b80630039c670399a9d515d03ca2470d01e7ac07515c0e21950f269025114aac2
6
+ metadata.gz: 24f8356243e4ab3349e1b94c4fb4d561d28e86575442bca4e7185cb1c2a39c745c3f98a25b7bdca0e06f09b5f9cf50af121963fba72d021d2ab3e61b53461dc3
7
+ data.tar.gz: f9c6ced6922d7254082c043e2317507d303b63fb4708a957ca8cf33f6fb462711e72690a19074639f37138f5d0e80c884ec13cc0e08d4fd9255edc99ad0df026
@@ -0,0 +1,374 @@
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. exit_page_at/split_test_id_at's own
54
+ # DEFAULT 0 sentinel, see
55
+ # db/migrate_virgodb/20260727000002_create_ahoy_visits.rb in hintpot)
56
+ # -- returns nil. `Column#default` itself is unaffected (DateTime is
57
+ # `mutable?`, so `Column#initialize` keeps the raw string rather than
58
+ # deserializing it -- see ActiveModel::Type::DateTime#mutable?'s own
59
+ # comment), which is why the schema_versions capture above was never
60
+ # wrong. But `Model.column_defaults` (what a real host app's own
61
+ # schema-annotation tooling reads) DOES deserialize through the cast
62
+ # type, surfacing as a misleading `default(NULL)` in a real model's
63
+ # annotation for a column that has a perfectly real default.
64
+ # 2. `Type::DateTime.new.cast(1753000000)` (a real Integer write-time
65
+ # value) ALSO doesn't produce a real Time object -- DateTime#cast
66
+ # only recognizes String/Time/Date/Hash input, so an Integer just
67
+ # passes through unchanged. Harmless today (nothing in this
68
+ # codebase reads a Timestamp column back through a real
69
+ # ActiveRecord attribute -- see Virgodb::Registry.query, which
70
+ # returns raw parsed JSON, bypassing AR type casting entirely), but
71
+ # a real latent gap the moment anything ever does.
72
+ #
73
+ # Subclassing ActiveRecord::Type::DateTime (not building from scratch)
74
+ # keeps `.type == :datetime` -- the semantically correct label for a
75
+ # timestamp column, unlike the columns String fix above where Rails had
76
+ # no keyword at all -- and keeps every other DateTime behavior (real
77
+ # date strings, Time/Date input, timezone handling) working exactly as
78
+ # before; only the epoch-seconds-as-Integer-or-String case needed a
79
+ # fix. `deserialize` isn't overridden separately -- ActiveModel::Type
80
+ # ::Value#deserialize just calls `cast` by default (confirmed: neither
81
+ # ActiveModel::Type::DateTime nor ActiveRecord::Type::DateTime override
82
+ # it), so fixing `cast` alone is enough for both directions.
83
+ class VirgodbTimestamp < ActiveRecord::Type::DateTime
84
+ def cast(value)
85
+ return value if value.nil? || value.is_a?(::Time) || value.is_a?(::DateTime)
86
+
87
+ seconds = Float(value, exception: false)
88
+ seconds ? ::Time.at(seconds).utc : super
89
+ end
90
+
91
+ def serialize(value)
92
+ value.respond_to?(:to_time) ? value.to_time.to_i : value
93
+ end
94
+ end
95
+
96
+ class VirgodbAdapter < SQLite3Adapter
97
+ ADAPTER_NAME = "Virgodb"
98
+
99
+ # Real bug, found (not assumed) by testing: setting `auto_vacuum =
100
+ # INCREMENTAL` from `create_table` -- even from create_table's very
101
+ # FIRST call on a brand-new manifest, before any table exists -- was
102
+ # already too late. SQLite3Adapter's own `configure_connection` sets
103
+ # `journal_mode = wal` via DEFAULT_PRAGMAS (`super`, below) as part of
104
+ # establishing the connection itself, which happens before this
105
+ # adapter's `create_table` ever runs -- and switching journal_mode to
106
+ # WAL requires SQLite to actually write to the file (creating the -wal
107
+ # file, an implicit checkpoint), which "poisons" the database from
108
+ # auto_vacuum's perspective the same way any other content would.
109
+ # Confirmed directly: a manual `PRAGMA auto_vacuum = INCREMENTAL`
110
+ # issued immediately after `establish_connection`, before ANY table or
111
+ # migration ran, still silently failed. The pragma has to be set
112
+ # before `super` runs here, not merely before the first CREATE TABLE.
113
+ #
114
+ # Second real bug, found the same way: `configure_connection` runs on
115
+ # EVERY new raw connection this adapter's pool ever opens, not just
116
+ # the manifest's first one -- so an unconditional `auto_vacuum =`
117
+ # unconditionally re-issued `PRAGMA auto_vacuum='2'` on every single
118
+ # one of them, forever. Once a manifest is genuinely in INCREMENTAL
119
+ # mode (the common case for any real, already-migrated manifest),
120
+ # re-setting it to that SAME value is not the free no-op re-setting an
121
+ # already-NONE manifest is: confirmed directly with a raw SQLite3
122
+ # connection, re-issuing the SET pragma while a second connection
123
+ # holds an open `BEGIN IMMEDIATE` write transaction (exactly what
124
+ # virgodb's own maintenance thread does while compacting, see
125
+ # crates/compact/src/maintenance.rs) blocks for the full busy_timeout
126
+ # and then raises SQLite3::BusyException -- surfacing in a host app as
127
+ # an ActiveRecord::StatementTimeout on whatever request happened to
128
+ # need a brand-new pooled connection at that moment. A bare read
129
+ # (`PRAGMA auto_vacuum` with no argument) does NOT block the same way,
130
+ # confirmed the same way -- so skip the write once it's already
131
+ # reached the target value instead of paying that contention risk on
132
+ # every connection after the first.
133
+ def configure_connection
134
+ if @raw_connection.respond_to?(:auto_vacuum=) && @raw_connection.auto_vacuum != 2
135
+ @raw_connection.auto_vacuum = "incremental"
136
+ end
137
+ super
138
+ end
139
+
140
+ class << self
141
+ private
142
+
143
+ # Real bug, found (not assumed) by a host app trying to build real
144
+ # ActiveRecord models on top of virgodb tables: `Column#type` (what
145
+ # every host app actually reads -- annotation generators, `cast`,
146
+ # attribute serialization) comes from `TYPE_MAP.lookup(sql_type)`,
147
+ # and Rails' own %r(char)i / %r(int)i / %r(date)i / etc. regexes
148
+ # (registered by AbstractAdapter#initialize_type_map, SQLite3Adapter
149
+ # only adds `int`) exist to recognize real SQL DDL keywords like
150
+ # "varchar" or "datetime" -- never anything from virgodb's own raw
151
+ # type vocabulary (crates/compact/src/schema.rs), since `t.column
152
+ # name, "raw string"` bypasses Rails' normal :string/:datetime/etc
153
+ # -> DDL-keyword translation entirely. Two confirmed failure modes:
154
+ #
155
+ # 1. A bare "String" or "LowCardinality String" column matches NONE
156
+ # of Rails' base regexes (there's no keyword for it -- real
157
+ # SQLite migrations only ever produce "varchar"), so `.type`
158
+ # falls through to `Type.default_value`, an untyped
159
+ # `ActiveModel::Type::Value` whose `.type` is nil.
160
+ # 2. An `AnyLast <type> <order_by_column>` column (the two-scalar-
161
+ # column tuple-workaround pairs -- see
162
+ # db/migrate_virgodb/20260727000002_create_ahoy_visits.rb in
163
+ # hintpot) can get a WRONG match instead of no match: Rails'
164
+ # %r(date)i is an unanchored substring regex, and
165
+ # "AnyLast String updated_at" contains the substring "date"
166
+ # (from "upDATEd_at") purely by accident, mis-casting a String
167
+ # column as :date. Confirmed directly: TYPE_MAP.lookup("AnyLast
168
+ # String updated_at").class == ActiveRecord::Type::Date.
169
+ #
170
+ # The fix strips virgodb's own wrapper syntax down to the bare base
171
+ # type and re-enters `m.lookup` for it, rather than trying to write
172
+ # an ever-more-specific whole-string regex per aggregate function x
173
+ # base type combination -- that would still be one accidental
174
+ # trailing-column-name substring match away from the same bug the
175
+ # moment a future order-by column happens to contain "int"/"char"/
176
+ # "time"/etc. `AnyLast` is the only aggregate function that ever
177
+ # carries a trailing order-by column (it's the one that needs to
178
+ # know which row is "last"; Any/Sum/Max/Min/UniqueArrayUnion are
179
+ # simple scalar aggregates with no ordering to track), so it's the
180
+ # only one that needs the extra trailing-token strip.
181
+ def initialize_type_map(m)
182
+ super
183
+
184
+ register_class_with_limit m, /\A(?:LowCardinality\s+)?String\z/i, Type::String
185
+ m.register_type(/\ATimestamp\z/i, VirgodbTimestamp.new)
186
+
187
+ m.register_type(/\AAnyLast\s+/i) do |sql_type|
188
+ base = sql_type.sub(/\AAnyLast\s+/i, "").sub(/\s+\S+\z/, "")
189
+ m.lookup(base)
190
+ end
191
+
192
+ m.register_type(/\A(?:Any|Sum|Max|Min|UniqueArrayUnion)\s+/i) do |sql_type|
193
+ base = sql_type.sub(/\A(?:Any|Sum|Max|Min|UniqueArrayUnion)\s+/i, "")
194
+ m.lookup(base)
195
+ end
196
+ end
197
+ end
198
+
199
+ # `type_map` (AbstractAdapter) reads `self.class::TYPE_MAP` -- a plain
200
+ # constant lookup, not a method call -- so merely overriding
201
+ # `initialize_type_map` above does nothing on its own: without a
202
+ # `TYPE_MAP` constant of ITS OWN, `VirgodbAdapter::TYPE_MAP` constant
203
+ # lookup just walks up to the already-frozen `SQLite3Adapter::TYPE_MAP`
204
+ # (built at THAT class's load time, from the un-overridden method).
205
+ # Confirmed directly: the override above alone left every virgodb
206
+ # column's `.type` unchanged. SQLite3Adapter builds its own `TYPE_MAP`
207
+ # constant the exact same way (see its `TYPE_MAP = Type::TypeMap.new
208
+ # .tap { |m| initialize_type_map(m) }` line) specifically so subclasses
209
+ # can shadow it like this.
210
+ TYPE_MAP = Type::TypeMap.new.tap { |m| initialize_type_map(m) }
211
+
212
+ def create_table(table_name, **options, &block)
213
+ result = super
214
+ record_schema_version!(table_name)
215
+ result
216
+ end
217
+
218
+ def add_column(table_name, column_name, type, **options)
219
+ result = super
220
+ record_added_column!(table_name, column_name, type, options)
221
+ result
222
+ end
223
+
224
+ def remove_column(table_name, column_name, type = nil, **options)
225
+ raise NotImplementedError, "virgodb schemas are additive-only -- columns can be added, never removed (see schema_versions)"
226
+ end
227
+
228
+ def rename_column(table_name, column_name, new_column_name)
229
+ raise NotImplementedError, "virgodb schemas are additive-only -- columns can't be renamed once created"
230
+ end
231
+
232
+ def change_column(table_name, column_name, type, **options)
233
+ raise NotImplementedError, "virgodb schemas are additive-only -- an existing column's type can't change"
234
+ end
235
+
236
+ private
237
+
238
+ # Reads the table back via ActiveRecord's own column introspection --
239
+ # `sql_type` round-trips the raw virgodb type string verbatim, proven
240
+ # against a real SQLite connection immediately after a fresh CREATE
241
+ # TABLE -- and writes a new `schema_versions` row: the full current
242
+ # column list, versioned one higher than whatever was there before FOR
243
+ # THIS table_name. Mirrors Manifest::register_schema_version's shape
244
+ # exactly (same table_name scoping, same column names, same JSON shape
245
+ # for `columns`) so the Rust side can read whatever Ruby wrote with
246
+ # zero translation. Scoping `MAX(version)` by `table_name` is what makes
247
+ # it safe for several logical tables to share one manifest/connection:
248
+ # without it, the second table's first migration would start at
249
+ # version 2 (or worse, silently share version numbers with an
250
+ # unrelated table). Rails' OWN bookkeeping tables (`schema_migrations`,
251
+ # `ar_internal_metadata`) go through this SAME overridden `create_table`
252
+ # the first time either is lazily created -- there's nothing
253
+ # virgodb-specific about them, and recording a schema_versions row for
254
+ # them is pure noise (found while dumping a real structure.sql and
255
+ # seeing them show up as rows next to the real logical tables). Guarded
256
+ # here, not by checking `table_name` against `TABLES` in some caller --
257
+ # this adapter has no such registry, `create_table`'s own argument is
258
+ # the one place that reliably sees every table, virgodb or not.
259
+ #
260
+ # ONLY safe for `create_table` (a brand-new table with no prior
261
+ # history). NOT reused by `add_column` -- see `record_added_column!`'s
262
+ # own doc comment for the real bug this avoids: `columns(table_name)`
263
+ # stops reliably returning the ORIGINAL declared type text for
264
+ # pre-existing columns once the table has been through certain ALTER
265
+ # operations.
266
+ RAILS_INTERNAL_TABLES = %w[schema_migrations ar_internal_metadata].freeze
267
+
268
+ def record_schema_version!(table_name)
269
+ return if RAILS_INTERNAL_TABLES.include?(table_name.to_s)
270
+
271
+ ensure_schema_versions_table!
272
+
273
+ cols = columns(table_name).map do |c|
274
+ { "name" => c.name, "virgodb_type" => c.sql_type, "nullable" => c.null, "default" => stringify_default(c.default) }
275
+ end
276
+
277
+ insert_schema_version!(table_name, cols)
278
+ end
279
+
280
+ # A migration's `default:` (e.g. `t.column :clid, "String", null:
281
+ # false, default: ""`) round-trips through `columns(table_name).first
282
+ # .default` as a real, correctly-typed Ruby value now (String/Integer/
283
+ # BigDecimal) -- only possible since the TYPE_MAP fix above; before it,
284
+ # every virgodb String column's `.type` was nil/wrong and `.default`
285
+ # came back nil regardless of what the migration declared. schema_versions
286
+ # itself only stores plain JSON strings (matching `virgodb_type`'s own
287
+ # convention of storing raw text the Rust side parses, not a typed
288
+ # value) -- the Rust write path (crates/virgodb_ruby's row_convert.rs)
289
+ # re-parses this string against the column's real Arrow type at write
290
+ # time. `BigDecimal#to_s` explicitly forced to fixed-point ("F") rather
291
+ # than its default format, which switches to scientific notation for
292
+ # some magnitudes -- avoids writing e.g. "0.12345e3" into
293
+ # schema_versions where a human reading a real dump would expect
294
+ # "123.45" (Rust's f64 parser would actually accept either form, this
295
+ # is purely for a human reading a real structure.sql/schema_versions
296
+ # dump, not a correctness requirement).
297
+ def stringify_default(value)
298
+ return nil if value.nil?
299
+ value.is_a?(BigDecimal) ? value.to_s("F") : value.to_s
300
+ end
301
+
302
+ # `add_column`'s schema_versions update -- deliberately does NOT
303
+ # re-introspect the whole table via `columns(table_name)` the way
304
+ # `record_schema_version!` does. Real bug, found (not assumed) by
305
+ # testing: `super`'s real `ALTER TABLE ADD COLUMN` -- specifically for
306
+ # a `null: false` column, which SQLite's native ALTER can't add without
307
+ # a default -- makes Rails' SQLite3Adapter fall back to its "rebuild
308
+ # the table" strategy (copy to a temp table, drop, rename). Confirmed
309
+ # directly: introspecting the table immediately afterward, even via a
310
+ # completely fresh connection/process (so not a Rails-process-level
311
+ # schema cache artifact), returns Rails' own GENERIC type names for
312
+ # every PRE-EXISTING column ("INTEGER"/"datetime"/"" instead of
313
+ # "Int64"/"Timestamp"/"LowCardinality String") -- the rebuild
314
+ # reconstructs the physical table's DDL from Rails' normalized column
315
+ # metadata, silently discarding the original literal type text for
316
+ # every column it didn't just add. Re-deriving the full column list
317
+ # via introspection after ANY add_column would therefore corrupt every
318
+ # already-correct entry in `schema_versions`.
319
+ #
320
+ # The fix: never re-derive what's already known to be correct. Read
321
+ # the CURRENT (highest-version) column list straight out of
322
+ # `schema_versions` itself (the one thing guaranteed not to have been
323
+ # touched by the ALTER), and append only the ONE new column -- whose
324
+ # name/type/nullability are already fully known from `add_column`'s own
325
+ # arguments, no introspection needed for it either.
326
+ def record_added_column!(table_name, column_name, type, options)
327
+ return if RAILS_INTERNAL_TABLES.include?(table_name.to_s)
328
+
329
+ ensure_schema_versions_table!
330
+
331
+ current_json = select_value("SELECT columns FROM schema_versions WHERE table_name = #{quote(table_name)} ORDER BY version DESC LIMIT 1")
332
+ current_cols = current_json ? JSON.parse(current_json) : []
333
+
334
+ new_col = { "name" => column_name.to_s, "virgodb_type" => type.to_s, "nullable" => options[:null] != false, "default" => stringify_default(options[:default]) }
335
+ insert_schema_version!(table_name, current_cols + [ new_col ])
336
+ end
337
+
338
+ def insert_schema_version!(table_name, cols)
339
+ next_version = (select_value("SELECT MAX(version) FROM schema_versions WHERE table_name = #{quote(table_name)}") || 0).to_i + 1
340
+
341
+ execute(<<~SQL)
342
+ INSERT INTO schema_versions (table_name, version, columns, applied_at)
343
+ VALUES (#{quote(table_name)}, #{next_version}, #{quote(cols.to_json)}, #{Time.now.to_i})
344
+ SQL
345
+ end
346
+
347
+ # Same DDL as Manifest::open()'s bootstrap on the Rust side (same
348
+ # table/column names, including the `table_name` scoping column) --
349
+ # IF NOT EXISTS makes this safe to call whether virgodb's own Rust
350
+ # code or this Ruby adapter created the manifest file first. Plain
351
+ # INTEGER PRIMARY KEY, not AUTOINCREMENT -- see the matching comment
352
+ # in manifest/src/lib.rs for why (breaks a real structure_dump/
353
+ # structure_load round trip the moment any host app's
354
+ # ActiveRecord::SchemaDumper.ignore_tables is non-empty).
355
+ def ensure_schema_versions_table!
356
+ execute(<<~SQL)
357
+ CREATE TABLE IF NOT EXISTS schema_versions (
358
+ id INTEGER PRIMARY KEY,
359
+ table_name TEXT NOT NULL,
360
+ version INTEGER NOT NULL,
361
+ columns TEXT NOT NULL,
362
+ applied_at INTEGER NOT NULL
363
+ )
364
+ SQL
365
+ end
366
+ end
367
+ end
368
+ end
369
+
370
+ ActiveRecord::ConnectionAdapters.register(
371
+ "virgodb",
372
+ "ActiveRecord::ConnectionAdapters::VirgodbAdapter",
373
+ "activerecord-virgodb-adapter"
374
+ )
@@ -0,0 +1,243 @@
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
+ ensure
117
+ raw&.close
118
+ end
119
+
120
+ # Only the LATEST schema_versions row per table_name is dumped.
121
+ # `Manifest::current_schema_version` (manifest/src/lib.rs) only ever
122
+ # reads `ORDER BY version DESC LIMIT 1` -- confirmed directly, no Rust
123
+ # or Ruby code anywhere reads an older version row, so keeping full
124
+ # history here isn't a correctness requirement. It also isn't a
125
+ # readability win: each version's `columns` re-lists every column from
126
+ # scratch, so N migrations means N near-duplicate JSON blobs, longer
127
+ # every time. The version count/dates are still worth one line for
128
+ # orientation -- the full column list per version isn't; the
129
+ # migration files themselves (db/migrate_virgodb_*/*.rb, timestamped,
130
+ # under git blame) are already the authoritative "what changed when"
131
+ # record. `register_schema_version`'s additive-only check is
132
+ # unaffected: it only ever compares a new migration's proposed columns
133
+ # against the CURRENT version, which is exactly what's still dumped.
134
+ def dump_schema_versions!(filename)
135
+ return unless connection.data_source_exists?("schema_versions")
136
+
137
+ rows = connection.select_all("SELECT id, table_name, version, columns, applied_at FROM schema_versions ORDER BY table_name, version")
138
+ return if rows.to_a.empty?
139
+
140
+ File.open(filename, "a") do |f|
141
+ f.puts
142
+ rows.to_a.group_by { |r| r["table_name"] }.each do |table_name, versions|
143
+ latest = versions.max_by { |r| r["version"] }
144
+ history = versions.map { |r| "v#{r['version']} (#{Time.at(r['applied_at'].to_i).utc.strftime('%Y-%m-%d')})" }.join(", ")
145
+
146
+ f.puts "-- #{table_name} schema history: #{history} -- only the current version is dumped below; see db/migrate_virgodb_*/ for the rest"
147
+ pretty_columns = JSON.pretty_generate(JSON.parse(latest["columns"]))
148
+ f.puts(
149
+ "INSERT INTO \"schema_versions\" (id, table_name, version, columns, applied_at) VALUES (\n" \
150
+ " #{latest['id']}, #{connection.quote(table_name)}, #{latest['version']},\n" \
151
+ " #{connection.quote(pretty_columns)},\n" \
152
+ " #{latest['applied_at']}\n" \
153
+ ");"
154
+ )
155
+ f.puts
156
+ end
157
+ end
158
+ end
159
+
160
+ # Writes `table_layout_provider`'s sort_by/key_column/partition_column
161
+ # as a comment directly above each table's (already reformatted,
162
+ # one-column-per-line) CREATE TABLE -- the closest a SQLite-flavored
163
+ # dump can get to ClickHouse's own `ORDER BY`/`PARTITION BY` clauses
164
+ # being right there in the DDL. No-op (and no dump format change at
165
+ # all) when no provider is registered.
166
+ def annotate_physical_layout!(filename)
167
+ provider = self.class.table_layout_provider
168
+ return unless provider
169
+
170
+ content = File.read(filename)
171
+ content.gsub!(/^CREATE TABLE (IF NOT EXISTS )?"?(\w+)"? \(/) do
172
+ statement_start = Regexp.last_match(0)
173
+ table_name = Regexp.last_match(2)
174
+ layout = provider.call(table_name)
175
+ next statement_start unless layout
176
+
177
+ comment = [
178
+ "-- Physical layout (app/services/virgodb/registry.rb):",
179
+ "-- sort_by: #{layout[:sort_by].join(', ')}",
180
+ "-- key_column: #{layout[:key_column]} (run file min/max pruning bounds)",
181
+ "-- partition_column: #{layout[:partition_column]}"
182
+ ].join("\n")
183
+
184
+ "#{comment}\n#{statement_start}"
185
+ end
186
+ File.write(filename, content)
187
+ end
188
+
189
+ # `SQLite3Adapter#structure_dump`'s raw `SELECT sql FROM sqlite_master`
190
+ # path (used whenever `ActiveRecord::SchemaDumper.ignore_tables` is
191
+ # non-empty, e.g. excluding tables owned by a separate replication/
192
+ # backup mechanism) reproduces each
193
+ # CREATE TABLE exactly as ActiveRecord originally generated it --
194
+ # one long line, every column crammed together. Fine for SQLite to
195
+ # load back (structure_load doesn't care about whitespace), useless to
196
+ # a human reading the dump next to `db/clickhouse_structure.sql`
197
+ # (ClickHouse's own `SHOW CREATE TABLE` is naturally one-column-per-line,
198
+ # for comparison). Purely cosmetic, safe to do unconditionally: only
199
+ # rewrites the exact same statement with different whitespace, no DDL
200
+ # semantics change. Skips statements that already span multiple lines
201
+ # (e.g. this adapter's own hand-written `schema_versions` DDL, created
202
+ # via a real newline-containing heredoc) -- nothing to reformat there.
203
+ def reformat_create_table_statements!(filename)
204
+ content = File.read(filename)
205
+ content.gsub!(/^CREATE TABLE (IF NOT EXISTS )?("[\w]+"|\w+) \((.*)\);$/) do
206
+ if_not_exists = Regexp.last_match(1)
207
+ table_name = Regexp.last_match(2)
208
+ columns = split_top_level_commas(Regexp.last_match(3))
209
+ next Regexp.last_match(0) if columns.size <= 1
210
+
211
+ "CREATE TABLE #{if_not_exists}#{table_name} (\n" + columns.map { |c| " #{c.strip}" }.join(",\n") + "\n);"
212
+ end
213
+ File.write(filename, content)
214
+ end
215
+
216
+ # Splits a CREATE TABLE column list on commas that are NOT nested
217
+ # inside a type's own parens (e.g. `Decimal(18,2)`) -- a plain
218
+ # `String#split(",")` would wrongly cut `Decimal(18` and `2)` apart.
219
+ def split_top_level_commas(str)
220
+ parts = []
221
+ depth = 0
222
+ current = +""
223
+ str.each_char do |ch|
224
+ case ch
225
+ when "(" then depth += 1; current << ch
226
+ when ")" then depth -= 1; current << ch
227
+ when ","
228
+ depth.zero? ? (parts << current; current = +"") : current << ch
229
+ else
230
+ current << ch
231
+ end
232
+ end
233
+ parts << current unless current.strip.empty?
234
+ parts
235
+ end
236
+ end
237
+ end
238
+ end
239
+
240
+ # Registering our own subclass (not plain SQLiteDatabaseTasks) so
241
+ # schema_versions' row data survives the dump/load round trip -- see the
242
+ # class doc comment above for why a real Rails app can't avoid hitting this.
243
+ 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.2
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