evil-seed 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
+
[![Gem Version](https://badge.fury.io/rb/evil-seed.svg)](https://rubygems.org/gems/evil-seed) [![Build Status](https://travis-ci.org/evilmartians/evil-seed.svg?branch=master)](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: []
|