evil-seed 0.6.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: 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: []