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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 757fecfe8176d3354cf42df451f013c814c2699b3ac1206a055a3ada8196fd10
|
|
4
|
+
data.tar.gz: 945f9ab23d59f055951dcbe55786545f106d7c0c90a26c23699a9e61a32cbec2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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)
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
# .
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
|
|
13
|
-
require
|
|
14
|
-
|
|
15
|
-
|
|
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.
|
|
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
|
|
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
|