seedie 0.1.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.
@@ -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