tina4ruby 3.13.37 → 3.13.38
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/lib/tina4/auth.rb +118 -7
- data/lib/tina4/cli.rb +106 -2
- data/lib/tina4/database.rb +356 -46
- data/lib/tina4/dev_admin.rb +27 -10
- data/lib/tina4/drivers/sqlite_driver.rb +23 -0
- data/lib/tina4/env.rb +40 -4
- data/lib/tina4/events.rb +54 -8
- data/lib/tina4/graphql.rb +68 -12
- data/lib/tina4/html_element.rb +55 -7
- data/lib/tina4/mcp.rb +10 -3
- data/lib/tina4/messenger.rb +130 -25
- data/lib/tina4/metrics.rb +238 -47
- data/lib/tina4/middleware.rb +136 -13
- data/lib/tina4/migration.rb +6 -4
- data/lib/tina4/orm.rb +13 -10
- data/lib/tina4/rack_app.rb +17 -10
- data/lib/tina4/response.rb +31 -11
- data/lib/tina4/seeder.rb +433 -84
- data/lib/tina4/session.rb +94 -17
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/websocket.rb +354 -18
- data/lib/tina4/wsdl.rb +25 -2
- data/lib/tina4.rb +11 -9
- metadata +6 -47
data/lib/tina4/response.rb
CHANGED
|
@@ -347,18 +347,38 @@ module Tina4
|
|
|
347
347
|
gen = @_stream_generator
|
|
348
348
|
blk = @_stream_block
|
|
349
349
|
body = Enumerator.new do |yielder|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
350
|
+
# SSE hardening: a streaming source that raises mid-stream (a
|
|
351
|
+
# generator/block error, or the client disconnecting and the server
|
|
352
|
+
# tearing the body down) must NEVER crash the worker. We catch the
|
|
353
|
+
# error, log it, and end the stream cleanly — the chunks emitted
|
|
354
|
+
# before the failure are still delivered.
|
|
355
|
+
#
|
|
356
|
+
# A client disconnect surfaces in a hijack/Puma streaming body as a
|
|
357
|
+
# write-side IOError/Errno on the socket; that is propagated up as a
|
|
358
|
+
# normal stop and re-raised so Rack/Puma can close the connection,
|
|
359
|
+
# while a *source* error is swallowed after logging.
|
|
360
|
+
begin
|
|
361
|
+
if gen
|
|
362
|
+
if gen.respond_to?(:each)
|
|
363
|
+
# Enumerator / array / any Enumerable of string chunks
|
|
364
|
+
gen.each { |chunk| yielder << chunk }
|
|
365
|
+
elsif gen.respond_to?(:call)
|
|
366
|
+
# Callable that receives the yielder, like the block form
|
|
367
|
+
gen.call(yielder)
|
|
368
|
+
else
|
|
369
|
+
yielder << gen.to_s
|
|
370
|
+
end
|
|
371
|
+
elsif blk
|
|
372
|
+
blk.call(yielder)
|
|
359
373
|
end
|
|
360
|
-
|
|
361
|
-
|
|
374
|
+
rescue IOError, Errno::EPIPE, Errno::ECONNRESET => e
|
|
375
|
+
# Client disconnected mid-stream — stop cleanly, do not crash, and
|
|
376
|
+
# do not log loudly (a normal browser closing an SSE stream).
|
|
377
|
+
Tina4::Log.debug("SSE/stream client disconnected: #{e.class}: #{e.message}") if defined?(Tina4::Log)
|
|
378
|
+
rescue StandardError => e
|
|
379
|
+
# The source (generator/block) itself raised mid-stream. Log it and
|
|
380
|
+
# end the stream cleanly rather than crashing the handler/worker.
|
|
381
|
+
Tina4::Log.error("SSE/stream source error: #{e.class}: #{e.message}") if defined?(Tina4::Log)
|
|
362
382
|
end
|
|
363
383
|
end
|
|
364
384
|
return [@status_code, final_headers, body]
|
data/lib/tina4/seeder.rb
CHANGED
|
@@ -349,181 +349,530 @@ module Tina4
|
|
|
349
349
|
end
|
|
350
350
|
end
|
|
351
351
|
|
|
352
|
+
# Result of a seed run — +{seeded, failed, errors}+.
|
|
353
|
+
#
|
|
354
|
+
# Mirrors the Python master's +SeedSummary(int)+. Ruby has no int subclass,
|
|
355
|
+
# but the OLD seed helpers returned the inserted count as an Integer and
|
|
356
|
+
# specs assert on it (+expect(count).to eq(5)+). To keep that contract
|
|
357
|
+
# intact while exposing the new struct, +SeedSummary+ defines +to_i+, +==+
|
|
358
|
+
# (against an Integer or another SeedSummary), +to_int+ (implicit coercion)
|
|
359
|
+
# and Hash-style read access (+summary[:seeded]+, +to_h+) so it behaves like
|
|
360
|
+
# the seeded count where an Integer is expected and like the struct
|
|
361
|
+
# everywhere else.
|
|
362
|
+
#
|
|
363
|
+
# +errors+ is a list of +{ row: <0-based index>, message: <str> }+ hashes
|
|
364
|
+
# describing every skipped row.
|
|
365
|
+
class SeedSummary
|
|
366
|
+
attr_reader :seeded, :failed, :errors
|
|
367
|
+
|
|
368
|
+
def initialize(seeded: 0, failed: 0, errors: nil)
|
|
369
|
+
@seeded = seeded.to_i
|
|
370
|
+
@failed = failed.to_i
|
|
371
|
+
@errors = errors || []
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# Integer value == seeded (preserves the pre-overhaul count contract).
|
|
375
|
+
def to_i
|
|
376
|
+
@seeded
|
|
377
|
+
end
|
|
378
|
+
alias to_int to_i
|
|
379
|
+
|
|
380
|
+
def to_h
|
|
381
|
+
{ seeded: @seeded, failed: @failed, errors: @errors }
|
|
382
|
+
end
|
|
383
|
+
alias to_hash to_h
|
|
384
|
+
|
|
385
|
+
# Hash-style read access: summary[:seeded] / summary["failed"].
|
|
386
|
+
def [](key)
|
|
387
|
+
to_h[key.to_sym]
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# Compare equal to a bare Integer (the seeded count) OR another summary.
|
|
391
|
+
def ==(other)
|
|
392
|
+
case other
|
|
393
|
+
when Integer then @seeded == other
|
|
394
|
+
when SeedSummary then to_h == other.to_h
|
|
395
|
+
when Hash then to_h == other
|
|
396
|
+
else false
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def eql?(other)
|
|
401
|
+
other.is_a?(SeedSummary) && to_h == other.to_h
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def hash
|
|
405
|
+
to_h.hash
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# Arithmetic / ordering against Integers so existing numeric assertions
|
|
409
|
+
# (e.g. +be >= 1+, +count + 1+) keep working.
|
|
410
|
+
def coerce(other)
|
|
411
|
+
[other, @seeded]
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def <=>(other)
|
|
415
|
+
@seeded <=> (other.is_a?(SeedSummary) ? other.to_i : other)
|
|
416
|
+
end
|
|
417
|
+
include Comparable
|
|
418
|
+
|
|
419
|
+
def to_json(*args)
|
|
420
|
+
to_h.to_json(*args)
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def to_s
|
|
424
|
+
"SeedSummary(seeded=#{@seeded}, failed=#{@failed}, errors=#{@errors.inspect})"
|
|
425
|
+
end
|
|
426
|
+
alias inspect to_s
|
|
427
|
+
end
|
|
428
|
+
|
|
352
429
|
# Seed an ORM class with auto-generated fake data.
|
|
353
430
|
#
|
|
431
|
+
# Visible-but-resilient: each row is wrapped. On a row failure the cause is
|
|
432
|
+
# logged (with the row index) and the row is skipped — unless +strict: true+,
|
|
433
|
+
# in which case the FIRST failure RE-RAISES. A one-line summary is logged at
|
|
434
|
+
# the end. This replaces both the old crash-prone path and the silent swallow.
|
|
435
|
+
#
|
|
354
436
|
# @param orm_class [Class] ORM subclass (e.g., User, Product)
|
|
355
437
|
# @param count [Integer] number of records to insert
|
|
356
438
|
# @param overrides [Hash] field overrides — static values or lambdas receiving FakeData
|
|
357
|
-
# @param clear [Boolean] delete existing records before seeding
|
|
358
|
-
# @param seed [Integer, nil] random seed for reproducible data
|
|
359
|
-
# @
|
|
439
|
+
# @param clear [Boolean] delete existing records before seeding (P2)
|
|
440
|
+
# @param seed [Integer, nil] random seed for reproducible data (P3)
|
|
441
|
+
# @param strict [Boolean] re-raise on the first failed row instead of skipping (P1)
|
|
442
|
+
# @return [SeedSummary] +{seeded, failed, errors}+ — also usable as the int count
|
|
360
443
|
#
|
|
361
444
|
# @example
|
|
362
445
|
# Tina4.seed_orm(User, count: 50)
|
|
363
446
|
# Tina4.seed_orm(Order, count: 200, overrides: { status: ->(f) { f.choice(%w[pending shipped]) } })
|
|
364
|
-
def self.seed_orm(orm_class, count: 10, overrides: {}, clear: false, seed: nil)
|
|
447
|
+
def self.seed_orm(orm_class, count: 10, overrides: {}, clear: false, seed: nil, strict: false)
|
|
365
448
|
fake = FakeData.new(seed: seed)
|
|
366
449
|
fields = orm_class.field_definitions
|
|
367
450
|
table = orm_class.table_name
|
|
368
451
|
|
|
369
452
|
if fields.empty?
|
|
370
453
|
Tina4::Log.error("Seeder: No fields found on #{orm_class.name}")
|
|
371
|
-
return
|
|
454
|
+
return SeedSummary.new
|
|
372
455
|
end
|
|
373
456
|
|
|
374
457
|
db = Tina4.database
|
|
375
458
|
unless db
|
|
376
459
|
Tina4::Log.error("Seeder: No database connection. Call Tina4.bind_database(db) first.")
|
|
377
|
-
return
|
|
460
|
+
return SeedSummary.new
|
|
378
461
|
end
|
|
379
462
|
|
|
380
|
-
# Idempotency
|
|
463
|
+
# Idempotency short-circuit (Ruby-specific, additive to the Python master):
|
|
464
|
+
# without an explicit clear, skip when the table already has >= count rows.
|
|
381
465
|
unless clear
|
|
382
466
|
begin
|
|
383
467
|
result = db.fetch_one("SELECT count(*) as cnt FROM #{table}")
|
|
384
468
|
if result && result[:cnt].to_i >= count
|
|
385
469
|
Tina4::Log.info("Seeder: #{table} already has #{result[:cnt]} records, skipping")
|
|
386
|
-
return
|
|
470
|
+
return SeedSummary.new
|
|
387
471
|
end
|
|
388
472
|
rescue => e
|
|
389
|
-
# Table might not exist
|
|
473
|
+
# Table might not exist — fall through and let row inserts surface it.
|
|
390
474
|
end
|
|
391
475
|
end
|
|
392
476
|
|
|
393
|
-
|
|
394
|
-
if clear
|
|
395
|
-
begin
|
|
396
|
-
db.execute("DELETE FROM #{table}")
|
|
397
|
-
Tina4::Log.info("Seeder: Cleared #{table}")
|
|
398
|
-
rescue => e
|
|
399
|
-
Tina4::Log.warn("Seeder: Could not clear #{table}: #{e.message}")
|
|
400
|
-
end
|
|
401
|
-
end
|
|
477
|
+
_clear_orm(orm_class) if clear
|
|
402
478
|
|
|
403
|
-
# Identify fields to populate
|
|
404
|
-
pk_field = orm_class.primary_key_field
|
|
405
479
|
insert_fields = fields.reject { |name, opts| opts[:primary_key] && opts[:auto_increment] }
|
|
406
480
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
481
|
+
# P4a — resolve FK columns to REAL parent PKs so a child row references an
|
|
482
|
+
# existing parent. Snapshotted once (parents are seeded first by
|
|
483
|
+
# seed_models's topo-sort, so the table is populated by now).
|
|
484
|
+
fk_pools = _foreign_key_pools(orm_class, insert_fields)
|
|
410
485
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
attrs[name] = val.respond_to?(:call) ? val.call(fake) : val
|
|
415
|
-
else
|
|
416
|
-
generated = fake.for_field(field_def, name)
|
|
417
|
-
attrs[name] = generated unless generated.nil?
|
|
418
|
-
end
|
|
419
|
-
end
|
|
486
|
+
seeded = 0
|
|
487
|
+
failed = 0
|
|
488
|
+
errors = []
|
|
420
489
|
|
|
490
|
+
count.times do |i|
|
|
421
491
|
begin
|
|
492
|
+
attrs = {}
|
|
493
|
+
insert_fields.each do |name, field_def|
|
|
494
|
+
if overrides.key?(name)
|
|
495
|
+
val = overrides[name]
|
|
496
|
+
attrs[name] = val.respond_to?(:call) ? val.call(fake) : val
|
|
497
|
+
elsif fk_pools[name] && !fk_pools[name].empty?
|
|
498
|
+
attrs[name] = fake.choice(fk_pools[name])
|
|
499
|
+
else
|
|
500
|
+
generated = fake.for_field(field_def, name)
|
|
501
|
+
attrs[name] = generated unless generated.nil?
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
_validate_types(fields, attrs, orm_class.name)
|
|
506
|
+
|
|
422
507
|
obj = orm_class.new(attrs)
|
|
508
|
+
# ORM#save returns false (it rolls back internally) instead of raising
|
|
509
|
+
# on a constraint failure — convert that falsy result into a counted
|
|
510
|
+
# failure so it is never reported as success.
|
|
423
511
|
if obj.save
|
|
424
|
-
|
|
512
|
+
seeded += 1
|
|
425
513
|
else
|
|
426
|
-
|
|
514
|
+
reason = obj.errors.empty? ? "save returned false" : obj.errors.join(", ")
|
|
515
|
+
raise "save failed: #{reason}"
|
|
427
516
|
end
|
|
428
517
|
rescue => e
|
|
429
|
-
|
|
518
|
+
if strict
|
|
519
|
+
Tina4::Log.error("Seeder: row #{i} failed seeding #{orm_class.name} (strict): #{e.message}")
|
|
520
|
+
raise
|
|
521
|
+
end
|
|
522
|
+
failed += 1
|
|
523
|
+
errors << { row: i, message: e.message }
|
|
524
|
+
Tina4::Log.warning("Seeder: row #{i} failed seeding #{orm_class.name}, skipped: #{e.message}")
|
|
430
525
|
end
|
|
431
526
|
end
|
|
432
527
|
|
|
433
|
-
Tina4::Log.info("Seeder:
|
|
434
|
-
|
|
528
|
+
Tina4::Log.info("Seeder: #{orm_class.name} — seeded #{seeded}, #{failed} failed")
|
|
529
|
+
SeedSummary.new(seeded: seeded, failed: failed, errors: errors)
|
|
435
530
|
end
|
|
436
531
|
|
|
437
532
|
# Seed a raw database table (no ORM class needed).
|
|
438
533
|
#
|
|
534
|
+
# Visible-but-resilient: each row is wrapped. On a row failure the cause is
|
|
535
|
+
# logged (with the row index) and the row is skipped — unless +strict: true+,
|
|
536
|
+
# in which case the FIRST failure RE-RAISES. A one-line summary is logged at
|
|
537
|
+
# the end.
|
|
538
|
+
#
|
|
439
539
|
# @param table_name [String] name of the table
|
|
440
|
-
# @param columns [Hash] { column_name
|
|
540
|
+
# @param columns [Hash, Array] +{ column_name => type_string }+ OR an array of
|
|
541
|
+
# column descriptor hashes (+{ name:, type: }+) as returned by +db.columns+.
|
|
542
|
+
# Values may also be callables (or FakeData-receiving lambdas) — parity with
|
|
543
|
+
# the Python +field_map+.
|
|
441
544
|
# @param count [Integer] number of records to insert
|
|
442
|
-
# @param overrides [Hash]
|
|
443
|
-
# @param clear [Boolean] delete before seeding
|
|
444
|
-
# @param seed [Integer, nil] random seed
|
|
445
|
-
#
|
|
446
|
-
|
|
545
|
+
# @param overrides [Hash] static values (or callables) set on every row
|
|
546
|
+
# @param clear [Boolean] delete every existing row before seeding (P2)
|
|
547
|
+
# @param seed [Integer, nil] random seed — seeds the FakeData RNG used for any
|
|
548
|
+
# generator that is not an explicit callable (P3 / signature parity)
|
|
549
|
+
# @param strict [Boolean] re-raise on the first failed row instead of skipping (P1)
|
|
550
|
+
# @return [SeedSummary] +{seeded, failed, errors}+ — also usable as the int count
|
|
551
|
+
def self.seed_table(table_name, columns, count: 10, overrides: {}, clear: false, seed: nil, strict: false)
|
|
447
552
|
fake = FakeData.new(seed: seed)
|
|
448
553
|
db = Tina4.database
|
|
449
554
|
|
|
450
555
|
unless db
|
|
451
556
|
Tina4::Log.error("Seeder: No database connection.")
|
|
452
|
-
return
|
|
557
|
+
return SeedSummary.new
|
|
453
558
|
end
|
|
454
559
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
560
|
+
field_map = _normalize_columns(columns)
|
|
561
|
+
|
|
562
|
+
_clear_table(db, table_name) if clear
|
|
563
|
+
|
|
564
|
+
seeded = 0
|
|
565
|
+
failed = 0
|
|
566
|
+
errors = []
|
|
462
567
|
|
|
463
|
-
inserted = 0
|
|
464
568
|
count.times do |i|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
569
|
+
begin
|
|
570
|
+
row = {}
|
|
571
|
+
field_map.each do |col_name, type_str|
|
|
572
|
+
if overrides.key?(col_name)
|
|
573
|
+
val = overrides[col_name]
|
|
574
|
+
row[col_name] = val.respond_to?(:call) ? val.call(fake) : val
|
|
575
|
+
elsif type_str.respond_to?(:call)
|
|
576
|
+
# field_map value is itself a generator (Python field_map parity).
|
|
577
|
+
row[col_name] = type_str.arity.zero? ? type_str.call : type_str.call(fake)
|
|
578
|
+
else
|
|
579
|
+
field_def = { type: type_str.to_sym }
|
|
580
|
+
row[col_name] = fake.for_field(field_def, col_name)
|
|
581
|
+
end
|
|
473
582
|
end
|
|
474
|
-
end
|
|
475
583
|
|
|
476
|
-
begin
|
|
477
584
|
db.insert(table_name, row)
|
|
478
|
-
|
|
585
|
+
seeded += 1
|
|
479
586
|
rescue => e
|
|
480
|
-
|
|
587
|
+
if strict
|
|
588
|
+
Tina4::Log.error("Seeder: row #{i} failed seeding '#{table_name}' (strict): #{e.message}")
|
|
589
|
+
raise
|
|
590
|
+
end
|
|
591
|
+
failed += 1
|
|
592
|
+
errors << { row: i, message: e.message }
|
|
593
|
+
Tina4::Log.warning("Seeder: row #{i} failed seeding '#{table_name}', skipped: #{e.message}")
|
|
481
594
|
end
|
|
482
595
|
end
|
|
483
596
|
|
|
484
|
-
|
|
485
|
-
|
|
597
|
+
begin
|
|
598
|
+
db.commit
|
|
599
|
+
rescue StandardError
|
|
600
|
+
# Autocommit-on engines / pooled standalone writes may not need an
|
|
601
|
+
# explicit commit; never let the summary itself crash.
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
Tina4::Log.info("Seeder: '#{table_name}' — seeded #{seeded}, #{failed} failed")
|
|
605
|
+
SeedSummary.new(seeded: seeded, failed: failed, errors: errors)
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
# Batch-seed several ORM models, ordering by their ForeignKeyField dependency
|
|
609
|
+
# graph (P4a). Parent tables seed before children (topological sort over the
|
|
610
|
+
# ORM's belongs_to/has_many FK metadata); when +clear: true+ the clear runs in
|
|
611
|
+
# the REVERSE order so children are removed before parents — no FK violations
|
|
612
|
+
# regardless of the order the caller lists the models in.
|
|
613
|
+
#
|
|
614
|
+
# @param orm_classes [Array<Class>] ORM subclasses to seed
|
|
615
|
+
# @param count [Integer] rows per model
|
|
616
|
+
# @param overrides [Hash] per-model overrides as +{ ModelClass => { field: value } }+
|
|
617
|
+
# or a single flat hash applied to every model
|
|
618
|
+
# @param clear [Boolean] clear each table first (reverse-topo order)
|
|
619
|
+
# @param seed [Integer, nil] PRNG seed (P3) — applied per model
|
|
620
|
+
# @param strict [Boolean] re-raise on the first failed row
|
|
621
|
+
# @return [Hash] +{ "ModelName" => SeedSummary }+ for each model seeded
|
|
622
|
+
def self.seed_models(orm_classes, count: 10, overrides: {}, clear: false, seed: nil, strict: false)
|
|
623
|
+
ordered = _topo_sort_models(orm_classes)
|
|
624
|
+
|
|
625
|
+
if clear
|
|
626
|
+
ordered.reverse_each { |model| _clear_orm(model) }
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
results = {}
|
|
630
|
+
ordered.each do |model|
|
|
631
|
+
model_overrides = overrides
|
|
632
|
+
if overrides.is_a?(Hash) && overrides.key?(model)
|
|
633
|
+
model_overrides = overrides[model]
|
|
634
|
+
end
|
|
635
|
+
results[model.name] = seed_orm(
|
|
636
|
+
model, count: count, overrides: model_overrides || {},
|
|
637
|
+
clear: false, seed: seed, strict: strict
|
|
638
|
+
)
|
|
639
|
+
end
|
|
640
|
+
results
|
|
486
641
|
end
|
|
487
642
|
|
|
488
|
-
# Seed multiple ORM classes in batch with
|
|
643
|
+
# Seed multiple ORM classes in batch with dependency-aware ordering.
|
|
644
|
+
#
|
|
645
|
+
# Backwards-compatible task form (+[{ orm_class:, count:, overrides:, seed: }]+).
|
|
646
|
+
# The tasks are reordered by the FK dependency graph so parents seed before
|
|
647
|
+
# children; +clear: true+ clears in reverse-topo order. Strict mode re-raises
|
|
648
|
+
# on the first failed row of any task.
|
|
489
649
|
#
|
|
490
650
|
# @param tasks [Array<Hash>] each hash has :orm_class, :count, :overrides, :seed
|
|
491
|
-
# @param clear [Boolean] delete existing records (
|
|
492
|
-
# @
|
|
651
|
+
# @param clear [Boolean] delete existing records (reverse-topo order) before seeding
|
|
652
|
+
# @param strict [Boolean] re-raise on the first failed row
|
|
653
|
+
# @return [Hash] +{ "ClassName" => SeedSummary }+
|
|
493
654
|
#
|
|
494
655
|
# @example
|
|
495
656
|
# Tina4.seed_batch([
|
|
496
657
|
# { orm_class: User, count: 20 },
|
|
497
658
|
# { orm_class: Order, count: 100, overrides: { status: "pending" } }
|
|
498
659
|
# ], clear: true)
|
|
499
|
-
def self.seed_batch(tasks, clear: false)
|
|
500
|
-
|
|
660
|
+
def self.seed_batch(tasks, clear: false, strict: false)
|
|
661
|
+
by_class = {}
|
|
662
|
+
tasks.each { |t| by_class[t[:orm_class]] = t }
|
|
663
|
+
ordered_classes = _topo_sort_models(tasks.map { |t| t[:orm_class] })
|
|
501
664
|
|
|
502
665
|
if clear
|
|
503
|
-
|
|
504
|
-
begin
|
|
505
|
-
Tina4.database&.execute("DELETE FROM #{task[:orm_class].table_name}")
|
|
506
|
-
Tina4::Log.info("Seeder: Cleared #{task[:orm_class].table_name}")
|
|
507
|
-
rescue => e
|
|
508
|
-
Tina4::Log.warn("Seeder: Could not clear #{task[:orm_class].table_name}: #{e.message}")
|
|
509
|
-
end
|
|
510
|
-
end
|
|
666
|
+
ordered_classes.reverse_each { |orm_class| _clear_orm(orm_class) }
|
|
511
667
|
end
|
|
512
668
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
669
|
+
results = {}
|
|
670
|
+
ordered_classes.each do |orm_class|
|
|
671
|
+
task = by_class[orm_class]
|
|
672
|
+
results[orm_class.name] = seed_orm(
|
|
673
|
+
orm_class,
|
|
516
674
|
count: task[:count] || 10,
|
|
517
675
|
overrides: task[:overrides] || {},
|
|
518
676
|
clear: false,
|
|
519
|
-
seed: task[:seed]
|
|
677
|
+
seed: task[:seed],
|
|
678
|
+
strict: strict
|
|
520
679
|
)
|
|
521
|
-
results[task[:orm_class].name] = n
|
|
522
680
|
end
|
|
523
681
|
|
|
524
682
|
results
|
|
525
683
|
end
|
|
526
684
|
|
|
685
|
+
# --- internal helpers (P2/P4a/P4c) --------------------------------------
|
|
686
|
+
|
|
687
|
+
# Normalise the +columns+ argument of +seed_table+ into a uniform
|
|
688
|
+
# +{ column_name => type_or_callable }+ hash. Accepts a plain hash (the
|
|
689
|
+
# documented form) OR an array of column-descriptor hashes
|
|
690
|
+
# (+{ name:, type:, primary_key:, ... }+) as returned by +db.columns+,
|
|
691
|
+
# skipping auto-increment / id primary keys so they are left to the engine.
|
|
692
|
+
def self._normalize_columns(columns)
|
|
693
|
+
return columns if columns.is_a?(Hash)
|
|
694
|
+
|
|
695
|
+
map = {}
|
|
696
|
+
Array(columns).each do |col|
|
|
697
|
+
next unless col.is_a?(Hash)
|
|
698
|
+
|
|
699
|
+
name = col[:name] || col["name"]
|
|
700
|
+
next if name.nil?
|
|
701
|
+
|
|
702
|
+
pk = col[:primary_key] || col["primary_key"]
|
|
703
|
+
lname = name.to_s.downcase
|
|
704
|
+
# Skip primary-key id columns — the engine assigns them.
|
|
705
|
+
next if pk && lname == "id"
|
|
706
|
+
|
|
707
|
+
type = col[:type] || col["type"] || "string"
|
|
708
|
+
map[name.to_sym] = _normalize_sql_type(type)
|
|
709
|
+
end
|
|
710
|
+
map
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
# Map a raw SQL/driver type string to a FakeData field type symbol.
|
|
714
|
+
def self._normalize_sql_type(type)
|
|
715
|
+
t = type.to_s.downcase
|
|
716
|
+
return :integer if t =~ /int|serial/
|
|
717
|
+
return :float if t =~ /real|float|double|numeric|decimal|money/
|
|
718
|
+
return :boolean if t =~ /bool|bit/
|
|
719
|
+
return :datetime if t =~ /datetime|timestamp/
|
|
720
|
+
return :date if t == "date"
|
|
721
|
+
return :blob if t =~ /blob|binary/
|
|
722
|
+
return :text if t =~ /text|clob/
|
|
723
|
+
:string
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
# Delete every row in +table+. Tolerant — logs and continues on error.
|
|
727
|
+
def self._clear_table(db, table)
|
|
728
|
+
db.delete(table, "1=1")
|
|
729
|
+
Tina4::Log.info("Seeder: Cleared #{table}")
|
|
730
|
+
rescue => e
|
|
731
|
+
Tina4::Log.warning("Seeder: could not clear '#{table}': #{e.message}")
|
|
732
|
+
end
|
|
733
|
+
|
|
734
|
+
# Delete every row backing an ORM model. Tolerant — logs and continues.
|
|
735
|
+
def self._clear_orm(orm_class)
|
|
736
|
+
db = orm_class.get_db
|
|
737
|
+
return unless db
|
|
738
|
+
|
|
739
|
+
db.delete(orm_class.table_name, "1=1")
|
|
740
|
+
Tina4::Log.info("Seeder: Cleared #{orm_class.table_name}")
|
|
741
|
+
rescue => e
|
|
742
|
+
Tina4::Log.warning("Seeder: could not clear #{orm_class.name}: #{e.message}")
|
|
743
|
+
end
|
|
744
|
+
|
|
745
|
+
# For each foreign-key column on the model, fetch the existing primary-key
|
|
746
|
+
# values of the referenced table so seeded child rows reference a real
|
|
747
|
+
# parent (P4a). Returns +{ fk_column_sym => [pk_value, ...] }+; columns with
|
|
748
|
+
# no resolvable / empty parent table are omitted (the generic generator then
|
|
749
|
+
# fills them, and the row may fail loudly — never silently).
|
|
750
|
+
def self._foreign_key_pools(orm_class, fields)
|
|
751
|
+
pools = {}
|
|
752
|
+
fk_columns = _foreign_keys_for(orm_class)
|
|
753
|
+
fields.each_key do |name|
|
|
754
|
+
ref_class = fk_columns[name.to_s]
|
|
755
|
+
next unless ref_class
|
|
756
|
+
|
|
757
|
+
begin
|
|
758
|
+
db = ref_class.get_db
|
|
759
|
+
next unless db
|
|
760
|
+
|
|
761
|
+
pk = ref_class.primary_key_field || :id
|
|
762
|
+
rows = db.fetch("SELECT #{pk} FROM #{ref_class.table_name}", [], limit: 100_000)
|
|
763
|
+
list = rows.respond_to?(:to_a) ? rows.to_a : Array(rows)
|
|
764
|
+
values = list.map { |r| r[pk] || r[pk.to_s] }.compact
|
|
765
|
+
pools[name] = values unless values.empty?
|
|
766
|
+
rescue => e
|
|
767
|
+
Tina4::Log.warning("Seeder: could not resolve FK pool for #{name}: #{e.message}")
|
|
768
|
+
end
|
|
769
|
+
end
|
|
770
|
+
pools
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
# Resolve the foreign-key columns declared on a model to their referenced
|
|
774
|
+
# ORM classes. Reads the model's belongs_to relationship metadata (the
|
|
775
|
+
# foreign_key_field DSL wires a belongs_to whose :foreign_key is the column
|
|
776
|
+
# and :class_name names the parent). Returns +{ "column_name" => ParentClass }+.
|
|
777
|
+
def self._foreign_keys_for(orm_class)
|
|
778
|
+
out = {}
|
|
779
|
+
return out unless orm_class.respond_to?(:relationship_definitions)
|
|
780
|
+
|
|
781
|
+
orm_class.relationship_definitions.each_value do |rel|
|
|
782
|
+
next unless rel[:type] == :belongs_to
|
|
783
|
+
|
|
784
|
+
fk = (rel[:foreign_key] || "").to_s
|
|
785
|
+
next if fk.empty?
|
|
786
|
+
|
|
787
|
+
target = _resolve_model_by_name(rel[:class_name])
|
|
788
|
+
out[fk] = target if target
|
|
789
|
+
end
|
|
790
|
+
out
|
|
791
|
+
end
|
|
792
|
+
|
|
793
|
+
# Topologically sort ORM models so parents (referenced tables) come before
|
|
794
|
+
# children (tables with a FK pointing at them). Uses the belongs_to FK
|
|
795
|
+
# metadata. Models not in the input list are ignored as dependencies.
|
|
796
|
+
# Cycles / unresolved deps fall back to declared order so nothing is dropped.
|
|
797
|
+
def self._topo_sort_models(orm_classes)
|
|
798
|
+
in_set = orm_classes.uniq
|
|
799
|
+
by_name = {}
|
|
800
|
+
in_set.each { |m| by_name[m.name.to_s.split("::").last] = m }
|
|
801
|
+
|
|
802
|
+
deps_of = {}
|
|
803
|
+
in_set.each do |model|
|
|
804
|
+
deps = []
|
|
805
|
+
_foreign_keys_for(model).each_value do |ref_class|
|
|
806
|
+
simple = ref_class.name.to_s.split("::").last
|
|
807
|
+
target = by_name[simple]
|
|
808
|
+
deps << target if target && !target.equal?(model)
|
|
809
|
+
end
|
|
810
|
+
deps_of[model] = deps.uniq
|
|
811
|
+
end
|
|
812
|
+
|
|
813
|
+
ordered = []
|
|
814
|
+
placed = []
|
|
815
|
+
remaining = in_set.dup
|
|
816
|
+
progressed = true
|
|
817
|
+
while !remaining.empty? && progressed
|
|
818
|
+
progressed = false
|
|
819
|
+
still = []
|
|
820
|
+
remaining.each do |model|
|
|
821
|
+
if deps_of[model].all? { |d| placed.include?(d) }
|
|
822
|
+
ordered << model
|
|
823
|
+
placed << model
|
|
824
|
+
progressed = true
|
|
825
|
+
else
|
|
826
|
+
still << model
|
|
827
|
+
end
|
|
828
|
+
end
|
|
829
|
+
remaining = still
|
|
830
|
+
end
|
|
831
|
+
# Cycle / unresolved deps — append in declared order so we never drop a model.
|
|
832
|
+
ordered.concat(remaining)
|
|
833
|
+
ordered
|
|
834
|
+
end
|
|
835
|
+
|
|
836
|
+
# Find a loaded Tina4::ORM subclass by its simple (unqualified) class name.
|
|
837
|
+
def self._resolve_model_by_name(class_name)
|
|
838
|
+
return nil if class_name.nil?
|
|
839
|
+
return class_name if class_name.is_a?(Class)
|
|
840
|
+
|
|
841
|
+
simple = class_name.to_s.split("::").last
|
|
842
|
+
return nil unless defined?(Tina4::ORM) && Tina4::ORM.respond_to?(:model_subclasses)
|
|
843
|
+
|
|
844
|
+
Tina4::ORM.model_subclasses.find do |k|
|
|
845
|
+
k.name && k.name.split("::").last == simple
|
|
846
|
+
end
|
|
847
|
+
end
|
|
848
|
+
|
|
849
|
+
# P4c — when a generated/static value's Ruby type clearly mismatches the
|
|
850
|
+
# target column's field type, LOG a warning (never hard-fail). bool-in-int
|
|
851
|
+
# is allowed (Ruby has no bool/int subclass relation, but seeded booleans are
|
|
852
|
+
# represented as 0/1 integers here, so only flag truly suspicious cases).
|
|
853
|
+
def self._validate_types(fields, attrs, model_name)
|
|
854
|
+
expected = { integer: Integer, float: Float, boolean: Integer }
|
|
855
|
+
attrs.each do |name, value|
|
|
856
|
+
next if value.nil?
|
|
857
|
+
|
|
858
|
+
field = fields[name]
|
|
859
|
+
next if field.nil?
|
|
860
|
+
|
|
861
|
+
want = expected[field[:type]]
|
|
862
|
+
next if want.nil?
|
|
863
|
+
|
|
864
|
+
# A Float landing in an :integer column (or vice-versa) is the suspicious
|
|
865
|
+
# case; everything that is_a? the expected numeric is fine.
|
|
866
|
+
next if value.is_a?(want)
|
|
867
|
+
next if want == Integer && value.is_a?(Numeric) && field[:type] == :boolean
|
|
868
|
+
|
|
869
|
+
Tina4::Log.warning(
|
|
870
|
+
"Seeder: #{model_name}.#{name} expected #{want} but generated " \
|
|
871
|
+
"#{value.class} (#{value.inspect}) — inserting anyway"
|
|
872
|
+
)
|
|
873
|
+
end
|
|
874
|
+
end
|
|
875
|
+
|
|
527
876
|
# Run all seed files in the given folder.
|
|
528
877
|
#
|
|
529
878
|
# Parity: Python/PHP/Node use `seed(n)` to set the PRNG seed on FakeData.
|