evil-seed 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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: []