activerecord-virgodb-adapter 0.1.0.dev.1424cbd → 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: 2648b136e688ad91ca03228e433bf7f7fa4665fb78d76a3dfc39a0753dc24094
4
- data.tar.gz: d046ad46492f2de29816497f566095f77d48defe0343f1bf6c44c9a455edc0bf
3
+ metadata.gz: 757fecfe8176d3354cf42df451f013c814c2699b3ac1206a055a3ada8196fd10
4
+ data.tar.gz: 945f9ab23d59f055951dcbe55786545f106d7c0c90a26c23699a9e61a32cbec2
5
5
  SHA512:
6
- metadata.gz: b4c229b5f97009e41ee6a795bd2308f13d697438eb16a846fafcf21aa252e1447801b09562ec79902bf387de184c6044368a27a9dc4e1270438d77ad2dee35c8
7
- data.tar.gz: fd8ff15112deb4bae56553011e7f2ac6e5d73a3d8847b276fdda719e4480a1f5a8d4b691c0316ee1056aa6d292ca3f9fa316f12fb046ec2423ae7927a8946d7a
6
+ metadata.gz: 24f8356243e4ab3349e1b94c4fb4d561d28e86575442bca4e7185cb1c2a39c745c3f98a25b7bdca0e06f09b5f9cf50af121963fba72d021d2ab3e61b53461dc3
7
+ data.tar.gz: f9c6ced6922d7254082c043e2317507d303b63fb4708a957ca8cf33f6fb462711e72690a19074639f37138f5d0e80c884ec13cc0e08d4fd9255edc99ad0df026
@@ -1,16 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "active_record/connection_adapters/sqlite3_adapter"
4
+ require "bigdecimal"
4
5
  require "json"
5
6
 
6
7
  module ActiveRecord
7
8
  module ConnectionAdapters
8
9
  # ActiveRecord adapter for virgodb migrations -- lets any Rails app
9
10
  # define and evolve a virgodb table's schema the same way it already
10
- # does for SQLite (bin/rails db:migrate) and ClickHouse (the
11
- # clickhouse-activerecord gem): real ActiveRecord::Migration subclasses,
12
- # raw type strings, picked up by `rails db:migrate` alongside every
13
- # other database.
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
14
  #
15
15
  # virgodb's manifest is already a real SQLite file, so subclassing
16
16
  # SQLite3Adapter (not building from AbstractAdapter) gets
@@ -40,6 +40,59 @@ module ActiveRecord
40
40
  # `rename_column`, and `change_column` are all disabled outright rather
41
41
  # than silently letting the real SQLite table drift out of sync with
42
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
+
43
96
  class VirgodbAdapter < SQLite3Adapter
44
97
  ADAPTER_NAME = "Virgodb"
45
98
 
@@ -57,11 +110,105 @@ module ActiveRecord
57
110
  # issued immediately after `establish_connection`, before ANY table or
58
111
  # migration ran, still silently failed. The pragma has to be set
59
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.
60
133
  def configure_connection
61
- @raw_connection.auto_vacuum = "incremental" if @raw_connection.respond_to?(:auto_vacuum=)
134
+ if @raw_connection.respond_to?(:auto_vacuum=) && @raw_connection.auto_vacuum != 2
135
+ @raw_connection.auto_vacuum = "incremental"
136
+ end
62
137
  super
63
138
  end
64
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
+
65
212
  def create_table(table_name, **options, &block)
66
213
  result = super
67
214
  record_schema_version!(table_name)
@@ -124,12 +271,34 @@ module ActiveRecord
124
271
  ensure_schema_versions_table!
125
272
 
126
273
  cols = columns(table_name).map do |c|
127
- { "name" => c.name, "virgodb_type" => c.sql_type, "nullable" => c.null }
274
+ { "name" => c.name, "virgodb_type" => c.sql_type, "nullable" => c.null, "default" => stringify_default(c.default) }
128
275
  end
129
276
 
130
277
  insert_schema_version!(table_name, cols)
131
278
  end
132
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
+
133
302
  # `add_column`'s schema_versions update -- deliberately does NOT
134
303
  # re-introspect the whole table via `columns(table_name)` the way
135
304
  # `record_schema_version!` does. Real bug, found (not assumed) by
@@ -162,7 +331,7 @@ module ActiveRecord
162
331
  current_json = select_value("SELECT columns FROM schema_versions WHERE table_name = #{quote(table_name)} ORDER BY version DESC LIMIT 1")
163
332
  current_cols = current_json ? JSON.parse(current_json) : []
164
333
 
165
- new_col = { "name" => column_name.to_s, "virgodb_type" => type.to_s, "nullable" => options[:null] != false }
334
+ new_col = { "name" => column_name.to_s, "virgodb_type" => type.to_s, "nullable" => options[:null] != false, "default" => stringify_default(options[:default]) }
166
335
  insert_schema_version!(table_name, current_cols + [ new_col ])
167
336
  end
168
337
 
@@ -201,183 +370,5 @@ end
201
370
  ActiveRecord::ConnectionAdapters.register(
202
371
  "virgodb",
203
372
  "ActiveRecord::ConnectionAdapters::VirgodbAdapter",
204
- "active_record/connection_adapters/virgodb_adapter"
373
+ "activerecord-virgodb-adapter"
205
374
  )
206
-
207
- module ActiveRecord
208
- module Tasks
209
- # `ActiveRecord::Tasks::SQLiteDatabaseTasks#structure_dump` just shells
210
- # out to the real `sqlite3` CLI (`.schema --nosys`, or a raw `SELECT sql
211
- # FROM sqlite_master` when a host app has any
212
- # `ActiveRecord::SchemaDumper.ignore_tables` configured -- e.g. excluding
213
- # tables owned by a separate replication/backup mechanism) against the
214
- # configured database file --
215
- # DDL only. `ActiveRecord::Tasks::DatabaseTasks.dump_schema` then
216
- # special-cases exactly one table on top of that: if `schema_migrations`
217
- # exists, it appends that table's real row data (`connection
218
- # .dump_schema_versions`) to the same dump file, because Rails' own
219
- # migration bookkeeping needs those rows to survive a `db:schema:load`/
220
- # `structure_load`, not just the table shape.
221
- #
222
- # `schema_versions` (this adapter's own bookkeeping table, plural,
223
- # distinct from Rails' singular `schema_migrations`) needs exactly the
224
- # same treatment and doesn't get it for free: it holds real data (the
225
- # versioned column list `compact::schema_from_columns` on the Rust side
226
- # depends on), not just structure, and Rails has no way to know that a
227
- # table it didn't create needs the same row-preserving special case.
228
- # Confirmed empirically: without this, a fresh database prepared via
229
- # `structure_load` (which Rails does automatically whenever a `db:migrate`
230
- # invocation for a later environment/connection finds an existing,
231
- # version-matching dump already on disk from an earlier one -- not
232
- # something this adapter can opt out of) ends up with the real
233
- # host-app table but an EMPTY `schema_versions`, so
234
- # `Manifest::current_schema_version()` on the Rust side returns `None`
235
- # even though the SQLite table itself is correct.
236
- class VirgodbDatabaseTasks < SQLiteDatabaseTasks
237
- class << self
238
- # Optional host-app hook: `->(sql_table_name) { {sort_by:, key_column:,
239
- # partition_column:} or nil }`. Purely a documentation comment written
240
- # above CREATE TABLE at dump time -- nothing on the Rust or Ruby side
241
- # ever reads it back. This adapter gem stays host-app-agnostic (see
242
- # the class doc comment above: "lets any Rails app..."), so it can't
243
- # hardcode any one host app's sort_by/key_column config (that's
244
- # app/services/virgodb/registry.rb's job, which already has this
245
- # exact data for real -- passed to Virgodb.start_maintenance on every
246
- # boot). A host app that wants it surfaced sets this once, e.g. in an
247
- # initializer; left nil, structure_dump behaves exactly as before.
248
- attr_accessor :table_layout_provider
249
- end
250
-
251
- def structure_dump(filename, extra_flags)
252
- super
253
- reformat_create_table_statements!(filename)
254
- annotate_physical_layout!(filename)
255
- dump_schema_versions!(filename)
256
- end
257
-
258
- private
259
-
260
- # Only the LATEST schema_versions row per table_name is dumped.
261
- # `Manifest::current_schema_version` (manifest/src/lib.rs) only ever
262
- # reads `ORDER BY version DESC LIMIT 1` -- confirmed directly, no Rust
263
- # or Ruby code anywhere reads an older version row, so keeping full
264
- # history here isn't a correctness requirement. It also isn't a
265
- # readability win: each version's `columns` re-lists every column from
266
- # scratch, so N migrations means N near-duplicate JSON blobs, longer
267
- # every time. The version count/dates are still worth one line for
268
- # orientation -- the full column list per version isn't; the
269
- # migration files themselves (db/migrate_virgodb_*/*.rb, timestamped,
270
- # under git blame) are already the authoritative "what changed when"
271
- # record. `register_schema_version`'s additive-only check is
272
- # unaffected: it only ever compares a new migration's proposed columns
273
- # against the CURRENT version, which is exactly what's still dumped.
274
- def dump_schema_versions!(filename)
275
- return unless connection.data_source_exists?("schema_versions")
276
-
277
- rows = connection.select_all("SELECT id, table_name, version, columns, applied_at FROM schema_versions ORDER BY table_name, version")
278
- return if rows.to_a.empty?
279
-
280
- File.open(filename, "a") do |f|
281
- f.puts
282
- rows.to_a.group_by { |r| r["table_name"] }.each do |table_name, versions|
283
- latest = versions.max_by { |r| r["version"] }
284
- history = versions.map { |r| "v#{r['version']} (#{Time.at(r['applied_at'].to_i).utc.strftime('%Y-%m-%d')})" }.join(", ")
285
-
286
- f.puts "-- #{table_name} schema history: #{history} -- only the current version is dumped below; see db/migrate_virgodb_*/ for the rest"
287
- pretty_columns = JSON.pretty_generate(JSON.parse(latest["columns"]))
288
- f.puts(
289
- "INSERT INTO \"schema_versions\" (id, table_name, version, columns, applied_at) VALUES (\n" \
290
- " #{latest['id']}, #{connection.quote(table_name)}, #{latest['version']},\n" \
291
- " #{connection.quote(pretty_columns)},\n" \
292
- " #{latest['applied_at']}\n" \
293
- ");"
294
- )
295
- f.puts
296
- end
297
- end
298
- end
299
-
300
- # Writes `table_layout_provider`'s sort_by/key_column/partition_column
301
- # as a comment directly above each table's (already reformatted,
302
- # one-column-per-line) CREATE TABLE -- the closest a SQLite-flavored
303
- # dump can get to ClickHouse's own `ORDER BY`/`PARTITION BY` clauses
304
- # being right there in the DDL. No-op (and no dump format change at
305
- # all) when no provider is registered.
306
- def annotate_physical_layout!(filename)
307
- provider = self.class.table_layout_provider
308
- return unless provider
309
-
310
- content = File.read(filename)
311
- content.gsub!(/^CREATE TABLE (IF NOT EXISTS )?"?(\w+)"? \(/) do
312
- statement_start = Regexp.last_match(0)
313
- table_name = Regexp.last_match(2)
314
- layout = provider.call(table_name)
315
- next statement_start unless layout
316
-
317
- comment = [
318
- "-- Physical layout (app/services/virgodb/registry.rb):",
319
- "-- sort_by: #{layout[:sort_by].join(', ')}",
320
- "-- key_column: #{layout[:key_column]} (run file min/max pruning bounds)",
321
- "-- partition_column: #{layout[:partition_column]}"
322
- ].join("\n")
323
-
324
- "#{comment}\n#{statement_start}"
325
- end
326
- File.write(filename, content)
327
- end
328
-
329
- # `SQLite3Adapter#structure_dump`'s raw `SELECT sql FROM sqlite_master`
330
- # path (used whenever `ActiveRecord::SchemaDumper.ignore_tables` is
331
- # non-empty, e.g. excluding tables owned by a separate replication/
332
- # backup mechanism) reproduces each
333
- # CREATE TABLE exactly as ActiveRecord originally generated it --
334
- # one long line, every column crammed together. Fine for SQLite to
335
- # load back (structure_load doesn't care about whitespace), useless to
336
- # a human reading the dump next to `db/clickhouse_structure.sql`
337
- # (ClickHouse's own `SHOW CREATE TABLE` is naturally one-column-per-line,
338
- # for comparison). Purely cosmetic, safe to do unconditionally: only
339
- # rewrites the exact same statement with different whitespace, no DDL
340
- # semantics change. Skips statements that already span multiple lines
341
- # (e.g. this adapter's own hand-written `schema_versions` DDL, created
342
- # via a real newline-containing heredoc) -- nothing to reformat there.
343
- def reformat_create_table_statements!(filename)
344
- content = File.read(filename)
345
- content.gsub!(/^CREATE TABLE (IF NOT EXISTS )?("[\w]+"|\w+) \((.*)\);$/) do
346
- if_not_exists = Regexp.last_match(1)
347
- table_name = Regexp.last_match(2)
348
- columns = split_top_level_commas(Regexp.last_match(3))
349
- next Regexp.last_match(0) if columns.size <= 1
350
-
351
- "CREATE TABLE #{if_not_exists}#{table_name} (\n" + columns.map { |c| " #{c.strip}" }.join(",\n") + "\n);"
352
- end
353
- File.write(filename, content)
354
- end
355
-
356
- # Splits a CREATE TABLE column list on commas that are NOT nested
357
- # inside a type's own parens (e.g. `Decimal(18,2)`) -- a plain
358
- # `String#split(",")` would wrongly cut `Decimal(18` and `2)` apart.
359
- def split_top_level_commas(str)
360
- parts = []
361
- depth = 0
362
- current = +""
363
- str.each_char do |ch|
364
- case ch
365
- when "(" then depth += 1; current << ch
366
- when ")" then depth -= 1; current << ch
367
- when ","
368
- depth.zero? ? (parts << current; current = +"") : current << ch
369
- else
370
- current << ch
371
- end
372
- end
373
- parts << current unless current.strip.empty?
374
- parts
375
- end
376
- end
377
- end
378
- end
379
-
380
- # Registering our own subclass (not plain SQLiteDatabaseTasks) so
381
- # schema_versions' row data survives the dump/load round trip -- see the
382
- # class doc comment above for why a real Rails app can't avoid hitting this.
383
- ActiveRecord::Tasks::DatabaseTasks.register_task(/virgodb/, "ActiveRecord::Tasks::VirgodbDatabaseTasks")
@@ -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")
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Lives at lib/activerecord-virgodb-adapter.rb (matching the gem name
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.0.dev.1424cbd
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: []
@@ -48,6 +62,8 @@ extra_rdoc_files: []
48
62
  files:
49
63
  - LICENSE
50
64
  - lib/active_record/connection_adapters/virgodb_adapter.rb
65
+ - lib/active_record/tasks/virgodb_database_tasks.rb
66
+ - lib/activerecord-virgodb-adapter.rb
51
67
  licenses:
52
68
  - Nonstandard
53
69
  metadata: {}