seedie 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,49 @@
1
+ module Seedie
2
+ module FieldValues
3
+ class FakeValue
4
+ def initialize(name, column)
5
+ @name = name
6
+ @column = column
7
+ end
8
+
9
+ def generate_fake_value
10
+ case @column.type
11
+ when :string, :text, :citext
12
+ Faker::Lorem.word
13
+ when :uuid
14
+ SecureRandom.uuid
15
+ when :integer, :bigint, :smallint
16
+ Faker::Number.number(digits: 5)
17
+ when :decimal, :float, :real
18
+ Faker::Number.decimal(l_digits: 2, r_digits: 2)
19
+ when :datetime, :timestamp, :timestamptz
20
+ Faker::Time.between(from: DateTime.now - 1, to: DateTime.now)
21
+ when :date
22
+ Faker::Date.between(from: Date.today - 2, to: Date.today)
23
+ when :time, :timetz
24
+ Faker::Time.forward(days: 23, period: :morning)
25
+ when :boolean
26
+ Faker::Boolean.boolean
27
+ when :json, :jsonb
28
+ { "key1" => Faker::Lorem.word, "key2" => Faker::Number.number(digits: 2) }
29
+ when :inet
30
+ Faker::Internet.ip_v4_address
31
+ when :cidr, :macaddr
32
+ Faker::Internet.mac_address
33
+ when :bytea
34
+ Faker::Internet.password
35
+ when :bit, :bit_varying
36
+ ["0","1"].sample
37
+ when :money
38
+ Faker::Commerce.price.to_s
39
+ when :hstore
40
+ { "key1" => Faker::Lorem.word, "key2" => Faker::Number.number(digits: 2) }
41
+ when :year
42
+ rand(1901..2155)
43
+ else
44
+ raise UnknownColumnTypeError, "Unknown column type: #{@column.type}"
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,138 @@
1
+ module Seedie
2
+ module FieldValues
3
+ class FakerBuilder
4
+ def initialize(name, column, validations)
5
+ @name = name
6
+ @column = column
7
+ @validations = validations
8
+ @faker_expression = "{{Faker::"
9
+ @unique_prefix = ""
10
+ @class_prefix = ""
11
+ @method_prefix = ""
12
+ @options = ""
13
+ end
14
+
15
+ def build_faker_constant
16
+ @unique_prefix = "unique." if has_validation?(:uniqueness)
17
+
18
+ add_faker_class_and_method(@column.type)
19
+
20
+ if has_validation?(:inclusion)
21
+ handle_inclusion_validation
22
+ else
23
+ @options += handle_numericality_validation if has_validation?(:numericality)
24
+ @options += handle_length_validation if has_validation?(:length)
25
+ end
26
+
27
+ if @faker_expression.is_a?(String)
28
+ @faker_expression += "#{@class_prefix}#{@unique_prefix}#{@method_prefix}#{@options}"
29
+ @faker_expression += "}}" if @faker_expression.start_with?("{{") # We may not need }} when random attributes
30
+ end
31
+
32
+ @faker_expression
33
+ end
34
+
35
+ private
36
+
37
+ def add_faker_class_and_method(type)
38
+ case type
39
+ when :string, :text, :citext
40
+ @class_prefix = "Lorem."
41
+ @method_prefix = "word"
42
+ when :uuid
43
+ @class_prefix = "Internet."
44
+ @method_prefix = "uuid"
45
+ when :integer, :bigint, :smallint
46
+ @class_prefix = "Number."
47
+ @method_prefix = "number"
48
+ @options = "(digits: 5)"
49
+ when :decimal, :float, :real
50
+ @class_prefix = "Number."
51
+ @method_prefix = "decimal"
52
+ @options = "(l_digits: 2, r_digits: 2)"
53
+ when :datetime, :timestamp, :timestamptz, :time, :timetz
54
+ @class_prefix = "Time."
55
+ @method_prefix = "between"
56
+ @options = "(from: DateTime.now - 1, to: DateTime.now)"
57
+ when :date
58
+ @class_prefix = "Date."
59
+ @method_prefix = "between"
60
+ @options = "(from: Date.today - 1, to: Date.today)"
61
+ when :boolean
62
+ @class_prefix = "Boolean."
63
+ @method_prefix = "boolean"
64
+ when :json, :jsonb
65
+ @class_prefix = "Json."
66
+ @method_prefix = "shallow_json(width: 3, options: { key: \"Name.first_name\", value: \"Number.number(digits: 2)\" })"
67
+ when :inet
68
+ @class_prefix = "Internet."
69
+ @method_prefix = "ip_v4_address"
70
+ when :cidr, :macaddr
71
+ @class_prefix = "Internet."
72
+ @method_prefix = "mac_address"
73
+ when :bytea
74
+ @class_prefix = "Internet."
75
+ @method_prefix = "password"
76
+ when :bit, :bit_varying
77
+ @class_prefix = "Internet."
78
+ @method_prefix = "password"
79
+ when :money
80
+ @class_prefix = "Commerce."
81
+ @method_prefix = "price.to_s"
82
+ when :hstore
83
+ @class_prefix = "Json."
84
+ @method_prefix = "shallow_json"
85
+ @options = "(width: 3, options: { key: \"Name.first_name\", value: \"Number.number(digits: 2)\" })"
86
+ when :year
87
+ @class_prefix = "Number."
88
+ @method_prefix = "number"
89
+ @options = "(digits: 4)"
90
+ else
91
+ raise UnknownColumnTypeError, "Unknown column type: #{type}"
92
+ end
93
+ end
94
+
95
+ def has_validation?(kind)
96
+ @validations.any? { |validation| validation.kind == kind }
97
+ end
98
+
99
+ def handle_numericality_validation
100
+ numericality_validator = @validations.find { |v| v.kind == :numericality }
101
+ options = numericality_validator.options
102
+ if options[:greater_than_or_equal_to] && options[:less_than_or_equal_to]
103
+ ".between(from: #{options[:greater_than_or_equal_to]}, to: #{options[:less_than_or_equal_to]})"
104
+ elsif options[:greater_than_or_equal_to]
105
+ ".between(from: #{options[:greater_than_or_equal_to]})"
106
+ elsif options[:less_than_or_equal_to]
107
+ ".between(to: #{options[:less_than_or_equal_to]})"
108
+ else
109
+ ""
110
+ end
111
+ end
112
+
113
+ def handle_length_validation
114
+ length_validator = @validations.find { |v| v.kind == :length }
115
+ @method_prefix = "characters"
116
+ options = length_validator.options
117
+ if options[:minimum] && options[:maximum]
118
+ "(number: rand(#{options[:minimum]}..#{options[:maximum]}))"
119
+ elsif options[:minimum]
120
+ "(number: rand(#{options[:minimum]}..100))"
121
+ elsif options[:maximum]
122
+ "(number: rand(1..#{options[:maximum]}))"
123
+ else
124
+ ""
125
+ end
126
+ end
127
+
128
+ def handle_inclusion_validation
129
+ inclusion_validator = @validations.find { |v| v.kind == :inclusion }
130
+ options = inclusion_validator.options
131
+ @class_prefix = ""
132
+ @method_prefix = ""
133
+ @options = ""
134
+ @faker_expression = { "custom_attr_value" => { "values" => options[:in], "pick_strategy" => "random" } }
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,50 @@
1
+ module Seedie
2
+ class FieldValuesSet
3
+ attr_reader :attributes_config, :index
4
+
5
+ def initialize(model, model_config, index)
6
+ @model = model
7
+ @model_config = model_config
8
+ @index = index
9
+ @attributes_config = model_config["attributes"]
10
+ @model_fields = ModelFields.new(model, model_config)
11
+ @field_values = {}
12
+ end
13
+
14
+ def generate_field_values
15
+ populate_values_for_model_fields
16
+ populate_values_for_virtual_fields if @attributes_config
17
+
18
+ @field_values
19
+ end
20
+
21
+ def generate_field_value(name, column)
22
+ return generate_custom_field_value(name) if @attributes_config&.key?(name)
23
+
24
+ FieldValues::FakeValue.new(name, column).generate_fake_value
25
+ end
26
+
27
+ private
28
+
29
+ def populate_values_for_model_fields
30
+ @field_values = @model.columns_hash.map do |name, column|
31
+ next if @model_fields.disabled_fields.include?(name)
32
+ next if @model_fields.foreign_fields.include?(name)
33
+
34
+ [name, generate_field_value(name, column)]
35
+ end.compact.to_h
36
+ end
37
+
38
+ def populate_values_for_virtual_fields
39
+ virtual_fields = @attributes_config.keys - @model.columns_hash.keys
40
+
41
+ virtual_fields.each do |name|
42
+ @field_values[name] = generate_custom_field_value(name) if @attributes_config[name]
43
+ end
44
+ end
45
+
46
+ def generate_custom_field_value(name)
47
+ FieldValues::CustomValue.new(name, @attributes_config[name], @index).generate_custom_field_value
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,30 @@
1
+ module Seedie
2
+ module Model
3
+ class Creator
4
+ include Reporters::Reportable
5
+
6
+ def initialize(model, reporters = [])
7
+ @model = model
8
+ @reporters = reporters
9
+
10
+ add_observers(@reporters)
11
+ end
12
+
13
+ def create!(field_values_set)
14
+ record = @model.create!(field_values_set)
15
+ report(:record_created, name: "#{record.class}", id: "#{record.id}")
16
+
17
+ record
18
+ end
19
+
20
+ def create(field_values_set)
21
+ begin
22
+ create!(field_values_set)
23
+ rescue ActiveRecord::RecordInvalid => e
24
+ report(:record_invalid, record: e.record)
25
+ return nil
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,32 @@
1
+ module Seedie
2
+ module Model
3
+ class IdGenerator
4
+ def initialize(model)
5
+ @model = model
6
+ end
7
+
8
+ def random_id
9
+ id = @model.pluck(:id).sample
10
+ raise InvalidAssociationConfigError, "#{@model} has no records" unless id
11
+
12
+ return id
13
+ end
14
+
15
+ def unique_id_for(association_klass)
16
+ model_id_column = "#{@model.to_s.underscore}_id"
17
+
18
+ unless association_klass.column_names.include?(model_id_column)
19
+ raise InvalidAssociationConfigError, "#{model_id_column} does not exist in #{association_klass}"
20
+ end
21
+
22
+ unique_ids = @model.ids - association_klass.pluck(model_id_column)
23
+
24
+ if unique_ids.empty?
25
+ raise InvalidAssociationConfigError, "No unique ids for #{@model}"
26
+ end
27
+
28
+ unique_ids.first
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,87 @@
1
+ module Seedie
2
+ module Model
3
+ class ModelSorter
4
+ def initialize(models)
5
+ @models = models
6
+ @model_dependencies = models.map {|m| [m, get_model_dependencies(m)]}.to_h
7
+ @resolved_queue = []
8
+ @unresolved = []
9
+ end
10
+
11
+ def sort_by_dependency
12
+ add_independent_models_to_queue
13
+
14
+ @models.each do |model|
15
+ resolve_dependencies(model) unless @resolved_queue.include?(model)
16
+ end
17
+
18
+ @resolved_queue
19
+ end
20
+
21
+ private
22
+
23
+
24
+ # Independent models need to be added first
25
+ def add_independent_models_to_queue
26
+ @models.each do |model|
27
+ if @model_dependencies[model].empty?
28
+ @resolved_queue << model
29
+ end
30
+ end
31
+ end
32
+
33
+ def resolve_dependencies(model)
34
+ if @unresolved.include?(model)
35
+ puts "Circular dependency detected for #{model}. Ignoring..."
36
+ return
37
+ end
38
+
39
+ @unresolved << model
40
+ dependencies = @model_dependencies[model]
41
+
42
+ if dependencies
43
+ dependencies.each do |dependency|
44
+ resolve_dependencies(dependency) unless @resolved_queue.include?(dependency)
45
+ end
46
+ end
47
+
48
+ @resolved_queue << model
49
+ @unresolved.delete(model)
50
+ end
51
+
52
+ def get_model_dependencies(model)
53
+ associations = model.reflect_on_all_associations(:belongs_to).reject do |association|
54
+ association.options[:polymorphic] == true || # Excluded Polymorphic Associations
55
+ association.options[:optional] == true # Excluded Optional Associations
56
+ end
57
+
58
+ return [] if associations.blank?
59
+
60
+ associations.map do |association|
61
+ if association.options[:class_name]
62
+ constantize_class_name(association.options[:class_name], model.name)
63
+ else
64
+ association.klass
65
+ end
66
+ end
67
+ end
68
+
69
+ private
70
+
71
+ def constantize_class_name(class_name, model_name)
72
+ namespaced_class_name = if model_name.include?("::")
73
+ "#{model_name.deconstantize}::#{class_name}"
74
+ else
75
+ class_name
76
+ end
77
+ begin
78
+ namespaced_class_name.constantize
79
+ rescue NameError
80
+ # If the class_name with the namespace doesn't exist, try without the namespace
81
+ puts "Class #{namespaced_class_name} not found. Trying without the namespace... #{class_name}"
82
+ class_name.constantize
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,16 @@
1
+ module Seedie
2
+ class ModelFields
3
+ DEFAULT_DISABLED_FIELDS = %w[id created_at updated_at]
4
+
5
+ attr_reader :model_name, :model_config, :fields, :disabled_fields, :foreign_fields
6
+
7
+ def initialize(model, model_config)
8
+ @model_name = model.to_s
9
+ @model_config = model_config
10
+ @custom_fields = model_config["attributes"]&.keys || []
11
+ @disabled_fields = [(model_config["disabled_fields"] || []) + DEFAULT_DISABLED_FIELDS].flatten.uniq
12
+ @foreign_fields = model.reflect_on_all_associations(:belongs_to).map(&:foreign_key)
13
+ @other_fields = model.column_names - @disabled_fields - @custom_fields - @foreign_fields
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,60 @@
1
+ module Seedie
2
+ class ModelSeeder
3
+ include Reporters::Reportable
4
+
5
+ DEFAULT_MODEL_COUNT = 1
6
+
7
+ attr_reader :model, :model_config, :config, :reporters
8
+
9
+ def initialize(model, model_config, config, reporters)
10
+ @model = model
11
+ @model_config = model_config
12
+ @config = config
13
+ @record_creator = Model::Creator.new(model, reporters)
14
+ @reporters = reporters
15
+
16
+ add_observers(@reporters)
17
+ end
18
+
19
+ def generate_records
20
+ report(:model_seed_start, name: "#{model.to_s}")
21
+ model_count(model_config).times do |index|
22
+ record = generate_record(model_config, index)
23
+ associations_config = model_config["associations"]
24
+
25
+ if associations_config.present?
26
+ Associations::HasMany.new(record, model, associations_config, reporters).generate_associations
27
+ Associations::HasOne.new(record, model, associations_config, reporters).generate_associations
28
+ end
29
+ end
30
+ report(:model_seed_finish, name: "#{model.to_s}")
31
+ end
32
+
33
+ private
34
+
35
+ def model_count(model_config)
36
+ return model_config["count"] if model_config["count"].present?
37
+ return config["default_count"] if config["default_count"].present?
38
+
39
+ DEFAULT_MODEL_COUNT
40
+ end
41
+
42
+ def generate_record(model_config, index)
43
+ associated_field_set = generate_belongs_to_associations(model, model_config)
44
+
45
+ field_values_set = FieldValuesSet.new(model, model_config, index).generate_field_values
46
+ field_values_set.merge!(associated_field_set)
47
+ @record_creator.create!(field_values_set)
48
+ end
49
+
50
+ def generate_belongs_to_associations(model, model_config)
51
+ associations_config = model_config["associations"]
52
+ return {} unless associations_config.present?
53
+
54
+ belongs_to_associations = Associations::BelongsTo.new(model, associations_config, reporters)
55
+ belongs_to_associations.generate_associations
56
+
57
+ return belongs_to_associations.associated_field_set
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,10 @@
1
+ require "seedie"
2
+ require "rails"
3
+
4
+ module Seedie
5
+ class Railtie < Rails::Railtie
6
+ rake_tasks do
7
+ load "tasks/seedie.rake"
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,81 @@
1
+ module Reporters
2
+ class BaseReporter
3
+ INDENT_SIZE = 2
4
+
5
+ attr_reader :output, :reports
6
+
7
+ def initialize(output = nil)
8
+ @output = output || StringIO.new
9
+ @reports = []
10
+ @indent_level = 0
11
+ end
12
+
13
+ def update(event_type, options)
14
+ raise NotImplementedError, "Subclasses must define 'update'."
15
+ end
16
+
17
+ def close
18
+ return if output.closed?
19
+ output.flush
20
+ end
21
+
22
+ private
23
+
24
+ def messages(event_type, options)
25
+ case event_type
26
+ when :seed_start
27
+ "############ SEEDIE RUNNING #############"
28
+ when :seed_finish
29
+ "############ SEEDIE FINISHED ############"
30
+ when :model_seed_start
31
+ "Seeding #{options[:name]}"
32
+ when :model_seed_finish
33
+ "Seeding #{options[:name]} finished!"
34
+ when :record_created
35
+ "Created #{options[:name]} with id: #{options[:id]}"
36
+ when :has_many_start
37
+ "Creating HasMany associations:"
38
+ when :belongs_to_start
39
+ "Creating BelongsTo associations:"
40
+ when :has_one_start
41
+ "Creating HasOne associations:"
42
+ when :associated_records
43
+ "Creating #{options[:count]} #{options[:name]} for #{options[:parent_name]}"
44
+ when :random_association
45
+ "Randomly associating #{options[:name]} with id: #{options[:id]} for #{options[:parent_name]}"
46
+ when :unique_association
47
+ "Uniquely associating #{options[:name]} for #{options[:parent_name]}"
48
+ when :belongs_to_associations
49
+ "Creating a new #{options[:name].titleize} for #{options[:parent_name]}"
50
+ else
51
+ "Unknown event type"
52
+ end
53
+ end
54
+
55
+ def indent_level_for(event_type)
56
+ indent_levels = {
57
+ seed_start: 0,
58
+ seed_finish: 0,
59
+ model_seed_start: 1,
60
+ model_seed_finish: 1,
61
+ record_created: 1,
62
+ random_association: 1,
63
+ has_many_start: 2,
64
+ belongs_to_start: 2,
65
+ has_one_start: 2,
66
+ associated_records: 3,
67
+ belongs_to_associations: 3
68
+ }
69
+
70
+ indent_levels[event_type]
71
+ end
72
+
73
+ def set_indent_level(event_type)
74
+ if event_type.in?([:record_created, :random_association, :unique_association])
75
+ @indent_level += 1 if !@reports.last[:event_type].in?([:record_created, :random_association, :unique_association])
76
+ elsif @reports.blank? || @reports.last[:event_type] != event_type
77
+ @indent_level = indent_level_for(event_type)
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,16 @@
1
+ module Reporters
2
+ class ConsoleReporter < BaseReporter
3
+ def initialize
4
+ super($stdout)
5
+ end
6
+
7
+ def update(event_type, options)
8
+ indent_level = set_indent_level(event_type)
9
+ message = messages(event_type, options)
10
+ @reports << { event_type: event_type, message: message }
11
+
12
+ output.print "#{" " * INDENT_SIZE * @indent_level}"
13
+ output.puts message
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,16 @@
1
+ require "observer"
2
+
3
+ module Reporters
4
+ module Reportable
5
+ include Observable
6
+
7
+ def report(event_type, options = {})
8
+ changed
9
+ notify_observers(event_type, options)
10
+ end
11
+
12
+ def add_observers(observers)
13
+ observers.each { |observer| add_observer(observer) }
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,39 @@
1
+ module Seedie
2
+ class Seeder
3
+ include Reporters::Reportable
4
+
5
+ attr_reader :config
6
+
7
+ def initialize(config_path = nil)
8
+ @config_path = config_path
9
+ @config = load_config(config_path)
10
+ @reporters = []
11
+ @console_reporter = Reporters::ConsoleReporter.new
12
+ @reporters << @console_reporter
13
+
14
+ add_observers(@reporters)
15
+ end
16
+
17
+ def seed_models
18
+ report(:seed_start)
19
+ ActiveRecord::Base.transaction do
20
+ config["models"].each do |model_name, model_config|
21
+ model = model_name.classify.constantize
22
+ ModelSeeder.new(model, model_config, config, @reporters).generate_records
23
+ end
24
+ end
25
+ report(:seed_finish)
26
+
27
+ @reporters.each(&:close)
28
+ end
29
+
30
+ private
31
+
32
+ def load_config(path)
33
+ path = Rails.root.join("config", "seedie.yml") if path.nil?
34
+ raise ConfigFileNotFound, "Config file not found in #{path}" unless File.exist?(path)
35
+
36
+ YAML.load_file(path)
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Seedie
4
+ VERSION = "0.1.0"
5
+ end
data/lib/seedie.rb ADDED
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "seedie/reporters/reportable"
4
+ require_relative "seedie/reporters/base_reporter"
5
+ require_relative "seedie/reporters/console_reporter"
6
+
7
+ require_relative "seedie/field_values/fake_value"
8
+ require_relative "seedie/field_values/custom_value"
9
+ require_relative "seedie/field_values/faker_builder"
10
+ require_relative "seedie/field_values_set"
11
+ require_relative "seedie/model_fields"
12
+ require_relative "seedie/model_seeder"
13
+
14
+ require_relative "seedie/model/creator"
15
+ require_relative "seedie/model/model_sorter"
16
+ require_relative "seedie/model/id_generator"
17
+
18
+ require_relative "seedie/associations/base_association"
19
+ require_relative "seedie/associations/has_many"
20
+ require_relative "seedie/associations/has_one"
21
+ require_relative "seedie/associations/belongs_to"
22
+
23
+ require_relative "seedie/seeder"
24
+ require_relative "seedie/version"
25
+
26
+ require "seedie/railtie" if defined?(Rails)
27
+
28
+ require "active_record"
29
+ require "faker"
30
+ require "yaml"
31
+
32
+ module Seedie
33
+ class Error < StandardError; end
34
+ class InvalidFakerMethodError < StandardError; end
35
+ class UnknownColumnTypeError < StandardError; end
36
+ class ConfigFileNotFound < StandardError; end
37
+ class InvalidAssociationConfigError < StandardError; end
38
+ class InvalidCustomFieldKeysError < StandardError; end
39
+ class InvalidCustomFieldValuesError < StandardError; end
40
+ class CustomFieldNotEnoughValuesError < StandardError; end
41
+ class CustomFieldInvalidPickValueError < StandardError; end
42
+ end