activerecord-virgodb-adapter 0.1.2 → 0.1.4
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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d6376b065e29146c096491b6ce2a68e4331e806161a570b6549b068561a85f1f
|
|
4
|
+
data.tar.gz: 1555e75fdf0c2418c1d3439fb1940f7dd284e48e28109248ffd249454b41dc55
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ab5ef2e0113e6c38295495cb10d1c2ca4bb77d135b65a636cf58aee8777146bc29795b1c7d3d2950898735a81367e2d88e3ce235a3d4f567b7e934ae8d69e885
|
|
7
|
+
data.tar.gz: 7375e48809a1b05cc4a1ae8add6ed5e1a36d33a4564e0c711a8a8228e910e4f79d9ad039aaeccd66b839b88a4ff31640aec9e5d0f9ac246398d320b6b1224611
|
|
@@ -37,9 +37,13 @@ module ActiveRecord
|
|
|
37
37
|
#
|
|
38
38
|
# Additive-only, matching `Manifest::register_schema_version`'s own
|
|
39
39
|
# rule and virgodb's immutable-Parquet philosophy: `remove_column`,
|
|
40
|
-
# `rename_column`, and `
|
|
41
|
-
#
|
|
42
|
-
#
|
|
40
|
+
# `rename_column`, `change_column`, and `rename_table` are all disabled
|
|
41
|
+
# outright, and `assert_additive!` (below) catches every OTHER way an
|
|
42
|
+
# existing column can be altered (`change_column_null`,
|
|
43
|
+
# `change_column_default`, `remove_columns`, and anything else that
|
|
44
|
+
# reaches `create_table` via SQLite3Adapter's rebuild-the-table
|
|
45
|
+
# fallback) -- all rather than silently letting the real SQLite table
|
|
46
|
+
# drift out of sync with what `schema_versions` describes.
|
|
43
47
|
|
|
44
48
|
# virgodb's raw "Timestamp" type (crates/compact/src/schema.rs) is plain
|
|
45
49
|
# UNIX seconds -- an Integer, not a real SQL DATETIME/TIMESTAMP string --
|
|
@@ -50,9 +54,8 @@ module ActiveRecord
|
|
|
50
54
|
# real failure modes from that mismatch, not just cosmetic ones:
|
|
51
55
|
#
|
|
52
56
|
# 1. `Type::DateTime.new.cast("0")` -- "0" being a perfectly valid
|
|
53
|
-
# epoch-seconds default (e.g.
|
|
54
|
-
#
|
|
55
|
-
# db/migrate_virgodb/20260727000002_create_ahoy_visits.rb in hintpot)
|
|
57
|
+
# epoch-seconds default (e.g. a real host app declaring `DEFAULT 0`
|
|
58
|
+
# as a sentinel on a nullable-in-spirit timestamp column)
|
|
56
59
|
# -- returns nil. `Column#default` itself is unaffected (DateTime is
|
|
57
60
|
# `mutable?`, so `Column#initialize` keeps the raw string rather than
|
|
58
61
|
# deserializing it -- see ActiveModel::Type::DateTime#mutable?'s own
|
|
@@ -158,9 +161,9 @@ module ActiveRecord
|
|
|
158
161
|
# falls through to `Type.default_value`, an untyped
|
|
159
162
|
# `ActiveModel::Type::Value` whose `.type` is nil.
|
|
160
163
|
# 2. An `AnyLast <type> <order_by_column>` column (the two-scalar-
|
|
161
|
-
# column tuple-workaround pairs
|
|
162
|
-
#
|
|
163
|
-
#
|
|
164
|
+
# column tuple-workaround pairs a real host app might use to
|
|
165
|
+
# emulate ClickHouse-style last-touch columns) can get a WRONG
|
|
166
|
+
# match instead of no match: Rails'
|
|
164
167
|
# %r(date)i is an unanchored substring regex, and
|
|
165
168
|
# "AnyLast String updated_at" contains the substring "date"
|
|
166
169
|
# (from "upDATEd_at") purely by accident, mis-casting a String
|
|
@@ -233,6 +236,70 @@ module ActiveRecord
|
|
|
233
236
|
raise NotImplementedError, "virgodb schemas are additive-only -- an existing column's type can't change"
|
|
234
237
|
end
|
|
235
238
|
|
|
239
|
+
# Not caught by `assert_additive!` -- SQLite renames a table via a
|
|
240
|
+
# native, lightweight `ALTER TABLE ... RENAME TO ...` (unlike
|
|
241
|
+
# `remove_columns`/`change_column_null`/`change_column_default`,
|
|
242
|
+
# never `alter_table`'s rebuild-the-table fallback), so it never
|
|
243
|
+
# reaches `record_schema_version!` at all. Confirmed by direct
|
|
244
|
+
# repro: renaming left `schema_versions` (and, on the real Rust
|
|
245
|
+
# side, `run_files`/`inline_batches`/every other manifest catalog
|
|
246
|
+
# table `manifest::Manifest` scopes by `table_name`) still keyed
|
|
247
|
+
# under the OLD name while the real SQLite table now answers to the
|
|
248
|
+
# new one -- every future migration or query against either name
|
|
249
|
+
# would silently miss the other's history. Blocked outright, same
|
|
250
|
+
# as the other identity-changing operations above: nothing in this
|
|
251
|
+
# codebase's fixed table set (events/visits/engagements/heatmaps,
|
|
252
|
+
# hardcoded in `Virgodb::Registry::TABLES` on the Ruby side and
|
|
253
|
+
# implicitly on the Rust side by whatever `table_name` callers pass)
|
|
254
|
+
# has a legitimate reason to rename.
|
|
255
|
+
def rename_table(table_name, new_name, **options)
|
|
256
|
+
raise NotImplementedError, "virgodb schemas are additive-only -- a table can't be renamed once created (schema_versions and the Rust-side manifest catalog are both keyed by table_name)"
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# `ActiveRecord::Tasks::DatabaseTasks.truncate_tables(db_config)` --
|
|
260
|
+
# what `db:truncate_all` and, through it, `db:seed:replant` both run
|
|
261
|
+
# for every configured database -- passes `conn.tables` (literally
|
|
262
|
+
# everything `create_table` has ever touched, unfiltered) straight
|
|
263
|
+
# into this method. `AbstractAdapter::DatabaseStatements
|
|
264
|
+
# #truncate_tables` already subtracts exactly two names from that
|
|
265
|
+
# list before truncating: `pool.schema_migration.table_name` and
|
|
266
|
+
# `pool.internal_metadata.table_name` -- Rails' OWN bookkeeping
|
|
267
|
+
# tables. `schema_versions` (this adapter's bookkeeping table) and
|
|
268
|
+
# the Rust side's manifest catalog tables (`Manifest::open()`'s
|
|
269
|
+
# bootstrap, crates/manifest/src/lib.rs: run_files, inline_batches,
|
|
270
|
+
# orphaned_files, flush_completions, maintenance_lease, tombstones)
|
|
271
|
+
# get no such exemption -- Rails has no way to know this adapter's
|
|
272
|
+
# `tables` list contains anything other than real app data.
|
|
273
|
+
#
|
|
274
|
+
# Found by a real host app, not assumed: its CI ran
|
|
275
|
+
# `db:seed:replant` (truncate_all + reseed) as its last step, which
|
|
276
|
+
# silently zeroed out schema_versions with nothing to reload it
|
|
277
|
+
# afterward -- `ActiveRecord::Migration.maintain_test_schema!` only
|
|
278
|
+
# checks for PENDING migrations, saw none, and left the now-empty
|
|
279
|
+
# table alone. The NEXT `bin/rails test` run then failed immediately:
|
|
280
|
+
# nothing left to read a table's committed column list from.
|
|
281
|
+
# Confirmed directly against a real manifest: truncate_all correctly
|
|
282
|
+
# left schema_migrations/ar_internal_metadata alone but zeroed
|
|
283
|
+
# schema_versions (4 rows -> 0).
|
|
284
|
+
#
|
|
285
|
+
# Fixed the same way Rails protects its own two tables: subtract
|
|
286
|
+
# this adapter's catalog tables before delegating to `super`. All 7
|
|
287
|
+
# names are excluded on principle, even though a manifest built from
|
|
288
|
+
# pure-Ruby migrations alone only ever populates schema_versions
|
|
289
|
+
# (the other 6 only exist once the Rust side's `Manifest::open()`
|
|
290
|
+
# has bootstrapped them) -- truncating any of them out from under a
|
|
291
|
+
# running maintenance thread (crates/compact/src/maintenance.rs)
|
|
292
|
+
# would be exactly as destructive as this bug, just harder to
|
|
293
|
+
# trigger from Ruby alone.
|
|
294
|
+
MANIFEST_CATALOG_TABLES = %w[
|
|
295
|
+
schema_versions run_files inline_batches orphaned_files
|
|
296
|
+
flush_completions maintenance_lease tombstones
|
|
297
|
+
].freeze
|
|
298
|
+
|
|
299
|
+
def truncate_tables(*table_names)
|
|
300
|
+
super(*(table_names - MANIFEST_CATALOG_TABLES))
|
|
301
|
+
end
|
|
302
|
+
|
|
236
303
|
private
|
|
237
304
|
|
|
238
305
|
# Reads the table back via ActiveRecord's own column introspection --
|
|
@@ -274,9 +341,61 @@ module ActiveRecord
|
|
|
274
341
|
{ "name" => c.name, "virgodb_type" => c.sql_type, "nullable" => c.null, "default" => stringify_default(c.default) }
|
|
275
342
|
end
|
|
276
343
|
|
|
344
|
+
assert_additive!(table_name, cols)
|
|
277
345
|
insert_schema_version!(table_name, cols)
|
|
278
346
|
end
|
|
279
347
|
|
|
348
|
+
# `remove_column`/`rename_column`/`change_column` are blocked outright
|
|
349
|
+
# above, but SQLite3Adapter has THREE other public methods
|
|
350
|
+
# (`change_column_null`, `change_column_default`, `remove_columns`
|
|
351
|
+
# -- plural) that were never individually overridden and reach the
|
|
352
|
+
# exact same place: none of them support SQLite's native ALTER
|
|
353
|
+
# TABLE, so `SQLite3Adapter#alter_table` falls back to its
|
|
354
|
+
# rebuild-the-table strategy (`move_table`/`copy_table`), which
|
|
355
|
+
# calls `create_table` -- THIS adapter's overridden one -- as part
|
|
356
|
+
# of that rebuild. Confirmed empirically, not assumed: calling
|
|
357
|
+
# `change_column_null` on a real virgodb-backed table raised
|
|
358
|
+
# nothing at all, silently succeeded, and left `schema_versions`
|
|
359
|
+
# with a brand-new "version" whose columns had both the intended
|
|
360
|
+
# change AND every untouched column's `virgodb_type` corrupted to
|
|
361
|
+
# Rails' generic SQL type name ("INTEGER" instead of "Int64") --
|
|
362
|
+
# the exact same re-introspection landmine `record_added_column!`'s
|
|
363
|
+
# own doc comment already documents and specifically avoids for
|
|
364
|
+
# `add_column`. Without this check, that corrupted row would reach
|
|
365
|
+
# the Rust side's `manifest::register_schema_version`, either
|
|
366
|
+
# getting rejected there too (in which case the Ruby-side SQLite
|
|
367
|
+
# table and the Rust-side manifest catalog would have already
|
|
368
|
+
# silently diverged -- the migration "succeeded" from Rails'
|
|
369
|
+
# perspective) or, worse, if a future version happened to look
|
|
370
|
+
# additive by coincidence, corrupting every future read of that
|
|
371
|
+
# column's stored type.
|
|
372
|
+
#
|
|
373
|
+
# Rather than hunt down and individually block every current and
|
|
374
|
+
# future Rails method that can reach `alter_table`'s rebuild path,
|
|
375
|
+
# this enforces the actual invariant (mirroring `manifest::Manifest
|
|
376
|
+
# ::register_schema_version`'s own `columns.starts_with(¤t)`
|
|
377
|
+
# check exactly) at the one place ALL of them funnel through:
|
|
378
|
+
# `record_schema_version!`, called by the overridden `create_table`
|
|
379
|
+
# on every real table AND on every rebuild `create_table` call
|
|
380
|
+
# `alter_table` makes internally. A genuinely new table (no prior
|
|
381
|
+
# schema_versions row) always passes trivially. Runs inside the
|
|
382
|
+
# same transaction `alter_table` already wraps its rebuild in, so
|
|
383
|
+
# raising here rolls back the real SQLite table change too --
|
|
384
|
+
# verified directly: after a rejected `change_column_null` call,
|
|
385
|
+
# the table's real column list is byte-for-byte what it was before
|
|
386
|
+
# the call, not left half-rebuilt.
|
|
387
|
+
def assert_additive!(table_name, new_cols)
|
|
388
|
+
current_json = select_value("SELECT columns FROM schema_versions WHERE table_name = #{quote(table_name)} ORDER BY version DESC LIMIT 1")
|
|
389
|
+
return unless current_json
|
|
390
|
+
|
|
391
|
+
current_cols = JSON.parse(current_json)
|
|
392
|
+
return if new_cols.first(current_cols.length) == current_cols
|
|
393
|
+
|
|
394
|
+
raise NotImplementedError,
|
|
395
|
+
"virgodb schemas are additive-only -- table `#{table_name}`'s existing columns can't be removed, reordered, renamed, or have their type/nullable/default changed (only add_column is supported for evolving an existing table). " \
|
|
396
|
+
"current columns: #{current_cols.inspect}; attempted: #{new_cols.inspect}"
|
|
397
|
+
end
|
|
398
|
+
|
|
280
399
|
# A migration's `default:` (e.g. `t.column :clid, "String", null:
|
|
281
400
|
# false, default: ""`) round-trips through `columns(table_name).first
|
|
282
401
|
# .default` as a real, correctly-typed Ruby value now (String/Integer/
|
|
@@ -113,6 +113,14 @@ module ActiveRecord
|
|
|
113
113
|
raw = SQLite3::Database.new(path, readonly: true)
|
|
114
114
|
raw.busy_timeout = 5_000
|
|
115
115
|
raw.execute("SELECT DISTINCT table_name FROM schema_versions").flatten
|
|
116
|
+
rescue SQLite3::SQLException
|
|
117
|
+
# File.exist? isn't proof schema_versions exists -- SQLite
|
|
118
|
+
# auto-creates an empty file the instant something connects to a
|
|
119
|
+
# path that doesn't exist yet, and Rails' parallel-test setup
|
|
120
|
+
# (ActiveRecord::TestDatabases.create_and_load_schema, one file per
|
|
121
|
+
# worker) does exactly that via schema_up_to_date? moments before
|
|
122
|
+
# calling drop. Nothing to clean up in that case.
|
|
123
|
+
[]
|
|
116
124
|
ensure
|
|
117
125
|
raw&.close
|
|
118
126
|
end
|