tina4ruby 3.13.37 → 3.13.39

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.
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
- # @return [Integer] number of records inserted
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 0
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 0
460
+ return SeedSummary.new
378
461
  end
379
462
 
380
- # Idempotency check
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 0
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
- # Clear if requested
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
- inserted = 0
408
- count.times do |i|
409
- attrs = {}
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
- insert_fields.each do |name, field_def|
412
- if overrides.key?(name)
413
- val = overrides[name]
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
- inserted += 1
512
+ seeded += 1
425
513
  else
426
- Tina4::Log.warn("Seeder: Insert failed for #{table} row #{i + 1}: #{obj.errors.join(', ')}")
514
+ reason = obj.errors.empty? ? "save returned false" : obj.errors.join(", ")
515
+ raise "save failed: #{reason}"
427
516
  end
428
517
  rescue => e
429
- Tina4::Log.warn("Seeder: Insert failed for #{table} row #{i + 1}: #{e.message}")
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: Inserted #{inserted}/#{count} records into #{table}")
434
- inserted
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: type_string } supports :integer, :string, :text, etc.
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] field overrides
443
- # @param clear [Boolean] delete before seeding
444
- # @param seed [Integer, nil] random seed
445
- # @return [Integer] records inserted
446
- def self.seed_table(table_name, columns, count: 10, overrides: {}, clear: false, seed: nil)
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 0
557
+ return SeedSummary.new
453
558
  end
454
559
 
455
- if clear
456
- begin
457
- db.execute("DELETE FROM #{table_name}")
458
- rescue => e
459
- Tina4::Log.warn("Seeder: Could not clear #{table_name}: #{e.message}")
460
- end
461
- end
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
- row = {}
466
- columns.each do |col_name, type_str|
467
- if overrides.key?(col_name)
468
- val = overrides[col_name]
469
- row[col_name] = val.respond_to?(:call) ? val.call(fake) : val
470
- else
471
- field_def = { type: type_str.to_sym }
472
- row[col_name] = fake.for_field(field_def, col_name)
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
- inserted += 1
585
+ seeded += 1
479
586
  rescue => e
480
- Tina4::Log.warn("Seeder: Insert failed for #{table_name} row #{i + 1}: #{e.message}")
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
- Tina4::Log.info("Seeder: Inserted #{inserted}/#{count} records into #{table_name}")
485
- inserted
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 optional dependency-aware clearing.
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 (in reverse order) before seeding
492
- # @return [Hash] { "ClassName" => inserted_count, ... }
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
- results = {}
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
- tasks.reverse_each do |task|
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
- tasks.each do |task|
514
- n = Tina4.seed_orm(
515
- task[:orm_class],
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.