evil-seed 0.1.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 +7 -0
- data/.gitignore +10 -0
- data/.rubocop.yml +32 -0
- data/.travis.yml +25 -0
- data/Appraisals +7 -0
- data/Gemfile +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +139 -0
- data/Rakefile +13 -0
- data/bin/console +11 -0
- data/bin/setup +8 -0
- data/evil-seed.gemspec +39 -0
- data/gemfiles/activerecord_4_2.gemfile +10 -0
- data/gemfiles/activerecord_5_0.gemfile +10 -0
- data/lib/evil/seed.rb +3 -0
- data/lib/evil_seed.rb +27 -0
- data/lib/evil_seed/anonymizer.rb +46 -0
- data/lib/evil_seed/configuration.rb +42 -0
- data/lib/evil_seed/configuration/root.rb +42 -0
- data/lib/evil_seed/dumper.rb +33 -0
- data/lib/evil_seed/record_dumper.rb +99 -0
- data/lib/evil_seed/relation_dumper.rb +158 -0
- data/lib/evil_seed/root_dumper.rb +53 -0
- data/lib/evil_seed/version.rb +5 -0
- metadata +183 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: f5cdab6653f529f88d18a2bd9edebf9ee64a4694
|
4
|
+
data.tar.gz: 244bfb7f54e1c4ca8c7451b79741a132d0a77dc9
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 24ac68a4ae6aa95bb7ebd65688686f60737e4e4de68fab3ce0d269b1b7b3fe82dbab501b623df5a463d7a2ad62283794e65426f1bfbb75b15d6179c75cbc9622
|
7
|
+
data.tar.gz: f6c0e7be2b76b401f4e6ad3c6755cdcab5fb7f568fdc91316889540f20462250e1bf2a4f9b8ce97439b344ab36e3683a8d0ad64a68896f3afb041a5c5ee074bc
|
data/.gitignore
ADDED
data/.rubocop.yml
ADDED
@@ -0,0 +1,32 @@
|
|
1
|
+
AllCops:
|
2
|
+
TargetRubyVersion: 2.2
|
3
|
+
UseCache: false
|
4
|
+
DisplayCopNames: true
|
5
|
+
|
6
|
+
Metrics/LineLength:
|
7
|
+
Max: 120
|
8
|
+
# To make it possible to copy or click on URIs in the code, we allow lines
|
9
|
+
# contaning a URI to be longer than Max.
|
10
|
+
AllowURI: true
|
11
|
+
URISchemes:
|
12
|
+
- http
|
13
|
+
- https
|
14
|
+
Enabled: true
|
15
|
+
|
16
|
+
Metrics/AbcSize:
|
17
|
+
Max: 30
|
18
|
+
|
19
|
+
Metrics/MethodLength:
|
20
|
+
Max: 25
|
21
|
+
|
22
|
+
Style/TrailingCommaInArguments:
|
23
|
+
Description: 'Checks for trailing comma in argument lists.'
|
24
|
+
StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-trailing-params-comma'
|
25
|
+
Enabled: true
|
26
|
+
EnforcedStyleForMultiline: comma
|
27
|
+
|
28
|
+
Style/TrailingCommaInLiteral:
|
29
|
+
Description: 'Checks for trailing comma in array and hash literals.'
|
30
|
+
StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-trailing-array-commas'
|
31
|
+
Enabled: true
|
32
|
+
EnforcedStyleForMultiline: comma
|
data/.travis.yml
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
cache: bundler
|
2
|
+
sudo: false
|
3
|
+
language: ruby
|
4
|
+
rvm:
|
5
|
+
- 2.4.1
|
6
|
+
- 2.3.4
|
7
|
+
- 2.2.7
|
8
|
+
gemfile:
|
9
|
+
- gemfiles/activerecord-5-0.gemfile
|
10
|
+
- gemfiles/activerecord-4-2.gemfile
|
11
|
+
env:
|
12
|
+
- DB=postgresql
|
13
|
+
- DB=sqlite
|
14
|
+
- DB=mysql
|
15
|
+
before_install:
|
16
|
+
- gem install bundler -v 1.14.6
|
17
|
+
- bundle install
|
18
|
+
- appraisal install
|
19
|
+
|
20
|
+
addons:
|
21
|
+
apt:
|
22
|
+
sources:
|
23
|
+
- travis-ci/sqlite3
|
24
|
+
packages:
|
25
|
+
- sqlite3
|
data/Appraisals
ADDED
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2017 Andrey Novikov
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,139 @@
|
|
1
|
+
[](https://rubygems.org/gems/evil-seed) [](https://travis-ci.org/evilmartians/evil-seed)
|
2
|
+
|
3
|
+
# EvilSeed
|
4
|
+
|
5
|
+
EvilSeed is a tool for creating partial anonymized dump of your database based on your app models.
|
6
|
+
|
7
|
+
<a href="https://evilmartians.com/">
|
8
|
+
<img src="https://evilmartians.com/badges/sponsored-by-evil-martians.svg" alt="Sponsored by Evil Martians" width="236" height="54"></a>
|
9
|
+
|
10
|
+
## Motivation
|
11
|
+
|
12
|
+
Using production-like data in your staging environment could be very useful, especially for debugging intricate production bugs.
|
13
|
+
|
14
|
+
The easiest way to achieve this is to use production database backups. But that's not an option for rather large applications for two reasons:
|
15
|
+
|
16
|
+
- production dump can be extremely large, and it just can't be dumped and restored in a reasonable time
|
17
|
+
|
18
|
+
- you should care about sensitive data (anonymization).
|
19
|
+
|
20
|
+
EvilSeed aims to solve these problems.
|
21
|
+
|
22
|
+
## Installation
|
23
|
+
|
24
|
+
Add this line to your application's Gemfile:
|
25
|
+
|
26
|
+
```ruby
|
27
|
+
gem 'evil-seed', require: false
|
28
|
+
```
|
29
|
+
|
30
|
+
And then execute:
|
31
|
+
|
32
|
+
$ bundle
|
33
|
+
|
34
|
+
Or install it yourself as:
|
35
|
+
|
36
|
+
$ gem install evil-seed
|
37
|
+
|
38
|
+
## Usage
|
39
|
+
|
40
|
+
### Configuration
|
41
|
+
|
42
|
+
```ruby
|
43
|
+
require 'evil-seed'
|
44
|
+
|
45
|
+
EvilSeed.configure do |config|
|
46
|
+
# First, you should specify +root models+ and their +constraints+ to limit the number of dumped records:
|
47
|
+
# This is like Forum.where(featured: true).all
|
48
|
+
config.root('Forum', featured: true) do |root|
|
49
|
+
# It's possible to remove some associations from dumping with pattern of association path to exclude
|
50
|
+
#
|
51
|
+
# Association path is a dot-delimited string of association chain starting from model itself:
|
52
|
+
# example: "forum.users.questions"
|
53
|
+
root.exclude(/\btracking_pixels\b/, 'forum.popular_questions')
|
54
|
+
|
55
|
+
# It's possible to limit the number of included into dump has_many and has_one records for every association
|
56
|
+
# Note that belongs_to records for all not excluded associations are always dumped to keep referential integrity.
|
57
|
+
root.limit_associations_size(100)
|
58
|
+
|
59
|
+
# Or for certain association only
|
60
|
+
root.limit_associations_size(10, 'forum.questions')
|
61
|
+
end
|
62
|
+
|
63
|
+
# Everything you can pass to +where+ method will work as constraints:
|
64
|
+
config.root('User', 'created_at > ?', Time.current.beginning_of_day - 1.day)
|
65
|
+
|
66
|
+
# For some system-wide models you may omit constraints to dump all records
|
67
|
+
config.root("Role") do |root|
|
68
|
+
# Exclude everything
|
69
|
+
root.exclude(/.*/)
|
70
|
+
end
|
71
|
+
|
72
|
+
# Transformations allows you to change dumped data e. g. to hide sensitive information
|
73
|
+
config.customize("User") do |user_attributes|
|
74
|
+
# Reset password for all users to the same for ease of debugging on developer's machine
|
75
|
+
u["encrypted_password"] = encrypt("qwerty")
|
76
|
+
u["created_at"] =
|
77
|
+
# Please note that there you have only hash of record attributes, not the record itself!
|
78
|
+
end
|
79
|
+
|
80
|
+
# Anonymization is a handy DSL for transformations allowing you to transform model attributes in declarative fashion
|
81
|
+
# Please note that model setters will NOT be called: results of the blocks will be assigned to
|
82
|
+
config.anonymize("User")
|
83
|
+
name { Faker::Name.name }
|
84
|
+
email { Faker::Internet.email }
|
85
|
+
end
|
86
|
+
```
|
87
|
+
|
88
|
+
### Creating dump
|
89
|
+
|
90
|
+
Just call the `#dump` method and pass a path where you want your SQL dump file to appear!
|
91
|
+
|
92
|
+
```ruby
|
93
|
+
require 'evil-seed'
|
94
|
+
EvilSeed.dump('path/to/new_dump.sql')
|
95
|
+
```
|
96
|
+
|
97
|
+
### Caveats, tips, and tricks
|
98
|
+
|
99
|
+
1. Specify `root`s for dictionaries and system-wide models like `Role` at the top without constraints and with all associations excluded.
|
100
|
+
|
101
|
+
2. Use `exclude` aggressively. You will be amazed, how much your app's models graph is connected. This, in conjunction with the fact that this gem traverses associations in deep-first fashion, sometimes leads to unwanted results: some records will get into dump even if you don't want them.
|
102
|
+
|
103
|
+
3. Look at the resulted dump: there are some useful debug comments.
|
104
|
+
|
105
|
+
## Database compatibility
|
106
|
+
|
107
|
+
This gem has been tested against:
|
108
|
+
|
109
|
+
- PostgreSQL: any version that works with ActiveRecord should work
|
110
|
+
- MySQL: any version that works with ActiveRecord should work
|
111
|
+
- SQLite: 3.7.11 or newer is required (with support for inserting multiple rows at a time)
|
112
|
+
|
113
|
+
|
114
|
+
## FIXME (help wanted)
|
115
|
+
|
116
|
+
1. `has_and_belongs_to_many` associations are traversed in a bit nonintuitive way for end user:
|
117
|
+
|
118
|
+
Association path for `User.has_and_belongs_to_many :roles` is `user.users_roles.role`, but should be `user.roles`
|
119
|
+
|
120
|
+
2. Test coverage is poor
|
121
|
+
|
122
|
+
3. Some internal refactoring is required
|
123
|
+
|
124
|
+
|
125
|
+
## Development
|
126
|
+
|
127
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
128
|
+
|
129
|
+
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).
|
130
|
+
|
131
|
+
|
132
|
+
## Contributing
|
133
|
+
|
134
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/palkan/evil-seed.
|
135
|
+
|
136
|
+
|
137
|
+
## License
|
138
|
+
|
139
|
+
The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require 'bundler/gem_tasks'
|
5
|
+
require 'rake/testtask'
|
6
|
+
|
7
|
+
Rake::TestTask.new(:test) do |t|
|
8
|
+
t.libs << 'test'
|
9
|
+
t.libs << 'lib'
|
10
|
+
t.test_files = FileList['test/**/*_test.rb']
|
11
|
+
end
|
12
|
+
|
13
|
+
task default: :test
|
data/bin/console
ADDED
@@ -0,0 +1,11 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require 'bundler/setup'
|
5
|
+
require 'evil/seed'
|
6
|
+
|
7
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
8
|
+
# with your gem easier. You can also use a different console, if you like.
|
9
|
+
|
10
|
+
require 'pry'
|
11
|
+
Pry.start
|
data/bin/setup
ADDED
data/evil-seed.gemspec
ADDED
@@ -0,0 +1,39 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
lib = File.expand_path('../lib', __FILE__)
|
5
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
6
|
+
require 'evil_seed/version'
|
7
|
+
|
8
|
+
Gem::Specification.new do |spec|
|
9
|
+
spec.name = 'evil-seed'
|
10
|
+
spec.version = EvilSeed::VERSION
|
11
|
+
spec.authors = ['Andrey Novikov', 'Vladimir Dementyev']
|
12
|
+
spec.email = ['envek@envek.name', 'palkan@evl.ms']
|
13
|
+
|
14
|
+
spec.summary = 'Create partial and anonymized production database dumps for use in development'
|
15
|
+
spec.description = <<-DESCRIPTION
|
16
|
+
This gem allows you to easily dump and transform subset of your ActiveRecord models and their relations.
|
17
|
+
DESCRIPTION
|
18
|
+
spec.homepage = 'https://github.com/palkan/evil-seed'
|
19
|
+
spec.license = 'MIT'
|
20
|
+
|
21
|
+
spec.files = `git ls-files -z`.split("\x0").reject do |f|
|
22
|
+
f.match(%r{^(test|spec|features)/})
|
23
|
+
end
|
24
|
+
spec.bindir = 'exe'
|
25
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
26
|
+
spec.require_paths = ['lib']
|
27
|
+
|
28
|
+
spec.required_ruby_version = '~> 2.0'
|
29
|
+
|
30
|
+
spec.add_dependency 'activerecord', '>= 4.2'
|
31
|
+
|
32
|
+
spec.add_development_dependency 'rake', '~> 12.0'
|
33
|
+
spec.add_development_dependency 'minitest', '~> 5.0'
|
34
|
+
spec.add_development_dependency 'pg', '~> 0.20'
|
35
|
+
spec.add_development_dependency 'rubocop'
|
36
|
+
spec.add_development_dependency 'bundler'
|
37
|
+
spec.add_development_dependency 'pry'
|
38
|
+
spec.add_development_dependency 'appraisal'
|
39
|
+
end
|
data/lib/evil/seed.rb
ADDED
data/lib/evil_seed.rb
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'active_record'
|
4
|
+
|
5
|
+
require_relative 'evil_seed/version'
|
6
|
+
require_relative 'evil_seed/configuration'
|
7
|
+
require_relative 'evil_seed/dumper'
|
8
|
+
|
9
|
+
# Generate anonymized dumps for your ActiveRecord models
|
10
|
+
module EvilSeed
|
11
|
+
DEFAULT_CONFIGURATION = EvilSeed::Configuration.new
|
12
|
+
|
13
|
+
def self.configure
|
14
|
+
yield DEFAULT_CONFIGURATION
|
15
|
+
end
|
16
|
+
|
17
|
+
# Make the actual dump
|
18
|
+
# @param filepath_or_io [String, IO] Path to result dumpfile or IO to write results into
|
19
|
+
def self.dump(filepath_or_io)
|
20
|
+
io = if filepath_or_io.respond_to?(:write) # IO
|
21
|
+
filepath_or_io
|
22
|
+
else
|
23
|
+
File.open(filepath_or_io, mode: 'w')
|
24
|
+
end
|
25
|
+
EvilSeed::Dumper.new(DEFAULT_CONFIGURATION).call(io)
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module EvilSeed
|
4
|
+
# This class constructs customizer callable with simple DSL:
|
5
|
+
#
|
6
|
+
# config.anonymize("User")
|
7
|
+
# name { Faker::Name.name }
|
8
|
+
# email { Faker::Internet.email }
|
9
|
+
# end
|
10
|
+
#
|
11
|
+
# Resulting object can be called with record attributes and will return modified copy.
|
12
|
+
#
|
13
|
+
# attrs = { name: 'Luke', email: 'luke@skywalker.com' }
|
14
|
+
# a.call(attrs)
|
15
|
+
# attrs # => { name: 'John', email: 'bob@example.com' }
|
16
|
+
#
|
17
|
+
class Anonymizer
|
18
|
+
# @param model_name [String] A string containing class name of your ActiveRecord model
|
19
|
+
def initialize(model_name, &block)
|
20
|
+
@model_class = model_name.constantize
|
21
|
+
@changers = {}
|
22
|
+
instance_eval(&block)
|
23
|
+
end
|
24
|
+
|
25
|
+
# @param attributes [Hash{String=>void}] Record attributes.
|
26
|
+
# @return [Hash{String=>void}] Modified deep copy of +attributes+
|
27
|
+
def call(attributes)
|
28
|
+
attributes.deep_dup.tap do |attrs|
|
29
|
+
@changers.each do |attribute, changer|
|
30
|
+
attrs[attribute] = changer.call
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def respond_to_missing?(attribute_name)
|
36
|
+
@model_class.attribute_names.include?(attribute_name.to_s) || super
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
def method_missing(attribute_name, &block)
|
42
|
+
return super unless @model_class.attribute_names.include?(attribute_name.to_s)
|
43
|
+
@changers[attribute_name.to_s] = block
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'configuration/root'
|
4
|
+
require_relative 'record_dumper'
|
5
|
+
require_relative 'anonymizer'
|
6
|
+
|
7
|
+
module EvilSeed
|
8
|
+
# This module holds configuration for creating dump: which models and their constraints
|
9
|
+
class Configuration
|
10
|
+
attr_accessor :record_dumper_class
|
11
|
+
|
12
|
+
def initialize
|
13
|
+
@record_dumper_class = RecordDumper
|
14
|
+
end
|
15
|
+
|
16
|
+
def roots
|
17
|
+
@roots ||= []
|
18
|
+
end
|
19
|
+
|
20
|
+
def root(model, *constraints)
|
21
|
+
new_root = Root.new(model, *constraints)
|
22
|
+
yield new_root if block_given?
|
23
|
+
roots << new_root
|
24
|
+
end
|
25
|
+
|
26
|
+
def customize(model_class, &block)
|
27
|
+
raise(ArgumentError, "You must provide block for #{__method__} method") unless block
|
28
|
+
customizers[model_class.to_s] << ->(attrs) { attrs.tap(&block) } # Ensure that we're returning attrs from it
|
29
|
+
end
|
30
|
+
|
31
|
+
def anonymize(model_class, &block)
|
32
|
+
raise(ArgumentError, "You must provide block for #{__method__} method") unless block
|
33
|
+
customizers[model_class.to_s] << Anonymizer.new(model_class, &block)
|
34
|
+
end
|
35
|
+
|
36
|
+
# Customizer objects for every model
|
37
|
+
# @return [Hash{String => Array<#call>}]
|
38
|
+
def customizers
|
39
|
+
@customizers ||= Hash.new { |h, k| h[k] = [] }
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module EvilSeed
|
4
|
+
class Configuration
|
5
|
+
# Configuration for dumping some root model and its associations
|
6
|
+
class Root
|
7
|
+
attr_reader :model, :constraints
|
8
|
+
attr_reader :total_limit, :association_limits
|
9
|
+
attr_reader :exclusions
|
10
|
+
|
11
|
+
# @param model [String] Name of the model class to dump
|
12
|
+
# @param constraints [String, Hash] Everything you can feed into +where+ to limit number of records
|
13
|
+
def initialize(model, *constraints)
|
14
|
+
@model = model
|
15
|
+
@constraints = constraints
|
16
|
+
@exclusions = []
|
17
|
+
@association_limits = {}
|
18
|
+
end
|
19
|
+
|
20
|
+
# Exclude some of associations from the dump
|
21
|
+
# @param association_patterns Array<String, Regex> Patterns to exclude associated models from dump
|
22
|
+
def exclude(*association_patterns)
|
23
|
+
@exclusions += association_patterns
|
24
|
+
end
|
25
|
+
|
26
|
+
# Limit number of records in all (if pattern is not provided) or given associations to include into dump
|
27
|
+
# @param limit [Integer] Maximum number of records in associations to include into dump
|
28
|
+
# @param association_pattern [String, Regex] Pattern to limit number of records for certain associated models
|
29
|
+
def limit_associations_size(limit, association_pattern = nil)
|
30
|
+
if association_pattern
|
31
|
+
@association_limits[association_pattern] = limit
|
32
|
+
else
|
33
|
+
@total_limit = limit
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def excluded?(association_path)
|
38
|
+
exclusions.any? { |exclusion| exclusion.match(association_path) }
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'set'
|
4
|
+
require_relative 'root_dumper'
|
5
|
+
|
6
|
+
module EvilSeed
|
7
|
+
# This class initiates dump creation for every root model of configuration
|
8
|
+
# and then concatenates dumps from all roots into one single IO.
|
9
|
+
class Dumper
|
10
|
+
attr_reader :configuration, :loaded_map
|
11
|
+
|
12
|
+
# @param configuration [Configuration]
|
13
|
+
def initialize(configuration)
|
14
|
+
@configuration = configuration
|
15
|
+
end
|
16
|
+
|
17
|
+
# Generate dump for this configuration and write it into provided +io+
|
18
|
+
# @param output [IO] Stream to write SQL dump into
|
19
|
+
def call(output)
|
20
|
+
@loaded_map = Hash.new { |h, k| h[k] = Set.new } # stores primary keys of already dumped records for every table
|
21
|
+
@output = output
|
22
|
+
configuration.roots.each do |root|
|
23
|
+
table_outputs = RootDumper.new(root, self).call
|
24
|
+
table_outputs.each do |table_dump_io|
|
25
|
+
table_dump_io.rewind
|
26
|
+
IO.copy_stream(table_dump_io, @output)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
ensure
|
30
|
+
@output.close
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,99 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module EvilSeed
|
4
|
+
# - Runs all transformation objects for every tuple
|
5
|
+
# - Serializes transformed values back to basic types and writes it to dump
|
6
|
+
class RecordDumper
|
7
|
+
MAX_TUPLES_PER_INSERT_STMT = 1_000
|
8
|
+
|
9
|
+
attr_reader :model_class, :configuration, :relation_dumper
|
10
|
+
|
11
|
+
delegate :loaded_map, to: :relation_dumper
|
12
|
+
|
13
|
+
def initialize(model_class, configuration, relation_dumper)
|
14
|
+
@model_class = model_class
|
15
|
+
@configuration = configuration
|
16
|
+
@relation_dumper = relation_dumper
|
17
|
+
@output = Tempfile.new(["evil_seed_#{model_class.table_name}_", '.sql'])
|
18
|
+
@header_written = false
|
19
|
+
@tuples_written = 0
|
20
|
+
end
|
21
|
+
|
22
|
+
# Extracts, transforms, and dumps record +attributes+
|
23
|
+
# @return [Boolean] Was this record dumped or not
|
24
|
+
def call(attributes)
|
25
|
+
return false unless loaded!(attributes)
|
26
|
+
write!(transform_and_anonymize(attributes))
|
27
|
+
true
|
28
|
+
end
|
29
|
+
|
30
|
+
# @return [IO] Dump for this model's table
|
31
|
+
def result
|
32
|
+
finalize!
|
33
|
+
@output
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
def loaded!(attributes)
|
39
|
+
id = model_class.primary_key && attributes[model_class.primary_key] || attributes
|
40
|
+
return false if loaded_map[model_class.table_name].include?(id)
|
41
|
+
loaded_map[model_class.table_name] << id
|
42
|
+
end
|
43
|
+
|
44
|
+
def transform_and_anonymize(attributes)
|
45
|
+
customizers = configuration.customizers[model_class.to_s]
|
46
|
+
return attributes unless customizers
|
47
|
+
customizers.inject(attributes) do |attrs, customizer|
|
48
|
+
customizer.call(attrs)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def insert_statement
|
53
|
+
connection = model_class.connection
|
54
|
+
table_name = connection.quote_table_name(model_class.table_name)
|
55
|
+
columns = model_class.attribute_names.map { |c| connection.quote_column_name(c) }.join(', ')
|
56
|
+
"INSERT INTO #{table_name} (#{columns}) VALUES\n"
|
57
|
+
end
|
58
|
+
|
59
|
+
def write!(attributes)
|
60
|
+
@output.write("-- #{relation_dumper.association_path}\n") && @header_written = true unless @header_written
|
61
|
+
@output.write(@tuples_written.zero? ? insert_statement : ",\n")
|
62
|
+
@output.write(" (#{prepare(attributes).join(', ')})")
|
63
|
+
@tuples_written += 1
|
64
|
+
@output.write(";\n") && @tuples_written = 0 if @tuples_written == MAX_TUPLES_PER_INSERT_STMT
|
65
|
+
end
|
66
|
+
|
67
|
+
def finalize!
|
68
|
+
return unless @header_written && @tuples_written > 0
|
69
|
+
@output.write(";\n\n")
|
70
|
+
@tuples_written = 0
|
71
|
+
end
|
72
|
+
|
73
|
+
def prepare(attributes)
|
74
|
+
attributes.map do |key, value|
|
75
|
+
model_class.connection.quote(serialize(attribute_types[key], value))
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
# Handles ActiveRecord API differences between AR 4.2 and 5.0
|
80
|
+
def attribute_types
|
81
|
+
return @attribute_types if defined?(@attribute_types)
|
82
|
+
@attribute_types = if model_class.respond_to?(:attribute_types)
|
83
|
+
model_class.attribute_types
|
84
|
+
else
|
85
|
+
model_class.column_types
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
# Handles ActiveRecord API differences between AR 4.2 and 5.0
|
90
|
+
# Casts a value from the ruby type to a type that the database knows how to understand.
|
91
|
+
def serialize(type, value)
|
92
|
+
if type.respond_to?(:serialize)
|
93
|
+
type.serialize(value)
|
94
|
+
else
|
95
|
+
type.type_cast_for_database(value)
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
@@ -0,0 +1,158 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module EvilSeed
|
4
|
+
# This class performs actual dump generation for single relation and all its not yet loaded dependencies
|
5
|
+
#
|
6
|
+
# - Fetches all tuples for root (it does not instantiate AR records but it casts values to Ruby types)
|
7
|
+
# - Extracts foreign key values for all belongs_to associations
|
8
|
+
# - Dumps belongs_to associations(recursion!)
|
9
|
+
# - Dumps all tuples for root, writes them in file
|
10
|
+
# - Dumps all other associations (recursion!)
|
11
|
+
# - Returns all results to caller in correct order
|
12
|
+
#
|
13
|
+
# TODO: This class obviously breaks SRP principle and thus should be split!
|
14
|
+
class RelationDumper
|
15
|
+
MAX_IDENTIFIERS_IN_IN_STMT = 1_000
|
16
|
+
|
17
|
+
attr_reader :relation, :root_dumper, :model_class, :association_path, :search_key, :identifiers, :nullify_columns,
|
18
|
+
:belongs_to_reflections, :has_many_reflections, :foreign_keys, :loaded_ids, :to_load_map,
|
19
|
+
:record_dumper, :inverse_reflection, :table_names, :options
|
20
|
+
|
21
|
+
delegate :root, :configuration, :total_limit, :loaded_map, to: :root_dumper
|
22
|
+
|
23
|
+
def initialize(relation, root_dumper, association_path, **options)
|
24
|
+
@relation = relation
|
25
|
+
@root_dumper = root_dumper
|
26
|
+
@identifiers = options[:identifiers]
|
27
|
+
@to_load_map = Hash.new { |h, k| h[k] = [] }
|
28
|
+
@foreign_keys = Hash.new { |h, k| h[k] = [] }
|
29
|
+
@loaded_ids = []
|
30
|
+
@model_class = relation.klass
|
31
|
+
@search_key = options[:search_key] || model_class.primary_key
|
32
|
+
@association_path = association_path
|
33
|
+
@inverse_reflection = options[:inverse_of]
|
34
|
+
@record_dumper = configuration.record_dumper_class.new(model_class, configuration, self)
|
35
|
+
@nullify_columns = []
|
36
|
+
@table_names = {}
|
37
|
+
@belongs_to_reflections = setup_belongs_to_reflections
|
38
|
+
@has_many_reflections = setup_has_many_reflections
|
39
|
+
@options = options
|
40
|
+
end
|
41
|
+
|
42
|
+
# Generate dump and write it into +io+
|
43
|
+
# @return [Array<IO>] List of dump IOs for separate tables in order of dependencies (belongs_to are first)
|
44
|
+
def call
|
45
|
+
dump!
|
46
|
+
belongs_to_dumps = dump_belongs_to_associations!
|
47
|
+
has_many_dumps = dump_has_many_associations!
|
48
|
+
[belongs_to_dumps, record_dumper.result, has_many_dumps].flatten.compact
|
49
|
+
end
|
50
|
+
|
51
|
+
private
|
52
|
+
|
53
|
+
def dump!
|
54
|
+
if identifiers.present?
|
55
|
+
# Don't use AR::Base#find_each as we will get error on Oracle if we will have more than 1000 ids in IN statement
|
56
|
+
identifiers.in_groups_of(MAX_IDENTIFIERS_IN_IN_STMT).each do |ids|
|
57
|
+
fetch_attributes(relation.where(search_key => ids.compact)).each do |attributes|
|
58
|
+
next unless check_limits!
|
59
|
+
dump_record!(attributes)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
else
|
63
|
+
relation.in_batches do |relation|
|
64
|
+
fetch_attributes(relation).each do |attributes|
|
65
|
+
next unless check_limits!
|
66
|
+
dump_record!(attributes)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
def dump_record!(attributes)
|
73
|
+
nullify_columns.each do |nullify_column|
|
74
|
+
attributes[nullify_column] = nil
|
75
|
+
end
|
76
|
+
return unless record_dumper.call(attributes)
|
77
|
+
foreign_keys.each do |reflection_name, fk_column|
|
78
|
+
foreign_key = attributes[fk_column]
|
79
|
+
next if foreign_key.nil? || loaded_map[table_names[reflection_name]].include?(foreign_key)
|
80
|
+
to_load_map[reflection_name] << foreign_key
|
81
|
+
end
|
82
|
+
loaded_ids << attributes[model_class.primary_key]
|
83
|
+
end
|
84
|
+
|
85
|
+
def dump_belongs_to_associations!
|
86
|
+
belongs_to_reflections.map do |reflection|
|
87
|
+
next if to_load_map[reflection.name].empty?
|
88
|
+
RelationDumper.new(
|
89
|
+
build_relation(reflection),
|
90
|
+
root_dumper,
|
91
|
+
"#{association_path}.#{reflection.name}",
|
92
|
+
search_key: reflection.association_primary_key,
|
93
|
+
identifiers: to_load_map[reflection.name],
|
94
|
+
limitable: false,
|
95
|
+
).call
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
def dump_has_many_associations!
|
100
|
+
has_many_reflections.map do |reflection|
|
101
|
+
next if loaded_ids.empty? || total_limit.try(:zero?)
|
102
|
+
RelationDumper.new(
|
103
|
+
build_relation(reflection),
|
104
|
+
root_dumper,
|
105
|
+
"#{association_path}.#{reflection.name}",
|
106
|
+
search_key: reflection.foreign_key,
|
107
|
+
identifiers: loaded_ids,
|
108
|
+
inverse_of: reflection.inverse_of.try(:name),
|
109
|
+
limitable: true,
|
110
|
+
).call
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
# Selects attributes as a hash with typecasted values for all rows from +relation+
|
115
|
+
# @param relation [ActiveRecord::Relation]
|
116
|
+
# @return [Array<Hash{String => String, Integer, Float, Boolean, nil}>]
|
117
|
+
def fetch_attributes(relation)
|
118
|
+
relation.pluck(*model_class.attribute_names).map do |row|
|
119
|
+
Hash[model_class.attribute_names.zip(row)]
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
def check_limits!
|
124
|
+
return true unless options[:limitable]
|
125
|
+
root_dumper.check_limits!(association_path)
|
126
|
+
end
|
127
|
+
|
128
|
+
def build_relation(reflection)
|
129
|
+
relation = reflection.klass.all
|
130
|
+
relation = relation.instance_eval(&reflection.scope) if reflection.scope
|
131
|
+
relation = relation.where(reflection.type => model_class.to_s) if reflection.options[:as] # polymorphic
|
132
|
+
relation
|
133
|
+
end
|
134
|
+
|
135
|
+
def setup_belongs_to_reflections
|
136
|
+
model_class.reflect_on_all_associations(:belongs_to).reject do |reflection|
|
137
|
+
next false if reflection.options[:polymorphic] # TODO: Add support for polymorphic belongs_to
|
138
|
+
excluded = root.excluded?("#{association_path}.#{reflection.name}") || reflection.name == inverse_reflection
|
139
|
+
if excluded
|
140
|
+
nullify_columns << reflection.foreign_key
|
141
|
+
else
|
142
|
+
foreign_keys[reflection.name] = reflection.foreign_key
|
143
|
+
table_names[reflection.name] = reflection.table_name
|
144
|
+
end
|
145
|
+
excluded
|
146
|
+
end
|
147
|
+
end
|
148
|
+
|
149
|
+
# This method returns only direct has_one and has_many reflections. For HABTM it returns intermediate has_many
|
150
|
+
def setup_has_many_reflections
|
151
|
+
model_class._reflections.select do |_reflection_name, reflection|
|
152
|
+
next false if model_class.primary_key.nil?
|
153
|
+
next false if reflection.is_a?(ActiveRecord::Reflection::ThroughReflection)
|
154
|
+
%i[has_one has_many].include?(reflection.macro) && !root.excluded?("#{association_path}.#{reflection.name}")
|
155
|
+
end.map(&:second)
|
156
|
+
end
|
157
|
+
end
|
158
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'relation_dumper'
|
4
|
+
|
5
|
+
module EvilSeed
|
6
|
+
# This module collects dumps generation for root and all it's dependencies
|
7
|
+
class RootDumper
|
8
|
+
attr_reader :root, :dumper, :model_class, :total_limit, :association_limits
|
9
|
+
|
10
|
+
delegate :loaded_map, :configuration, to: :dumper
|
11
|
+
|
12
|
+
def initialize(root, dumper)
|
13
|
+
@root = root
|
14
|
+
@dumper = dumper
|
15
|
+
@to_load_map = {}
|
16
|
+
@total_limit = root.total_limit
|
17
|
+
@association_limits = root.association_limits.dup
|
18
|
+
|
19
|
+
@model_class = root.model.constantize
|
20
|
+
end
|
21
|
+
|
22
|
+
# Generate dump and write it into +io+
|
23
|
+
# @param output [IO] Stream to write SQL dump into
|
24
|
+
def call
|
25
|
+
association_path = model_class.model_name.singular
|
26
|
+
RelationDumper.new(model_class.where(*root.constraints), self, association_path).call
|
27
|
+
end
|
28
|
+
|
29
|
+
# @return [Boolean] +true+ if limits are NOT reached and +false+ otherwise
|
30
|
+
def check_limits!(association_path)
|
31
|
+
check_total_limit! && check_association_limits!(association_path)
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def check_total_limit!
|
37
|
+
return true if total_limit.nil?
|
38
|
+
return false if total_limit.zero?
|
39
|
+
@total_limit -= 1
|
40
|
+
true
|
41
|
+
end
|
42
|
+
|
43
|
+
def check_association_limits!(association_path)
|
44
|
+
return true if association_limits.none?
|
45
|
+
applied_limits = association_limits.select { |path, _limit| path.match(association_path) }
|
46
|
+
return false if applied_limits.any? { |_path, limit| limit.zero? }
|
47
|
+
applied_limits.each do |path, _limit|
|
48
|
+
association_limits[path] -= 1
|
49
|
+
end
|
50
|
+
true
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
metadata
ADDED
@@ -0,0 +1,183 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: evil-seed
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Andrey Novikov
|
8
|
+
- Vladimir Dementyev
|
9
|
+
autorequire:
|
10
|
+
bindir: exe
|
11
|
+
cert_chain: []
|
12
|
+
date: 2017-05-09 00:00:00.000000000 Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: activerecord
|
16
|
+
requirement: !ruby/object:Gem::Requirement
|
17
|
+
requirements:
|
18
|
+
- - ">="
|
19
|
+
- !ruby/object:Gem::Version
|
20
|
+
version: '4.2'
|
21
|
+
type: :runtime
|
22
|
+
prerelease: false
|
23
|
+
version_requirements: !ruby/object:Gem::Requirement
|
24
|
+
requirements:
|
25
|
+
- - ">="
|
26
|
+
- !ruby/object:Gem::Version
|
27
|
+
version: '4.2'
|
28
|
+
- !ruby/object:Gem::Dependency
|
29
|
+
name: rake
|
30
|
+
requirement: !ruby/object:Gem::Requirement
|
31
|
+
requirements:
|
32
|
+
- - "~>"
|
33
|
+
- !ruby/object:Gem::Version
|
34
|
+
version: '12.0'
|
35
|
+
type: :development
|
36
|
+
prerelease: false
|
37
|
+
version_requirements: !ruby/object:Gem::Requirement
|
38
|
+
requirements:
|
39
|
+
- - "~>"
|
40
|
+
- !ruby/object:Gem::Version
|
41
|
+
version: '12.0'
|
42
|
+
- !ruby/object:Gem::Dependency
|
43
|
+
name: minitest
|
44
|
+
requirement: !ruby/object:Gem::Requirement
|
45
|
+
requirements:
|
46
|
+
- - "~>"
|
47
|
+
- !ruby/object:Gem::Version
|
48
|
+
version: '5.0'
|
49
|
+
type: :development
|
50
|
+
prerelease: false
|
51
|
+
version_requirements: !ruby/object:Gem::Requirement
|
52
|
+
requirements:
|
53
|
+
- - "~>"
|
54
|
+
- !ruby/object:Gem::Version
|
55
|
+
version: '5.0'
|
56
|
+
- !ruby/object:Gem::Dependency
|
57
|
+
name: pg
|
58
|
+
requirement: !ruby/object:Gem::Requirement
|
59
|
+
requirements:
|
60
|
+
- - "~>"
|
61
|
+
- !ruby/object:Gem::Version
|
62
|
+
version: '0.20'
|
63
|
+
type: :development
|
64
|
+
prerelease: false
|
65
|
+
version_requirements: !ruby/object:Gem::Requirement
|
66
|
+
requirements:
|
67
|
+
- - "~>"
|
68
|
+
- !ruby/object:Gem::Version
|
69
|
+
version: '0.20'
|
70
|
+
- !ruby/object:Gem::Dependency
|
71
|
+
name: rubocop
|
72
|
+
requirement: !ruby/object:Gem::Requirement
|
73
|
+
requirements:
|
74
|
+
- - ">="
|
75
|
+
- !ruby/object:Gem::Version
|
76
|
+
version: '0'
|
77
|
+
type: :development
|
78
|
+
prerelease: false
|
79
|
+
version_requirements: !ruby/object:Gem::Requirement
|
80
|
+
requirements:
|
81
|
+
- - ">="
|
82
|
+
- !ruby/object:Gem::Version
|
83
|
+
version: '0'
|
84
|
+
- !ruby/object:Gem::Dependency
|
85
|
+
name: bundler
|
86
|
+
requirement: !ruby/object:Gem::Requirement
|
87
|
+
requirements:
|
88
|
+
- - ">="
|
89
|
+
- !ruby/object:Gem::Version
|
90
|
+
version: '0'
|
91
|
+
type: :development
|
92
|
+
prerelease: false
|
93
|
+
version_requirements: !ruby/object:Gem::Requirement
|
94
|
+
requirements:
|
95
|
+
- - ">="
|
96
|
+
- !ruby/object:Gem::Version
|
97
|
+
version: '0'
|
98
|
+
- !ruby/object:Gem::Dependency
|
99
|
+
name: pry
|
100
|
+
requirement: !ruby/object:Gem::Requirement
|
101
|
+
requirements:
|
102
|
+
- - ">="
|
103
|
+
- !ruby/object:Gem::Version
|
104
|
+
version: '0'
|
105
|
+
type: :development
|
106
|
+
prerelease: false
|
107
|
+
version_requirements: !ruby/object:Gem::Requirement
|
108
|
+
requirements:
|
109
|
+
- - ">="
|
110
|
+
- !ruby/object:Gem::Version
|
111
|
+
version: '0'
|
112
|
+
- !ruby/object:Gem::Dependency
|
113
|
+
name: appraisal
|
114
|
+
requirement: !ruby/object:Gem::Requirement
|
115
|
+
requirements:
|
116
|
+
- - ">="
|
117
|
+
- !ruby/object:Gem::Version
|
118
|
+
version: '0'
|
119
|
+
type: :development
|
120
|
+
prerelease: false
|
121
|
+
version_requirements: !ruby/object:Gem::Requirement
|
122
|
+
requirements:
|
123
|
+
- - ">="
|
124
|
+
- !ruby/object:Gem::Version
|
125
|
+
version: '0'
|
126
|
+
description: " This gem allows you to easily dump and transform subset of your
|
127
|
+
ActiveRecord models and their relations.\n"
|
128
|
+
email:
|
129
|
+
- envek@envek.name
|
130
|
+
- palkan@evl.ms
|
131
|
+
executables: []
|
132
|
+
extensions: []
|
133
|
+
extra_rdoc_files: []
|
134
|
+
files:
|
135
|
+
- ".gitignore"
|
136
|
+
- ".rubocop.yml"
|
137
|
+
- ".travis.yml"
|
138
|
+
- Appraisals
|
139
|
+
- Gemfile
|
140
|
+
- LICENSE.txt
|
141
|
+
- README.md
|
142
|
+
- Rakefile
|
143
|
+
- bin/console
|
144
|
+
- bin/setup
|
145
|
+
- evil-seed.gemspec
|
146
|
+
- gemfiles/activerecord_4_2.gemfile
|
147
|
+
- gemfiles/activerecord_5_0.gemfile
|
148
|
+
- lib/evil/seed.rb
|
149
|
+
- lib/evil_seed.rb
|
150
|
+
- lib/evil_seed/anonymizer.rb
|
151
|
+
- lib/evil_seed/configuration.rb
|
152
|
+
- lib/evil_seed/configuration/root.rb
|
153
|
+
- lib/evil_seed/dumper.rb
|
154
|
+
- lib/evil_seed/record_dumper.rb
|
155
|
+
- lib/evil_seed/relation_dumper.rb
|
156
|
+
- lib/evil_seed/root_dumper.rb
|
157
|
+
- lib/evil_seed/version.rb
|
158
|
+
- tmp/.keep
|
159
|
+
homepage: https://github.com/palkan/evil-seed
|
160
|
+
licenses:
|
161
|
+
- MIT
|
162
|
+
metadata: {}
|
163
|
+
post_install_message:
|
164
|
+
rdoc_options: []
|
165
|
+
require_paths:
|
166
|
+
- lib
|
167
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
168
|
+
requirements:
|
169
|
+
- - "~>"
|
170
|
+
- !ruby/object:Gem::Version
|
171
|
+
version: '2.0'
|
172
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
173
|
+
requirements:
|
174
|
+
- - ">="
|
175
|
+
- !ruby/object:Gem::Version
|
176
|
+
version: '0'
|
177
|
+
requirements: []
|
178
|
+
rubyforge_project:
|
179
|
+
rubygems_version: 2.6.4
|
180
|
+
signing_key:
|
181
|
+
specification_version: 4
|
182
|
+
summary: Create partial and anonymized production database dumps for use in development
|
183
|
+
test_files: []
|