planter 0.5.0 → 1.0.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: eb6405a71e2d12a6f934ba4715d5166f1fba3eac5034dd22c1fe0b98f9b3ec07
4
- data.tar.gz: 4680be7ea150b89db17372e9da80b855e39ca4702bc8067f71fa3b6c6cbc0698
3
+ metadata.gz: b66f9b16f720b193d4dee6e0a23382b22a073982634bccbad2d52092516090eb
4
+ data.tar.gz: 5d66d7905379b7b148586b52b85afb2d715501543e88f0d84a88973b734d9f6b
5
5
  SHA512:
6
- metadata.gz: 22dcb8b7683e1bbcfeb186c62d6250b36b7882271bba2d8e062c845a877225eefe117eef27b28a2c419a1ef860e19ff563c944b04998383b3c6551c075ee0844
7
- data.tar.gz: 93b858d71f6f32324648d1fcd85604b373bc8f4a69601a445891eb81330b41413afdda8dc5c08526bd45bd1c7601f17ef9b1e93dc50ceb4ed67c05ba69b83921
6
+ metadata.gz: dde579bb6c055ce0c819148d1d818b39bf44ef9e75c692799dd760c7f9a96a0828d959f3204e6741fa55c41a6b9c2d8045f746142c139b5f20f1ea450ba7b259
7
+ data.tar.gz: 9f208c04796ae9f80ce6d46b4ec94e56da4fc2c62c36abf3198d806621bf2c7d79d9f52fd2d30ad0ee73c94206e7f0997b953ff93dbc93b83ff8064ee7ab85ed
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
@@ -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,13 +9,13 @@ 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.
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.0
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