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.
@@ -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
@@ -0,0 +1,10 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
10
+ /gemfiles/*.lock
@@ -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
@@ -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
@@ -0,0 +1,7 @@
1
+ appraise 'activerecord-4-2' do
2
+ gem 'activerecord', '~> 4.2.0'
3
+ end
4
+
5
+ appraise 'activerecord-5-0' do
6
+ gem 'activerecord', '~> 5.0.0'
7
+ end
data/Gemfile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ # Specify your gem's dependencies in evil-seed.gemspec
6
+ gemspec
7
+
8
+ gem 'mysql2'
9
+ gem 'pg'
10
+ gem 'sqlite3'
@@ -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.
@@ -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).
@@ -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
@@ -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
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -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
@@ -0,0 +1,10 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "mysql2"
6
+ gem "pg"
7
+ gem "sqlite3"
8
+ gem "activerecord", "~> 4.2.0"
9
+
10
+ gemspec path: "../"
@@ -0,0 +1,10 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "mysql2"
6
+ gem "pg"
7
+ gem "sqlite3"
8
+ gem "activerecord", "~> 5.0.0"
9
+
10
+ gemspec path: "../"
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../evil_seed'
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EvilSeed
4
+ VERSION = '0.1.0'
5
+ 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: []