Class: Synthra::DatabaseSeeder

Inherits:
Object
  • Object
show all
Defined in:
lib/synthra/database_seeder.rb

Overview

Database Seeder with Foreign Key Awareness

Intelligently seeds databases respecting foreign key constraints and relationships between tables.

Examples:

Basic usage

Synthra.seed_database do
  create User, count: 100
  create Order, count: 500      # Links to Users
  create OrderItem, count: 2000 # Links to Orders
end

With options

Synthra.seed_database(truncate: true) do
  create User, count: 50, mode: :edge
end

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(registry: nil, **options) ⇒ DatabaseSeeder

Create a new seeder

Parameters:

  • registry (Registry) (defaults to: nil)

    schema registry

  • options (Hash)

    seeder options

Options Hash (**options):

  • :truncate (Boolean)

    Truncate tables before seeding

  • :disable_callbacks (Boolean)

    Disable ActiveRecord callbacks

  • :batch_size (Integer)

    Batch insert size (default: 1000)



34
35
36
37
38
39
40
41
42
43
# File 'lib/synthra/database_seeder.rb', line 34

def initialize(registry: nil, **options)
  @registry = registry || Synthra.instance_variable_get(:@default_registry) || Registry.new
  @options = {
    truncate: false,
    disable_callbacks: false,
    batch_size: 1000
  }.merge(options)
  @created_records = Hash.new { |h, k| h[k] = [] }
  @foreign_key_cache = {}
end

Instance Attribute Details

#created_recordsObject (readonly)

Returns the value of attribute created_records.



24
25
26
# File 'lib/synthra/database_seeder.rb', line 24

def created_records
  @created_records
end

#optionsObject (readonly)

Returns the value of attribute options.



24
25
26
# File 'lib/synthra/database_seeder.rb', line 24

def options
  @options
end

#registryObject (readonly)

Returns the value of attribute registry.



24
25
26
# File 'lib/synthra/database_seeder.rb', line 24

def registry
  @registry
end

Instance Method Details

#create(model, count:, mode: :random, **overrides) ⇒ Object

Create records for a model

Parameters:

  • model (Class)

    ActiveRecord model class

  • count (Integer)

    number of records

  • mode (Symbol) (defaults to: :random)

    generation mode

  • overrides (Hash)

    field overrides



64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
# File 'lib/synthra/database_seeder.rb', line 64

def create(model, count:, mode: :random, **overrides)
  schema_name = model.respond_to?(:name) ? model.name : model.to_s
  schema = find_or_infer_schema(schema_name, model)

  puts "Creating #{count} #{schema_name} records..."

  records = []
  count.times do
    data = generate_with_fk_resolution(schema, model, mode, overrides)
    records << data
  end

  # Batch insert for performance — also the path for disable_callbacks: insert_all skips AR
  # callbacks at the DB layer per-model, so we never mutate the global ActiveRecord::Base chain.
  if model.respond_to?(:insert_all) && (@options[:batch_size] > 1 || @options[:disable_callbacks])
    insert_in_batches(model, records)
  else
    records.each do |data|
      record = model.create!(data)
      @created_records[schema_name] << record
    end
  end

  puts "  ✓ Created #{count} #{schema_name} records"
end

#create_one(model, mode: :random, **overrides) ⇒ Object

Create a single record and return it

Parameters:

  • model (Class)

    ActiveRecord model

  • mode (Symbol) (defaults to: :random)

    generation mode

  • overrides (Hash)

    field overrides

Returns:

  • (Object)

    created record



97
98
99
100
101
102
103
104
105
106
# File 'lib/synthra/database_seeder.rb', line 97

def create_one(model, mode: :random, **overrides)
  schema_name = model.name
  schema = find_or_infer_schema(schema_name, model)
  
  data = generate_with_fk_resolution(schema, model, mode, overrides)
  record = model.create!(data)
  @created_records[schema_name] << record
  
  record
end

#filter_columns(model, data) ⇒ Object (private)



218
219
220
221
222
# File 'lib/synthra/database_seeder.rb', line 218

def filter_columns(model, data)
  return data unless model.respond_to?(:column_names)
  
  data.select { |k, _| model.column_names.include?(k.to_s) }
end

#find_or_infer_schema(name, model) ⇒ Object (private)



178
179
180
181
182
183
184
185
186
187
# File 'lib/synthra/database_seeder.rb', line 178

def find_or_infer_schema(name, model)
  if @registry.schema?(name)
    @registry.schema(name)
  elsif model.respond_to?(:columns)
    dsl = SchemaInference.from_active_record(model)
    Synthra.parse(dsl)
  else
    raise ArgumentError, "No schema found for #{name} and model doesn't support inference"
  end
end

#generate_with_fk_resolution(schema, model, mode, overrides) ⇒ Object (private)



189
190
191
192
193
194
195
196
197
198
# File 'lib/synthra/database_seeder.rb', line 189

def generate_with_fk_resolution(schema, model, mode, overrides)
  # Detect foreign keys
  fk_overrides = resolve_foreign_keys(model)
  
  # Generate data
  data = schema.generate(mode: mode, overrides: overrides.merge(fk_overrides))
  
  # Filter to only include valid columns
  filter_columns(model, data)
end

#insert_in_batches(model, records) ⇒ Object (private)



224
225
226
227
228
229
230
231
232
233
234
235
236
# File 'lib/synthra/database_seeder.rb', line 224

def insert_in_batches(model, records)
  batch_size = @options[:batch_size]
  
  records.each_slice(batch_size) do |batch|
    model.insert_all(batch)
  end
  
  # Store records for FK resolution (simplified - just IDs)
  schema_name = model.name
  records.each do |data|
    @created_records[schema_name] << OpenStruct.new(data)
  end
end

#random_id(model) ⇒ Object?

Get the ID of a random record

Parameters:

  • model (Class)

    model class

Returns:

  • (Object, nil)

    random ID or nil



123
124
125
126
# File 'lib/synthra/database_seeder.rb', line 123

def random_id(model)
  record = random_record(model)
  record&.id
end

#random_record(model) ⇒ Object?

Get a random record from already created records

Parameters:

  • model (Class)

    model class

Returns:

  • (Object, nil)

    random record or nil



113
114
115
116
# File 'lib/synthra/database_seeder.rb', line 113

def random_record(model)
  records = @created_records[model.name]
  records.sample if records.any?
end

#refuse_in_production!Object (private)

SECURITY: the seeder truncates tables and (with disable_callbacks) flips ActiveRecord callbacks process-wide — catastrophic in production, especially multi-tenant where suppressed before_create callbacks (tenant scoping, audit) affect every concurrent request. Refuse to run in the production Rails env unless an operator explicitly overrides. Fail closed.



135
136
137
138
139
140
141
142
# File 'lib/synthra/database_seeder.rb', line 135

def refuse_in_production!
  return unless defined?(Rails) && Rails.respond_to?(:env) && Rails.env.production?
  return if ENV["ALLOW_PRODUCTION_SEED"] == "true"

  raise Synthra::Errors::SecurityError,
        "DatabaseSeeder will not run in production: it truncates tables and disables " \
        "ActiveRecord callbacks process-wide. Set ALLOW_PRODUCTION_SEED=true to override."
end

#resolve_foreign_keys(model) ⇒ Object (private)



200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
# File 'lib/synthra/database_seeder.rb', line 200

def resolve_foreign_keys(model)
  return {} unless model.respond_to?(:reflect_on_all_associations)
  
  fk_overrides = {}
  
  model.reflect_on_all_associations(:belongs_to).each do |assoc|
    fk_column = assoc.foreign_key
    related_model = assoc.klass rescue next
    
    # Get a random ID from created records
    if (id = random_id(related_model))
      fk_overrides[fk_column] = id
    end
  end
  
  fk_overrides
end

#seed { ... } ⇒ Object

Execute seeding with DSL

Yields:

  • seeding DSL block



49
50
51
52
53
54
55
# File 'lib/synthra/database_seeder.rb', line 49

def seed(&block)
  refuse_in_production!
  setup_environment
  instance_eval(&block)

  summary
end

#setup_environmentObject (private)



144
145
146
147
148
149
# File 'lib/synthra/database_seeder.rb', line 144

def setup_environment
  truncate_tables if @options[:truncate] && defined?(ActiveRecord)
  # disable_callbacks is now honoured per-model via insert_all in #create (skips callbacks at the
  # DB layer). We no longer call ActiveRecord::Base.skip_callback: mutating the global callback
  # chain suppresses callbacks (tenant scoping, audit, webhooks) for every concurrent request.
end

#summaryObject (private)



238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
# File 'lib/synthra/database_seeder.rb', line 238

def summary
  total = @created_records.values.flatten.count
  
  puts "\n" + "=" * 50
  puts "Seeding Complete!"
  puts "=" * 50
  
  @created_records.each do |model, records|
    puts "  #{model}: #{records.count} records"
  end
  
  puts "-" * 50
  puts "  Total: #{total} records"
  
  @created_records
end

#truncate_tablesObject (private)



151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
# File 'lib/synthra/database_seeder.rb', line 151

def truncate_tables
  return unless defined?(ActiveRecord)

  refuse_in_production! # defense in depth — never TRUNCATE prod tables

  # Disable foreign key checks temporarily
  connection = ActiveRecord::Base.connection
  
  case connection.adapter_name
  when "PostgreSQL"
    connection.execute("SET CONSTRAINTS ALL DEFERRED")
  when "MySQL", "Mysql2"
    connection.execute("SET FOREIGN_KEY_CHECKS = 0")
  end

  @created_records.keys.each do |table|
    connection.truncate(table.tableize) rescue nil
  end

  case connection.adapter_name
  when "PostgreSQL"
    connection.execute("SET CONSTRAINTS ALL IMMEDIATE")
  when "MySQL", "Mysql2"
    connection.execute("SET FOREIGN_KEY_CHECKS = 1")
  end
end