evil-seed 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: beab28e2e707c60ae58a1a30d000975f7b460918f369c7177800f732f34cfee5
4
- data.tar.gz: 710c2d0a680da6d0c558d50e448048ec5338693e6ce112745ac41a750d34cd3b
3
+ metadata.gz: 757a2cee2b774b29a9d248270642db41faa431fdfd1b738aed0b8ea275fa358a
4
+ data.tar.gz: 8dc1887a03f1169aa332f9ef345ea666d5582e578ec6c8c97c6041b6f281dff4
5
5
  SHA512:
6
- metadata.gz: 0de18595e7b4498352c7056f6b9f3c1baf5fe4263cc64f202a1cc06642077767bbe8950c94553cc70fc3157f90687807e851cf6b99403f8a367329045a97bea2
7
- data.tar.gz: df586316b366d0b4657cf25cdf6ac56610296c6faa630d1dfdc7314f605df7c2f9454c3f7af72a5f9dea1fd7987bdf553fbd5cf44dff7fd6657dd514a7c15206
6
+ metadata.gz: aea3be4c8d73fc5ec6e04457d6322306d12a70d44a4792eb1dc855162b28b3b325617815a8a5898733cdc31067e8c51fa3714909c123fe890d54e21123121f7f
7
+ data.tar.gz: 98ed722479590c6615d87dfa3a0262d7012f008f0c9be2c4ddb3066a01ef2959febaf3ecea60c25e362417e9eba986f7ada50fad9ce06355fb8b5dfbc75e664c
@@ -0,0 +1,86 @@
1
+ name: Release gem
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - v*
7
+
8
+ jobs:
9
+ release:
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ contents: write
13
+ id-token: write
14
+ packages: write
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ with:
18
+ fetch-depth: 0 # Fetch current tag as annotated. See https://github.com/actions/checkout/issues/290
19
+ - uses: ruby/setup-ruby@v1
20
+ with:
21
+ ruby-version: "3.3"
22
+ - name: "Extract data from tag: version, message, body"
23
+ id: tag
24
+ run: |
25
+ git fetch --tags --force # Really fetch annotated tag. See https://github.com/actions/checkout/issues/290#issuecomment-680260080
26
+ echo ::set-output name=version::${GITHUB_REF#refs/tags/v}
27
+ echo ::set-output name=subject::$(git for-each-ref $GITHUB_REF --format='%(contents:subject)')
28
+ BODY="$(git for-each-ref $GITHUB_REF --format='%(contents:body)')"
29
+ # Extract changelog entries between this and previous version headers
30
+ escaped_version=$(echo ${GITHUB_REF#refs/tags/v} | sed -e 's/[]\/$*.^[]/\\&/g')
31
+ changelog=$(awk "BEGIN{inrelease=0} /## \[${escaped_version}\]/{inrelease=1;next} /## \[[0-9]+\.[0-9]+\.[0-9]+.*?\]/{inrelease=0;exit} {if (inrelease) print}" CHANGELOG.md)
32
+ # Multiline body for release. See https://github.community/t/set-output-truncates-multiline-strings/16852/5
33
+ BODY="${BODY}"$'\n'"${changelog}"
34
+ BODY="${BODY//'%'/'%25'}"
35
+ BODY="${BODY//$'\n'/'%0A'}"
36
+ BODY="${BODY//$'\r'/'%0D'}"
37
+ echo "::set-output name=body::$BODY"
38
+ # Add pre-release option if tag name has any suffix after vMAJOR.MINOR.PATCH
39
+ if [[ ${GITHUB_REF#refs/tags/} =~ ^v[0-9]+\.[0-9]+\.[0-9]+.+ ]]; then
40
+ echo ::set-output name=prerelease::true
41
+ fi
42
+ - name: Build gem
43
+ run: gem build
44
+ - name: Calculate checksums
45
+ run: sha256sum evil-seed-${{ steps.tag.outputs.version }}.gem > SHA256SUM
46
+ - name: Check version
47
+ run: ls -l evil-seed-${{ steps.tag.outputs.version }}.gem
48
+ - name: Create Release
49
+ id: create_release
50
+ uses: actions/create-release@v1
51
+ env:
52
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
53
+ with:
54
+ tag_name: ${{ github.ref }}
55
+ release_name: ${{ steps.tag.outputs.subject }}
56
+ body: ${{ steps.tag.outputs.body }}
57
+ draft: false
58
+ prerelease: ${{ steps.tag.outputs.prerelease }}
59
+ - name: Upload built gem as release asset
60
+ uses: actions/upload-release-asset@v1
61
+ env:
62
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
63
+ with:
64
+ upload_url: ${{ steps.create_release.outputs.upload_url }}
65
+ asset_path: evil-seed-${{ steps.tag.outputs.version }}.gem
66
+ asset_name: evil-seed-${{ steps.tag.outputs.version }}.gem
67
+ asset_content_type: application/x-tar
68
+ - name: Upload checksums as release asset
69
+ uses: actions/upload-release-asset@v1
70
+ env:
71
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
72
+ with:
73
+ upload_url: ${{ steps.create_release.outputs.upload_url }}
74
+ asset_path: SHA256SUM
75
+ asset_name: SHA256SUM
76
+ asset_content_type: text/plain
77
+ - name: Publish to GitHub packages
78
+ env:
79
+ GEM_HOST_API_KEY: Bearer ${{ secrets.GITHUB_TOKEN }}
80
+ run: |
81
+ gem push evil-seed-${{ steps.tag.outputs.version }}.gem --host https://rubygems.pkg.github.com/${{ github.repository_owner }}
82
+ - name: Configure RubyGems Credentials
83
+ uses: rubygems/configure-rubygems-credentials@main
84
+ - name: Publish to RubyGems
85
+ run: |
86
+ gem push evil-seed-${{ steps.tag.outputs.version }}.gem
@@ -19,30 +19,48 @@ jobs:
19
19
  - ruby: "head"
20
20
  activerecord: "head"
21
21
  database: sqlite
22
+ - ruby: "3.4"
23
+ activerecord: "8.0"
24
+ database: postgresql
25
+ - ruby: "3.4"
26
+ activerecord: "8.0"
27
+ database: mysql
28
+ - ruby: "3.4"
29
+ activerecord: "8.0"
30
+ database: sqlite
31
+ - ruby: "3.3"
32
+ activerecord: "7.2"
33
+ database: postgresql
34
+ - ruby: "3.3"
35
+ activerecord: "7.2"
36
+ database: mysql
37
+ - ruby: "3.3"
38
+ activerecord: "7.2"
39
+ database: sqlite
22
40
  - ruby: "3.2"
23
- activerecord: "7.0"
41
+ activerecord: "7.1"
24
42
  database: postgresql
25
43
  - ruby: "3.2"
26
- activerecord: "7.0"
44
+ activerecord: "7.1"
27
45
  database: mysql
28
46
  - ruby: "3.2"
29
- activerecord: "7.0"
47
+ activerecord: "7.1"
30
48
  database: sqlite
31
49
  - ruby: "3.1"
32
- activerecord: "6.1"
50
+ activerecord: "7.0"
33
51
  database: sqlite
34
52
  - ruby: "3.0"
35
- activerecord: "6.0"
53
+ activerecord: "6.1"
36
54
  database: sqlite
37
55
  - ruby: "2.7"
38
- activerecord: "5.2"
56
+ activerecord: "6.0"
39
57
  database: sqlite
40
58
 
41
59
  runs-on: ubuntu-latest
42
60
 
43
61
  services:
44
62
  postgres:
45
- image: postgres:15
63
+ image: ${{ (matrix.database == 'postgresql') && 'postgres:17' || '' }}
46
64
  env:
47
65
  POSTGRES_USER: postgres
48
66
  POSTGRES_PASSWORD: postgres
@@ -54,7 +72,7 @@ jobs:
54
72
  --health-timeout 5s
55
73
  --health-retries 5
56
74
  mysql:
57
- image: mysql:8.0
75
+ image: ${{ (matrix.database == 'mysql') && 'mysql:9' || '' }}
58
76
  env:
59
77
  MYSQL_ALLOW_EMPTY_PASSWORD: yes
60
78
  MYSQL_DATABASE: evil_seed_test
@@ -70,7 +88,7 @@ jobs:
70
88
  POSTGRES_PASSWORD: postgres
71
89
 
72
90
  steps:
73
- - uses: actions/checkout@v3
91
+ - uses: actions/checkout@v4
74
92
  - uses: ruby/setup-ruby@v1
75
93
  with:
76
94
  ruby-version: ${{ matrix.ruby }}
data/CHANGELOG.md ADDED
@@ -0,0 +1,142 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.7.0] - 2025-02-07
11
+
12
+ ### Added
13
+
14
+ - Options to exclude all `has_many` and `has_one` or optional `belongs_to` associations by default. [@Envek]
15
+
16
+ ```ruby
17
+ root.exclude_has_relations
18
+ root.exclude_optional_belongs_to
19
+ ```
20
+
21
+ Excluded associations can be re-included by `include` with matching pattern.
22
+
23
+ - Exclusion and inclusion patterns can be specified as hashes and/or arrays. [@Envek]
24
+
25
+ ```ruby
26
+ config.root('Forum', featured: true) do |forum|
27
+ forum.include(parent: {questions: %i[answers votes]})
28
+ end
29
+ ```
30
+
31
+ Which is equivalent to:
32
+
33
+ ```ruby
34
+ config.root('Forum', featured: true) do |forum|
35
+ forum.include(/\Aforum(\.parent(\.questions(\.answers))?)?)?\z/)
36
+ forum.include(/\Aforum(\.parent(\.questions(\.votes))?)?)?\z/)
37
+ end
38
+ ```
39
+
40
+ - Association limits also can be specified as hashes and/or arrays. [@Envek]
41
+
42
+ ```ruby
43
+ config.root('Forum', featured: true) do |forum|
44
+ forum.limit_associations_size(15, questions: %i[answers votes])
45
+ end
46
+ ```
47
+
48
+ Which is equivalent to:
49
+
50
+ ```ruby
51
+ config.root('Forum', featured: true) do |forum|
52
+ forum.limit_associations_size(15, 'forum.questions.answers')
53
+ forum.limit_associations_size(15, 'forum.questions.votes')
54
+ end
55
+ ```
56
+
57
+
58
+ - Print reason of association exclusion or inclusion in verbose mode. [@Envek]
59
+
60
+ - Allow to apply custom scoping to included associations. [@Envek]
61
+
62
+ ```ruby
63
+ config.root('Forum', featured: true) do |forum|
64
+ forum.include('questions.answers') do
65
+ order(created_at: :desc)
66
+ end
67
+ end
68
+ ```
69
+
70
+ ### Fixed
71
+
72
+ - Error when dumping single-column tables. [@lsantobuono]
73
+ - Bug with null foreign key to back to auxiliary `has_one` association with not matching names. E.g. user has many profiles and has one default profile, profile belongs to user.
74
+ - Ignored columns handling.
75
+ - Dump relations for records that were dumped earlier with relations excluded.
76
+
77
+ ## [0.6.0] - 2024-06-18
78
+
79
+ ### Added
80
+
81
+ - Association inclusion option. [@gazay] ([#13](https://github.com/evilmartians/evil-seed/pull/13))
82
+ - Option to limit association depth. [@gazay] ([#13](https://github.com/evilmartians/evil-seed/pull/13))
83
+ - Option to ignore `default_scope` in models. [@gazay] ([#13](https://github.com/evilmartians/evil-seed/pull/13))
84
+ - Option to disable nullifying of foreign keys. [@gazay] ([#13](https://github.com/evilmartians/evil-seed/pull/13))
85
+
86
+ ## [0.5.0] - 2023-02-16
87
+
88
+ ### Added
89
+
90
+ - Option to ignore columns from a given model. [@nhocki] ([#17](https://github.com/evilmartians/evil-seed/pull/17))
91
+
92
+ ## [0.4.0] - 2022-12-07
93
+
94
+ ### Fixed
95
+
96
+ - Ignore generated database columns. [@cmer] ([#16](https://github.com/evilmartians/evil-seed/pull/16))
97
+
98
+ ## [0.3.0] - 2022-03-14
99
+
100
+ ### Added
101
+
102
+ - Passing attribute value to anonymizer block (to partially modify it). [@Envek]
103
+
104
+ ## [0.2.0] - 2022-03-10
105
+
106
+ ### Fixed
107
+
108
+ - Ignore virtual ActiveRecord attributes. [@Envek]
109
+
110
+ ### Removed
111
+
112
+ - Support for ActiveRecord 4.2
113
+
114
+ ## [0.1.3] - 2021-09-02
115
+
116
+ ### Fixed
117
+
118
+ - Compatibility with Ruby 3.0 and ActiveRecord 6.x.
119
+
120
+ ## [0.1.2] - 2018-03-27
121
+
122
+ ### Fixed
123
+
124
+ - Bug with unwanted pseudo columns in dump when dumping HABTM join table without one side.
125
+
126
+ ## [0.1.1] - 2017-05-15
127
+
128
+ ### Fixed
129
+
130
+ - ActiveRecord 4.2 support by backporting of `ActiveRecord::Relation#in_batches`
131
+ - Dumping of the whole model without constraints
132
+
133
+ ## [0.1.0] - 2017-05-09
134
+
135
+ Initial release. [@palkan], [@Envek]
136
+
137
+ [@Envek]: https://github.com/Envek "Andrey Novikov"
138
+ [@palkan]: https://github.com/palkan "Vladimir Dementyev"
139
+ [@cmer]: https://github.com/cmer "Carl Mercier"
140
+ [@nhocki]: https://github.com/nhocki "Nicolás Hock-Isaza"
141
+ [@gazay]: https://github.com/gazay "Alex Gaziev"
142
+ [@lsantobuono]: https://github.com/lsantobuono ""
data/Gemfile CHANGED
@@ -5,7 +5,7 @@ source 'https://rubygems.org'
5
5
  # Specify your gem's dependencies in evil-seed.gemspec
6
6
  gemspec
7
7
 
8
- activerecord_version = ENV.fetch("ACTIVERECORD_VERSION", "~> 7.0")
8
+ activerecord_version = ENV.fetch("ACTIVERECORD_VERSION", "~> 8.0")
9
9
  case activerecord_version.upcase
10
10
  when "HEAD"
11
11
  git "https://github.com/rails/rails.git" do
@@ -15,4 +15,10 @@ when "HEAD"
15
15
  else
16
16
  activerecord_version = "~> #{activerecord_version}.0" if activerecord_version.match?(/^\d+\.\d+$/)
17
17
  gem "activerecord", activerecord_version
18
+ if Gem::Version.new("7.2") > Gem::Version.new(activerecord_version.scan(/\d+\.\d+/).first)
19
+ gem "sqlite3", "~> 1.4"
20
+ gem "concurrent-ruby", "< 1.3.5"
21
+ end
18
22
  end
23
+
24
+ gem "debug"
data/README.md CHANGED
@@ -48,18 +48,42 @@ EvilSeed.configure do |config|
48
48
  # First, you should specify +root models+ and their +constraints+ to limit the number of dumped records:
49
49
  # This is like Forum.where(featured: true).all
50
50
  config.root('Forum', featured: true) do |root|
51
+ # You can limit number of records to be dumped
52
+ root.limit(100)
53
+ # Specify order for records to be selected for dump
54
+ root.order(created_at: :desc)
55
+
51
56
  # It's possible to remove some associations from dumping with pattern of association path to exclude
52
57
  #
53
58
  # Association path is a dot-delimited string of association chain starting from model itself:
54
59
  # example: "forum.users.questions"
55
- root.exclude(/\btracking_pixels\b/, 'forum.popular_questions')
60
+ root.exclude(/\btracking_pixels\b/, 'forum.popular_questions', /\Aforum\.parent\b/)
61
+
62
+ # Include back only certain association chains
63
+ root.include(parent: {questions: %i[answers votes]})
64
+ # which is the same as
65
+ root.include(/\Aforum(\.parent(\.questions(\.(answers|votes))?)?)?\z/)
66
+
67
+ # You can also specify custom scoping for associations
68
+ root.include(questions: { answers: :reactions }) do
69
+ order(created_at: :desc) # Any ActiveRecord query method is allowed
70
+ end
56
71
 
57
72
  # It's possible to limit the number of included into dump has_many and has_one records for every association
58
73
  # Note that belongs_to records for all not excluded associations are always dumped to keep referential integrity.
59
74
  root.limit_associations_size(100)
60
75
 
61
76
  # Or for certain association only
62
- root.limit_associations_size(10, 'forum.questions')
77
+ root.limit_associations_size(5, 'forum.questions')
78
+ root.limit_associations_size(15, 'forum.questions.answers')
79
+ # or
80
+ root.limit_associations_size(5, :questions)
81
+ root.limit_associations_size(15, questions: :answers)
82
+
83
+ # Limit the depth of associations to be dumped from the root level
84
+ # All traverses through has_many, belongs_to, etc are counted
85
+ # So forum.subforums.subforums.questions.answers will be 5 levels deep
86
+ root.limit_deep(10)
63
87
  end
64
88
 
65
89
  # Everything you can pass to +where+ method will work as constraints:
@@ -95,6 +119,20 @@ EvilSeed.configure do |config|
95
119
  # This will remove the columns even if the model is not a root node and is
96
120
  # dumped via an association.
97
121
  config.ignore_columns("Profile", :name)
122
+
123
+ # Disable foreign key nullification for records that are not included in the dump
124
+ # By default, EvilSeed will nullify foreign keys for records that are not included in the dump
125
+ config.dont_nullify = true
126
+
127
+ # Unscope relations to include soft-deleted records etc
128
+ # This is useful when you want to include all records, including those that are hidden by default
129
+ # By default, EvilSeed will abide default scope of models
130
+ config.unscoped = true
131
+
132
+ # Verbose mode will print out the progress of the dump to the console along with writing the file
133
+ # By default, verbose mode is off
134
+ config.verbose = true
135
+ config.verbose_sql = true
98
136
  end
99
137
  ```
100
138
 
data/evil-seed.gemspec CHANGED
@@ -29,7 +29,7 @@ Gem::Specification.new do |spec|
29
29
 
30
30
  spec.add_dependency 'activerecord', '>= 5.0'
31
31
 
32
- spec.add_development_dependency 'rake', '~> 12.0'
32
+ spec.add_development_dependency 'rake', '~> 13.0'
33
33
  spec.add_development_dependency 'minitest', '~> 5.0'
34
34
  spec.add_development_dependency 'pg', '>= 0.20'
35
35
  spec.add_development_dependency 'mysql2'
@@ -4,38 +4,145 @@ module EvilSeed
4
4
  class Configuration
5
5
  # Configuration for dumping some root model and its associations
6
6
  class Root
7
- attr_reader :model, :constraints
8
- attr_reader :total_limit, :association_limits
9
- attr_reader :exclusions
7
+ attr_reader :model, :constraints, :limit, :order
8
+ attr_reader :total_limit, :association_limits, :deep_limit, :dont_nullify
9
+ attr_reader :exclusions, :inclusions
10
10
 
11
11
  # @param model [String] Name of the model class to dump
12
12
  # @param constraints [String, Hash] Everything you can feed into +where+ to limit number of records
13
- def initialize(model, *constraints)
13
+ def initialize(model, dont_nullify, *constraints)
14
14
  @model = model
15
15
  @constraints = constraints
16
16
  @exclusions = []
17
+ @inclusions = {}
17
18
  @association_limits = {}
19
+ @deep_limit = nil
20
+ @dont_nullify = dont_nullify
18
21
  end
19
22
 
20
23
  # Exclude some of associations from the dump
21
24
  # @param association_patterns Array<String, Regex> Patterns to exclude associated models from dump
22
25
  def exclude(*association_patterns)
23
- @exclusions += association_patterns
26
+ association_patterns.each do |pattern|
27
+ case pattern
28
+ when String, Regexp
29
+ @exclusions << pattern
30
+ else
31
+ path_prefix = model.constantize.model_name.singular
32
+ @exclusions += compile_patterns(pattern, prefix: path_prefix).map { |p| Regexp.new(/\A#{p}\z/) }
33
+ end
34
+ end
35
+ end
36
+
37
+ # Include some excluded associations back to the dump
38
+ # @param association_patterns Array<String, Regex> Patterns to exclude associated models from dump
39
+ def include(*association_patterns, &block)
40
+ association_patterns.each do |pattern|
41
+ case pattern
42
+ when String, Regexp
43
+ @inclusions[pattern] = block
44
+ else
45
+ path_prefix = model.constantize.model_name.singular
46
+ compile_patterns(pattern, prefix: path_prefix).map do |p|
47
+ @inclusions[Regexp.new(/\A#{p}\z/)] = block
48
+ end
49
+ end
50
+ end
51
+ end
52
+
53
+ def exclude_has_relations
54
+ @excluded_has_relations = :exclude_has_relations
55
+ end
56
+
57
+ def exclude_optional_belongs_to
58
+ @excluded_optional_belongs_to = :exclude_optional_belongs_to
59
+ end
60
+
61
+ def limit(limit = nil)
62
+ return @limit if limit.nil?
63
+
64
+ @limit = limit
65
+ end
66
+
67
+ def order(order = nil)
68
+ return @order if order.nil?
69
+
70
+ @order = order
24
71
  end
25
72
 
26
73
  # Limit number of records in all (if pattern is not provided) or given associations to include into dump
27
74
  # @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
75
+ # @param association_pattern Array<String, Regex, Hash> Pattern to limit number of records for certain associated models
76
+ def limit_associations_size(limit, *association_patterns)
77
+ return @total_limit = limit if association_patterns.empty?
78
+
79
+ association_patterns.each do |pattern|
80
+ case pattern
81
+ when String, Regexp
82
+ @association_limits[pattern] = limit
83
+ else
84
+ path_prefix = model.constantize.model_name.singular
85
+ compile_patterns(pattern, prefix: path_prefix, partial: false).map do |p|
86
+ @association_limits[Regexp.new(/\A#{p}\z/)] = limit
87
+ end
88
+ end
34
89
  end
35
90
  end
36
91
 
92
+ # Limit deepenes of associations to include into dump
93
+ # @param limit [Integer] Maximum level to recursively dive into associations
94
+ def limit_deep(limit)
95
+ @deep_limit = limit
96
+ end
97
+
98
+ def do_not_nullify(nullify_flag)
99
+ @dont_nullify = nullify_flag
100
+ end
101
+
37
102
  def excluded?(association_path)
38
- exclusions.any? { |exclusion| exclusion.match(association_path) }
103
+ exclusions.find { |exclusion| association_path.match(exclusion) } #.match(association_path) }
104
+ end
105
+
106
+ def included?(association_path)
107
+ inclusions.find { |inclusion, _block| association_path.match(inclusion) }
108
+ end
109
+
110
+ def excluded_has_relations?
111
+ @excluded_has_relations
112
+ end
113
+
114
+ def excluded_optional_belongs_to?
115
+ @excluded_optional_belongs_to
116
+ end
117
+
118
+ private
119
+
120
+ def compile_patterns(pattern, prefix: "", partial: true)
121
+ wrap = -> (p) { partial ? "(?:\\.#{p})?" : "\\.#{p}" }
122
+ case pattern
123
+ when String, Symbol
124
+ ["#{prefix}#{wrap.(pattern.to_s)}"]
125
+ when Regexp
126
+ ["#{prefix}#{wrap.("(?:#{pattern.source})")}"]
127
+ when Array
128
+ pattern.map { |p| compile_patterns(p, prefix: prefix, partial: partial) }.flatten
129
+ when Hash
130
+ pattern.map do |k, v|
131
+ next nil unless v
132
+ subpatterns = compile_patterns(v, partial: partial)
133
+ next "#{prefix}#{wrap.(k)}" if subpatterns.empty?
134
+
135
+ subpatterns.map do |p|
136
+ "#{prefix}#{wrap.("#{k}#{p}")}"
137
+ end
138
+ end.compact.flatten
139
+ when false, nil
140
+ nil
141
+ when true
142
+ [prefix]
143
+ else
144
+ raise ArgumentError, "Unknown pattern type: #{pattern.class} for #{pattern.inspect}"
145
+ end
39
146
  end
40
147
  end
41
148
  end
@@ -7,10 +7,14 @@ require_relative 'anonymizer'
7
7
  module EvilSeed
8
8
  # This module holds configuration for creating dump: which models and their constraints
9
9
  class Configuration
10
- attr_accessor :record_dumper_class
10
+ attr_accessor :record_dumper_class, :verbose, :verbose_sql, :unscoped, :dont_nullify
11
11
 
12
12
  def initialize
13
13
  @record_dumper_class = RecordDumper
14
+ @verbose = false
15
+ @verbose_sql = false
16
+ @unscoped = false
17
+ @dont_nullify = false
14
18
  @ignored_columns = Hash.new { |h, k| h[k] = [] }
15
19
  end
16
20
 
@@ -19,7 +23,7 @@ module EvilSeed
19
23
  end
20
24
 
21
25
  def root(model, *constraints)
22
- new_root = Root.new(model, *constraints)
26
+ new_root = Root.new(model, dont_nullify, *constraints)
23
27
  yield new_root if block_given?
24
28
  roots << new_root
25
29
  end
@@ -35,7 +39,7 @@ module EvilSeed
35
39
  end
36
40
 
37
41
  def ignore_columns(model_class, *columns)
38
- @ignored_columns[model_class] += columns
42
+ @ignored_columns[model_class.to_s] += columns.map(&:to_s)
39
43
  end
40
44
 
41
45
  # Customizer objects for every model
@@ -7,7 +7,7 @@ module EvilSeed
7
7
  # This class initiates dump creation for every root model of configuration
8
8
  # and then concatenates dumps from all roots into one single IO.
9
9
  class Dumper
10
- attr_reader :configuration, :loaded_map
10
+ attr_reader :configuration, :loaded_map, :to_load_map
11
11
 
12
12
  # @param configuration [Configuration]
13
13
  def initialize(configuration)
@@ -18,6 +18,7 @@ module EvilSeed
18
18
  # @param output [IO] Stream to write SQL dump into
19
19
  def call(output)
20
20
  @loaded_map = Hash.new { |h, k| h[k] = Set.new } # stores primary keys of already dumped records for every table
21
+ @to_load_map = Hash.new { |h, k| h[k] = Set.new } # stores primary keys of records we're going to dump to avoid cycles
21
22
  @output = output
22
23
  configuration.roots.each do |root|
23
24
  table_outputs = RootDumper.new(root, self).call
@@ -9,7 +9,7 @@ module EvilSeed
9
9
 
10
10
  attr_reader :model_class, :configuration, :relation_dumper
11
11
 
12
- delegate :loaded_map, to: :relation_dumper
12
+ delegate :loaded_map, :to_load_map, to: :relation_dumper
13
13
 
14
14
  def initialize(model_class, configuration, relation_dumper)
15
15
  @model_class = model_class
@@ -38,8 +38,10 @@ module EvilSeed
38
38
 
39
39
  def loaded!(attributes)
40
40
  id = model_class.primary_key && attributes[model_class.primary_key] || attributes
41
- return false if loaded_map[model_class.table_name].include?(id)
42
- loaded_map[model_class.table_name] << id
41
+ !loaded_map[model_class.table_name].include?(id).tap do
42
+ loaded_map[model_class.table_name] << id
43
+ to_load_map[model_class.table_name].delete(id)
44
+ end
43
45
  end
44
46
 
45
47
  def transform_and_anonymize(attributes)
@@ -51,9 +53,10 @@ module EvilSeed
51
53
  end
52
54
 
53
55
  def insertable_column_names
54
- model_class.columns_hash.reject do |k,v|
55
- v.respond_to?(:virtual?) ? v.virtual? : false
56
- end.keys
56
+ @insertable_column_names ||=
57
+ model_class.columns_hash.reject do |_k, v|
58
+ v.respond_to?(:virtual?) ? v.virtual? : false
59
+ end.keys - configuration.ignored_columns_for(model_class.to_s)
57
60
  end
58
61
 
59
62
  def insert_statement
@@ -65,26 +68,34 @@ module EvilSeed
65
68
 
66
69
  def write!(attributes)
67
70
  # Remove non-insertable columns from attributes
68
- attributes = attributes.slice(*insertable_column_names)
71
+ attributes = prepare(attributes.slice(*insertable_column_names))
72
+
73
+ if configuration.verbose_sql
74
+ puts("-- #{relation_dumper.association_path}\n")
75
+ puts(@tuples_written.zero? ? insert_statement : ",\n")
76
+ puts(" (#{attributes.join(', ')})")
77
+ end
69
78
 
70
79
  @output.write("-- #{relation_dumper.association_path}\n") && @header_written = true unless @header_written
71
80
  @output.write(@tuples_written.zero? ? insert_statement : ",\n")
72
- @output.write(" (#{prepare(attributes).join(', ')})")
81
+ @output.write(" (#{attributes.join(', ')})")
73
82
  @tuples_written += 1
74
83
  @output.write(";\n") && @tuples_written = 0 if @tuples_written == MAX_TUPLES_PER_INSERT_STMT
75
84
  end
76
85
 
77
86
  def finalize!
78
- return unless @header_written && @tuples_written > 0
87
+ return true if @finalized
88
+ return false unless @header_written && @tuples_written > 0
79
89
  @output.write(";\n\n")
80
90
  @tuples_written = 0
91
+ @finalized = true
81
92
  end
82
93
 
83
94
  def prepare(attributes)
84
95
  attributes.map do |key, value|
85
96
  type = model_class.attribute_types[key]
86
97
  model_class.connection.quote(type.serialize(value))
87
- end
98
+ end.flatten.compact
88
99
  end
89
100
  end
90
101
  end
@@ -24,37 +24,51 @@ module EvilSeed
24
24
  MAX_IDENTIFIERS_IN_IN_STMT = 1_000
25
25
 
26
26
  attr_reader :relation, :root_dumper, :model_class, :association_path, :search_key, :identifiers, :nullify_columns,
27
- :belongs_to_reflections, :has_many_reflections, :foreign_keys, :loaded_ids, :to_load_map,
28
- :record_dumper, :inverse_reflection, :table_names, :options
27
+ :belongs_to_reflections, :has_many_reflections, :foreign_keys, :loaded_ids, :local_load_map,
28
+ :records, :record_dumper, :inverse_reflection, :table_names, :options,
29
+ :current_deep, :verbose, :custom_scope
29
30
 
30
- delegate :root, :configuration, :total_limit, :loaded_map, to: :root_dumper
31
+ delegate :root, :configuration, :dont_nullify, :total_limit, :deep_limit, :loaded_map, :to_load_map, to: :root_dumper
31
32
 
32
33
  def initialize(relation, root_dumper, association_path, **options)
34
+ puts("- #{association_path}") if root_dumper.configuration.verbose
35
+
33
36
  @relation = relation
34
37
  @root_dumper = root_dumper
38
+ @verbose = configuration.verbose
35
39
  @identifiers = options[:identifiers]
36
- @to_load_map = Hash.new { |h, k| h[k] = [] }
40
+ @local_load_map = Hash.new { |h, k| h[k] = [] }
37
41
  @foreign_keys = Hash.new { |h, k| h[k] = [] }
38
42
  @loaded_ids = []
39
43
  @model_class = relation.klass
40
44
  @search_key = options[:search_key] || model_class.primary_key
41
45
  @association_path = association_path
42
46
  @inverse_reflection = options[:inverse_of]
47
+ @records = []
43
48
  @record_dumper = configuration.record_dumper_class.new(model_class, configuration, self)
44
49
  @nullify_columns = []
45
50
  @table_names = {}
46
51
  @belongs_to_reflections = setup_belongs_to_reflections
47
52
  @has_many_reflections = setup_has_many_reflections
48
53
  @options = options
54
+ @current_deep = association_path.split('.').size
55
+ @dont_nullify = dont_nullify
56
+ @custom_scope = options[:custom_scope]
49
57
  end
50
58
 
51
59
  # Generate dump and write it into +io+
52
60
  # @return [Array<IO>] List of dump IOs for separate tables in order of dependencies (belongs_to are first)
53
61
  def call
54
62
  dump!
55
- belongs_to_dumps = dump_belongs_to_associations!
56
- has_many_dumps = dump_has_many_associations!
57
- [belongs_to_dumps, record_dumper.result, has_many_dumps].flatten.compact
63
+ if deep_limit and current_deep > deep_limit
64
+ [dump_records!].flatten.compact
65
+ else
66
+ [
67
+ dump_belongs_to_associations!,
68
+ dump_records!,
69
+ dump_has_many_associations!,
70
+ ].flatten.compact
71
+ end
58
72
  end
59
73
 
60
74
  private
@@ -63,17 +77,31 @@ module EvilSeed
63
77
  original_ignored_columns = model_class.ignored_columns
64
78
  model_class.ignored_columns += Array(configuration.ignored_columns_for(model_class.sti_name))
65
79
  model_class.send(:reload_schema_from_cache) if ActiveRecord.version < Gem::Version.new("6.1.0.rc1") # See https://github.com/rails/rails/pull/37581
66
- if identifiers.present?
80
+ if custom_scope
81
+ puts(" # #{search_key} (with scope)") if verbose
82
+ attrs = fetch_attributes(relation)
83
+ puts(" -- dumped #{attrs.size}") if verbose
84
+ attrs.each do |attributes|
85
+ next unless check_limits!
86
+ dump_record!(attributes)
87
+ end
88
+ elsif identifiers.present?
89
+ puts(" # #{search_key} => #{identifiers}") if verbose
67
90
  # 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
68
91
  identifiers.in_groups_of(MAX_IDENTIFIERS_IN_IN_STMT).each do |ids|
69
- fetch_attributes(relation.where(search_key => ids.compact)).each do |attributes|
92
+ attrs = fetch_attributes(relation.where(search_key => ids.compact))
93
+ puts(" -- dumped #{attrs.size}") if verbose
94
+ attrs.each do |attributes|
70
95
  next unless check_limits!
71
96
  dump_record!(attributes)
72
97
  end
73
98
  end
74
99
  else
100
+ puts(" # #{relation.count}") if verbose
75
101
  relation.in_batches do |relation|
76
- fetch_attributes(relation).each do |attributes|
102
+ attrs = fetch_attributes(relation)
103
+ puts(" -- dumped #{attrs.size}") if verbose
104
+ attrs.each do |attributes|
77
105
  next unless check_limits!
78
106
  dump_record!(attributes)
79
107
  end
@@ -84,43 +112,54 @@ module EvilSeed
84
112
  end
85
113
 
86
114
  def dump_record!(attributes)
87
- nullify_columns.each do |nullify_column|
88
- attributes[nullify_column] = nil
115
+ unless dont_nullify
116
+ nullify_columns.each do |nullify_column|
117
+ attributes[nullify_column] = nil
118
+ end
89
119
  end
90
- return unless record_dumper.call(attributes)
120
+ records << attributes
91
121
  foreign_keys.each do |reflection_name, fk_column|
92
122
  foreign_key = attributes[fk_column]
93
- next if foreign_key.nil? || loaded_map[table_names[reflection_name]].include?(foreign_key)
94
- to_load_map[reflection_name] << foreign_key
123
+ next if foreign_key.nil? || loaded_map[table_names[reflection_name]].include?(foreign_key) || to_load_map[table_names[reflection_name]].include?(foreign_key)
124
+ local_load_map[reflection_name] << foreign_key
125
+ to_load_map[table_names[reflection_name]] << foreign_key
95
126
  end
96
127
  loaded_ids << attributes[model_class.primary_key]
97
128
  end
98
129
 
130
+ def dump_records!
131
+ records.each do |attributes|
132
+ record_dumper.call(attributes)
133
+ end
134
+ record_dumper.result
135
+ end
136
+
99
137
  def dump_belongs_to_associations!
100
138
  belongs_to_reflections.map do |reflection|
101
- next if to_load_map[reflection.name].empty?
139
+ next if local_load_map[reflection.name].empty?
102
140
  RelationDumper.new(
103
141
  build_relation(reflection),
104
142
  root_dumper,
105
143
  "#{association_path}.#{reflection.name}",
106
144
  search_key: reflection.association_primary_key,
107
- identifiers: to_load_map[reflection.name],
145
+ identifiers: local_load_map[reflection.name],
108
146
  limitable: false,
109
147
  ).call
110
148
  end
111
149
  end
112
150
 
113
151
  def dump_has_many_associations!
114
- has_many_reflections.map do |reflection|
152
+ has_many_reflections.map do |reflection, custom_scope|
115
153
  next if loaded_ids.empty? || total_limit.try(:zero?)
116
154
  RelationDumper.new(
117
- build_relation(reflection),
155
+ build_relation(reflection, custom_scope),
118
156
  root_dumper,
119
157
  "#{association_path}.#{reflection.name}",
120
158
  search_key: reflection.foreign_key,
121
- identifiers: loaded_ids,
159
+ identifiers: loaded_ids - local_load_map[reflection.name],
122
160
  inverse_of: reflection.inverse_of.try(:name),
123
161
  limitable: true,
162
+ custom_scope: custom_scope,
124
163
  ).call
125
164
  end
126
165
  end
@@ -130,7 +169,7 @@ module EvilSeed
130
169
  # @return [Array<Hash{String => String, Integer, Float, Boolean, nil}>]
131
170
  def fetch_attributes(relation)
132
171
  relation.pluck(*model_class.column_names).map do |row|
133
- Hash[model_class.column_names.zip(row)]
172
+ Hash[model_class.column_names.zip(Array(row))]
134
173
  end
135
174
  end
136
175
 
@@ -139,9 +178,14 @@ module EvilSeed
139
178
  root_dumper.check_limits!(association_path)
140
179
  end
141
180
 
142
- def build_relation(reflection)
143
- relation = reflection.klass.all
181
+ def build_relation(reflection, custom_scope = nil)
182
+ if configuration.unscoped
183
+ relation = reflection.klass.unscoped
184
+ else
185
+ relation = reflection.klass.all
186
+ end
144
187
  relation = relation.instance_eval(&reflection.scope) if reflection.scope
188
+ relation = relation.instance_eval(&custom_scope) if custom_scope
145
189
  relation = relation.where(reflection.type => model_class.to_s) if reflection.options[:as] # polymorphic
146
190
  relation
147
191
  end
@@ -149,24 +193,43 @@ module EvilSeed
149
193
  def setup_belongs_to_reflections
150
194
  model_class.reflect_on_all_associations(:belongs_to).reject do |reflection|
151
195
  next false if reflection.options[:polymorphic] # TODO: Add support for polymorphic belongs_to
152
- excluded = root.excluded?("#{association_path}.#{reflection.name}") || reflection.name == inverse_reflection
153
- if excluded
154
- nullify_columns << reflection.foreign_key if model_class.column_names.include?(reflection.foreign_key)
196
+ included = root.included?("#{association_path}.#{reflection.name}")
197
+ excluded = reflection.options[:optional] && root.excluded_optional_belongs_to?
198
+ excluded ||= root.excluded?("#{association_path}.#{reflection.name}")
199
+ inverse = reflection.name == inverse_reflection
200
+ puts " -- belongs_to #{reflection.name} #{"excluded by #{excluded}" if excluded} #{"re-included by #{included}" if included}" if verbose
201
+ if excluded and not included
202
+ if model_class.column_names.include?(reflection.foreign_key)
203
+ puts(" -- excluded #{reflection.foreign_key}") if verbose
204
+ nullify_columns << reflection.foreign_key
205
+ end
155
206
  else
156
207
  foreign_keys[reflection.name] = reflection.foreign_key
157
208
  table_names[reflection.name] = reflection.table_name
158
209
  end
159
- excluded
210
+ excluded and not included or inverse
160
211
  end
161
212
  end
162
213
 
163
214
  # This method returns only direct has_one and has_many reflections. For HABTM it returns intermediate has_many
164
215
  def setup_has_many_reflections
216
+ puts(" -- reflections #{model_class._reflections.keys}") if verbose
165
217
  model_class._reflections.select do |_reflection_name, reflection|
218
+ next false unless %i[has_one has_many].include?(reflection.macro)
219
+
166
220
  next false if model_class.primary_key.nil?
221
+
167
222
  next false if reflection.is_a?(ActiveRecord::Reflection::ThroughReflection)
168
- %i[has_one has_many].include?(reflection.macro) && !root.excluded?("#{association_path}.#{reflection.name}")
169
- end.map(&:second)
223
+
224
+ included = root.included?("#{association_path}.#{reflection.name}")
225
+ excluded = :inverse if reflection.name == inverse_reflection
226
+ excluded ||= root.excluded_has_relations?
227
+ excluded ||= root.excluded?("#{association_path}.#{reflection.name}")
228
+ puts " -- #{reflection.macro} #{reflection.name} #{"excluded by #{excluded}" if excluded} #{"re-included by #{included}" if included}" if verbose
229
+ !(excluded and not included)
230
+ end.map do |_reflection_name, reflection|
231
+ [reflection, root.included?("#{association_path}.#{reflection.name}")&.last]
232
+ end
170
233
  end
171
234
  end
172
235
  end
@@ -5,15 +5,16 @@ require_relative 'relation_dumper'
5
5
  module EvilSeed
6
6
  # This module collects dumps generation for root and all it's dependencies
7
7
  class RootDumper
8
- attr_reader :root, :dumper, :model_class, :total_limit, :association_limits
8
+ attr_reader :root, :dumper, :model_class, :total_limit, :deep_limit, :dont_nullify, :association_limits
9
9
 
10
- delegate :loaded_map, :configuration, to: :dumper
10
+ delegate :loaded_map, :to_load_map, :configuration, to: :dumper
11
11
 
12
12
  def initialize(root, dumper)
13
13
  @root = root
14
14
  @dumper = dumper
15
- @to_load_map = {}
16
15
  @total_limit = root.total_limit
16
+ @deep_limit = root.deep_limit
17
+ @dont_nullify = root.dont_nullify
17
18
  @association_limits = root.association_limits.dup
18
19
 
19
20
  @model_class = root.model.constantize
@@ -24,7 +25,10 @@ module EvilSeed
24
25
  def call
25
26
  association_path = model_class.model_name.singular
26
27
  relation = model_class.all
28
+ relation = relation.unscoped if configuration.unscoped
27
29
  relation = relation.where(*root.constraints) if root.constraints.any? # without arguments returns not a relation
30
+ relation = relation.limit(root.limit) if root.limit
31
+ relation = relation.order(root.order) if root.order
28
32
  RelationDumper.new(relation, self, association_path).call
29
33
  end
30
34
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EvilSeed
4
- VERSION = '0.5.0'
4
+ VERSION = '0.7.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: evil-seed
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrey Novikov
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: exe
11
11
  cert_chain: []
12
- date: 2023-02-16 00:00:00.000000000 Z
12
+ date: 2025-02-07 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: activerecord
@@ -31,14 +31,14 @@ dependencies:
31
31
  requirements:
32
32
  - - "~>"
33
33
  - !ruby/object:Gem::Version
34
- version: '12.0'
34
+ version: '13.0'
35
35
  type: :development
36
36
  prerelease: false
37
37
  version_requirements: !ruby/object:Gem::Requirement
38
38
  requirements:
39
39
  - - "~>"
40
40
  - !ruby/object:Gem::Version
41
- version: '12.0'
41
+ version: '13.0'
42
42
  - !ruby/object:Gem::Dependency
43
43
  name: minitest
44
44
  requirement: !ruby/object:Gem::Requirement
@@ -146,9 +146,11 @@ executables: []
146
146
  extensions: []
147
147
  extra_rdoc_files: []
148
148
  files:
149
+ - ".github/workflows/release.yml"
149
150
  - ".github/workflows/test.yml"
150
151
  - ".gitignore"
151
152
  - ".rubocop.yml"
153
+ - CHANGELOG.md
152
154
  - Gemfile
153
155
  - LICENSE.txt
154
156
  - README.md
@@ -188,7 +190,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
188
190
  - !ruby/object:Gem::Version
189
191
  version: '0'
190
192
  requirements: []
191
- rubygems_version: 3.3.7
193
+ rubygems_version: 3.5.22
192
194
  signing_key:
193
195
  specification_version: 4
194
196
  summary: Create partial and anonymized production database dumps for use in development