Class: Synthra::DatabaseSeeder
- Inherits:
-
Object
- Object
- Synthra::DatabaseSeeder
- 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.
Instance Attribute Summary collapse
-
#created_records ⇒ Object
readonly
Returns the value of attribute created_records.
-
#options ⇒ Object
readonly
Returns the value of attribute options.
-
#registry ⇒ Object
readonly
Returns the value of attribute registry.
Instance Method Summary collapse
-
#create(model, count:, mode: :random, **overrides) ⇒ Object
Create records for a model.
-
#create_one(model, mode: :random, **overrides) ⇒ Object
Create a single record and return it.
- #filter_columns(model, data) ⇒ Object private
- #find_or_infer_schema(name, model) ⇒ Object private
- #generate_with_fk_resolution(schema, model, mode, overrides) ⇒ Object private
-
#initialize(registry: nil, **options) ⇒ DatabaseSeeder
constructor
Create a new seeder.
- #insert_in_batches(model, records) ⇒ Object private
-
#random_id(model) ⇒ Object?
Get the ID of a random record.
-
#random_record(model) ⇒ Object?
Get a random record from already created records.
-
#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.
- #resolve_foreign_keys(model) ⇒ Object private
-
#seed { ... } ⇒ Object
Execute seeding with DSL.
- #setup_environment ⇒ Object private
- #summary ⇒ Object private
- #truncate_tables ⇒ Object private
Constructor Details
#initialize(registry: nil, **options) ⇒ DatabaseSeeder
Create a new seeder
34 35 36 37 38 39 40 41 42 43 |
# File 'lib/synthra/database_seeder.rb', line 34 def initialize(registry: nil, **) @registry = registry || Synthra.instance_variable_get(:@default_registry) || Registry.new @options = { truncate: false, disable_callbacks: false, batch_size: 1000 }.merge() @created_records = Hash.new { |h, k| h[k] = [] } @foreign_key_cache = {} end |
Instance Attribute Details
#created_records ⇒ Object (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 |
#options ⇒ Object (readonly)
Returns the value of attribute options.
24 25 26 |
# File 'lib/synthra/database_seeder.rb', line 24 def @options end |
#registry ⇒ Object (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
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
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
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
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 = assoc.klass rescue next # Get a random ID from created records if (id = random_id()) fk_overrides[fk_column] = id end end fk_overrides end |
#seed { ... } ⇒ Object
Execute seeding with DSL
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_environment ⇒ Object (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 |
#summary ⇒ Object (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_tables ⇒ Object (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 |