evil-seed 0.6.0 → 0.7.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dfdedee7cac6da3803d6071ccbda5eaf19ee07bc07c67775b202160c304dd323
4
- data.tar.gz: e04bc0672ccb683928651843c749ad05378af846c6d4b51d7e7c3d14eb43c179
3
+ metadata.gz: 757a2cee2b774b29a9d248270642db41faa431fdfd1b738aed0b8ea275fa358a
4
+ data.tar.gz: 8dc1887a03f1169aa332f9ef345ea666d5582e578ec6c8c97c6041b6f281dff4
5
5
  SHA512:
6
- metadata.gz: d2973540b6fd16936419e9e76e389df4dd854313e3674ff7c73f7056c18c3e8979ca302d0c395a60e3a17abb7f9bc2b886ef4939711ad43c3df72cebc06cf944
7
- data.tar.gz: cb2772174b471dc7977fcaa00bc79dc0bf45429f836a7e490487d37e625f4d8328583d1d103b13d2a8899e56736793acb24e35fd362c196aab1a60489f023159
6
+ metadata.gz: aea3be4c8d73fc5ec6e04457d6322306d12a70d44a4792eb1dc855162b28b3b325617815a8a5898733cdc31067e8c51fa3714909c123fe890d54e21123121f7f
7
+ data.tar.gz: 98ed722479590c6615d87dfa3a0262d7012f008f0c9be2c4ddb3066a01ef2959febaf3ecea60c25e362417e9eba986f7ada50fad9ce06355fb8b5dfbc75e664c
@@ -19,22 +19,40 @@ 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
22
31
  - ruby: "3.3"
23
- activerecord: "7.1"
32
+ activerecord: "7.2"
24
33
  database: postgresql
25
34
  - ruby: "3.3"
26
- activerecord: "7.1"
35
+ activerecord: "7.2"
27
36
  database: mysql
28
37
  - ruby: "3.3"
29
- activerecord: "7.1"
38
+ activerecord: "7.2"
30
39
  database: sqlite
31
40
  - ruby: "3.2"
32
- activerecord: "7.0"
41
+ activerecord: "7.1"
42
+ database: postgresql
43
+ - ruby: "3.2"
44
+ activerecord: "7.1"
45
+ database: mysql
46
+ - ruby: "3.2"
47
+ activerecord: "7.1"
33
48
  database: sqlite
34
49
  - ruby: "3.1"
35
- activerecord: "6.1"
50
+ activerecord: "7.0"
36
51
  database: sqlite
37
52
  - ruby: "3.0"
53
+ activerecord: "6.1"
54
+ database: sqlite
55
+ - ruby: "2.7"
38
56
  activerecord: "6.0"
39
57
  database: sqlite
40
58
 
@@ -42,7 +60,7 @@ jobs:
42
60
 
43
61
  services:
44
62
  postgres:
45
- image: postgres:16
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.4
75
+ image: ${{ (matrix.database == 'mysql') && 'mysql:9' || '' }}
58
76
  env:
59
77
  MYSQL_ALLOW_EMPTY_PASSWORD: yes
60
78
  MYSQL_DATABASE: evil_seed_test
data/CHANGELOG.md CHANGED
@@ -7,6 +7,73 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
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
+
10
77
  ## [0.6.0] - 2024-06-18
11
78
 
12
79
  ### Added
@@ -72,3 +139,4 @@ Initial release. [@palkan], [@Envek]
72
139
  [@cmer]: https://github.com/cmer "Carl Mercier"
73
140
  [@nhocki]: https://github.com/nhocki "Nicolás Hock-Isaza"
74
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.1")
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,5 +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
- gem "sqlite3", "~> 1.4"
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
19
22
  end
23
+
24
+ gem "debug"
data/README.md CHANGED
@@ -48,21 +48,37 @@ 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
60
  root.exclude(/\btracking_pixels\b/, 'forum.popular_questions', /\Aforum\.parent\b/)
56
61
 
57
- # Include back only certain associations
58
- root.include(/\Aforum(\.parent(\.questions(\.answers)?)?)?\z/)
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
59
71
 
60
72
  # It's possible to limit the number of included into dump has_many and has_one records for every association
61
73
  # Note that belongs_to records for all not excluded associations are always dumped to keep referential integrity.
62
74
  root.limit_associations_size(100)
63
75
 
64
76
  # Or for certain association only
65
- 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)
66
82
 
67
83
  # Limit the depth of associations to be dumped from the root level
68
84
  # All traverses through has_many, belongs_to, etc are counted
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,7 +4,7 @@ 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
7
+ attr_reader :model, :constraints, :limit, :order
8
8
  attr_reader :total_limit, :association_limits, :deep_limit, :dont_nullify
9
9
  attr_reader :exclusions, :inclusions
10
10
 
@@ -14,7 +14,7 @@ module EvilSeed
14
14
  @model = model
15
15
  @constraints = constraints
16
16
  @exclusions = []
17
- @inclusions = []
17
+ @inclusions = {}
18
18
  @association_limits = {}
19
19
  @deep_limit = nil
20
20
  @dont_nullify = dont_nullify
@@ -23,23 +23,69 @@ module EvilSeed
23
23
  # Exclude some of associations from the dump
24
24
  # @param association_patterns Array<String, Regex> Patterns to exclude associated models from dump
25
25
  def exclude(*association_patterns)
26
- @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
27
35
  end
28
36
 
29
37
  # Include some excluded associations back to the dump
30
38
  # @param association_patterns Array<String, Regex> Patterns to exclude associated models from dump
31
- def include(*association_patterns)
32
- @inclusions += association_patterns
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
33
71
  end
34
72
 
35
73
  # Limit number of records in all (if pattern is not provided) or given associations to include into dump
36
74
  # @param limit [Integer] Maximum number of records in associations to include into dump
37
- # @param association_pattern [String, Regex] Pattern to limit number of records for certain associated models
38
- def limit_associations_size(limit, association_pattern = nil)
39
- if association_pattern
40
- @association_limits[association_pattern] = limit
41
- else
42
- @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
43
89
  end
44
90
  end
45
91
 
@@ -54,11 +100,49 @@ module EvilSeed
54
100
  end
55
101
 
56
102
  def excluded?(association_path)
57
- exclusions.any? { |exclusion| association_path.match(exclusion) } #.match(association_path) }
103
+ exclusions.find { |exclusion| association_path.match(exclusion) } #.match(association_path) }
58
104
  end
59
105
 
60
106
  def included?(association_path)
61
- inclusions.any? { |inclusion| association_path.match(inclusion) } #.match(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
62
146
  end
63
147
  end
64
148
  end
@@ -39,7 +39,7 @@ module EvilSeed
39
39
  end
40
40
 
41
41
  def ignore_columns(model_class, *columns)
42
- @ignored_columns[model_class] += columns
42
+ @ignored_columns[model_class.to_s] += columns.map(&:to_s)
43
43
  end
44
44
 
45
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
@@ -81,9 +84,11 @@ module EvilSeed
81
84
  end
82
85
 
83
86
  def finalize!
84
- return unless @header_written && @tuples_written > 0
87
+ return true if @finalized
88
+ return false unless @header_written && @tuples_written > 0
85
89
  @output.write(";\n\n")
86
90
  @tuples_written = 0
91
+ @finalized = true
87
92
  end
88
93
 
89
94
  def prepare(attributes)
@@ -24,24 +24,27 @@ 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,
29
- :current_deep, :verbose
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
30
30
 
31
- delegate :root, :configuration, :dont_nullify, :total_limit, :deep_limit, :loaded_map, to: :root_dumper
31
+ delegate :root, :configuration, :dont_nullify, :total_limit, :deep_limit, :loaded_map, :to_load_map, to: :root_dumper
32
32
 
33
33
  def initialize(relation, root_dumper, association_path, **options)
34
+ puts("- #{association_path}") if root_dumper.configuration.verbose
35
+
34
36
  @relation = relation
35
37
  @root_dumper = root_dumper
36
38
  @verbose = configuration.verbose
37
39
  @identifiers = options[:identifiers]
38
- @to_load_map = Hash.new { |h, k| h[k] = [] }
40
+ @local_load_map = Hash.new { |h, k| h[k] = [] }
39
41
  @foreign_keys = Hash.new { |h, k| h[k] = [] }
40
42
  @loaded_ids = []
41
43
  @model_class = relation.klass
42
44
  @search_key = options[:search_key] || model_class.primary_key
43
45
  @association_path = association_path
44
46
  @inverse_reflection = options[:inverse_of]
47
+ @records = []
45
48
  @record_dumper = configuration.record_dumper_class.new(model_class, configuration, self)
46
49
  @nullify_columns = []
47
50
  @table_names = {}
@@ -50,8 +53,7 @@ module EvilSeed
50
53
  @options = options
51
54
  @current_deep = association_path.split('.').size
52
55
  @dont_nullify = dont_nullify
53
-
54
- puts("- #{association_path}") if verbose
56
+ @custom_scope = options[:custom_scope]
55
57
  end
56
58
 
57
59
  # Generate dump and write it into +io+
@@ -59,11 +61,13 @@ module EvilSeed
59
61
  def call
60
62
  dump!
61
63
  if deep_limit and current_deep > deep_limit
62
- [record_dumper.result].flatten.compact
64
+ [dump_records!].flatten.compact
63
65
  else
64
- belongs_to_dumps = dump_belongs_to_associations!
65
- has_many_dumps = dump_has_many_associations!
66
- [belongs_to_dumps, record_dumper.result, has_many_dumps].flatten.compact
66
+ [
67
+ dump_belongs_to_associations!,
68
+ dump_records!,
69
+ dump_has_many_associations!,
70
+ ].flatten.compact
67
71
  end
68
72
  end
69
73
 
@@ -73,7 +77,15 @@ module EvilSeed
73
77
  original_ignored_columns = model_class.ignored_columns
74
78
  model_class.ignored_columns += Array(configuration.ignored_columns_for(model_class.sti_name))
75
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
76
- 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?
77
89
  puts(" # #{search_key} => #{identifiers}") if verbose
78
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
79
91
  identifiers.in_groups_of(MAX_IDENTIFIERS_IN_IN_STMT).each do |ids|
@@ -105,40 +117,49 @@ module EvilSeed
105
117
  attributes[nullify_column] = nil
106
118
  end
107
119
  end
108
- return unless record_dumper.call(attributes)
120
+ records << attributes
109
121
  foreign_keys.each do |reflection_name, fk_column|
110
122
  foreign_key = attributes[fk_column]
111
- next if foreign_key.nil? || loaded_map[table_names[reflection_name]].include?(foreign_key)
112
- 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
113
126
  end
114
127
  loaded_ids << attributes[model_class.primary_key]
115
128
  end
116
129
 
130
+ def dump_records!
131
+ records.each do |attributes|
132
+ record_dumper.call(attributes)
133
+ end
134
+ record_dumper.result
135
+ end
136
+
117
137
  def dump_belongs_to_associations!
118
138
  belongs_to_reflections.map do |reflection|
119
- next if to_load_map[reflection.name].empty?
139
+ next if local_load_map[reflection.name].empty?
120
140
  RelationDumper.new(
121
141
  build_relation(reflection),
122
142
  root_dumper,
123
143
  "#{association_path}.#{reflection.name}",
124
144
  search_key: reflection.association_primary_key,
125
- identifiers: to_load_map[reflection.name],
145
+ identifiers: local_load_map[reflection.name],
126
146
  limitable: false,
127
147
  ).call
128
148
  end
129
149
  end
130
150
 
131
151
  def dump_has_many_associations!
132
- has_many_reflections.map do |reflection|
152
+ has_many_reflections.map do |reflection, custom_scope|
133
153
  next if loaded_ids.empty? || total_limit.try(:zero?)
134
154
  RelationDumper.new(
135
- build_relation(reflection),
155
+ build_relation(reflection, custom_scope),
136
156
  root_dumper,
137
157
  "#{association_path}.#{reflection.name}",
138
158
  search_key: reflection.foreign_key,
139
- identifiers: loaded_ids,
159
+ identifiers: loaded_ids - local_load_map[reflection.name],
140
160
  inverse_of: reflection.inverse_of.try(:name),
141
161
  limitable: true,
162
+ custom_scope: custom_scope,
142
163
  ).call
143
164
  end
144
165
  end
@@ -148,7 +169,7 @@ module EvilSeed
148
169
  # @return [Array<Hash{String => String, Integer, Float, Boolean, nil}>]
149
170
  def fetch_attributes(relation)
150
171
  relation.pluck(*model_class.column_names).map do |row|
151
- Hash[model_class.column_names.zip(row)]
172
+ Hash[model_class.column_names.zip(Array(row))]
152
173
  end
153
174
  end
154
175
 
@@ -157,13 +178,14 @@ module EvilSeed
157
178
  root_dumper.check_limits!(association_path)
158
179
  end
159
180
 
160
- def build_relation(reflection)
181
+ def build_relation(reflection, custom_scope = nil)
161
182
  if configuration.unscoped
162
183
  relation = reflection.klass.unscoped
163
184
  else
164
185
  relation = reflection.klass.all
165
186
  end
166
187
  relation = relation.instance_eval(&reflection.scope) if reflection.scope
188
+ relation = relation.instance_eval(&custom_scope) if custom_scope
167
189
  relation = relation.where(reflection.type => model_class.to_s) if reflection.options[:as] # polymorphic
168
190
  relation
169
191
  end
@@ -172,7 +194,10 @@ module EvilSeed
172
194
  model_class.reflect_on_all_associations(:belongs_to).reject do |reflection|
173
195
  next false if reflection.options[:polymorphic] # TODO: Add support for polymorphic belongs_to
174
196
  included = root.included?("#{association_path}.#{reflection.name}")
175
- excluded = root.excluded?("#{association_path}.#{reflection.name}") || reflection.name == inverse_reflection
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
176
201
  if excluded and not included
177
202
  if model_class.column_names.include?(reflection.foreign_key)
178
203
  puts(" -- excluded #{reflection.foreign_key}") if verbose
@@ -182,7 +207,7 @@ module EvilSeed
182
207
  foreign_keys[reflection.name] = reflection.foreign_key
183
208
  table_names[reflection.name] = reflection.table_name
184
209
  end
185
- excluded and not included
210
+ excluded and not included or inverse
186
211
  end
187
212
  end
188
213
 
@@ -190,14 +215,21 @@ module EvilSeed
190
215
  def setup_has_many_reflections
191
216
  puts(" -- reflections #{model_class._reflections.keys}") if verbose
192
217
  model_class._reflections.select do |_reflection_name, reflection|
218
+ next false unless %i[has_one has_many].include?(reflection.macro)
219
+
193
220
  next false if model_class.primary_key.nil?
194
221
 
195
222
  next false if reflection.is_a?(ActiveRecord::Reflection::ThroughReflection)
196
223
 
197
224
  included = root.included?("#{association_path}.#{reflection.name}")
198
- excluded = root.excluded?("#{association_path}.#{reflection.name}") || reflection.name == inverse_reflection
199
- %i[has_one has_many].include?(reflection.macro) && !(excluded and not included)
200
- end.map(&:second)
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
201
233
  end
202
234
  end
203
235
  end
@@ -7,12 +7,11 @@ module EvilSeed
7
7
  class RootDumper
8
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
17
16
  @deep_limit = root.deep_limit
18
17
  @dont_nullify = root.dont_nullify
@@ -28,6 +27,8 @@ module EvilSeed
28
27
  relation = model_class.all
29
28
  relation = relation.unscoped if configuration.unscoped
30
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
31
32
  RelationDumper.new(relation, self, association_path).call
32
33
  end
33
34
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EvilSeed
4
- VERSION = '0.6.0'
4
+ VERSION = '0.7.0'
5
5
  end
metadata CHANGED
@@ -1,15 +1,15 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: evil-seed
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrey Novikov
8
8
  - Vladimir Dementyev
9
- autorequire:
9
+ autorequire:
10
10
  bindir: exe
11
11
  cert_chain: []
12
- date: 2024-06-18 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
@@ -175,7 +175,7 @@ homepage: https://github.com/palkan/evil-seed
175
175
  licenses:
176
176
  - MIT
177
177
  metadata: {}
178
- post_install_message:
178
+ post_install_message:
179
179
  rdoc_options: []
180
180
  require_paths:
181
181
  - lib
@@ -190,8 +190,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
190
190
  - !ruby/object:Gem::Version
191
191
  version: '0'
192
192
  requirements: []
193
- rubygems_version: 3.5.11
194
- signing_key:
193
+ rubygems_version: 3.5.22
194
+ signing_key:
195
195
  specification_version: 4
196
196
  summary: Create partial and anonymized production database dumps for use in development
197
197
  test_files: []