fixture_champagne 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9c999bd5772d62cce811ec251c0a6703a58bd7daf7b31d6d00ed08fe80f9133f
4
+ data.tar.gz: 1bb147d5724d90fd9341b480b449218e71827ab00e694bc6c10d1ffaa99bda59
5
+ SHA512:
6
+ metadata.gz: b0a14630348c2e463ca683801cc72be51e9e8b65466a1fb73143072a486979991484dc6d2c80fe081287821d4afa8570c45ec4f33c13f4d2996a22c2202021f4
7
+ data.tar.gz: 6c6e4d55bf6654cee801ca4545f052351f4ef81505921240e1e007d362e12e9c050f22cc3f9305c566b8946b7fd81831309dce87f00619266c35bd2263cf3bd5
data/README.md ADDED
@@ -0,0 +1,267 @@
1
+ # Fixture Champagne :champagne:
2
+
3
+ ### Fixture migrations for your Ruby on Rails applications
4
+
5
+
6
+ Fixture Champagne is designed to help you keep your fixtures tidy, applying the data migration pattern to create, update or destroy fixtures.
7
+
8
+ It supports label references for `belongs_to` associations, both regular and polymorphic, single table inheritance, enums and all the different data types.
9
+
10
+
11
+ ## Installation
12
+
13
+ 1. Add `fixture-champagne` to the development group of your Rails app's `Gemfile`:
14
+
15
+ ```ruby
16
+ group :development do
17
+ gem 'fixture-champagne'
18
+ end
19
+ ```
20
+
21
+ 2. Then, in your project directory:
22
+
23
+ ```sh
24
+ # Download and install
25
+ $ bundle install
26
+
27
+ # Generate fixture_migrations folder in your test or spec folder, depending on your test suite
28
+ $ bin/rails generate fixture_champagne:install
29
+ ```
30
+
31
+
32
+ ## Usage
33
+
34
+
35
+ ### Sync fixtures with schema
36
+
37
+ If your schema version changed and you need to add any new column to the current fixtures, simply run:
38
+
39
+ ```sh
40
+ bin/rails fixture_champagne:migrate
41
+ ```
42
+
43
+ The migration process will regenarate your `fixtures` folder.
44
+
45
+
46
+ ### Add, update or destroy fixtures
47
+
48
+ If you need specific values for the any new columns or you want to populate a newly created table, you might find it useful to create a fixture migration. This can be done using the generator:
49
+
50
+ ```sh
51
+ bin/rails generate fixture_champagne:migration new_migration_name
52
+ ```
53
+
54
+ A new versioned migration file will be created in the `fixture_migrations` folder. If this is your first migration, make sure that folder exists or run the installation command.
55
+
56
+ `ActiveRecord` queries and fixture accessors can be used inside the migrations. For example, let's suppose you've just added the `Enemy` model and you need to create a new enemy fixture having the following files:
57
+
58
+ ```ruby
59
+ # models/level.rb
60
+
61
+ class Level < ApplicationRecord
62
+ has_many :enemies
63
+ end
64
+
65
+ # models/enemy.rb
66
+
67
+ class Enemy < ApplicationRecord
68
+ belongs_to :level
69
+ end
70
+ ```
71
+
72
+ ```yaml
73
+ # test/fixtures/levels.yml
74
+
75
+ first_level:
76
+ name: Initial
77
+ ```
78
+
79
+ You can then generate a new migration:
80
+
81
+ ```sh
82
+ bin/rails generate fixture_champagne:migration create_initial_enemy
83
+ ```
84
+
85
+ The generator automatically adds a version number to the new migration file, which is important to keep track of executed migrations. Also, the migration filename must correspond with the migration class inside the file. All this should feel similar to the way schema migrations are handled by Rails.
86
+
87
+ Add the `up` and `down` logic to the new migration:
88
+
89
+ ```ruby
90
+ # 20230126153650_create_initial_enemy.rb
91
+
92
+ class CreateInitialEnemy < FixtureChampagne::Migration::Base
93
+ def up
94
+ unless Enemy.find_by(name: "Initial Enemy").present?
95
+ Enemy.create!(name: "Initial Enemy", level: levels(:first_level))
96
+ end
97
+ end
98
+
99
+ def down
100
+ Enemy.find_by(name: "Initial Enemy").destroy!
101
+ end
102
+ end
103
+ ```
104
+
105
+ Running `bin/rails fixture_champagne:migrate` will execute the `up` method of all pending migrations in ascending version order to update the test database. The `fixtures` folder is regenerated at the end of the process only if all the migrations were successfully executed. In this case, it would generate the following file:
106
+
107
+ ```yaml
108
+ # test/fixtures/enemies.yml
109
+
110
+ enemies_12345678:
111
+ level: first_level
112
+ name: Initial Enemy
113
+ ```
114
+
115
+ If the migration is successful, the migrator will take the max version all available migrations and the current schema version and save both numbers in `test/.fixture_champagne_versions.yml` (or `spec/` if using Rspec) to identify future pending migrations.
116
+
117
+ The default label for a new fixture is a unique identifier composed of the table name and the record id. However, this label can be configured (keep reading to know how).
118
+
119
+
120
+ ### Rollback
121
+
122
+ You can optionally complete the `down` method in the migration to allow rollback. Running the following command will rollback the last executed migration:
123
+
124
+ ```sh
125
+ bin/rails fixture_champagne:rollback
126
+ ```
127
+
128
+ New max version will be set to the next one in descending order. Schema version won't change. Any changes in the configuration apply to both `migrate` or `rollback`.
129
+
130
+
131
+ ### Configuration
132
+
133
+ You can better control fixture migrations by creating a config YAML file: `test/fixture_champagne.yml` (or, again, `spec/`).
134
+
135
+ #### Overwrite current fixtures
136
+
137
+ Setting the `overwrite` key to `false` will leave your current fixtures untouched. The generated fixtures will go to `tmp/fixtures`. Default value is set to `true`.
138
+
139
+ ```yaml
140
+ # test/fixture_champagne.yml
141
+
142
+ overwrite: false
143
+ ```
144
+
145
+ #### Fixture labels
146
+
147
+ Setting the `label` key will allow you to control the names your fixtures get. It accepts a hash, where keys are table names and values are label templates: strings interpolated with a I18n style syntax. Interpolated keywords must be instance methods or attributes.
148
+
149
+ In the previous example, you can configure:
150
+
151
+ ```yaml
152
+ # test/fixture_champagne.yml
153
+
154
+ label:
155
+ enemy: "%{name}"
156
+ ```
157
+
158
+ To generate:
159
+
160
+ ```yaml
161
+ # test/fixtures/enemies.yml
162
+
163
+ initial_enemy:
164
+ level: first_level
165
+ name: Initial Enemy
166
+ ```
167
+
168
+ #### Rename current fixtures
169
+
170
+ Setting the `rename` key to `true` will force every fixture label to follow the templates in the configuration. Default value is `false`. If any table is not configured, the default label will be used (something like `%{table_name}_%{id}` if `table_name` was an instance method).
171
+
172
+ ```yaml
173
+ # test/fixture_champagne.yml
174
+
175
+ rename: true
176
+ ```
177
+
178
+ If `rename` is set to `true`, every time you run `migrate` or `rollback` all fixtures will be regenerated in the corresponding folder (depending on `overwrite`), even if there's no pending migrations or schema version is up to date. Keep in mind that a renaming might break the fixture accessors in your tests or previous migrations. It could also break unsupported attachment fixtures.
179
+
180
+ #### Ignore tables
181
+
182
+ Setting the `ignore` key will allow you to control which tables get saved as fixtures. It accepts an array, where items are table names. Any table ignored by this configuration will disappear from the corresponding fixture folder (depending on `overwrite`).
183
+
184
+ Let's say for example that each time a new `Enemy` gets created, it creates an associated `Event` in a callback that runs some processing in the background. If that event belongs to a polymorphic `eventable`, for every single one of those, a new event will be added to your fixtures, making the `events.yml` a big but not very useful file. Or maybe events get incinerated a couple of days after execution and it makes no sense to have fixtures for them. In any of those situations, you could ignore them from fixtures like this:
185
+
186
+ ```yaml
187
+ # test/fixture_champagne.yml
188
+
189
+ ignore:
190
+ - events
191
+ ```
192
+
193
+ This configuration does not change the shape of the database after the migrations as the database transactions are left untouched (for example, events will be created anyway) but next time fixtures get loaded, no item from ignored tables will be present. This could break the integrity of your database, so make sure everything is working afterwards.
194
+
195
+
196
+ ### Manually adding or editing fixtures
197
+
198
+ Nothing prevents you from manually editing your fixture files. Take into account that the next time that you run migrations, what's on your fixtures will define the initial state of your migration database, which could break previous migrations or rollbacks (in the rare case that you need to run them again). The next time you run `migrate`, the migrator will tidy the information you added manually.
199
+
200
+
201
+ ### Generated fixtures folder structure
202
+
203
+ On namespaced models, the migrator will create a folder for each level and a `.yml` file for the last one. For example, `Level::Enemy` fixtures will be saved in `fixtures/level/enemies.yml`.
204
+
205
+ If you use single table inheritance, then the file will correspond with the parent model, the owner of the table. For example, `class Weapon::Rocket < Weapon; end` will be saved in `fixtures/weapons.yml`.
206
+
207
+ All fixtures files that correspond to attachments will be copied as they are. Those are the ones located in `fixtures/files`, `fixtures/active_storage` and `fixtures/action_text`.
208
+
209
+
210
+ ## Features currently not supported
211
+
212
+ The following fixture features are not supported:
213
+ - More than one test suite in the same application
214
+ - Dynamic ERB fixtures (considered a code smell in the [Rails documentation](https://edgeapi.rubyonrails.org/classes/ActiveRecord/FixtureSet.html))
215
+ - Explicit `created_at` or `updated_at` timestamps (favoured autofilled ones)
216
+ - Explicit `id` (favoured label references)
217
+ - Fixture label interpolation (favoured configuration)
218
+ - HABTM (`have_and_belong_to_many`) associations as inline lists
219
+ - Support for YAML defaults (this could be nice)
220
+
221
+ As stated before, at least for now, fixtures files that correspond to attachments will be copied as they are. This means:
222
+ - This fixtures must be generated manually
223
+ - This fixtures must be updated manually if other fixtures labels change
224
+ - All this files will be left untouched
225
+
226
+ ## A few soft recommendations
227
+
228
+
229
+ #### Don't have too many fixtures
230
+
231
+ The goal of this gem is to make it easier to keep fixtures tidy and up to date as things start to get complicated, so that factories aren't your only option. But no gem can replace good ol' discipline. If a new fixture gets added for every single small feature or bugfix, maintenance will be hard no matter the tool.
232
+
233
+ Reduce repetition, reuse fixtures using helpers to modify them in the tests or use factories for some of your tests.
234
+
235
+ #### Make your migrations idempotent
236
+
237
+ Versions saved in `.fixture_champagne_versions.yml` are there to ensure that your migrations are only executed once, but it would be a good idea to design your migrations to be idempotent, meaning that executing them more than once does not change the results.
238
+
239
+ #### Raise errors
240
+
241
+ Raise errors to stop the migration if there are invalid objects. A good way to do that is using `ActiveRecord` bang methods `create!`, `update!` and `destroy!`.
242
+
243
+ #### Review changes before git commits
244
+
245
+ The safest way to rollback a migration is to revert any changes made to your `fixtures` folder and versions file using git. After migrating, inspect the changes made to the fixture folder and run the whole test suite.
246
+
247
+
248
+ ## Contributing
249
+
250
+ Feel free to open an issue if you have any doubt, suggestion or find buggy behaviour. If it's a bug, it's always great if you can provide a minimum Rails app that reproduces the issue.
251
+
252
+ This project uses [Rubocop](https://github.com/rubocop/rubocop) to format Ruby code. Please make sure to run `rubocop` on your branch before submitting pull requests. You can do that by running `bundle exec rubocop -A`.
253
+
254
+ Also run the tests for each supported Rails version with:
255
+ ```sh
256
+ bundle exec appraisal rake test
257
+ ```
258
+
259
+
260
+ ## License
261
+
262
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
263
+
264
+
265
+ ## Code of Conduct
266
+
267
+ Everyone interacting in the FixtureChampagne project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/fixture_champagne/blob/master/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+
5
+ require "bundler/gem_tasks"
6
+
7
+ require "rdoc/task"
8
+
9
+ RDoc::Task.new(:rdoc) do |rdoc|
10
+ rdoc.rdoc_dir = "rdoc"
11
+ rdoc.title = "Fixture Champagne"
12
+ rdoc.options << "--line-numbers"
13
+ rdoc.rdoc_files.include("README.md")
14
+ rdoc.rdoc_files.include("lib/**/*.rb")
15
+ end
16
+
17
+ $LOAD_PATH << File.expand_path("test", __dir__)
18
+ require "rails/plugin/test"
19
+
20
+ task default: :test
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FixtureChampagne
4
+ # Migration contain naming rules for migrations files and objects
5
+ class Migration
6
+ MIGRATION_FILENAME_REGEXP = /\A([0-9]+)_([_a-z0-9]*)\.rb\z/.freeze
7
+
8
+ class << self
9
+ def new_migration_version
10
+ Time.now.utc.strftime("%Y%m%d%H%M%S")
11
+ end
12
+ end
13
+
14
+ # Migration::Base is the ancestor of generated fixture migrations.
15
+ #
16
+ # Inheriting from Base allows migrations to have access to fixture accessors.
17
+ class Base
18
+ attr_reader :version, :migrator
19
+
20
+ def initialize(version)
21
+ @version = version
22
+ end
23
+
24
+ def migrate(direction:, migrator:)
25
+ @migrator = migrator
26
+ send(direction)
27
+ end
28
+
29
+ private
30
+
31
+ def method_missing(name, *args, **kwargs, &block)
32
+ if migrator.pre_existing_fixture_accessors.include?(name.to_s)
33
+ migrator.send(name, *args, **kwargs, &block)
34
+ else
35
+ super
36
+ end
37
+ end
38
+
39
+ def respond_to_missing?(name, include_private = false)
40
+ if include_private && migrator.pre_existing_fixture_accessors.include?(name.to_s)
41
+ true
42
+ else
43
+ super
44
+ end
45
+ end
46
+ end
47
+
48
+ Proxy = Struct.new(:name, :version, :filename) do
49
+ def initialize(name, version, filename)
50
+ super
51
+ @migration = nil
52
+ end
53
+
54
+ def basename
55
+ File.basename(filename)
56
+ end
57
+
58
+ delegate :migrate, to: :migration
59
+
60
+ private
61
+
62
+ def migration
63
+ @migration ||= load_migration
64
+ end
65
+
66
+ def load_migration
67
+ begin
68
+ Object.send(:remove_const, name)
69
+ rescue StandardError
70
+ nil
71
+ end
72
+
73
+ load(File.expand_path(filename))
74
+ name.constantize.new(version)
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FixtureChampagne
4
+ # MigrationContext sets the context in which a fixture migration is run.
5
+ #
6
+ # A migration context checks where the application files are located, which could be
7
+ # in /test if the app uses Minitest or /spec if the app uses Rspec, and from that base
8
+ # it decides where everything else is located:
9
+ # - Fixture migrations folder
10
+ # - Configuration YAML file
11
+ # - Saved versions YAML file
12
+ # - Fixtures path
13
+ #
14
+ # With all that it decides which migrations are pending, which are executed and the target
15
+ # versions. Depending on the method called, it also decides the direction the Migrator should execute.
16
+ class MigrationContext
17
+ class << self
18
+ def migrate
19
+ build_context.migrate
20
+ end
21
+
22
+ def rollback
23
+ build_context.rollback
24
+ end
25
+
26
+ def build_context
27
+ new(
28
+ fixture_migrations_path: fixture_migrations_path,
29
+ schema_current_version: schema_current_version,
30
+ fixtures_migration_version: fixture_versions["version"]&.to_i || 0,
31
+ fixtures_schema_version: fixture_versions["schema_version"]&.to_i || 0,
32
+ configuration: configuration
33
+ )
34
+ end
35
+
36
+ def fixture_migrations_path
37
+ raise MissingMigrationsFolderError unless expected_fixture_migrations_path.exist?
38
+
39
+ expected_fixture_migrations_path
40
+ end
41
+
42
+ def expected_fixture_migrations_path
43
+ test_suite_folder_path.join("fixture_migrations")
44
+ end
45
+
46
+ def test_suite_folder_path
47
+ rspec_path = Rails.root.join("test")
48
+ minitest_path = Rails.root.join("spec")
49
+
50
+ if minitest_path.exist?
51
+ minitest_path
52
+ elsif rspec_path.exist?
53
+ rspec_path
54
+ else
55
+ raise "No test nor spec folder found"
56
+ end
57
+ end
58
+
59
+ def schema_current_version
60
+ ::ActiveRecord::Migrator.current_version
61
+ end
62
+
63
+ def fixture_versions
64
+ @fixture_versions ||= if fixture_versions_path.exist?
65
+ YAML.load_file(fixture_versions_path)
66
+ else
67
+ {}
68
+ end
69
+ end
70
+
71
+ def fixture_versions_path
72
+ test_suite_folder_path.join(".fixture_champagne_versions.yml")
73
+ end
74
+
75
+ def configuration
76
+ @configuration ||= if configuration_path.exist?
77
+ Configuration.new(YAML.load_file(configuration_path))
78
+ else
79
+ Configuration.new({})
80
+ end
81
+ end
82
+
83
+ def configuration_path
84
+ test_suite_folder_path.join("fixture_champagne.yml")
85
+ end
86
+
87
+ def fixtures_path
88
+ test_suite_folder_path.join("fixtures")
89
+ end
90
+ end
91
+
92
+ attr_reader :fixture_migrations_path, :schema_current_version,
93
+ :fixtures_migration_version, :fixtures_schema_version, :configuration
94
+
95
+ def initialize(
96
+ fixture_migrations_path:, schema_current_version:,
97
+ fixtures_migration_version:, fixtures_schema_version:,
98
+ configuration:
99
+ )
100
+ @fixture_migrations_path = fixture_migrations_path
101
+ @schema_current_version = schema_current_version
102
+ @fixtures_migration_version = fixtures_migration_version
103
+ @fixtures_schema_version = fixtures_schema_version
104
+ @configuration = configuration
105
+ end
106
+
107
+ def migrate
108
+ # If rename_fixtures? is set to true, the migration should run as some label could have changed.
109
+ if pending_migrations.any? || fixtures_schema_version != schema_current_version || configuration.rename_fixtures?
110
+ up
111
+ else
112
+ puts "No fixture migrations pending."
113
+ end
114
+ end
115
+
116
+ def rollback
117
+ if executed_migrations.any?
118
+ down
119
+ else
120
+ puts "No migration to rollback."
121
+ end
122
+ end
123
+
124
+ def up
125
+ Migrator.new(
126
+ direction: :up,
127
+ migrations: pending_migrations,
128
+ target_migration_version: up_target_fixture_migration_version,
129
+ target_schema_version: schema_current_version,
130
+ configuration: configuration
131
+ ).migrate
132
+ end
133
+
134
+ def down
135
+ Migrator.new(
136
+ direction: :down,
137
+ migrations: [executed_migrations.last],
138
+ target_migration_version: down_target_fixture_migration_version,
139
+ target_schema_version: schema_current_version,
140
+ configuration: configuration
141
+ ).migrate
142
+ end
143
+
144
+ def up_target_fixture_migration_version
145
+ return fixtures_migration_version if pending_migrations.empty?
146
+
147
+ pending_migrations.map(&:version).max
148
+ end
149
+
150
+ def down_target_fixture_migration_version
151
+ return 0 if executed_migrations.one?
152
+
153
+ executed_migrations.last(2).first.version
154
+ end
155
+
156
+ def pending_migrations
157
+ migrations.select { |m| m.version > fixtures_migration_version }
158
+ end
159
+
160
+ def executed_migrations
161
+ migrations.select { |m| m.version <= fixtures_migration_version }
162
+ end
163
+
164
+ def migrations
165
+ @migrations ||= set_migrations
166
+ end
167
+
168
+ def set_migrations
169
+ migrations = migration_files.map do |file|
170
+ version, name = parse_migration_filename(file)
171
+ raise IllegalMigrationNameError, file unless version
172
+
173
+ Migration::Proxy.new(name.camelize, version.to_i, file)
174
+ end
175
+
176
+ migrations.sort_by(&:version)
177
+ end
178
+
179
+ def migration_files
180
+ Dir["#{fixture_migrations_path}/**/[0-9]*_*.rb"]
181
+ end
182
+
183
+ def parse_migration_filename(filename)
184
+ File.basename(filename).scan(Migration::MIGRATION_FILENAME_REGEXP).first
185
+ end
186
+
187
+ Configuration = Struct.new(:configuration_data) do
188
+ def initialize(configuration_data)
189
+ super
190
+ @configuration_data = configuration_data.with_indifferent_access
191
+ end
192
+
193
+ def label_templates
194
+ @configuration_data["label"] || {}
195
+ end
196
+
197
+ def overwrite_fixtures?
198
+ return true unless @configuration_data.key?("overwrite")
199
+
200
+ @configuration_data["overwrite"]
201
+ end
202
+
203
+ def rename_fixtures?
204
+ @configuration_data["rename"]
205
+ end
206
+
207
+ def ignored_tables
208
+ @configuration_data["ignore"].to_a || []
209
+ end
210
+ end
211
+ end
212
+ end
@@ -0,0 +1,326 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_fixtures"
4
+
5
+ module FixtureChampagne
6
+ # Migrator parses pre existing fixtures, executes given migrations in the given direction and regenerates fixtures.
7
+ #
8
+ # The migrator implements the TestFixtures module to have access to a test database with pre existing fixtures
9
+ # already loaded and fixture accessors. It allows migrations access to all this.
10
+ # It contains the rules on how to avoid repeated fixtures, how to handle attached files, where to put temporary
11
+ # fixtures before overwritting (if necessary) and how to structure the fixtures folder tree.
12
+ class Migrator
13
+ include TestFixtures
14
+
15
+ attr_reader :direction, :migrations, :target_migration_version, :target_schema_version, :configuration
16
+
17
+ class << self
18
+ def fixture_unique_id(table_name:, id:)
19
+ "#{table_name}_#{id}"
20
+ end
21
+
22
+ def tmp_fixture_path
23
+ Rails.root.join("tmp", "fixtures")
24
+ end
25
+
26
+ def fixture_attachment_folders
27
+ %w[files active_storage action_text].map { |f| fixture_path.join(f) }
28
+ end
29
+ end
30
+
31
+ def initialize(
32
+ direction:, migrations:,
33
+ target_migration_version:, target_schema_version:, configuration:
34
+ )
35
+ @direction = direction
36
+ @migrations = migrations
37
+ @target_migration_version = target_migration_version
38
+ @target_schema_version = target_schema_version
39
+ @configuration = configuration
40
+ end
41
+
42
+ def migrate
43
+ before_migrate
44
+
45
+ process_pre_existing_fixtures
46
+ run_migrations
47
+ create_fixture_files_from_test_db
48
+
49
+ after_migrate
50
+ end
51
+
52
+ def process_pre_existing_fixtures
53
+ @pre_existing_fixtures_label_mapping = pre_existing_fixture_sets.each_with_object({}) do |fixture_set, mapping|
54
+ fixture_set.fixtures.each do |label, fixture|
55
+ unique_id = fixture_unique_id(fixture, fixture_set)
56
+ if mapping.key?(unique_id)
57
+ raise RepeatedFixtureError,
58
+ "repeated fixture in preprocess for label #{label}, unique id #{unique_id}"
59
+ end
60
+ mapping[unique_id] = label.to_s
61
+ end
62
+ end
63
+ end
64
+
65
+ def run_migrations
66
+ migrations.each { |m| m.migrate(direction: direction, migrator: self) }
67
+ end
68
+
69
+ def create_fixture_files_from_test_db
70
+ klasses = fixtureable_models
71
+ fixtures_data = build_fixture_data(klasses)
72
+ create_new_fixture_files(klasses, fixtures_data)
73
+ end
74
+
75
+ # Only generate fixture files for application models that own a table to avoid several files
76
+ # in the case of STI.
77
+ def fixtureable_models
78
+ descendants = ApplicationRecord.descendants
79
+ descendants.filter(&:table_exists?).map do |descendant|
80
+ next if configuration.ignored_tables.include?(descendant.table_name.to_s)
81
+
82
+ table_ancestor = descendant
83
+ table_ancestor = table_ancestor.superclass while table_ancestor.superclass.table_exists?
84
+ table_ancestor
85
+ end.compact.uniq
86
+ end
87
+
88
+ def build_fixture_data(klasses)
89
+ data = serialize_database_records(klasses)
90
+
91
+ data.each_with_object({}) do |(table, table_data), sorted_data|
92
+ next if table_data.empty?
93
+
94
+ sorted_data[table] = table_data.sort.to_h
95
+ end
96
+ end
97
+
98
+ def serialize_database_records(klasses)
99
+ klasses.each_with_object({}) do |klass, hash|
100
+ table_name = klass.table_name.to_s
101
+ if hash.key?(table_name)
102
+ raise RepeatedFixtureError,
103
+ "repeated table key for new fixtures, table #{table_name}, class: #{klass}"
104
+ end
105
+
106
+ hash[table_name] = serialize_table_records(klass)
107
+ end
108
+ end
109
+
110
+ def serialize_table_records(klass)
111
+ table_data = {}
112
+
113
+ klass.all.each do |record|
114
+ label = fixture_label(record)
115
+ if table_data.key?(label)
116
+ raise RepeatedFixtureError,
117
+ "repeated fixture in serialization for label #{label}, class #{klass}"
118
+ end
119
+
120
+ table_data[label] = fixture_serialized_attributes(record)
121
+ end
122
+
123
+ table_data
124
+ end
125
+
126
+ # Record id is built from fixture label via ActiveRecord::FixtureSet.identify
127
+ def fixture_unique_id(fixture, fixture_set)
128
+ self.class.fixture_unique_id(table_name: fixture_set.table_name, id: fixture.find.id)
129
+ end
130
+
131
+ def fixture_label(record)
132
+ labeler.label_for(record: record)
133
+ end
134
+
135
+ def labeler
136
+ @labeler ||= Labeler.new(
137
+ pre_existing_fixtures_labels: @pre_existing_fixtures_label_mapping,
138
+ templates: configuration.label_templates,
139
+ rename: configuration.rename_fixtures?
140
+ )
141
+ end
142
+
143
+ def fixture_serialized_attributes(record)
144
+ serializer.serialized_attributes_for(record: record)
145
+ end
146
+
147
+ def serializer
148
+ @serializer ||= Serializer.new(labeler: labeler)
149
+ end
150
+
151
+ def create_new_fixture_files(klasses, fixtures_data)
152
+ setup_temporary_fixtures_dir
153
+ copy_fixture_attachments
154
+ klasses.each do |klass|
155
+ data = fixtures_data[klass.table_name]
156
+ filename = temporary_fixture_filename(klass)
157
+ create_temporary_fixture_file(data, filename)
158
+ end
159
+ return unless configuration.overwrite_fixtures?
160
+
161
+ overwrite_fixtures
162
+ remember_new_fixture_versions
163
+ end
164
+
165
+ def setup_temporary_fixtures_dir
166
+ FileUtils.rm_r(self.class.tmp_fixture_path, secure: true) if self.class.tmp_fixture_path.exist?
167
+ FileUtils.mkdir(self.class.tmp_fixture_path)
168
+ end
169
+
170
+ def copy_fixture_attachments
171
+ self.class.fixture_attachment_folders.each do |folder|
172
+ FileUtils.cp_r(folder, self.class.tmp_fixture_path) if folder.exist?
173
+ end
174
+ end
175
+
176
+ def temporary_fixture_filename(klass)
177
+ parts = klass.to_s.split("::").map(&:underscore)
178
+ parts << parts.pop.pluralize.concat(".yml")
179
+ self.class.tmp_fixture_path.join(*parts)
180
+ end
181
+
182
+ def create_temporary_fixture_file(data, filename)
183
+ FileUtils.mkdir_p(filename.dirname)
184
+ File.open(filename, "w") do |file|
185
+ yaml = YAML.dump(data).gsub(/\n(?=[^\s])/, "\n\n").delete_prefix("---\n\n")
186
+ file.write(yaml)
187
+ end
188
+ end
189
+
190
+ def overwrite_fixtures
191
+ removable_fixture_path = self.class.fixture_path.dirname.join("old_fixtures")
192
+ FileUtils.mv(self.class.fixture_path, removable_fixture_path)
193
+ FileUtils.mv(self.class.tmp_fixture_path, self.class.fixture_path)
194
+ FileUtils.rm_r(removable_fixture_path, secure: true)
195
+ end
196
+
197
+ def remember_new_fixture_versions
198
+ File.open(MigrationContext.fixture_versions_path, "w") do |file|
199
+ yaml = YAML.dump({ "version" => target_migration_version, "schema_version" => target_schema_version })
200
+ file.write(yaml)
201
+ end
202
+ end
203
+
204
+ # Labeler decides how a fixture should be labeled based on the config file and interpolation rules.
205
+ class Labeler
206
+ INTERPOLATION_PATTERN = /%\{([\w|]+)\}/.freeze
207
+
208
+ def initialize(pre_existing_fixtures_labels:, templates:, rename:)
209
+ @pre_existing_fixtures_labels = pre_existing_fixtures_labels
210
+ @templates = templates
211
+ @rename = rename
212
+ end
213
+
214
+ def label_for(record:)
215
+ if @rename
216
+ build_label_for(record)
217
+ else
218
+ find_label_for(record) || build_label_for(record)
219
+ end
220
+ end
221
+
222
+ private
223
+
224
+ def find_label_for(record)
225
+ @pre_existing_fixtures_labels[record_unique_id(record)]
226
+ end
227
+
228
+ def build_label_for(record)
229
+ template = @templates[record.class.table_name]
230
+
231
+ if template.nil? || template == "DEFAULT"
232
+ default_label(record)
233
+ else
234
+ interpolate_template(template, record)
235
+ end
236
+ end
237
+
238
+ def default_label(record)
239
+ record_unique_id(record)
240
+ end
241
+
242
+ def record_unique_id(record)
243
+ Migrator.fixture_unique_id(table_name: record.class.table_name, id: record.id)
244
+ end
245
+
246
+ def interpolate_template(template, record)
247
+ template.gsub(INTERPOLATION_PATTERN) do
248
+ attribute = ::Regexp.last_match(1).to_sym
249
+ value = if record.respond_to?(attribute)
250
+ record.send(attribute)
251
+ else
252
+ raise WrongFixtureLabelInterpolationError, attribute: attribute, klass: record.class
253
+ end
254
+ value
255
+ end.parameterize(separator: "_")
256
+ end
257
+ end
258
+
259
+ # Serializer decides how instance attributes translate to a hash, later saved as a fixture in a YAML file.
260
+ class Serializer
261
+ attr_reader :labeler
262
+
263
+ def initialize(labeler:)
264
+ @labeler = labeler
265
+ end
266
+
267
+ def serialized_attributes_for(record:)
268
+ column_attributes = record.attributes.select { |a| record.class.column_names.include?(a) }
269
+
270
+ # Favour fixtures autofilled timestamps and autogenerated ids
271
+ filtered_attributes = %w[id created_at updated_at]
272
+
273
+ serialized_attributes = column_attributes.map do |attribute, value|
274
+ serialize_attribute(record, attribute, value, filtered_attributes)
275
+ end
276
+
277
+ serialized_attributes.sort_by(&:first).to_h.except(*filtered_attributes)
278
+ end
279
+
280
+ def serialize_attribute(record, attribute, value, filtered_attributes)
281
+ belongs_to_association = record.class.reflect_on_all_associations.filter(&:belongs_to?).find do |a|
282
+ a.foreign_key.to_s == attribute
283
+ end
284
+ type = record.class.type_for_attribute(attribute)
285
+
286
+ if belongs_to_association.present?
287
+ filter_belongs_to_columns(belongs_to_association, filtered_attributes)
288
+ serialize_belongs_to(record, belongs_to_association)
289
+ else
290
+ serialize_type(record, attribute, value, type)
291
+ end
292
+ end
293
+
294
+ def serialize_belongs_to(record, belongs_to_association)
295
+ associated_record = record.send(belongs_to_association.name)
296
+
297
+ reference_label = if belongs_to_association.polymorphic?
298
+ foreign_type = record.send(belongs_to_association.foreign_type)
299
+ "#{labeler.label_for(record: associated_record)} (#{foreign_type})"
300
+ else
301
+ labeler.label_for(record: associated_record)
302
+ end
303
+
304
+ [belongs_to_association.name.to_s, reference_label]
305
+ end
306
+
307
+ def serialize_type(record, attribute, value, type)
308
+ if type.type == :datetime
309
+ # ActiveRecord::Type::DateTime#serialize returns a TimeWithZone object that makes the YAML dump less clear
310
+ [attribute, record.read_attribute_before_type_cast(attribute)]
311
+ elsif type.respond_to?(:serialize)
312
+ [attribute, type.serialize(value)]
313
+ else
314
+ [attribute, value.to_s]
315
+ end
316
+ end
317
+
318
+ def filter_belongs_to_columns(belongs_to_association, filtered_attributes)
319
+ filtered_attributes << belongs_to_association.foreign_key.to_s
320
+ return unless belongs_to_association.polymorphic?
321
+
322
+ filtered_attributes << belongs_to_association.foreign_type.to_s
323
+ end
324
+ end
325
+ end
326
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FixtureChampagne
4
+ class Railtie < Rails::Railtie # :nodoc:
5
+ railtie_name :fixture_champagne
6
+
7
+ rake_tasks do
8
+ path = File.expand_path(__dir__)
9
+ Dir.glob("#{path}/../tasks/**/*.rake").each { |f| load f }
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FixtureChampagne
4
+ # TestFixtures allows access to a test database with fixtures preloaded and all fixture accessors.
5
+ #
6
+ # TestFixtures calls the ActiveRecord::TestDatabases to regenerate a test db and adapts the
7
+ # ActiveRecord::TestFixtures module to parse all pre existing fixtures and generate the required
8
+ # accessors.
9
+ module TestFixtures
10
+ extend ActiveSupport::Concern
11
+ include ActiveRecord::TestFixtures
12
+
13
+ included do
14
+ self.fixture_path = MigrationContext.fixtures_path
15
+ fixtures :all
16
+ end
17
+
18
+ def name
19
+ self.class.name
20
+ end
21
+
22
+ def before_migrate
23
+ # Database is already prepared
24
+ ActiveRecord::TestDatabases.create_and_load_schema(0, env_name: "test") unless Rails.env.test?
25
+
26
+ setup_fixtures
27
+ end
28
+
29
+ def after_migrate
30
+ teardown_fixtures
31
+ end
32
+
33
+ def pre_existing_fixtures
34
+ @loaded_fixtures
35
+ end
36
+
37
+ def pre_existing_fixture_sets
38
+ pre_existing_fixtures.map { |_k, v| v }
39
+ end
40
+
41
+ def pre_existing_fixture_accessors
42
+ # fixture_sets implementation: https://github.com/rails/rails/commit/05d80fc24f03ca5310931eacefdc247a393dd861
43
+ # Still not released
44
+ return fixture_sets.keys if respond_to?(:fixture_sets) && fixture_sets.keys.any?
45
+
46
+ fixture_table_names.map do |t_name|
47
+ t_name.to_s.include?("/") ? t_name.to_s.tr("/", "_") : t_name.to_s
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FixtureChampagne
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "fixture_champagne/version"
4
+
5
+ module FixtureChampagne # :nodoc:
6
+ require "fixture_champagne/railtie" if defined?(Rails)
7
+
8
+ autoload :MigrationContext, "fixture_champagne/migration_context"
9
+ autoload :Migrator, "fixture_champagne/migrator"
10
+ autoload :Migration, "fixture_champagne/migration"
11
+
12
+ class MissingMigrationsFolderError < StandardError # :nodoc:
13
+ def initialize
14
+ super("No fixture_migrations folder found in test suite folder")
15
+ end
16
+ end
17
+
18
+ class RepeatedFixtureError < StandardError; end # :nodoc:
19
+
20
+ class WrongFixtureLabelInterpolationError < StandardError # :nodoc:
21
+ def initialize(error_data = {})
22
+ super("Missing attribute or method #{error_data[:attribute]} for record class #{error_data[:klass]}")
23
+ end
24
+ end
25
+
26
+ class IllegalMigrationNameError < StandardError # :nodoc:
27
+ def initialize(name = nil)
28
+ if name
29
+ super("Illegal name for migration file: #{name}\n\t(only lower case letters, numbers, and '_' allowed).")
30
+ else
31
+ super("Illegal name for migration.")
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FixtureChampagne
4
+ module Generators
5
+ # InstallGenerator generates a the boilerplate necessary to use the gem.
6
+ #
7
+ # From your app directory you can run:
8
+ # bin/rails generate fixture_champagne:install
9
+ #
10
+ # This will create the folder set at FixtureChampagne::MigrationContext.fixture_migrations_path
11
+ # if it does not already exist.
12
+ class InstallGenerator < Rails::Generators::Base
13
+ desc "Setup fixture_champagne required files and folders."
14
+
15
+ def create_migrations_folder
16
+ return if FixtureChampagne::MigrationContext.expected_fixture_migrations_path.exist?
17
+
18
+ empty_directory FixtureChampagne::MigrationContext.expected_fixture_migrations_path
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FixtureChampagne
4
+ module Generators
5
+ # MigrationGenerator generates a new migration using the migration.rb.tt template.
6
+ #
7
+ # Ensure that a fixture_migrations folder exists in your test suite folder or create one
8
+ # running the install generator. This folder is set at FixtureChampagne::MigrationContext.fixture_migrations_path
9
+ # Then from your app directory you can run:
10
+ #
11
+ # bin/rails generate fixture_champagne:migration <new_migration_name>
12
+ #
13
+ # Where <new_migration_name> should be the name of your new migration. For example:
14
+ # bin/rails generate fixture_champagne:migration add_new_user
15
+ #
16
+ # Will generate fixture_migrations/20230126153650_add_new_user.rb with the following content:
17
+ # class AddTurtle < FixtureChampagne::Migration::Base
18
+ # def up
19
+ # # Create, update or destroy records here
20
+ # end
21
+ #
22
+ # def down
23
+ # # Optionally, reverse changes made by the :up method
24
+ # end
25
+ # end
26
+ #
27
+ # The generator automatically adds a version number to the new migration file, which is important
28
+ # to keep track of executed migrations. Also, the migration filename must correspond with the migration
29
+ # class inside the file.
30
+ class MigrationGenerator < Rails::Generators::NamedBase
31
+ include Rails::Generators::ResourceHelpers
32
+
33
+ source_root File.expand_path("templates", __dir__)
34
+
35
+ desc "Generates a migration with the given NAME and a version."
36
+
37
+ def generate_migration
38
+ validate_new_migration_file_name!
39
+ template "migration.rb", new_migration_file_path
40
+ end
41
+
42
+ private
43
+
44
+ def validate_new_migration_file_name!
45
+ return if FixtureChampagne::Migration::MIGRATION_FILENAME_REGEXP.match?(File.basename(new_migration_file_path))
46
+
47
+ raise IllegalMigrationNameError, new_migration_file_path
48
+ end
49
+
50
+ def new_migration_file_path
51
+ fixture_migrations_path = FixtureChampagne::MigrationContext.fixture_migrations_path
52
+ "#{fixture_migrations_path}/#{new_migration_filename}.rb"
53
+ end
54
+
55
+ def new_migration_filename
56
+ @new_migration_filename ||= build_new_migration_filename
57
+ end
58
+
59
+ def build_new_migration_filename
60
+ new_migration_version = FixtureChampagne::Migration.new_migration_version
61
+ given_name = file_name
62
+ "#{new_migration_version}_#{given_name}"
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,9 @@
1
+ class <%= class_name %> < FixtureChampagne::Migration::Base
2
+ def up
3
+ # Create, update or destroy records here
4
+ end
5
+
6
+ def down
7
+ # Optionally, reverse changes made by the :up method
8
+ end
9
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fixture_champagne"
4
+
5
+ namespace :fixture_champagne do
6
+ desc "Run Fixture Champagne migrations to update fixtures"
7
+ task migrate: :environment do
8
+ FixtureChampagne::MigrationContext.migrate
9
+ end
10
+
11
+ desc "Rollback Fixture Champagne last executed migration"
12
+ task rollback: :environment do
13
+ FixtureChampagne::MigrationContext.rollback
14
+ end
15
+ end
metadata ADDED
@@ -0,0 +1,76 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: fixture_champagne
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Juan Gueçaimburu
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2023-02-07 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 6.1.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 6.1.0
27
+ description: |
28
+ Data migration pattern applied to fixtures.
29
+ Create, update and keep fixtures synced with db schema.
30
+ email:
31
+ - guecaimburu.j@gmail.com
32
+ executables: []
33
+ extensions: []
34
+ extra_rdoc_files: []
35
+ files:
36
+ - README.md
37
+ - Rakefile
38
+ - lib/fixture_champagne.rb
39
+ - lib/fixture_champagne/migration.rb
40
+ - lib/fixture_champagne/migration_context.rb
41
+ - lib/fixture_champagne/migrator.rb
42
+ - lib/fixture_champagne/railtie.rb
43
+ - lib/fixture_champagne/test_fixtures.rb
44
+ - lib/fixture_champagne/version.rb
45
+ - lib/generators/fixture_champagne/install_generator.rb
46
+ - lib/generators/fixture_champagne/migration_generator.rb
47
+ - lib/generators/fixture_champagne/templates/migration.rb.tt
48
+ - lib/tasks/fixture_champagne_tasks.rake
49
+ homepage: https://github.com/jguecaimburu/fixture_champagne
50
+ licenses:
51
+ - MIT
52
+ metadata:
53
+ homepage_uri: https://github.com/jguecaimburu/fixture_champagne
54
+ source_code_uri: https://github.com/jguecaimburu/fixture_champagne
55
+ changelog_uri: https://github.com/jguecaimburu/fixture_champagne/CHANGELOG.md
56
+ rubygems_mfa_required: 'true'
57
+ post_install_message:
58
+ rdoc_options: []
59
+ require_paths:
60
+ - lib
61
+ required_ruby_version: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: 2.7.0
66
+ required_rubygems_version: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - ">="
69
+ - !ruby/object:Gem::Version
70
+ version: '0'
71
+ requirements: []
72
+ rubygems_version: 3.3.26
73
+ signing_key:
74
+ specification_version: 4
75
+ summary: Fixture migrations for Ruby on Rails applications
76
+ test_files: []