planter 0.4.1 → 0.5.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.
@@ -1,24 +1,31 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Planter
4
+ ##
5
+ # Namespace for persistence adapters used by Planter seeders.
4
6
  module Adapters
5
7
  ##
6
- # Default adapter for seeding Active Record models.
8
+ # Default adapter for seeding Active Record tables.
9
+ #
10
+ # When a table maps to an Active Record model, records are created through
11
+ # that model. Tables without a matching model, such as join tables, are
12
+ # seeded directly through the database connection.
7
13
  #
8
14
  # Custom adapters should implement this public API:
9
- # - +create_record(model_name:, lookup_attributes:, create_attributes:)+
10
- # - +parent_ids(model_name:, parent:)+
11
- # - +foreign_key(model_name:, parent:)+
15
+ # - +create_record(context:, lookup_attributes:, create_attributes:)+
16
+ # - +parent_ids(context:)+
17
+ # - +foreign_key(context:)+
18
+ # - +table_columns(context:)+
12
19
  # - +table_names+
13
20
  #
14
- # +model_name+ is the configured seeder model name. +parent+ is the
15
- # configured parent association name. Adapters are responsible for resolving
16
- # those values into whatever persistence or reflection objects they need.
21
+ # +context+ is a +Planter::SeedContext+. Adapters are responsible for
22
+ # resolving those values into whatever persistence or reflection objects
23
+ # they need.
17
24
  class ActiveRecord
18
25
  ##
19
26
  # Create a record unless one already exists.
20
27
  #
21
- # @param [String] model_name the model being seeded
28
+ # @param [Planter::SeedContext] context seeder configuration
22
29
  #
23
30
  # @param [Hash] lookup_attributes attributes used to find the record
24
31
  #
@@ -26,34 +33,52 @@ module Planter
26
33
  # creating a new record
27
34
  #
28
35
  # @return [Object]
29
- def create_record(model_name:, lookup_attributes:, create_attributes:)
30
- model_name.constantize
31
- .where(lookup_attributes)
32
- .first_or_create!(create_attributes)
36
+ def create_record(context:, lookup_attributes:, create_attributes:)
37
+ if (model = model(context))
38
+ model
39
+ .where(lookup_attributes)
40
+ .first_or_create!(create_attributes)
41
+ else
42
+ create_table_record(context, lookup_attributes, create_attributes)
43
+ end
33
44
  end
34
45
 
35
46
  ##
36
47
  # Return the parent ids to use when seeding child records.
37
48
  #
38
- # @param [String] model_name the model being seeded
49
+ # The Active Record adapter resolves parents through model associations.
50
+ # Tables without matching models should use a custom adapter for parent
51
+ # seeding.
39
52
  #
40
- # @param [String, Symbol] parent the parent association name
53
+ # @param [Planter::SeedContext] context seeder configuration
41
54
  #
42
55
  # @return [Array]
43
- def parent_ids(model_name:, parent:)
44
- parent_model(model_name, parent).constantize.pluck(primary_key(model_name, parent))
56
+ def parent_ids(context:)
57
+ parent_model(context).constantize.pluck(primary_key(context))
45
58
  end
46
59
 
47
60
  ##
48
61
  # Return the foreign key used to assign a parent id on a child record.
49
62
  #
50
- # @param [String] model_name the model being seeded
63
+ # The Active Record adapter resolves foreign keys through model
64
+ # associations. Tables without matching models should use a custom adapter
65
+ # for parent seeding.
51
66
  #
52
- # @param [String, Symbol] parent the parent association name
67
+ # @param [Planter::SeedContext] context seeder configuration
53
68
  #
54
69
  # @return [String, Symbol]
55
- def foreign_key(model_name:, parent:)
56
- association_options(model_name, parent).fetch(:foreign_key, "#{parent}_id")
70
+ def foreign_key(context:)
71
+ association_options(context).fetch(:foreign_key, "#{context.parent}_id")
72
+ end
73
+
74
+ ##
75
+ # Return native table columns for the table being seeded.
76
+ #
77
+ # @param [Planter::SeedContext] context seeder configuration
78
+ #
79
+ # @return [Array<String>]
80
+ def table_columns(context:)
81
+ ::ActiveRecord::Base.connection.columns(context.table_name).map(&:name)
57
82
  end
58
83
 
59
84
  ##
@@ -68,16 +93,77 @@ module Planter
68
93
 
69
94
  private
70
95
 
71
- def association_options(model_name, parent)
72
- model_name.constantize.reflect_on_association(parent).options
96
+ def model(context)
97
+ context.table_name.classify.safe_constantize&.then do |model|
98
+ model if model.respond_to?(:table_name) && model.table_name == context.table_name
99
+ end
100
+ end
101
+
102
+ def association_options(context)
103
+ model!(context).reflect_on_association(context.parent).options
104
+ end
105
+
106
+ def primary_key(context)
107
+ association_options(context).fetch(:primary_key, :id)
108
+ end
109
+
110
+ def parent_model(context)
111
+ association_options(context).fetch(:class_name, context.parent.to_s.classify)
112
+ end
113
+
114
+ def model!(context)
115
+ model(context) || raise(
116
+ "Planter's Active Record adapter requires a model-backed table for " \
117
+ "parent seeding. Define a model for #{context.table_name} or use a " \
118
+ "custom adapter."
119
+ )
120
+ end
121
+
122
+ def create_table_record(context, lookup_attributes, create_attributes)
123
+ find_table_record(context, lookup_attributes) ||
124
+ insert_table_record(context, lookup_attributes.merge(create_attributes))
125
+ end
126
+
127
+ def find_table_record(context, lookup_attributes)
128
+ connection.select_one(
129
+ [
130
+ "SELECT * FROM #{quote_table(context.table_name)}",
131
+ "WHERE #{where_clause(lookup_attributes)}",
132
+ "LIMIT 1"
133
+ ].join(" ")
134
+ )
135
+ end
136
+
137
+ def insert_table_record(context, attributes)
138
+ connection.execute(
139
+ [
140
+ "INSERT INTO #{quote_table(context.table_name)}",
141
+ "(#{attributes.keys.map { |key| quote_column(key) }.join(", ")})",
142
+ "VALUES (#{attributes.values.map { |value| connection.quote(value) }.join(", ")})"
143
+ ].join(" ")
144
+ )
145
+ end
146
+
147
+ def where_clause(attributes)
148
+ attributes.map do |key, value|
149
+ if value.nil?
150
+ "#{quote_column(key)} IS NULL"
151
+ else
152
+ "#{quote_column(key)} = #{connection.quote(value)}"
153
+ end
154
+ end.join(" AND ")
155
+ end
156
+
157
+ def quote_table(table_name)
158
+ connection.quote_table_name(table_name)
73
159
  end
74
160
 
75
- def primary_key(model_name, parent)
76
- association_options(model_name, parent).fetch(:primary_key, :id)
161
+ def quote_column(column_name)
162
+ connection.quote_column_name(column_name)
77
163
  end
78
164
 
79
- def parent_model(model_name, parent)
80
- association_options(model_name, parent).fetch(:class_name, parent.to_s.classify)
165
+ def connection
166
+ ::ActiveRecord::Base.connection
81
167
  end
82
168
  end
83
169
  end
@@ -53,8 +53,8 @@ module Planter
53
53
 
54
54
  ##
55
55
  # The adapter used to create records, discover parent records, and inspect
56
- # database table names. Custom adapters should implement the public API
57
- # documented by +Planter::Adapters::ActiveRecord+.
56
+ # database table names. Custom adapters should implement the table-oriented
57
+ # public API documented by +Planter::Adapters::ActiveRecord+.
58
58
  #
59
59
  # @return [Object]
60
60
  attr_writer :adapter
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Planter
4
+ ##
5
+ # Loads CSV seed data for a configured seeder.
6
+ class CsvDataSource
7
+ ##
8
+ # Create a CSV data source.
9
+ #
10
+ # @param [Planter::SeedContext] context seeder configuration
11
+ #
12
+ # @param [Planter::Seeder] seeder seeder instance used for ERB binding
13
+ def initialize(context:, seeder:)
14
+ @context = context
15
+ @seeder = seeder
16
+ end
17
+
18
+ ##
19
+ # Return parsed CSV rows as hashes.
20
+ #
21
+ # @return [Array<Hash>]
22
+ def data
23
+ contents = ::File.read(path)
24
+ if path.include?(".erb")
25
+ contents = ERB.new(contents, trim_mode: context.erb_trim_mode).result(seeder_binding)
26
+ end
27
+
28
+ ::CSV.parse(
29
+ contents,
30
+ headers: true,
31
+ header_converters: :symbol
32
+ ).map(&:to_hash)
33
+ end
34
+
35
+ ##
36
+ # Return the first matching CSV path for the configured CSV name.
37
+ #
38
+ # @return [String, nil]
39
+ def path
40
+ @path ||=
41
+ %W[#{context.csv_name}.csv #{context.csv_name}.csv.erb #{context.csv_name}.erb.csv]
42
+ .map { |file| Rails.root.join(Planter.config.csv_files_directory, file).to_s }
43
+ .find { |file| ::File.file?(file) }
44
+ end
45
+
46
+ private
47
+
48
+ attr_reader :context, :seeder
49
+
50
+ def seeder_binding
51
+ seeder.instance_eval { binding }
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Planter
4
+ ##
5
+ # Prepares seed records for adapter persistence.
6
+ class RecordAttributes
7
+ ##
8
+ # Create a record attribute preparer.
9
+ #
10
+ # @param [Planter::SeedContext] context seeder configuration
11
+ #
12
+ # @param [Object] adapter configured persistence adapter
13
+ #
14
+ # @param [#call] transformations_provider returns value transformations by
15
+ # field
16
+ def initialize(context:, adapter:, transformations_provider:)
17
+ @context = context
18
+ @adapter = adapter
19
+ @transformations_provider = transformations_provider
20
+ @warned_non_column_lookup_attributes = []
21
+ end
22
+
23
+ ##
24
+ # Prepare lookup and create attributes for adapter persistence.
25
+ #
26
+ # @param [Hash] record seed record attributes
27
+ #
28
+ # @param [Object, nil] parent_id parent record id
29
+ #
30
+ # @return [Array<Hash, Hash>]
31
+ def prepare(record, parent_id: nil)
32
+ lookup_attributes, create_attributes = split_record(apply_transformations(record))
33
+ lookup_attributes = lookup_attributes.merge(foreign_key => parent_id) if parent_id
34
+
35
+ filter_lookup_attributes(lookup_attributes, create_attributes)
36
+ end
37
+
38
+ private
39
+
40
+ attr_reader :context, :adapter, :transformations_provider
41
+
42
+ def apply_transformations(record)
43
+ transformations = transformations_provider.call
44
+ return record if transformations.nil?
45
+
46
+ record.map { |field, value| map_record(field, value, record, transformations) }.to_h
47
+ end
48
+
49
+ def map_record(field, value, record, transformations)
50
+ [
51
+ field,
52
+ transformations.key?(field) ? transform(field, value, record, transformations) : value
53
+ ]
54
+ end
55
+
56
+ def transform(field, value, record, transformations)
57
+ case transformations[field].arity
58
+ when 0 then transformations[field].call
59
+ when 1 then transformations[field].call(value)
60
+ when 2 then transformations[field].call(value, record)
61
+ end
62
+ end
63
+
64
+ def split_record(record)
65
+ return [record, {}] unless context.unique_columns
66
+
67
+ lookup_attributes = context.unique_columns.each_with_object({}) do |column, attrs|
68
+ attrs[column] = record[column]
69
+ end
70
+ [lookup_attributes, record.except(*context.unique_columns)]
71
+ end
72
+
73
+ def filter_lookup_attributes(lookup_attributes, create_attributes)
74
+ native_lookup_attributes = lookup_attributes.select do |field, _value|
75
+ table_columns.include?(field.to_s)
76
+ end
77
+ non_column_lookup_attributes = lookup_attributes.except(*native_lookup_attributes.keys)
78
+
79
+ warn_non_column_lookup_attributes(non_column_lookup_attributes.keys) if non_column_lookup_attributes.any?
80
+
81
+ if native_lookup_attributes.empty?
82
+ raise "No native lookup columns found for #{context.table_name}. " \
83
+ "Add a native table column to the seed data or unique_columns."
84
+ end
85
+
86
+ [
87
+ native_lookup_attributes,
88
+ non_column_lookup_attributes.merge(create_attributes)
89
+ ]
90
+ end
91
+
92
+ def warn_non_column_lookup_attributes(fields)
93
+ warning_key = fields.map(&:to_s).sort
94
+ return if warned_non_column_lookup_attributes.include?(warning_key)
95
+
96
+ warned_non_column_lookup_attributes << warning_key
97
+ warn(
98
+ "WARNING: Planter moved non-column lookup attributes for #{context.table_name} " \
99
+ "into create attributes: #{warning_key.join(", ")}"
100
+ )
101
+ end
102
+
103
+ def foreign_key
104
+ @foreign_key ||= adapter.foreign_key(context: context)
105
+ end
106
+
107
+ def table_columns
108
+ @table_columns ||= adapter.table_columns(context: context).map(&:to_s)
109
+ end
110
+
111
+ attr_reader :warned_non_column_lookup_attributes
112
+ end
113
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Planter
4
+ ##
5
+ # Parameter object carrying normalized seeder configuration into adapters.
6
+ class SeedContext
7
+ ##
8
+ # The table being seeded.
9
+ #
10
+ # @return [String]
11
+ attr_reader :table_name
12
+
13
+ ##
14
+ # The configured seeding method.
15
+ #
16
+ # @return [Symbol, nil]
17
+ attr_reader :seed_method
18
+
19
+ ##
20
+ # The CSV seed file name without an extension.
21
+ #
22
+ # @return [String]
23
+ attr_reader :csv_name
24
+
25
+ ##
26
+ # The adapter-defined parent relation.
27
+ #
28
+ # @return [String, Symbol, nil]
29
+ attr_reader :parent
30
+
31
+ ##
32
+ # How many times to create each data record.
33
+ #
34
+ # @return [Integer]
35
+ attr_reader :number_of_records
36
+
37
+ ##
38
+ # Columns used to look up existing records.
39
+ #
40
+ # @return [Array<Symbol>, nil]
41
+ attr_reader :unique_columns
42
+
43
+ ##
44
+ # The ERB trim mode used for CSV templates.
45
+ #
46
+ # @return [String, nil]
47
+ attr_reader :erb_trim_mode
48
+
49
+ ##
50
+ # Create a new seed context.
51
+ #
52
+ # @param [String, Symbol] table_name
53
+ #
54
+ # @param [String, Symbol, nil] seed_method
55
+ #
56
+ # @param [String, Symbol] csv_name
57
+ #
58
+ # @param [String, Symbol, nil] parent
59
+ #
60
+ # @param [Integer] number_of_records
61
+ #
62
+ # @param [Array<Symbol>, nil] unique_columns
63
+ #
64
+ # @param [String, nil] erb_trim_mode
65
+ def initialize(
66
+ table_name:,
67
+ seed_method:,
68
+ csv_name:,
69
+ parent:,
70
+ number_of_records:,
71
+ unique_columns:,
72
+ erb_trim_mode:
73
+ )
74
+ @table_name = table_name.to_s
75
+ @seed_method = seed_method&.intern
76
+ @csv_name = csv_name.to_s
77
+ @parent = parent
78
+ @number_of_records = number_of_records
79
+ @unique_columns = unique_columns
80
+ @erb_trim_mode = erb_trim_mode
81
+ end
82
+ end
83
+ end