static_db 0.2.0 → 0.3.0
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 +4 -4
- data/CHANGELOG.md +23 -1
- data/README.md +35 -1
- data/lib/static_db/dump.rb +82 -39
- data/lib/static_db/load.rb +42 -3
- data/lib/static_db/version.rb +1 -1
- metadata +1 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fda821f04db0db10b8c6beea35eb177028dedfb6ba8e2f22fbbddd5164ea4d30
|
|
4
|
+
data.tar.gz: 31bab97a76c1f75499d93fcbbe4e0a448109069d47ef85ad7cddf7dbf0f25957
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 69fbeebc35245d0a9d47d6ab0fc3e646e4e20dd4a98a01a12b5e706d0ea4155ae17656f171288feb86663ced7c6bdc3fe32ce8adf396ce9f04116fa5ce051dc9
|
|
7
|
+
data.tar.gz: 7debc1202b841a20e1149fc6898deb55ae998bb7ac14b78c019c6e59c00bda91b81069cec0af2be26b366a8210273097f9f86ce2a96779e31f4ff56480188b0a
|
data/CHANGELOG.md
CHANGED
|
@@ -6,12 +6,34 @@ This project adheres to [Break Versioning](https://www.taoensso.com/break-versio
|
|
|
6
6
|
|
|
7
7
|
### Breaking
|
|
8
8
|
|
|
9
|
-
-
|
|
9
|
+
- Loading no longer goes through Rails fixtures. It inserts the YAML values as they are, so values that Rails fixtures used to change now load unchanged.
|
|
10
|
+
- ERB is no longer evaluated, and `$LABEL` is no longer replaced with the record's label.
|
|
11
|
+
- A missing key loads as `NULL` instead of the column default.
|
|
12
|
+
- Ids, timestamps and enum values are no longer filled in or looked up. If you add records to the YAML by hand, give them every column they need, including the id.
|
|
13
|
+
- Dumping works per table instead of per model. Every table gets dumped, including tables without a model, except Rails' own `schema_migrations` and `ar_internal_metadata`.
|
|
14
|
+
- Labels are the table name and all primary key values, e.g. `posts_1` instead of `Post_1`. With a composite primary key, values containing `_` or `"` are quoted. This changes every label once.
|
|
15
|
+
- Tables without a primary key, e.g. `has_and_belongs_to_many` join tables, are dumped as a list of rows instead of a mapping.
|
|
10
16
|
|
|
11
17
|
### Non-breaking
|
|
12
18
|
|
|
19
|
+
- Dump columns in alphabetical order, after the primary key columns. The order no longer depends on when a migration added a column. This reorders the keys in your YAML files once.
|
|
20
|
+
- Fix JSON columns getting encoded a second time when loading.
|
|
21
|
+
- Fix tables with generated columns: the dump leaves those columns out, so loading no longer fails on them.
|
|
22
|
+
- Fix single-table inheritance: all rows of a table end up in its file, instead of each class overwriting the file with its own rows.
|
|
23
|
+
- Support primary keys not called `id`, and composite primary keys.
|
|
24
|
+
- Validate each row once, with the validations of its own class. Rows whose single-table inheritance class does not exist are reported as invalid.
|
|
25
|
+
- Fix records hidden by a `default_scope`: they are now validated before dumping, and a table whose records are all hidden is no longer left out of the dump.
|
|
26
|
+
|
|
27
|
+
## [0.2.1] - 2026-05-08
|
|
28
|
+
|
|
29
|
+
### Breaking
|
|
30
|
+
|
|
13
31
|
-
|
|
14
32
|
|
|
33
|
+
### Non-breaking
|
|
34
|
+
|
|
35
|
+
- Drop post-install message
|
|
36
|
+
|
|
15
37
|
## [0.2.0] - 2026-05-08
|
|
16
38
|
|
|
17
39
|
### Breaking
|
data/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
[](https://rubygems.org/gems/static_db)
|
|
2
|
-
[](https://github.com/dunkelziffer/static_db/actions/workflows/ci.yml)
|
|
3
3
|
|
|
4
4
|
# static_db
|
|
5
5
|
|
|
@@ -55,10 +55,44 @@ The boot sequence hooks are inactive in all other cases, e.g.:
|
|
|
55
55
|
- When you run migrations: `bin/rails db:migrate`
|
|
56
56
|
- When you run any other rake tasks
|
|
57
57
|
|
|
58
|
+
## Data format
|
|
59
|
+
|
|
60
|
+
Every table with rows gets a YAML file named after it, except Rails' own `schema_migrations` and `ar_internal_metadata`. Records are ordered by primary key and labeled with the table name and their primary key values, e.g. `posts_1`. Their columns come in alphabetical order, after the primary key columns. `NULL` values are left out.
|
|
61
|
+
|
|
62
|
+
> ⚠️ A table without a primary key, like the join table of a `has_and_belongs_to_many` association, has nothing to label its rows with. It gets dumped as a list of rows instead, ordered by all columns, which makes for less readable diffs. Prefer tables with a primary key, e.g. `has_many :through` with a join model.
|
|
63
|
+
|
|
64
|
+
## Workflows
|
|
65
|
+
|
|
66
|
+
Both workflows run the rake tasks by hand, so make sure no server is running: it would load or dump on its own.
|
|
67
|
+
|
|
68
|
+
### Applying a migration
|
|
69
|
+
|
|
70
|
+
The hooks don't run for `db:migrate`, so load the YAML files first and dump the migrated database afterwards:
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
bin/rails static:load
|
|
74
|
+
# write the migration
|
|
75
|
+
bin/rails db:migrate static:dump
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Upgrading static_db
|
|
79
|
+
|
|
80
|
+
When the [CHANGELOG](CHANGELOG.md) lists a breaking change, the new version may read or write your YAML files differently. Load them with the version that wrote them, then dump them with the new one:
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
bin/rails static:load # still on the old version
|
|
84
|
+
bundle update static_db
|
|
85
|
+
bin/rails static:dump
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Review the diff before you commit it.
|
|
89
|
+
|
|
58
90
|
## Contributing
|
|
59
91
|
|
|
60
92
|
Bug reports and pull requests are welcome on GitHub at [https://github.com/dunkelziffer/static_db](https://github.com/dunkelziffer/static_db).
|
|
61
93
|
|
|
94
|
+
Run the tests with `bundle exec rspec` and the linter with `bundle exec rubocop`. Each directory in `spec/cases/` is a small app with its own schema, models and golden YAML files. After an intended change to the dump format, regenerate the golden files with `UPDATE_GOLDEN=1 bundle exec rspec` and review the diff.
|
|
95
|
+
|
|
62
96
|
## License
|
|
63
97
|
|
|
64
98
|
The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
|
data/lib/static_db/dump.rb
CHANGED
|
@@ -1,37 +1,55 @@
|
|
|
1
1
|
module StaticDb
|
|
2
2
|
class Dump
|
|
3
|
-
|
|
3
|
+
# A table's rows as the database returns them, ordered by primary key, or by all
|
|
4
|
+
# columns for a table without one.
|
|
5
|
+
Table = Data.define(:name, :primary_key, :columns, :rows)
|
|
4
6
|
|
|
5
7
|
def initialize(static_db_path: StaticDb.path)
|
|
6
8
|
@static_db_path = Pathname.new(static_db_path)
|
|
7
|
-
@models_to_be_saved = models
|
|
8
9
|
end
|
|
9
10
|
|
|
10
11
|
def perform
|
|
11
12
|
exit 1 if $skip_static_db_dump
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
tables = ActiveRecord::Base.with_connection { |connection| read_tables(connection) }
|
|
15
|
+
validate_records!(tables)
|
|
14
16
|
reset_data_directory!
|
|
15
|
-
dump_data!
|
|
17
|
+
dump_data!(tables)
|
|
16
18
|
end
|
|
17
19
|
|
|
18
20
|
private
|
|
19
21
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
# Every table, except Rails' own and empty ones.
|
|
23
|
+
def read_tables(connection)
|
|
24
|
+
(connection.tables - internal_tables).sort.filter_map do |name|
|
|
25
|
+
primary_key = connection.primary_keys(name)
|
|
26
|
+
columns = dumped_columns(connection.columns(name), primary_key)
|
|
27
|
+
order = (primary_key.presence || columns.map(&:name)).map { |column| connection.quote_column_name(column) }
|
|
28
|
+
rows = connection.select_all("SELECT * FROM #{connection.quote_table_name(name)} ORDER BY #{order.join(", ")}").to_a
|
|
22
29
|
|
|
23
|
-
|
|
30
|
+
Table.new(name:, primary_key:, columns:, rows:) if rows.any?
|
|
31
|
+
end
|
|
32
|
+
end
|
|
24
33
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
34
|
+
def internal_tables
|
|
35
|
+
base = ActiveRecord::Base
|
|
36
|
+
[ base.schema_migrations_table_name, base.internal_metadata_table_name ].map do |name|
|
|
37
|
+
"#{base.table_name_prefix}#{name}#{base.table_name_suffix}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
28
40
|
|
|
29
|
-
|
|
30
|
-
|
|
41
|
+
# Generated columns are left out: the database computes them and refuses values for them.
|
|
42
|
+
# Primary key columns come first, in the key's order, then the others alphabetically, so
|
|
43
|
+
# the order does not depend on when a migration added a column.
|
|
44
|
+
def dumped_columns(columns, primary_key)
|
|
45
|
+
columns.reject(&:virtual?).sort_by { |column| [ primary_key.index(column.name) || primary_key.size, column.name ] }
|
|
46
|
+
end
|
|
31
47
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
48
|
+
def validate_records!(tables)
|
|
49
|
+
puts green("Validating records ...")
|
|
50
|
+
|
|
51
|
+
Rails.application.eager_load!
|
|
52
|
+
errors = tables.flat_map { |table| validation_errors(table) }
|
|
35
53
|
|
|
36
54
|
if errors.any?
|
|
37
55
|
puts red("Found #{errors.length} invalid records!")
|
|
@@ -52,48 +70,73 @@ module StaticDb
|
|
|
52
70
|
end
|
|
53
71
|
end
|
|
54
72
|
|
|
73
|
+
# Validates every row of the table once per model that owns the table, as an instance of
|
|
74
|
+
# the row's own class: for single-table inheritance, Rails picks the subclass from the
|
|
75
|
+
# type column. Scopes do not apply, so no row is left out.
|
|
76
|
+
def validation_errors(table)
|
|
77
|
+
table_models(table.name).flat_map do |model|
|
|
78
|
+
table.rows.filter_map do |row|
|
|
79
|
+
record = model.instantiate(row)
|
|
80
|
+
next if record.valid?
|
|
81
|
+
|
|
82
|
+
error = { model: record.class.name, id: record.id, errors: record.errors.full_messages }
|
|
83
|
+
error[:slug] = record.slug if record.respond_to?(:slug)
|
|
84
|
+
error
|
|
85
|
+
rescue ActiveRecord::SubclassNotFound
|
|
86
|
+
type = model.inheritance_column
|
|
87
|
+
{ model: model.name, id: row.values_at(*table.primary_key).join(", "), errors: [ "Unknown #{type} #{row[type].inspect}" ] }
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Models whose parent class does not use the same table: the base class of a
|
|
93
|
+
# single-table inheritance hierarchy, or any other model of that table.
|
|
94
|
+
def table_models(table_name)
|
|
95
|
+
ActiveRecord::Base.descendants.select do |model|
|
|
96
|
+
next false if model.abstract_class? || model.table_name != table_name
|
|
97
|
+
|
|
98
|
+
parent = model.superclass
|
|
99
|
+
parent == ActiveRecord::Base || parent.abstract_class? || parent.table_name != table_name
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
55
103
|
def reset_data_directory!
|
|
56
104
|
FileUtils.rm_rf(@static_db_path)
|
|
57
105
|
FileUtils.mkdir_p(@static_db_path)
|
|
58
106
|
end
|
|
59
107
|
|
|
60
|
-
def dump_data!
|
|
108
|
+
def dump_data!(tables)
|
|
61
109
|
puts green("Dumping data ...")
|
|
62
110
|
|
|
63
|
-
|
|
111
|
+
tables.each { |table| File.write(@static_db_path.join("#{table.name}.yml"), records(table).to_yaml) }
|
|
64
112
|
|
|
65
113
|
puts green("Done!")
|
|
66
114
|
end
|
|
67
115
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
116
|
+
# A mapping from labels to records, or a list of records for a table without a primary
|
|
117
|
+
# key, whose rows may not even be unique.
|
|
118
|
+
def records(table)
|
|
119
|
+
records = table.rows.map { |row| record(row, table.columns) }
|
|
120
|
+
return records if table.primary_key.empty?
|
|
71
121
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
end
|
|
122
|
+
labels = table.rows.map { |row| label(table, row) }
|
|
123
|
+
raise "#{table.name}: labels are not unique" unless labels.uniq.size == labels.size
|
|
75
124
|
|
|
76
|
-
|
|
77
|
-
model.unscoped.order(:id).to_h do |instance|
|
|
78
|
-
[ "#{model}_#{instance.id}", instance_data(instance, model.columns) ]
|
|
79
|
-
end
|
|
125
|
+
labels.zip(records).to_h
|
|
80
126
|
end
|
|
81
127
|
|
|
82
|
-
def
|
|
83
|
-
columns.filter_map
|
|
84
|
-
value = instance.read_attribute_before_type_cast(column.name)
|
|
85
|
-
next if value.nil?
|
|
86
|
-
|
|
87
|
-
[ column.name, value ]
|
|
88
|
-
end.to_h
|
|
128
|
+
def record(row, columns)
|
|
129
|
+
columns.filter_map { |column| [ column.name, row[column.name] ] unless row[column.name].nil? }.to_h
|
|
89
130
|
end
|
|
90
131
|
|
|
91
|
-
|
|
92
|
-
|
|
132
|
+
# The table name and the primary key values, e.g. "posts_1" or "translations_de_hello".
|
|
133
|
+
# With more than one key column, a value containing "_" or '"' is quoted, so that
|
|
134
|
+
# ("a_b", "c") and ("a", "b_c") get different labels.
|
|
135
|
+
def label(table, row)
|
|
136
|
+
values = row.values_at(*table.primary_key).map(&:to_s)
|
|
137
|
+
values = values.map { |value| value.match?(/[_"]/) ? value.inspect : value } if values.size > 1
|
|
93
138
|
|
|
94
|
-
|
|
95
|
-
model.table_exists? && model.any? && model != ActiveRecord::SchemaMigration
|
|
96
|
-
end
|
|
139
|
+
[ table.name, *values ].join("_")
|
|
97
140
|
end
|
|
98
141
|
|
|
99
142
|
def green(text)
|
data/lib/static_db/load.rb
CHANGED
|
@@ -24,17 +24,56 @@ module StaticDb
|
|
|
24
24
|
puts green("Done!")
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
# Inserts the rows exactly as they were dumped, one table per file. Unlike Rails fixtures:
|
|
28
|
+
# no ERB, no $LABEL, no generated ids or timestamps, no enum or association lookups, no
|
|
29
|
+
# type casting, and a missing column is NULL instead of the column default.
|
|
27
30
|
def load_data!
|
|
28
31
|
puts green("Loading data ...")
|
|
29
32
|
|
|
30
|
-
|
|
31
|
-
File.basename(file, "
|
|
33
|
+
tables = Dir.glob(@static_db_path.join("*.yml")).sort.to_h do |file|
|
|
34
|
+
[ File.basename(file, ".yml"), read_rows(file) ]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
ActiveRecord::Base.with_connection do |connection|
|
|
38
|
+
connection.transaction do
|
|
39
|
+
connection.disable_referential_integrity do
|
|
40
|
+
tables.each { |table, rows| insert(connection, table, rows) }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
connection.check_all_foreign_keys_valid!
|
|
45
|
+
tables.each_key { |table| connection.reset_pk_sequence!(table) } if connection.respond_to?(:reset_pk_sequence!)
|
|
32
46
|
end
|
|
33
|
-
ActiveRecord::FixtureSet.create_fixtures(@static_db_path, base_names)
|
|
34
47
|
|
|
35
48
|
puts green("Done!")
|
|
36
49
|
end
|
|
37
50
|
|
|
51
|
+
# A mapping from labels to records, or a list of records for a table without a primary key.
|
|
52
|
+
def read_rows(file)
|
|
53
|
+
records = YAML.safe_load_file(file, permitted_classes: [ Date, Time ], aliases: true) || []
|
|
54
|
+
records = records.values if records.is_a?(Hash)
|
|
55
|
+
raise "#{file} is neither a mapping of labels to records nor a list of records" unless records.is_a?(Array) && records.all?(Hash)
|
|
56
|
+
|
|
57
|
+
records
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def insert(connection, table, rows)
|
|
61
|
+
columns = connection.columns(table).reject(&:virtual?)
|
|
62
|
+
unknown = rows.flat_map(&:keys).uniq - columns.map(&:name)
|
|
63
|
+
raise "Cannot load #{table}.#{unknown.join(", ")}: no such column, or a generated one" if unknown.any?
|
|
64
|
+
|
|
65
|
+
column_list = columns.map { |column| connection.quote_column_name(column.name) }.join(", ")
|
|
66
|
+
rows.each_slice(1000) do |slice|
|
|
67
|
+
values = slice.map { |row| "(#{columns.map { |column| quote(connection, column, row[column.name]) }.join(", ")})" }
|
|
68
|
+
connection.execute("INSERT INTO #{connection.quote_table_name(table)} (#{column_list}) VALUES #{values.join(", ")}")
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def quote(connection, column, value)
|
|
73
|
+
value = ActiveModel::Type::Binary::Data.new(value) if column.type == :binary && value.is_a?(String)
|
|
74
|
+
connection.quote(value)
|
|
75
|
+
end
|
|
76
|
+
|
|
38
77
|
def green(text)
|
|
39
78
|
"\e[32m#{text}\e[0m"
|
|
40
79
|
end
|
data/lib/static_db/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: static_db
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Klaus Weidinger
|
|
@@ -52,9 +52,6 @@ metadata:
|
|
|
52
52
|
bug_tracker_uri: https://github.com/dunkelziffer/static_db/issues
|
|
53
53
|
documentation_uri: https://github.com/dunkelziffer/static_db/blob/main/README.md
|
|
54
54
|
rubygems_mfa_required: 'true'
|
|
55
|
-
post_install_message: "⚠️ This gem modifies the Rails startup sequence. Don't use
|
|
56
|
-
this gem unless you want to build a static site generator. This gem also creates
|
|
57
|
-
and drops the DB for you."
|
|
58
55
|
rdoc_options: []
|
|
59
56
|
require_paths:
|
|
60
57
|
- lib
|