static_db 0.2.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6d592e282b1c5d72ea6a18964d5e9ac5e98fa8c42f9c417c11ea21454a8e6273
4
- data.tar.gz: 136cb048f205ffb2bb54334ba3f95bc099fbbb17712a2afe9aea1487b565250b
3
+ metadata.gz: fda821f04db0db10b8c6beea35eb177028dedfb6ba8e2f22fbbddd5164ea4d30
4
+ data.tar.gz: 31bab97a76c1f75499d93fcbbe4e0a448109069d47ef85ad7cddf7dbf0f25957
5
5
  SHA512:
6
- metadata.gz: 54feb805dff8f4e80228178f3c6d1180fa5d8be3ff23297824d711dc58656834fe68fc54fb9e1fff38a9b78db4e56ed8bc732e45c6b8c0dc4b009a3fdb7ef5ca
7
- data.tar.gz: 455ce380cc64e703dfd1058742eb26c6568f0497e7c307dbff16a7cb803904b410cca2e104fe39021876ceee75631fe7b2a5c50c0d898dfc25d864b80d307ea0
6
+ metadata.gz: 69fbeebc35245d0a9d47d6ab0fc3e646e4e20dd4a98a01a12b5e706d0ea4155ae17656f171288feb86663ced7c6bdc3fe32ce8adf396ce9f04116fa5ce051dc9
7
+ data.tar.gz: 7debc1202b841a20e1149fc6898deb55ae998bb7ac14b78c019c6e59c00bda91b81069cec0af2be26b366a8210273097f9f86ce2a96779e31f4ff56480188b0a
data/CHANGELOG.md CHANGED
@@ -6,11 +6,23 @@ 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
 
13
- -
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.
14
26
 
15
27
  ## [0.2.1] - 2026-05-08
16
28
 
data/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  [![Gem Version](https://badge.fury.io/rb/static_db.svg)](https://rubygems.org/gems/static_db)
2
- [![Build](https://github.com/dunkelziffer/static_db/workflows/Build/badge.svg)](https://github.com/dunkelziffer/static_db/actions)
2
+ [![CI](https://github.com/dunkelziffer/static_db/actions/workflows/ci.yml/badge.svg)](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).
@@ -1,37 +1,55 @@
1
1
  module StaticDb
2
2
  class Dump
3
- attr_reader :models_to_be_saved
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
- validate_records!
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
- def validate_records!
21
- puts green("Validating records ...")
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
- errors = []
30
+ Table.new(name:, primary_key:, columns:, rows:) if rows.any?
31
+ end
32
+ end
24
33
 
25
- models_to_be_saved.each do |model|
26
- model.find_each do |record|
27
- next if record.valid?
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
- debug_data = { model: model.name, id: record.id, errors: record.errors.full_messages }
30
- debug_data[:slug] = record.slug if record.respond_to?(:slug)
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
- errors << debug_data
33
- end
34
- end
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
- models_to_be_saved.each { |model| write_file!(path: file_path(model), data: data(model)) }
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
- def write_file!(path:, data:)
69
- File.open(path, "w") { |file| file << data.to_yaml }
70
- end
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
- def file_path(model)
73
- File.join(@static_db_path, "#{model.table_name}.yml")
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
- def data(model)
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 instance_data(instance, columns)
83
- columns.filter_map do |column|
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
- def models
92
- Rails.application.eager_load!
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
- ActiveRecord::Base.descendants.select do |model|
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)
@@ -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
- base_names = Dir.glob(File.join(@static_db_path, "*.yml")).map do |file|
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
@@ -1,3 +1,3 @@
1
1
  module StaticDb # :nodoc:
2
- VERSION = "0.2.1"
2
+ VERSION = "0.3.0"
3
3
  end
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.2.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Klaus Weidinger