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.
- checksums.yaml +4 -4
- data/README.md +111 -35
- data/lib/generators/planter/adapter_generator.rb +26 -11
- data/lib/generators/planter/initializer_generator.rb +7 -1
- data/lib/generators/planter/seeder_generator.rb +109 -9
- data/lib/planter/adapters/active_record.rb +112 -26
- data/lib/planter/config.rb +2 -2
- data/lib/planter/csv_data_source.rb +54 -0
- data/lib/planter/record_attributes.rb +113 -0
- data/lib/planter/seed_context.rb +83 -0
- data/lib/planter/seeder.rb +71 -85
- data/lib/planter/version.rb +2 -2
- data/lib/planter.rb +5 -2
- metadata +4 -1
|
@@ -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
|
|
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(
|
|
10
|
-
# - +parent_ids(
|
|
11
|
-
# - +foreign_key(
|
|
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
|
-
# +
|
|
15
|
-
#
|
|
16
|
-
#
|
|
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 [
|
|
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(
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
#
|
|
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 [
|
|
53
|
+
# @param [Planter::SeedContext] context seeder configuration
|
|
41
54
|
#
|
|
42
55
|
# @return [Array]
|
|
43
|
-
def parent_ids(
|
|
44
|
-
parent_model(
|
|
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
|
-
#
|
|
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 [
|
|
67
|
+
# @param [Planter::SeedContext] context seeder configuration
|
|
53
68
|
#
|
|
54
69
|
# @return [String, Symbol]
|
|
55
|
-
def foreign_key(
|
|
56
|
-
association_options(
|
|
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
|
|
72
|
-
|
|
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
|
|
76
|
-
|
|
161
|
+
def quote_column(column_name)
|
|
162
|
+
connection.quote_column_name(column_name)
|
|
77
163
|
end
|
|
78
164
|
|
|
79
|
-
def
|
|
80
|
-
|
|
165
|
+
def connection
|
|
166
|
+
::ActiveRecord::Base.connection
|
|
81
167
|
end
|
|
82
168
|
end
|
|
83
169
|
end
|
data/lib/planter/config.rb
CHANGED
|
@@ -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
|
|
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
|