planter 0.5.0 → 1.0.1

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: eb6405a71e2d12a6f934ba4715d5166f1fba3eac5034dd22c1fe0b98f9b3ec07
4
- data.tar.gz: 4680be7ea150b89db17372e9da80b855e39ca4702bc8067f71fa3b6c6cbc0698
3
+ metadata.gz: d5ec6b0fc5c0ec804e4a1ea2f1c050bcad9eadcc4cea08a8816a6b3ff17a1c37
4
+ data.tar.gz: 3639096bccfaf6aac225c78c7acfc919610802f4569fa239ffe4ffe7b648edcc
5
5
  SHA512:
6
- metadata.gz: 22dcb8b7683e1bbcfeb186c62d6250b36b7882271bba2d8e062c845a877225eefe117eef27b28a2c419a1ef860e19ff563c944b04998383b3c6551c075ee0844
7
- data.tar.gz: 93b858d71f6f32324648d1fcd85604b373bc8f4a69601a445891eb81330b41413afdda8dc5c08526bd45bd1c7601f17ef9b1e93dc50ceb4ed67c05ba69b83921
6
+ metadata.gz: 2da58e993a59e4019e83f9885826672158bf7483b7310ef07f86b54d93ceb3675bf7509c656a976372debac167aa2c12d6e12214d71be0acff253e0dd3d682b5
7
+ data.tar.gz: 80ce1e443cc6eafaf9eb1746f3d617fcf4b33bae7621d28e1f36a2a00a1f57a2e2638a2a629e02b0bdda049208b458a72e3bf43b4098d054e17f41ff8351d9de
data/README.md CHANGED
@@ -18,12 +18,10 @@ Features include:
18
18
  You can view the documentation [here](https://evanthegrayt.github.io/planter/).
19
19
 
20
20
  ## Installation
21
- Add the following line to your application's Gemfile. Because this plugin is
22
- currently a pre-release version, it's recommended to lock it to a specific
23
- version, as breaking changes may occur, even at the minor level.
21
+ Add the following line to your application's Gemfile.
24
22
 
25
23
  ```ruby
26
- gem 'planter', '0.5.0'
24
+ gem 'planter'
27
25
  ```
28
26
 
29
27
  And then execute:
@@ -97,6 +95,25 @@ into the existing `db:seed` task, simply add the following to `db/seeds.rb`.
97
95
  Planter.seed
98
96
  ```
99
97
 
98
+ Before seeding, you can validate your Planter configuration without creating
99
+ records.
100
+
101
+ ```bash
102
+ rails planter:validate
103
+ ```
104
+
105
+ This command loads the Rails environment, checks the configured seeders, and
106
+ verifies that the configured adapter exposes Planter's adapter API. It prints a
107
+ success message when no issues are found, warnings for non-fatal issues, and
108
+ errors before exiting non-zero when the seed plan is not valid.
109
+
110
+ Like `planter:seed`, validation supports the `SEEDERS` environment variable if
111
+ you want to check only part of the configured plan.
112
+
113
+ ```bash
114
+ rails planter:validate SEEDERS=users,addresses
115
+ ```
116
+
100
117
  To create a users seeder, run `rails generate planter:seeder users`. Usually,
101
118
  seeders seed a specific table, so it's recommended to name your seeders after
102
119
  the table. If you don't, specify the table with the `table` option in
@@ -119,8 +136,8 @@ things to note.
119
136
 
120
137
  - The seeder will always be appended at the end of the array. If this is not the
121
138
  correct order, you'll need to adjust the array manually.
122
- - When adjusting the array, always keep the closing bracket on its own line, or
123
- the generator won't know where to put the new seeders.
139
+ - The generator can append to either multiline or inline `%i[...]` seeders
140
+ arrays.
124
141
 
125
142
  You can also tell the generator which seeding style to use.
126
143
 
@@ -50,9 +50,7 @@ module Planter
50
50
 
51
51
  create_csv(seeder) if selected_seeding_method == :csv
52
52
 
53
- inject_into_file "config/initializers/planter.rb",
54
- " #{seeder}\n",
55
- before: /^\s*\]\s*$/
53
+ register_seeder(seeder)
56
54
  end
57
55
 
58
56
  def seeder_contents
@@ -94,6 +92,110 @@ module Planter
94
92
  end.join
95
93
  end
96
94
 
95
+ def register_seeder(seeder)
96
+ contents = ::File.read(initializer_full_path)
97
+ ::File.write(initializer_full_path, add_seeder_to_initializer(contents, seeder))
98
+ end
99
+
100
+ def add_seeder_to_initializer(contents, seeder)
101
+ assignment = contents.match(seeders_assignment_pattern)
102
+ unless assignment
103
+ raise Thor::Error, "Could not find config.seeders = %i[...] in #{initializer_path}"
104
+ end
105
+
106
+ opening_bracket_index = assignment.end(0) - 1
107
+ closing_bracket_index = closing_bracket_index_for(contents, opening_bracket_index)
108
+ unless closing_bracket_index
109
+ raise Thor::Error,
110
+ "Could not find the closing bracket for config.seeders in #{initializer_path}"
111
+ end
112
+
113
+ insert_seeder(contents, seeder, assignment[1], opening_bracket_index, closing_bracket_index)
114
+ end
115
+
116
+ def insert_seeder(
117
+ contents,
118
+ seeder,
119
+ assignment_indent,
120
+ opening_bracket_index,
121
+ closing_bracket_index
122
+ )
123
+ body = contents[(opening_bracket_index + 1)...closing_bracket_index]
124
+ updated = contents.dup
125
+
126
+ if body.include?("\n")
127
+ insert_multiline_seeder(
128
+ updated,
129
+ contents,
130
+ seeder,
131
+ assignment_indent,
132
+ body,
133
+ closing_bracket_index
134
+ )
135
+ elsif body.strip.empty?
136
+ updated.insert(closing_bracket_index, seeder)
137
+ else
138
+ updated.insert(closing_bracket_index, " #{seeder}")
139
+ end
140
+
141
+ updated
142
+ end
143
+
144
+ def insert_multiline_seeder(
145
+ updated,
146
+ contents,
147
+ seeder,
148
+ assignment_indent,
149
+ body,
150
+ closing_bracket_index
151
+ )
152
+ closing_line_start = contents.rindex("\n", closing_bracket_index) + 1
153
+ closing_line_prefix = contents[closing_line_start...closing_bracket_index]
154
+
155
+ if closing_line_prefix.match?(/\A[ \t]*\z/)
156
+ updated.insert(closing_line_start, "#{seeder_indent(body, assignment_indent)}#{seeder}\n")
157
+ else
158
+ updated.insert(closing_bracket_index, " #{seeder}")
159
+ end
160
+ end
161
+
162
+ def seeder_indent(body, assignment_indent)
163
+ item_line = body.lines.find { |line| line.match?(/\S/) }
164
+ indent = item_line&.match(/\A[ \t]*/).to_s
165
+ indent.empty? ? "#{assignment_indent} " : indent
166
+ end
167
+
168
+ def closing_bracket_index_for(contents, opening_bracket_index)
169
+ depth = 1
170
+ index = opening_bracket_index + 1
171
+
172
+ while index < contents.length
173
+ if contents[index] == "\\"
174
+ index += 2
175
+ next
176
+ elsif contents[index] == "["
177
+ depth += 1
178
+ elsif contents[index] == "]"
179
+ depth -= 1
180
+ return index if depth.zero?
181
+ end
182
+
183
+ index += 1
184
+ end
185
+ end
186
+
187
+ def seeders_assignment_pattern
188
+ /^([ \t]*)config\.seeders\s*=\s*%i\[/
189
+ end
190
+
191
+ def initializer_path
192
+ "config/initializers/planter.rb"
193
+ end
194
+
195
+ def initializer_full_path
196
+ ::File.join(destination_root, initializer_path)
197
+ end
198
+
97
199
  def selected_seeding_method
98
200
  return unless options["seeding_method"]
99
201
 
@@ -0,0 +1,296 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Planter
4
+ ##
5
+ # Performs read-only validation of the configured seed plan.
6
+ #
7
+ # The validator checks that requested seeders can be loaded, that built-in
8
+ # seeding methods have the files or data they need, and that the configured
9
+ # adapter exposes Planter's public adapter API.
10
+ class Validator
11
+ ##
12
+ # Structured validation result containing fatal errors and non-fatal
13
+ # warnings.
14
+ class Result
15
+ ##
16
+ # Fatal validation errors.
17
+ #
18
+ # @return [Array<String>]
19
+ attr_reader :errors
20
+
21
+ ##
22
+ # Non-fatal validation warnings.
23
+ #
24
+ # @return [Array<String>]
25
+ attr_reader :warnings
26
+
27
+ ##
28
+ # Create an empty validation result.
29
+ def initialize
30
+ @errors = []
31
+ @warnings = []
32
+ end
33
+
34
+ ##
35
+ # Record a fatal validation error.
36
+ #
37
+ # @param message [String]
38
+ def add_error(message)
39
+ errors << message
40
+ end
41
+
42
+ ##
43
+ # Record a non-fatal validation warning.
44
+ #
45
+ # @param message [String]
46
+ def add_warning(message)
47
+ warnings << message
48
+ end
49
+
50
+ ##
51
+ # Whether validation completed without fatal errors.
52
+ #
53
+ # @return [Boolean]
54
+ def success?
55
+ errors.empty?
56
+ end
57
+ end
58
+
59
+ ##
60
+ # Adapter methods required for Planter's public adapter API.
61
+ #
62
+ # @return [Array<Symbol>]
63
+ REQUIRED_ADAPTER_METHODS = %i[
64
+ create_record
65
+ parent_ids
66
+ foreign_key
67
+ table_columns
68
+ table_names
69
+ ].freeze
70
+
71
+ ##
72
+ # Create a validator.
73
+ #
74
+ # @param config [Planter::Config]
75
+ #
76
+ # @param seeders [Array<String>, nil] seeders to validate
77
+ def initialize(config: Planter.config, seeders: nil)
78
+ @config = config
79
+ @seeders = seeders || requested_seeders
80
+ @result = Result.new
81
+ end
82
+
83
+ ##
84
+ # Validate the seed plan without creating, updating, or deleting records.
85
+ #
86
+ # @return [Planter::Validator::Result]
87
+ def validate
88
+ seeders_present = validate_seeders_present
89
+ validate_adapter
90
+ validate_seeders if seeders_present
91
+ result
92
+ end
93
+
94
+ private
95
+
96
+ attr_reader :config, :seeders, :result
97
+
98
+ def requested_seeders
99
+ ENV["SEEDERS"]&.split(",") || config.seeders&.map(&:to_s)
100
+ end
101
+
102
+ def validate_seeders_present
103
+ return true if seeders.present?
104
+
105
+ result.add_error(
106
+ "No seeders configured. Add seeders to config.seeders in " \
107
+ "config/initializers/planter.rb or set SEEDERS."
108
+ )
109
+ false
110
+ end
111
+
112
+ def validate_adapter
113
+ REQUIRED_ADAPTER_METHODS.each do |method|
114
+ next if adapter.respond_to?(method)
115
+
116
+ result.add_error("Configured adapter must respond to ##{method}.")
117
+ end
118
+ end
119
+
120
+ def validate_seeders
121
+ seeders.each { |seeder| validate_seeder(seeder) }
122
+ end
123
+
124
+ def validate_seeder(seeder)
125
+ return unless require_seeder(seeder)
126
+
127
+ seeder_class = constantize_seeder(seeder)
128
+ return unless seeder_class
129
+ return unless validate_seeder_class(seeder, seeder_class)
130
+ return if custom_seed?(seeder_class)
131
+
132
+ validate_default_seeder(seeder, seeder_class)
133
+ end
134
+
135
+ def require_seeder(seeder)
136
+ path = seeder_path(seeder)
137
+ unless ::File.file?(path)
138
+ result.add_error("Seeder file not found for #{seeder}: #{path}.")
139
+ return false
140
+ end
141
+
142
+ require path
143
+ true
144
+ rescue LoadError, SyntaxError, StandardError => error
145
+ result.add_error(
146
+ "Could not load seeder file for #{seeder}: #{error.class}: #{error.message}."
147
+ )
148
+ false
149
+ end
150
+
151
+ def constantize_seeder(seeder)
152
+ "#{seeder.camelize}Seeder".constantize
153
+ rescue NameError
154
+ result.add_error("Seeder class #{seeder.camelize}Seeder could not be constantized.")
155
+ nil
156
+ end
157
+
158
+ def validate_seeder_class(seeder, seeder_class)
159
+ return true if seeder_class <= Planter::Seeder
160
+
161
+ result.add_error("#{seeder.camelize}Seeder must inherit from Planter::Seeder.")
162
+ false
163
+ end
164
+
165
+ def custom_seed?(seeder_class)
166
+ seeder_class.instance_method(:seed).owner != Planter::Seeder
167
+ end
168
+
169
+ def validate_default_seeder(seeder, seeder_class)
170
+ unless Planter::Seeder::SEEDING_METHODS.include?(seeder_class.seed_method&.intern)
171
+ result.add_error("#{seeder.camelize}Seeder must define a valid seeding_method.")
172
+ return
173
+ end
174
+
175
+ case seeder_class.seed_method.intern
176
+ when :csv
177
+ validate_csv_seeder(seeder, seeder_class)
178
+ when :data_array
179
+ validate_data_array_seeder(seeder, seeder_class)
180
+ end
181
+ end
182
+
183
+ def validate_csv_seeder(seeder, seeder_class)
184
+ seeder_instance = build_seeder(seeder, seeder_class)
185
+ return unless seeder_instance
186
+
187
+ context = context_for(seeder_class)
188
+ data_source = Planter::CsvDataSource.new(context: context, seeder: seeder_instance)
189
+ unless data_source.path
190
+ result.add_error("CSV seed file not found for #{seeder_class.table_name}.")
191
+ return
192
+ end
193
+
194
+ return unless adapter.respond_to?(:table_columns)
195
+
196
+ validate_csv_headers(data_source.path, context, seeder_instance)
197
+ end
198
+
199
+ def validate_data_array_seeder(seeder, seeder_class)
200
+ seeder_instance = build_seeder(seeder, seeder_class)
201
+ return unless seeder_instance
202
+
203
+ return unless seeder_instance.public_send(:data).nil?
204
+
205
+ result.add_error("#{seeder.camelize}Seeder must define non-nil data.")
206
+ rescue => error
207
+ result.add_error(
208
+ "#{seeder.camelize}Seeder data could not be read: " \
209
+ "#{error.class}: #{error.message}."
210
+ )
211
+ end
212
+
213
+ def validate_csv_headers(path, context, seeder_instance)
214
+ headers = csv_headers(path, context, seeder_instance)
215
+ return if headers.nil?
216
+
217
+ table_columns = adapter.table_columns(context: context).map(&:to_s)
218
+ lookup_fields = csv_lookup_fields(headers, context)
219
+ native_lookup_fields = lookup_fields.select do |field|
220
+ table_columns.include?(field.to_s)
221
+ end
222
+ non_column_lookup_fields = lookup_fields - native_lookup_fields
223
+
224
+ if native_lookup_fields.empty?
225
+ result.add_error(
226
+ "No native lookup columns found for #{context.table_name}. " \
227
+ "Add a native table column to the seed data or unique_columns."
228
+ )
229
+ end
230
+
231
+ return if non_column_lookup_fields.empty?
232
+
233
+ result.add_warning(
234
+ "Planter will move non-column lookup attributes for #{context.table_name} " \
235
+ "into create attributes: #{non_column_lookup_fields.map(&:to_s).sort.join(", ")}."
236
+ )
237
+ rescue => error
238
+ result.add_error(
239
+ "CSV headers could not be read for #{context.table_name}: " \
240
+ "#{error.class}: #{error.message}."
241
+ )
242
+ end
243
+
244
+ def csv_headers(path, context, seeder_instance)
245
+ header = ::File.foreach(path).first.to_s
246
+ if path.include?(".erb") && header.include?("<%")
247
+ header = ERB.new(header, trim_mode: context.erb_trim_mode).result(
248
+ seeder_instance.instance_eval { binding }
249
+ )
250
+ end
251
+
252
+ ::CSV.parse(header, headers: true, header_converters: :symbol).headers
253
+ end
254
+
255
+ def csv_lookup_fields(headers, context)
256
+ fields = context.unique_columns || headers
257
+ fields = fields.compact.map(&:to_sym)
258
+
259
+ if context.parent && adapter.respond_to?(:foreign_key)
260
+ fields << adapter.foreign_key(context: context).to_sym
261
+ end
262
+
263
+ fields
264
+ end
265
+
266
+ def build_seeder(seeder, seeder_class)
267
+ seeder_class.new
268
+ rescue => error
269
+ result.add_error(
270
+ "#{seeder.camelize}Seeder could not be initialized: " \
271
+ "#{error.class}: #{error.message}."
272
+ )
273
+ nil
274
+ end
275
+
276
+ def context_for(seeder_class)
277
+ Planter::SeedContext.new(
278
+ table_name: seeder_class.table_name,
279
+ seed_method: seeder_class.seed_method,
280
+ csv_name: seeder_class.csv_name,
281
+ parent: seeder_class.parent,
282
+ number_of_records: seeder_class.number_of_records,
283
+ unique_columns: seeder_class.unique_columns,
284
+ erb_trim_mode: seeder_class.erb_trim_mode
285
+ )
286
+ end
287
+
288
+ def seeder_path(seeder)
289
+ Rails.root.join(config.seeders_directory, "#{seeder}_seeder.rb").to_s
290
+ end
291
+
292
+ def adapter
293
+ @adapter ||= config.adapter
294
+ end
295
+ end
296
+ end
@@ -9,19 +9,19 @@ module Planter
9
9
  # Major version.
10
10
  #
11
11
  # @return [Integer]
12
- MAJOR = 0
12
+ MAJOR = 1
13
13
 
14
14
  ##
15
15
  # Minor version.
16
16
  #
17
17
  # @return [Integer]
18
- MINOR = 5
18
+ MINOR = 0
19
19
 
20
20
  ##
21
21
  # Patch version.
22
22
  #
23
23
  # @return [Integer]
24
- PATCH = 0
24
+ PATCH = 1
25
25
 
26
26
  module_function
27
27
 
data/lib/planter.rb CHANGED
@@ -9,6 +9,7 @@ require "planter/seed_context"
9
9
  require "planter/csv_data_source"
10
10
  require "planter/record_attributes"
11
11
  require "planter/seeder"
12
+ require "planter/validator"
12
13
 
13
14
  ##
14
15
  # The main module for the plugin. It nicely wraps the +Planter::Config+ class
@@ -83,4 +84,14 @@ module Planter
83
84
  "#{s.camelize}Seeder".constantize.new.seed
84
85
  end
85
86
  end
87
+
88
+ ##
89
+ # Validate the configured seed plan without creating records. This checks
90
+ # seeder files, seeder classes, built-in seeding method configuration, CSV
91
+ # headers where possible, and the configured adapter API.
92
+ #
93
+ # @return [Planter::Validator::Result]
94
+ def validate
95
+ Planter::Validator.new.validate
96
+ end
86
97
  end
@@ -1,13 +1,39 @@
1
1
  require "planter"
2
2
 
3
3
  namespace :planter do
4
+ apply_directory_overrides = lambda do
5
+ if ENV["SEEDERS_DIRECTORY"]
6
+ Planter.config.seeders_directory = ENV["SEEDERS_DIRECTORY"]
7
+ end
8
+
9
+ if ENV["CSV_FILES_DIRECTORY"]
10
+ Planter.config.csv_files_directory = ENV["CSV_FILES_DIRECTORY"]
11
+ end
12
+ end
13
+
4
14
  desc "Seed application. Use this to keep planter separate from db:seed"
5
15
  task seed: :environment do
6
- Planter.configure do |config|
7
- # NOTE: the seed method already looks for ENV['SEEDERS']
8
- ENV["SEEDERS_DIRECTORY"] && config.seeders_directory = ENV["SEEDERS_DIRECTORY"]
9
- ENV["CSV_FILES_DIRECTORY"] && config.csv_files_directory = ENV["CSV_FILES_DIRECTORY"]
10
- end
16
+ # NOTE: the seed method already looks for ENV['SEEDERS']
17
+ apply_directory_overrides.call
11
18
  Planter.seed
12
19
  end
20
+
21
+ desc "Validate Planter configuration without creating records"
22
+ task validate: :environment do
23
+ apply_directory_overrides.call
24
+ result = Planter.validate
25
+
26
+ result.warnings.each { |warning| warn "WARNING: #{warning}" }
27
+ result.errors.each { |error| warn "ERROR: #{error}" }
28
+
29
+ if result.success?
30
+ puts(
31
+ result.warnings.empty? ?
32
+ "Planter validation passed." :
33
+ "Planter validation completed with warnings."
34
+ )
35
+ else
36
+ abort "Planter validation failed."
37
+ end
38
+ end
13
39
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: planter
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 1.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Evan Gray
@@ -107,6 +107,7 @@ files:
107
107
  - lib/planter/record_attributes.rb
108
108
  - lib/planter/seed_context.rb
109
109
  - lib/planter/seeder.rb
110
+ - lib/planter/validator.rb
110
111
  - lib/planter/version.rb
111
112
  - lib/tasks/planter_tasks.rake
112
113
  homepage: https://github.com/evanthegrayt/planter