live_fixtures 0.2.0 → 0.3.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +13 -1
- data/README.md +265 -8
- data/lib/live_fixtures/export.rb +46 -1
- data/lib/live_fixtures/export/fixture.rb +43 -19
- data/lib/live_fixtures/import.rb +40 -58
- data/lib/live_fixtures/import/fixtures.rb +39 -7
- data/lib/live_fixtures/version.rb +1 -1
- metadata +31 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: a728fd952006fc7d03f41ba8a0e7680005937f08
|
4
|
+
data.tar.gz: 169ea945ee73cc0d6183ddd590b72f520d851186
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: e7b69490985e19b66e30726fda1d0a1c41f031a6f166512f1b1c929658453259ed7945237b926cd73991233284f35717dcb9a9076a5e307d80c8abed30b11a9f
|
7
|
+
data.tar.gz: 2fc58a3b1b222bea1a9869269a5241cf269dec3447ad00c17dcec0a9f6a2ffa0e356795c84a88abfab3f793c6ddde7dcec9d4dec446c48eff4f62371e1eee2af
|
data/CHANGELOG.md
CHANGED
@@ -2,7 +2,19 @@
|
|
2
2
|
All notable changes to this project will be documented in this file.
|
3
3
|
This project adheres to [Semantic Versioning](http://semver.org/).
|
4
4
|
|
5
|
-
## [0.
|
5
|
+
## [0.3.0] - 2017-08-10
|
6
|
+
### Breaking changes
|
7
|
+
- Imports now raise an error when unable to find a referenced model.
|
8
|
+
To avoid this behavior, pass the option `skip_missing_refs: true`. #24
|
9
|
+
|
10
|
+
### Fixed
|
11
|
+
- For models with serialized attributes (for storing a ruby object or json blob in a single column, for example), live_fixtures will now use the coder specified in the model definition to dump the value to a string, rather than serializing it to yaml. #10, #11
|
12
|
+
|
13
|
+
### Added
|
14
|
+
- Enhanced documentation #1, #12
|
15
|
+
- Options to suppress progress bar output and import errors. #23
|
16
|
+
|
17
|
+
## [0.2.0] - 2017-05-09
|
6
18
|
### Breaking change
|
7
19
|
- live_fixtures now depends on activerecord ~> 4.2
|
8
20
|
|
data/README.md
CHANGED
@@ -1,16 +1,23 @@
|
|
1
1
|
# LiveFixtures
|
2
2
|
|
3
|
-
|
3
|
+
Like ActiveRecord::Fixtures, but for production.
|
4
4
|
|
5
|
-
|
5
|
+
> A test fixture is a fixed state of a set of objects used as a baseline for running tests.
|
6
|
+
> The purpose of a test fixture is to ensure that there is a well known and fixed environment in which tests are run so that results are repeatable.
|
7
|
+
>
|
8
|
+
> [https://github.com/junit-team/junit4/wiki/test-fixtures](https://github.com/junit-team/junit4/wiki/test-fixtures)
|
9
|
+
|
10
|
+
[ActiveRecord::Fixtures](http://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html) provide a powerful way to populate a database with test fixtures, but its strategy for handling primary keys and associations is not intended for use with a production db.
|
11
|
+
|
12
|
+
LiveFixtures uses a different strategy that means it is safer to use in a live environment.
|
13
|
+
|
14
|
+
For more information, see [the motivation section below](#motivation).
|
6
15
|
|
7
16
|
## Installation
|
8
17
|
|
9
18
|
Add this line to your application's Gemfile:
|
10
19
|
|
11
|
-
|
12
|
-
gem 'live_fixtures'
|
13
|
-
```
|
20
|
+
gem 'live_fixtures'
|
14
21
|
|
15
22
|
And then execute:
|
16
23
|
|
@@ -22,7 +29,256 @@ Or install it yourself as:
|
|
22
29
|
|
23
30
|
## Usage
|
24
31
|
|
25
|
-
|
32
|
+
This gem provides functionality for both the export and the import of fixtures.
|
33
|
+
|
34
|
+
While they work nicely together, it is also possible to import manually generated fixtures.
|
35
|
+
|
36
|
+
### Exporting
|
37
|
+
|
38
|
+
The `LiveFixtures::Export` module is meant to be included into your export class.
|
39
|
+
|
40
|
+
|
41
|
+
class Export::User
|
42
|
+
include LiveFixtures::Export
|
43
|
+
|
44
|
+
def export(user_id)
|
45
|
+
set_export_dir "#{Rails.root}/data/export/user/#{user_id}/"
|
46
|
+
|
47
|
+
export_user_and_posts(user_id)
|
48
|
+
end
|
49
|
+
|
50
|
+
def export_user_and_posts(user_id)
|
51
|
+
user = User.find(user_id)
|
52
|
+
export_fixtures([user])
|
53
|
+
|
54
|
+
export_fixtures user.posts, :user do |post|
|
55
|
+
{ "likes" => post.likes.count,
|
56
|
+
"unique_url" => Template.new("<%= Export::Helper.unique_url %>") }
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
|
62
|
+
1. Call #set_export_dir to set the dir where files should be created.
|
63
|
+
If the dir does not already exist, it will be created for you.
|
64
|
+
|
65
|
+
2. Then call #export_fixtures for each db table, which will produce
|
66
|
+
one yml file for each db table. Do *not* call export_fixtures multiple
|
67
|
+
times for the same db table - that will overwrite the file each time!
|
68
|
+
|
69
|
+
3. You can optionally call #set_export_options, passing {show_progress: false}
|
70
|
+
if you'd like to disable the progress bar.
|
71
|
+
|
72
|
+
4. For advanced usage, read the sections about Additional Attributes, References, and Templates.
|
73
|
+
|
74
|
+
### Importing
|
75
|
+
|
76
|
+
The `LiveFixtures::Import` class allows you to specify the location of your fixtures and the order in which to import them. Once you've done that, you can import them directly to your database.
|
77
|
+
|
78
|
+
|
79
|
+
module Seed::User
|
80
|
+
def self.from_fixtures(fixtures_directory)
|
81
|
+
insert_order = %w{users posts}
|
82
|
+
|
83
|
+
importer = LiveFixtures::Import.new fixtures_directory, insert_order
|
84
|
+
importer.import_all
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
Options may be passed when initializing an importer as follow:
|
89
|
+
- show_progress: defaults to true.
|
90
|
+
Pass false to disable the progress bar output.
|
91
|
+
- skip_missing_tables: defaults to false.
|
92
|
+
Pass true to avoid raising an error when a table listed in insert_order has
|
93
|
+
no yml file.
|
94
|
+
- skip_missing_refs: defaults to false.
|
95
|
+
Pass false to raise an error when the importer is unable to re-establish a
|
96
|
+
relation.
|
97
|
+
|
98
|
+
## Advanced Usage
|
99
|
+
|
100
|
+
The following topics reference this schema and exporter:
|
101
|
+
|
102
|
+
class User < ActiveRecord::Base
|
103
|
+
has_many :posts
|
104
|
+
end
|
105
|
+
|
106
|
+
class Post < ActiveRecord::Base
|
107
|
+
belongs_to :user
|
108
|
+
has_and_belongs_to_many :channels
|
109
|
+
end
|
110
|
+
|
111
|
+
class Channel < ActiveRecord::Base
|
112
|
+
has_and_belongs_to_many :users
|
113
|
+
end
|
114
|
+
|
115
|
+
class YourExporter
|
116
|
+
include LiveFixtures::Export
|
117
|
+
def initialize(fixture_path)
|
118
|
+
set_export_dir fixture_path
|
119
|
+
end
|
120
|
+
|
121
|
+
def export_models(models, references = [], &additional_attributes)
|
122
|
+
export_fixtures(models, references, &additional_attributes)
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
### Additional Attributes
|
127
|
+
|
128
|
+
We can use a block to add more attributes to a fixture. Each model is passed to the block as an argument, and the block should return a hash of additional arguments.
|
129
|
+
|
130
|
+
dev_ops_posts = Post.where(topic: "Dev Ops")
|
131
|
+
exporter = YourExporter.new("fixtures/")
|
132
|
+
exporter.export_models(dev_ops_posts) do |post|
|
133
|
+
{ summary: PostSummarizer.summarize(post) }
|
134
|
+
end
|
135
|
+
|
136
|
+
# In our fixtures/posts.yml file
|
137
|
+
posts_1234:
|
138
|
+
...
|
139
|
+
user_id: 5678
|
140
|
+
summary: "Dev ops is cool."
|
141
|
+
|
142
|
+
### References
|
143
|
+
|
144
|
+
References allow fixtures to capture a model's associations, so they can be correctly re-established on import.
|
145
|
+
|
146
|
+
When we export a fixture for a post above, we'd expect to see an attribute `user_id`
|
147
|
+
|
148
|
+
post = Post.find(1234)
|
149
|
+
post.user_id = 5678
|
150
|
+
post.save!
|
151
|
+
exporter = YourExporter.new("fixtures/")
|
152
|
+
exporter.export_models([post])
|
153
|
+
|
154
|
+
# In our posts.yml file
|
155
|
+
posts_1234:
|
156
|
+
...
|
157
|
+
user_id: 5678
|
158
|
+
|
159
|
+
|
160
|
+
If we import this post, it will still have 5678 for its user_id foreign key. This may or may not be the desired outcome.
|
161
|
+
|
162
|
+
If we pass `:user` as references, LiveFixtures will replace the foreign key with a reference, so that the association can be correctly re-established on import:
|
163
|
+
|
164
|
+
post = Post.find(1234)
|
165
|
+
post.user_id = 5678
|
166
|
+
post.save!
|
167
|
+
exporter = YourExporter.new("fixtures/")
|
168
|
+
exporter.export_models([post], :user)
|
169
|
+
exporter.export_models([post.user])
|
170
|
+
|
171
|
+
# In our posts.yml file
|
172
|
+
posts_1234:
|
173
|
+
...
|
174
|
+
user: users_5678
|
175
|
+
|
176
|
+
# In our users.yml file
|
177
|
+
users_5678:
|
178
|
+
...
|
179
|
+
|
180
|
+
When we import these fixtures using the correct `insert_order` (`['users', 'posts']`), the newly imported post will belong to the newly imported user, no matter what their new ids are.
|
181
|
+
|
182
|
+
Currently, this works for belongs_to and has_and_belongs_to_many associations.
|
183
|
+
|
184
|
+
For has_and_belongs_to_many relation, add a field to one of the records, and the import will populate the join table.
|
185
|
+
|
186
|
+
The formatting of the fixture is quite flexible, and the value for this field can be either a list or comma-separated string containing either references or IDs of the associated records. In all cases, though, the value should match the association name. Note that in all cases below the key is `channels` and not `channel_ids`:
|
187
|
+
|
188
|
+
# In our users.yml file
|
189
|
+
users_5678:
|
190
|
+
channels: "1,2,3"
|
191
|
+
|
192
|
+
users_1234:
|
193
|
+
channels:
|
194
|
+
- channel_1
|
195
|
+
- bobs_cool_channel
|
196
|
+
|
197
|
+
# In our channels.yml file
|
198
|
+
channel_1:
|
199
|
+
...
|
200
|
+
|
201
|
+
bobs_cool_channel:
|
202
|
+
...
|
203
|
+
|
204
|
+
Also note it's not necessary to format your references the way the exporter does - `bobs_cool_channel` is a totally valid reference.
|
205
|
+
|
206
|
+
### Templates
|
207
|
+
|
208
|
+
Templates allow you to export fixtures containing erb, that will be evaluated at the time of fixture import.
|
209
|
+
|
210
|
+
posts = Post.where(user_id: 5678
|
211
|
+
exporter = YourExporter.new("fixtures/")
|
212
|
+
exporter.export_models([post]) do |post|
|
213
|
+
{ unique_promo_code: Template.new("<%= PromoCodeGenerator.unique_promo_code(#{post.id}) %>")
|
214
|
+
end
|
215
|
+
|
216
|
+
# In our fixtures/posts.yml file
|
217
|
+
posts_1234:
|
218
|
+
...
|
219
|
+
user_id: 5678
|
220
|
+
unique_promo_code: <%= PromoCodeGenerator.unique_promo_code(1234) %>
|
221
|
+
|
222
|
+
In the example above, we'd be able to generate a new unique promo code for each post as we import them.
|
223
|
+
|
224
|
+
|
225
|
+
## Motivation
|
226
|
+
|
227
|
+
One particular challenge when working with fixtures is describing associations between records. When they're in the database, records have unique primary keys, and associations are captured using foreign keys (references to the associated record's primary key). For example, a row in the `posts` table may have a column `user_id`. If it's value is `1234`, it indicated that `Post` belongs to the `User` with id `1234`.
|
228
|
+
|
229
|
+
How can we model associations when fixtures are removed from the database? It's not enough to just serialize the foreign keys, as we expect each record to have a different primary key each time it is imported into a database.
|
230
|
+
|
231
|
+
ActiveRecord::Fixtures answers this question in a way that is very effective for a test database, but that is not safe for a live database.
|
232
|
+
|
233
|
+
### Here's how ActiveRecord::Fixtures work
|
234
|
+
|
235
|
+
Each record is assigned a label in its yml file. Primary key values are
|
236
|
+
assigned using a guid algorithm that maps a label to a consistent integer
|
237
|
+
between 1 and 2^30-1. Primary keys can then be assigned before saving any
|
238
|
+
records to the db.
|
239
|
+
|
240
|
+
Why would they do this? Because, this enables us to use labels in the
|
241
|
+
Fixture yml files to refer to associations. For example:
|
242
|
+
|
243
|
+
<users.yml>
|
244
|
+
bob:
|
245
|
+
username: thebob
|
246
|
+
|
247
|
+
<posts.yml>
|
248
|
+
hello:
|
249
|
+
message: Hello everyone!
|
250
|
+
user: bob
|
251
|
+
|
252
|
+
|
253
|
+
|
254
|
+
The ActiveRecord::Fixture system first converts every instance of `bob` and
|
255
|
+
`hello` into an integer using ActiveRecord::Fixture#identify, and then can
|
256
|
+
save the records IN ANY ORDER and know that all foreign keys will be valid.
|
257
|
+
|
258
|
+
There is a big problem with this. In a test db, each table is empty and so the
|
259
|
+
odds of inserting a few dozen records causing a primary key collision is
|
260
|
+
very small. However, for a production table with a hundred million rows, this
|
261
|
+
is no longer the case! Collisions abound and db insertion fails.
|
262
|
+
|
263
|
+
Also, auto-increment primary keys will continue from the LARGEST existing
|
264
|
+
primary key value. If we insert a record at 1,000,000,000 - we've reduced the
|
265
|
+
total number of records we can store in that table in half. Fine for a test db
|
266
|
+
but not ideal for production.
|
267
|
+
|
268
|
+
|
269
|
+
### LiveFixtures work differently
|
270
|
+
|
271
|
+
Since we want to be able to take advantage of normal auto-increment behavior,
|
272
|
+
we cannot know the primary keys of each record before saving it to the db.
|
273
|
+
Instead, we save each record, and then maintain a mapping (`@label_to_id`)
|
274
|
+
from that record's label (`bob`), to its primary key (`213`). Later, when
|
275
|
+
another record (`hello`) references `bob`, we can use this mapping to look up
|
276
|
+
the primary key for `bob` before saving `hello`.
|
277
|
+
|
278
|
+
This means that the order we insert records into the db matters: `bob` must
|
279
|
+
be inserted before `hello`! This order is defined in INSERT_ORDER, and
|
280
|
+
reflected in the order of the `@table_names` array.
|
281
|
+
|
26
282
|
|
27
283
|
## Development
|
28
284
|
|
@@ -30,12 +286,13 @@ After checking out the repo, run `bin/setup` to install dependencies. Then, run
|
|
30
286
|
|
31
287
|
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
32
288
|
|
289
|
+
Please remember to update the docs on your PR if you change anything. You can see the YARD docs live while you change them by running `yard server --reload`.
|
290
|
+
|
33
291
|
## Contributing
|
34
292
|
|
35
|
-
Bug reports and pull requests are welcome on GitHub at https://github.com/
|
293
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/NoRedInk/live_fixtures. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
|
36
294
|
|
37
295
|
|
38
296
|
## License
|
39
297
|
|
40
298
|
The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
|
41
|
-
|
data/lib/live_fixtures/export.rb
CHANGED
@@ -8,29 +8,61 @@
|
|
8
8
|
# times for the same db table - that will overwrite the file each time!
|
9
9
|
|
10
10
|
module LiveFixtures::Export
|
11
|
+
# Templates allow you to export fixtures containing erb, that will be evaluated at the time of fixture import.
|
12
|
+
# You should initialize them with a String containing the erb to evaluate, like
|
13
|
+
# @example A template with export and import times.
|
14
|
+
# Template.new("<%= \"I was exported at #{Time.now} and imported at \" + Time.now.to_s %>")
|
11
15
|
Template = Struct.new(:code)
|
16
|
+
|
17
|
+
# References represent associations between fixtures, in the same way that foreign_keys do for records.
|
18
|
+
# These will be initialized for you based on the contents of `with_references` passed to `export_fixtures`.
|
19
|
+
# They will be initialized with the name of the association (a Symbol) and the particular associated model.
|
12
20
|
Reference = Struct.new(:name, :value)
|
13
21
|
|
14
22
|
private
|
15
23
|
|
24
|
+
# Specify the directory into which to export the yml files containing your fixtures.
|
25
|
+
# The directory will be created if it does not yet exist.
|
26
|
+
# @param dir [String] a path to a directory into which the fixtures will be exported.
|
16
27
|
def set_export_dir(dir)
|
17
28
|
@dir = dir
|
18
29
|
FileUtils.mkdir_p(@dir) unless File.directory?(@dir)
|
19
30
|
end
|
20
31
|
|
32
|
+
# Specify the options to use when exporting your fixtures.
|
33
|
+
# @param [Hash] opts export configuration options
|
34
|
+
# @option opts [Boolean] show_progress whether or not to show the progress bar
|
35
|
+
def set_export_options(**opts)
|
36
|
+
defaults = { show_progress: true }
|
37
|
+
@export_options = defaults.merge(opts)
|
38
|
+
end
|
39
|
+
|
40
|
+
# The options to use when exporting your fixtures.
|
41
|
+
# @return [Hash] export configuration options
|
42
|
+
def export_options
|
43
|
+
@export_options ||= set_export_options
|
44
|
+
end
|
45
|
+
|
21
46
|
##
|
22
47
|
# Export models to a yml file named after the corresponding table.
|
48
|
+
# @param models [Enumerable] an Enumerable containing ActiveRecord models.
|
49
|
+
# @param with_references [Array<Symbol>] the associations whose foreign_keys should be replaced with references.
|
23
50
|
#
|
24
51
|
# Takes an optional block that will be invoked for each model.
|
25
52
|
# The block should return a hash of attributes to be merged and
|
26
53
|
# saved with the model's attributes.
|
54
|
+
# @yield [model] an optional block that will be invoked for each model.
|
55
|
+
# @yieldparam model [ActiveRecord::Base] each successive model.
|
56
|
+
# @yieldreturn [Hash{String => Object}] a hash of attributes to be merged and saved with the model's attributes.
|
27
57
|
def export_fixtures(models, with_references = [])
|
28
58
|
return unless models.present?
|
29
59
|
|
30
60
|
table_name = models.first.class.table_name
|
31
61
|
File.open(File.join(@dir, table_name + '.yml'), 'w') do |file|
|
32
62
|
|
33
|
-
ProgressBarIterator
|
63
|
+
iterator = export_options[:show_progress] ? ProgressBarIterator : SimpleIterator
|
64
|
+
|
65
|
+
iterator.new(models).each do |model|
|
34
66
|
more_attributes = block_given? ? yield(model) : {}
|
35
67
|
file.write Fixture.to_yaml(model, with_references, more_attributes)
|
36
68
|
end
|
@@ -54,4 +86,17 @@ module LiveFixtures::Export
|
|
54
86
|
end
|
55
87
|
end
|
56
88
|
end
|
89
|
+
|
90
|
+
class SimpleIterator
|
91
|
+
def initialize(models)
|
92
|
+
@models = models
|
93
|
+
end
|
94
|
+
|
95
|
+
def each
|
96
|
+
puts @models.first.class.name
|
97
|
+
@models.each do |model|
|
98
|
+
yield model
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
57
102
|
end
|
@@ -1,5 +1,13 @@
|
|
1
|
+
# Exposes functionality to serialize an ActiveRecord record (a model) into a
|
2
|
+
# YAML fixture.
|
1
3
|
module LiveFixtures::Export::Fixture
|
2
4
|
module_function
|
5
|
+
# YAML-Serializes the provided model, including any references and additional
|
6
|
+
# attribtues.
|
7
|
+
# @param model [ActiveRecord::Base] an ActiveRecord record to serialize
|
8
|
+
# @param references [Symbol, Array<Symbol>] the names of associations whose foreign_keys should be replaced with references
|
9
|
+
# @param more_attributes [Hash{String => Time, DateTime, Date, Hash, String, LiveFixtures::Export::Template, LiveFixtures::Export::Reference, #to_s}] a hash of additional attributes to serialize with each record.
|
10
|
+
# @return [String] the model serialized in YAML, with specified foreign_keys replaced by references, including additional attributes.
|
3
11
|
def to_yaml(model, references = [], more_attributes = {})
|
4
12
|
table_name = model.class.table_name
|
5
13
|
|
@@ -32,25 +40,41 @@ module LiveFixtures::Export::Fixture
|
|
32
40
|
next if %w{id}.include? name
|
33
41
|
next if value.nil?
|
34
42
|
|
35
|
-
|
36
|
-
when Time, DateTime
|
37
|
-
value.utc.to_s(:db)
|
38
|
-
when Date
|
39
|
-
value.to_s(:db)
|
40
|
-
when Hash
|
41
|
-
value.to_yaml.inspect
|
42
|
-
when String
|
43
|
-
value.inspect
|
44
|
-
when LiveFixtures::Export::Template
|
45
|
-
value.code
|
46
|
-
when LiveFixtures::Export::Reference
|
47
|
-
name, value = value.name, value.value
|
48
|
-
"#{value.class.table_name}_#{value.id}"
|
49
|
-
else
|
50
|
-
value.to_s
|
51
|
-
end
|
52
|
-
|
53
|
-
"#{name}: " + yml_value
|
43
|
+
serialize_attribute(model, name, value)
|
54
44
|
end.compact.join("\n ")
|
55
45
|
end
|
46
|
+
|
47
|
+
private_class_method def serialize_attribute(model, name, value)
|
48
|
+
attribute_type = model.class.type_for_attribute(name)
|
49
|
+
|
50
|
+
if attribute_type.is_a?(ActiveRecord::Type::Serialized)
|
51
|
+
value = attribute_type.type_cast_for_database(value) unless value.is_a?(String)
|
52
|
+
|
53
|
+
"#{name}: |-\n#{value.to_s.indent(4)}" unless value.nil?
|
54
|
+
elsif value.is_a? LiveFixtures::Export::Reference
|
55
|
+
"#{value.name}: #{yml_value(value)}"
|
56
|
+
else
|
57
|
+
"#{name}: #{yml_value(value)}"
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
private_class_method def yml_value(value)
|
62
|
+
case value
|
63
|
+
when Time, DateTime
|
64
|
+
value.utc.to_s(:db)
|
65
|
+
when Date
|
66
|
+
value.to_s(:db)
|
67
|
+
when Hash
|
68
|
+
value.to_yaml.inspect
|
69
|
+
when String
|
70
|
+
value.inspect
|
71
|
+
when LiveFixtures::Export::Template
|
72
|
+
value.code
|
73
|
+
when LiveFixtures::Export::Reference
|
74
|
+
reference_value = value.value
|
75
|
+
"#{reference_value.class.table_name}_#{reference_value.id}"
|
76
|
+
else
|
77
|
+
value.to_s
|
78
|
+
end
|
79
|
+
end
|
56
80
|
end
|
data/lib/live_fixtures/import.rb
CHANGED
@@ -1,77 +1,44 @@
|
|
1
|
-
#
|
2
|
-
# however, its strategy for handling primary keys and associations is
|
3
|
-
# UNACCEPTABLE for use with a production db. LiveFixtures works around this.
|
4
|
-
#
|
5
|
-
#
|
6
|
-
########### Here's how ActiveRecord::Fixtures work ###########################
|
7
|
-
#
|
8
|
-
# Each record is assigned a label in its yml file. Primary key values are
|
9
|
-
# assigned using a guid algorithm that maps a label to a consistent integer
|
10
|
-
# between 1 and 2^30-1. Primary keys can then be assigned before saving any
|
11
|
-
# records to the db.
|
12
|
-
#
|
13
|
-
# Why would they do this? Because, this enables us to use labels in the
|
14
|
-
# Fixture yml files to refer to associations. For example:
|
15
|
-
#
|
16
|
-
# <users.yml>
|
17
|
-
# bob:
|
18
|
-
# username: thebob
|
19
|
-
#
|
20
|
-
# <posts.yml>
|
21
|
-
# hello:
|
22
|
-
# message: Hello everyone!
|
23
|
-
# user: bob
|
24
|
-
#
|
25
|
-
# The ActiveRecord::Fixture system first converts every instance of `bob` and
|
26
|
-
# `hello` into an integer using ActiveRecord::Fixture#identify, and then can
|
27
|
-
# save the records IN ANY ORDER and know that all foreign keys will be valid.
|
28
|
-
#
|
29
|
-
# There is a big problem with this. In a test db, each table is empty and so the
|
30
|
-
# odds of inserting a few dozen records causing a primary key collision is
|
31
|
-
# very small. However, for a production table with a hundred million rows, this
|
32
|
-
# is no longer the case! Collisions abound and db insertion fails.
|
33
|
-
#
|
34
|
-
# Also, autoincrement primary keys will continue from the LARGEST existing
|
35
|
-
# primary key value. If we insert a record at 1,000,000,000 - we've reduced the
|
36
|
-
# total number of records we can store in that table in half. Fine for a test db
|
37
|
-
# but not ideal for production.
|
38
|
-
#
|
39
|
-
#
|
40
|
-
########### LiveFixtures work differently ####################################
|
41
|
-
#
|
42
|
-
# Since we want to be able to take advantage of normal autoincrement behavior,
|
43
|
-
# we cannot know the primary keys of each record before saving it to the db.
|
44
|
-
# Instead, we save each record, and then maintain a mapping (`@label_to_id`)
|
45
|
-
# from that record's label (`bob`), to its primary key (`213`). Later, when
|
46
|
-
# another record (`hello`) references `bob`, we can use this mapping to look up
|
47
|
-
# the primary key for `bob` before saving `hello`.
|
48
|
-
#
|
49
|
-
# This means that the order we insert records into the db matters: `bob` must
|
50
|
-
# be inserted before `hello`! This order is defined in INSERT_ORDER, and
|
51
|
-
# reflected in the order of the `@table_names` array.
|
52
|
-
|
1
|
+
# An object that facilitates the import of fixtures into a database.
|
53
2
|
class LiveFixtures::Import
|
54
3
|
NO_LABEL = nil
|
55
4
|
|
56
|
-
|
5
|
+
# Instantiate a new Import with the directory containing your fixtures, and
|
6
|
+
# the order in which to import them. The order should ensure fixtures
|
7
|
+
# containing references to another fixture are imported AFTER the referenced
|
8
|
+
# fixture.
|
9
|
+
# @raise [ArgumentError] raises an argument error if not every element in the insert_order has a corresponding yml file.
|
10
|
+
# @param root_path [String] path to the directory containing the yml files to import.
|
11
|
+
# @param insert_order [Array<String>] a list of yml files (without .yml extension) in the order they should be imported.
|
12
|
+
# @param [Hash] opts export configuration options
|
13
|
+
# @option opts [Boolean] show_progress whether or not to show the progress bar
|
14
|
+
# @option opts [Boolean] skip_missing_tables when false, an error will be raised if a yaml file isn't found for each table in insert_order
|
15
|
+
# @option opts [Boolean] skip_missing_refs when false, an error will be raised if an ID isn't found for a label.
|
16
|
+
# @return [LiveFixtures::Import] an importer
|
17
|
+
# @see LiveFixtures::Export::Reference
|
18
|
+
def initialize(root_path, insert_order, **opts)
|
19
|
+
defaut_options = { show_progress: true, skip_missing_tables: false, skip_missing_refs: false }
|
20
|
+
@options = defaut_options.merge(opts)
|
57
21
|
@root_path = root_path
|
58
22
|
@table_names = Dir.glob(File.join(@root_path, '{*,**}/*.yml')).map do |filepath|
|
59
23
|
File.basename filepath, ".yml"
|
60
24
|
end
|
61
25
|
@table_names = insert_order.select {|table_name| @table_names.include? table_name}
|
62
|
-
if @table_names.size < insert_order.size
|
26
|
+
if @table_names.size < insert_order.size && !@options[:skip_missing_tables]
|
63
27
|
raise ArgumentError, "table(s) mentioned in `insert_order` which has no yml file to import: #{insert_order - @table_names}"
|
64
28
|
end
|
65
29
|
@label_to_id = {}
|
66
30
|
end
|
67
31
|
|
68
|
-
#
|
32
|
+
# Within a transaction, import all the fixtures into the database.
|
33
|
+
# @param class_names [Hash{Symbol => String}] a mapping table name => Model class, for any that don't follow convention.
|
34
|
+
#
|
69
35
|
# The very similar method: ActiveRecord::FixtureSet.create_fixtures has the
|
70
36
|
# unfortunate side effect of truncating each table!!
|
71
37
|
#
|
72
38
|
# Therefore, we have reproduced the relevant sections here, without DELETEs,
|
73
|
-
# with calling
|
39
|
+
# with calling {LiveFixtures::Import::Fixtures#each_table_row_with_label} instead of
|
74
40
|
# `AR::Fixtures#table_rows`, and using those labels to populate `@label_to_id`.
|
41
|
+
# @see https://github.com/rails/rails/blob/4-2-stable/activerecord/lib/active_record/fixtures.rb#L496
|
75
42
|
def import_all(class_names = {})
|
76
43
|
@table_names.each { |n|
|
77
44
|
class_names[n.tr('/', '_').to_sym] ||= n.classify if n.include?('/')
|
@@ -91,10 +58,12 @@ class LiveFixtures::Import
|
|
91
58
|
table_name,
|
92
59
|
class_name,
|
93
60
|
::File.join(@root_path, path),
|
94
|
-
@label_to_id
|
61
|
+
@label_to_id,
|
62
|
+
skip_missing_refs: @options[:skip_missing_refs])
|
95
63
|
|
96
64
|
conn = ff.model_connection || connection
|
97
|
-
|
65
|
+
iterator = @options[:show_progress] ? ProgressBarIterator : SimpleIterator
|
66
|
+
iterator.new(ff).each do |table_name, label, row|
|
98
67
|
conn.insert_fixture(row, table_name)
|
99
68
|
@label_to_id[label] = conn.last_inserted_id(table_name) unless label == NO_LABEL
|
100
69
|
end
|
@@ -120,4 +89,17 @@ class LiveFixtures::Import
|
|
120
89
|
@bar.finish
|
121
90
|
end
|
122
91
|
end
|
92
|
+
|
93
|
+
class SimpleIterator
|
94
|
+
def initialize(ff)
|
95
|
+
@ff = ff
|
96
|
+
end
|
97
|
+
|
98
|
+
def each
|
99
|
+
puts @ff.model_class.name
|
100
|
+
@ff.each_table_row_with_label do |*args|
|
101
|
+
yield(*args)
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
123
105
|
end
|
@@ -1,13 +1,23 @@
|
|
1
1
|
require 'active_record/fixtures'
|
2
2
|
|
3
|
-
#rubocop:disable Style/PerlBackrefs
|
4
|
-
|
5
3
|
class LiveFixtures::Import
|
4
|
+
# A labeled reference was not found.
|
5
|
+
# Maybe the referenced model was not exported, or the insert order attempted
|
6
|
+
# to import the reference before the referenced model?
|
7
|
+
LiveFixtures::MissingReferenceError = Class.new(KeyError)
|
8
|
+
|
6
9
|
class Fixtures
|
7
10
|
delegate :model_class, :table_name, :fixtures, to: :ar_fixtures
|
11
|
+
# ActiveRecord::FixtureSet for delegation
|
8
12
|
attr_reader :ar_fixtures
|
9
13
|
|
10
|
-
|
14
|
+
# @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] connection to the database into which to import the data.
|
15
|
+
# @param table_name [String] name of the database table to populate with models
|
16
|
+
# @param class_name [Constant] the model's class name
|
17
|
+
# @param filepath [String] path to the yml file containing the fixtures
|
18
|
+
# @param label_to_id [Hash{String => Int}] map from a reference's label to its new id.
|
19
|
+
def initialize(connection, table_name, class_name, filepath, label_to_id, skip_missing_refs: false)
|
20
|
+
@skip_missing_refs = skip_missing_refs
|
11
21
|
@ar_fixtures = ActiveRecord::FixtureSet.new connection,
|
12
22
|
table_name,
|
13
23
|
class_name,
|
@@ -15,12 +25,16 @@ class LiveFixtures::Import
|
|
15
25
|
@label_to_id = label_to_id
|
16
26
|
end
|
17
27
|
|
18
|
-
# https://github.com/rails/rails/blob/4-2-stable/activerecord/lib/active_record/fixtures.rb#L611
|
19
28
|
# Rewritten to take advantage of @label_to_id instead of AR::FixtureSet#identify,
|
20
29
|
# and to make an iterator.
|
21
30
|
#
|
31
|
+
# @yieldparam table_name [String] the database table's name
|
32
|
+
# @yieldparam label [String] the label for the model being currently imported
|
33
|
+
# @yieldparam row [Hash{String => Value}] the model's attributes to be imported
|
22
34
|
# Iterator which yields [table_name, label, row] for each fixture
|
23
|
-
# (and for any implicit join table records)
|
35
|
+
# (and for any implicit join table records). The block is expected to insert
|
36
|
+
# the row and update @label_to_id with the record's newly assigned id.
|
37
|
+
# @see https://github.com/rails/rails/blob/4-2-stable/activerecord/lib/active_record/fixtures.rb#L611
|
24
38
|
def each_table_row_with_label
|
25
39
|
join_table_rows = Hash.new { |h,table| h[table] = [] }
|
26
40
|
|
@@ -55,7 +69,7 @@ class LiveFixtures::Import
|
|
55
69
|
rows.each do |targets:, association:, label:|
|
56
70
|
targets.each do |target|
|
57
71
|
assoc_fk = @label_to_id[target] || target
|
58
|
-
row = { association.foreign_key =>
|
72
|
+
row = { association.foreign_key => fetch_id_for_label(label),
|
59
73
|
association.association_foreign_key => assoc_fk }
|
60
74
|
yield [table_name, NO_LABEL, row]
|
61
75
|
end
|
@@ -95,7 +109,25 @@ class LiveFixtures::Import
|
|
95
109
|
row[association.foreign_type] = $1
|
96
110
|
end
|
97
111
|
|
98
|
-
row[fk_name] =
|
112
|
+
row[fk_name] = fetch_id_for_label(value)
|
113
|
+
end
|
114
|
+
|
115
|
+
# Uses the underlying map of labels to return the referenced model's newly
|
116
|
+
# assigned ID.
|
117
|
+
# @raise [LiveFixtures::MissingReferenceError] if the label isn't found.
|
118
|
+
# @param label_to_fetch [String] the label of the referenced model.
|
119
|
+
# @return [Integer] the newly assigned ID of the referenced model.
|
120
|
+
def fetch_id_for_label(label_to_fetch)
|
121
|
+
@label_to_id.fetch(label_to_fetch)
|
122
|
+
rescue KeyError
|
123
|
+
return nil if @skip_missing_refs
|
124
|
+
|
125
|
+
raise LiveFixtures::MissingReferenceError, <<-ERROR.squish
|
126
|
+
Unable to find ID for model referenced by label #{label_to_fetch} while
|
127
|
+
importing #{model_class} from #{table_name}.yml. Perhaps it isn't included
|
128
|
+
in these fixtures or it is too late in the insert_order and has not yet
|
129
|
+
been imported.
|
130
|
+
ERROR
|
99
131
|
end
|
100
132
|
|
101
133
|
private :ar_fixtures
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: live_fixtures
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.3.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- jleven
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2017-
|
11
|
+
date: 2017-08-10 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activerecord
|
@@ -122,6 +122,34 @@ dependencies:
|
|
122
122
|
- - ">="
|
123
123
|
- !ruby/object:Gem::Version
|
124
124
|
version: '0'
|
125
|
+
- !ruby/object:Gem::Dependency
|
126
|
+
name: yard
|
127
|
+
requirement: !ruby/object:Gem::Requirement
|
128
|
+
requirements:
|
129
|
+
- - ">="
|
130
|
+
- !ruby/object:Gem::Version
|
131
|
+
version: '0'
|
132
|
+
type: :development
|
133
|
+
prerelease: false
|
134
|
+
version_requirements: !ruby/object:Gem::Requirement
|
135
|
+
requirements:
|
136
|
+
- - ">="
|
137
|
+
- !ruby/object:Gem::Version
|
138
|
+
version: '0'
|
139
|
+
- !ruby/object:Gem::Dependency
|
140
|
+
name: reek
|
141
|
+
requirement: !ruby/object:Gem::Requirement
|
142
|
+
requirements:
|
143
|
+
- - ">="
|
144
|
+
- !ruby/object:Gem::Version
|
145
|
+
version: '0'
|
146
|
+
type: :development
|
147
|
+
prerelease: false
|
148
|
+
version_requirements: !ruby/object:Gem::Requirement
|
149
|
+
requirements:
|
150
|
+
- - ">="
|
151
|
+
- !ruby/object:Gem::Version
|
152
|
+
version: '0'
|
125
153
|
description:
|
126
154
|
email:
|
127
155
|
- josh@noredink.com
|
@@ -158,7 +186,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
158
186
|
version: '0'
|
159
187
|
requirements: []
|
160
188
|
rubyforge_project:
|
161
|
-
rubygems_version: 2.
|
189
|
+
rubygems_version: 2.4.8
|
162
190
|
signing_key:
|
163
191
|
specification_version: 4
|
164
192
|
summary: Tools for exporting and importing between databases managed by ActiveRecord.
|