tina4ruby 3.13.38 → 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/metrics.rb CHANGED
@@ -649,7 +649,10 @@ module Tina4
649
649
 
650
650
  # Top-level class/module names defined in the file at rel_path (resolved
651
651
  # against the last scan root when present). Distinctive names only:
652
- # leading-uppercase, longer than 3 chars.
652
+ # leading-uppercase, longer than 2 chars — so genuine 3-char constants like
653
+ # ORM (orm.rb) and API (api.rb), which specs reference as `Tina4::ORM` /
654
+ # `Tina4::API`, are detected as tested instead of being mislabelled
655
+ # untested. (Was > 3, which silently excluded every 3-char constant.)
653
656
  def self._defined_constants(rel_path)
654
657
  src_file = if @last_scan_root && !@last_scan_root.empty? && !File.exist?(rel_path)
655
658
  File.join(@last_scan_root, rel_path)
@@ -667,7 +670,7 @@ module Tina4
667
670
  m = stripped.match(/\A(?:class|module)\s+([A-Z][A-Za-z0-9_]*)/)
668
671
  next unless m
669
672
  const = m[1]
670
- symbols.add(const) if const.length > 3
673
+ symbols.add(const) if const.length > 2
671
674
  end
672
675
  symbols
673
676
  end
@@ -702,8 +705,61 @@ module Tina4
702
705
  imports
703
706
  end
704
707
 
705
- def self._extract_functions(source, tokens, lines)
708
+ # Replace the CONTENT of Ruby string literals, regex literals, and comments
709
+ # with neutral spaces — keeping every line's length and the line count
710
+ # identical to the original — so decision-point keywords and method-shaped
711
+ # text that live INSIDE strings/comments are never miscounted. Returns an
712
+ # array of cleaned lines (chomped) aligned 1:1 with the original lines.
713
+ #
714
+ # Ruby's own lexer (Ripper) does the hard parsing: it tags string/heredoc/
715
+ # regex bodies as :on_tstring_content (and :on_comment, :on_embdoc — the
716
+ # =begin/=end block-comment body), which we blank out positionally. The
717
+ # surrounding code structure (def/if/end keywords, operators) is left intact.
718
+ NOISE_TOKEN_TYPES = %i[
719
+ on_tstring_content on_comment on_embdoc on_embdoc_beg on_embdoc_end
720
+ ].freeze
721
+
722
+ def self._clean_source(source)
723
+ lines = source.lines.map(&:chomp)
724
+ # Mutable per-line character buffers we can blank out by column range.
725
+ buffers = lines.map(&:dup)
726
+
727
+ tokens = begin
728
+ Ripper.lex(source)
729
+ rescue StandardError
730
+ return lines
731
+ end
732
+
733
+ tokens.each do |(pos, type, token)|
734
+ next unless NOISE_TOKEN_TYPES.include?(type)
735
+
736
+ row = pos[0] - 1
737
+ col = pos[1]
738
+ # A noise token may span multiple physical lines (heredocs, block
739
+ # comments, multi-line strings). Blank each covered line segment.
740
+ token.to_s.each_line.with_index do |seg, offset|
741
+ line_idx = row + offset
742
+ next if line_idx.negative? || line_idx >= buffers.length
743
+
744
+ buf = buffers[line_idx]
745
+ # On the token's first line the content starts at `col`; on
746
+ # continuation lines it starts at column 0.
747
+ start = offset.zero? ? col : 0
748
+ seg_len = seg.chomp.length
749
+ stop = [start + seg_len, buf.length].min
750
+ (start...stop).each { |c| buf[c] = ' ' } if stop > start
751
+ end
752
+ end
753
+
754
+ buffers
755
+ end
756
+
757
+ def self._extract_functions(source, _tokens, _lines)
706
758
  functions = []
759
+ # Operate on a neutralised copy: string/regex/comment CONTENT is blanked
760
+ # so keywords inside them are never read as real code (line numbers, line
761
+ # count and column widths are preserved).
762
+ lines = _clean_source(source)
707
763
  # Track class/module nesting for method names
708
764
  context_stack = []
709
765
  i = 0
@@ -718,7 +774,8 @@ module Tina4
718
774
  context_stack.push(class_name) unless class_name.empty?
719
775
  end
720
776
 
721
- # Detect method definitions
777
+ # Detect method definitions — require a real `def ` declaration so a
778
+ # `def`-shaped substring inside a (now-blanked) string is never a method.
722
779
  if stripped.match?(/\Adef\s+/)
723
780
  method_match = stripped.match(/\Adef\s+(self\.)?(\S+?)(\(.*\))?\s*$/)
724
781
  if method_match
@@ -779,43 +836,73 @@ module Tina4
779
836
  functions
780
837
  end
781
838
 
839
+ # Keywords that ALWAYS open a block needing a matching `end`.
840
+ BLOCK_OPENERS = %w[def class module begin case].freeze
841
+ # Keywords that open a block ONLY in statement-leading position; in trailing
842
+ # position they are modifiers (`return x if y`) and need no `end`.
843
+ CONDITIONAL_OPENERS = %w[if unless while until for].freeze
844
+
845
+ # Find the line index where the method that starts at `start_index` ends.
846
+ #
847
+ # Token-driven (Ripper) so it is immune to the line-regex footguns that made
848
+ # this over-run to end-of-file (CC 496 on tiny methods):
849
+ # * `self.class` — `class` after a `.` is an identifier, not a block opener
850
+ # (Ripper tags it :on_ident), so it no longer bumps depth.
851
+ # * modifier `if/unless/while/until/for` (`return x if y`) — only counted
852
+ # as an opener in statement-LEADING position (first real token of a
853
+ # statement), never trailing.
854
+ # * `lines` are already string/comment-cleaned, so keywords inside string
855
+ # bodies are gone too.
856
+ # Falls back to the last line only if no matching `end` is found.
782
857
  def self._find_method_end(lines, start_index)
783
- depth = 0
784
- i = start_index
785
- base_indent = lines[i].length - lines[i].lstrip.length
858
+ source = lines[start_index..].join("\n")
859
+ tokens = begin
860
+ Ripper.lex(source)
861
+ rescue StandardError
862
+ return lines.length - 1
863
+ end
786
864
 
787
- while i < lines.length
788
- stripped = lines[i].strip
865
+ depth = 0
866
+ # A keyword is a block opener only when it leads a statement. Track that:
867
+ # we are at statement start initially and right after a newline / `;`.
868
+ at_statement_start = true
869
+ seen_opener = false
789
870
 
790
- unless stripped.empty? || stripped.start_with?('#')
791
- # Count block openers
792
- if stripped.match?(/\b(def|class|module|if|unless|case|while|until|for|begin|do)\b/) &&
793
- !stripped.match?(/\bend\b/) &&
794
- !stripped.end_with?(' if ', ' unless ', ' while ', ' until ') &&
795
- !(stripped.match?(/\bif\b|\bunless\b|\bwhile\b|\buntil\b/) && i != start_index && _is_modifier?(stripped))
871
+ tokens.each do |(pos, type, token)|
872
+ case type
873
+ when :on_kw
874
+ if BLOCK_OPENERS.include?(token)
796
875
  depth += 1
797
- end
798
-
799
- if stripped == 'end' || stripped.start_with?('end ') || stripped.start_with?('end;')
876
+ seen_opener = true
877
+ elsif token == 'do'
878
+ depth += 1
879
+ seen_opener = true
880
+ elsif CONDITIONAL_OPENERS.include?(token)
881
+ # Leading => real block opener; trailing => modifier (no end).
882
+ if at_statement_start
883
+ depth += 1
884
+ seen_opener = true
885
+ end
886
+ elsif token == 'end'
800
887
  depth -= 1
801
- return i if depth <= 0
888
+ if seen_opener && depth <= 0
889
+ return start_index + (pos[0] - 1)
890
+ end
802
891
  end
892
+ at_statement_start = false
893
+ when :on_nl, :on_ignored_nl, :on_semicolon
894
+ at_statement_start = true
895
+ when :on_sp, :on_comment, :on_embdoc, :on_embdoc_beg, :on_embdoc_end
896
+ # whitespace/comments don't change statement-start state
897
+ else
898
+ at_statement_start = false
803
899
  end
804
-
805
- i += 1
806
900
  end
807
901
 
808
902
  # If we never found the end, return last line
809
903
  lines.length - 1
810
904
  end
811
905
 
812
- def self._is_modifier?(line)
813
- # A rough check: if the keyword is not at the start of the meaningful content,
814
- # it's likely a modifier (e.g., "return x if condition")
815
- stripped = line.strip
816
- !stripped.match?(/\A(if|unless|while|until)\b/)
817
- end
818
-
819
906
  def self._cyclomatic_complexity_from_source(source)
820
907
  cc = 1
821
908
 
@@ -179,10 +179,12 @@ module Tina4
179
179
  rescue
180
180
  # Generator may already exist
181
181
  end
182
+ # migration_name is UNIQUE: a migration is "applied" iff a success row
183
+ # exists, so a re-applied name must never duplicate a tracking row.
182
184
  @db.execute(<<~SQL)
183
185
  CREATE TABLE #{TRACKING_TABLE} (
184
186
  id INTEGER NOT NULL PRIMARY KEY,
185
- migration_name VARCHAR(500) NOT NULL,
187
+ migration_name VARCHAR(500) NOT NULL UNIQUE,
186
188
  description VARCHAR(500) DEFAULT '',
187
189
  batch INTEGER NOT NULL DEFAULT 1,
188
190
  executed_at VARCHAR(50) DEFAULT CURRENT_TIMESTAMP,
@@ -190,10 +192,12 @@ module Tina4
190
192
  )
191
193
  SQL
192
194
  else
195
+ # migration_name is UNIQUE: a migration is "applied" iff a success row
196
+ # exists, so a re-applied name must never duplicate a tracking row.
193
197
  @db.execute(<<~SQL)
194
198
  CREATE TABLE #{TRACKING_TABLE} (
195
199
  id INTEGER PRIMARY KEY,
196
- migration_name VARCHAR(255) NOT NULL,
200
+ migration_name VARCHAR(255) NOT NULL UNIQUE,
197
201
  description VARCHAR(255) DEFAULT '',
198
202
  batch INTEGER NOT NULL DEFAULT 1,
199
203
  executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
@@ -224,22 +228,37 @@ module Tina4
224
228
  return [] unless Dir.exist?(@migrations_dir)
225
229
 
226
230
  completed = completed_migrations
227
- # Support both .rb and .sql migration files
228
- # Accept both 000001_name.sql (sequential) and YYYYMMDDHHMMSS_name.sql (timestamp) patterns
229
- Dir.glob(File.join(@migrations_dir, "*.{rb,sql}"))
230
- .reject { |f| f.end_with?(".down.sql") }
231
- .sort_by { |f| migration_sort_key(File.basename(f)) }
232
- .reject { |f| completed.include?(File.basename(f)) }
231
+ # Support both .rb and .sql migration files. Accept both 000001_name.sql
232
+ # (sequential) and YYYYMMDDHHMMSS_name.sql (timestamp) patterns. Sort by a
233
+ # leading numeric/timestamp prefix (numeric-aware) so `9_*` applies before
234
+ # `10_*` a plain lexical sort misorders unpadded prefixes ("10" < "9").
235
+ # Files with no numeric prefix sort after the numbered ones, lexically.
236
+ files = Dir.glob(File.join(@migrations_dir, "*.{rb,sql}"))
237
+ .reject { |f| f.end_with?(".down.sql") }
238
+ .sort_by { |f| migration_sort_key(File.basename(f)) }
239
+
240
+ # Warn about filenames without a recognized NNNNNN_/timestamp prefix —
241
+ # their ordering relative to numbered migrations is undefined, a silent
242
+ # out-of-order-apply footgun.
243
+ unprefixed = files.map { |f| File.basename(f) }.reject { |n| n =~ /\A\d+[_-]/ }
244
+ unless unprefixed.empty?
245
+ Tina4::Log.warning(
246
+ "Migration file(s) without a numeric/timestamp prefix may apply out of order: " +
247
+ unprefixed.join(", ")
248
+ )
249
+ end
250
+
251
+ files.reject { |f| completed.include?(File.basename(f)) }
233
252
  end
234
253
 
235
- # Sort key that handles both 000001_name.sql and 20240315120000_name.sql patterns.
236
- # Both are zero-padded numeric prefixes so alphabetical sorting works, but we
237
- # extract the prefix explicitly to guarantee correct ordering when mixed.
254
+ # Numeric-aware sort key so `9_name.sql` sorts before `10_name.sql` (plain
255
+ # lexical sort puts "10" before "9"). Files with a leading numeric/timestamp
256
+ # prefix sort first by that number; the rest sort after, lexically.
238
257
  def migration_sort_key(filename)
239
258
  if filename =~ /\A(\d+)/
240
- [$1.to_i, filename]
259
+ [0, $1.to_i, filename]
241
260
  else
242
- [0, filename]
261
+ [1, 0, filename]
243
262
  end
244
263
  end
245
264
 
@@ -247,16 +266,27 @@ module Tina4
247
266
  name = File.basename(file)
248
267
  Tina4::Log.info("Running migration: #{name}")
249
268
  begin
269
+ # Wrap each migration FILE in its own transaction so a multi-statement
270
+ # file that fails midway rolls back as a unit. Truly atomic only on
271
+ # engines with transactional DDL (PostgreSQL); MySQL/Firebird/SQLite
272
+ # auto-commit DDL, so earlier statements may persist there — keep one
273
+ # logical change per file.
274
+ @db.start_transaction
250
275
  if file.end_with?(".rb")
251
276
  execute_ruby_migration(file, :up)
252
277
  else
253
278
  execute_sql_file(file)
254
279
  end
280
+ # ROW-EXISTENCE tracking: only a SUCCESS row is ever written. A
281
+ # migration is "applied" iff a success row exists — failures are never
282
+ # recorded, nothing is deleted, the file rolls back and we surface the
283
+ # error. Fix the bad file and re-run.
255
284
  _record_migration(name, batch, passed: 1)
285
+ @db.commit
256
286
  { name: name, status: "success" }
257
287
  rescue => e
288
+ @db.rollback rescue nil
258
289
  Tina4::Log.error("Migration failed: #{name} - #{e.message}")
259
- _record_migration(name, batch, passed: 0)
260
290
  { name: name, status: "failed", error: e.message }
261
291
  end
262
292
  end
@@ -296,6 +326,26 @@ module Tina4
296
326
  migration.__send__(direction, @db)
297
327
  end
298
328
 
329
+ # Smart/curly quotes — editors, word processors, docs and chat apps silently
330
+ # convert a straight " to “ ” and a straight ' to ‘ ’ (plus primes ′ ″). Those
331
+ # characters are NOT valid SQL string/identifier delimiters, so a pasted-in
332
+ # migration fails to run ("syntax error near …"). Map them back to straight
333
+ # ASCII quotes. (Real string CONTENTS are unaffected by intent — we only swap
334
+ # the lookalike code points for their ASCII equivalents.)
335
+ SMART_QUOTES = {
336
+ "“" => '"', "”" => '"', "„" => '"', "‟" => '"', "″" => '"', # “ ” „ ‟ ″
337
+ "‘" => "'", "’" => "'", "‚" => "'", "‛" => "'", "′" => "'" # ‘ ’ ‚ ‛ ′
338
+ }.freeze
339
+ SMART_QUOTE_RE = Regexp.union(SMART_QUOTES.keys)
340
+
341
+ # Replace smart/curly quotes with straight ASCII quotes so migration SQL
342
+ # authored or pasted from an editor/doc actually runs (those code points are
343
+ # not valid SQL delimiters). Already-straight quotes and ordinary content are
344
+ # returned byte-for-byte unchanged.
345
+ def normalize_quotes(sql)
346
+ sql.gsub(SMART_QUOTE_RE, SMART_QUOTES)
347
+ end
348
+
299
349
  # Split SQL into individual statements, handling:
300
350
  # - $$ delimited stored procedure blocks
301
351
  # - // delimited blocks
@@ -303,6 +353,10 @@ module Tina4
303
353
  # - Line comments -- ...
304
354
  # Matches the Python/Node.js approach: extract blocks first, split on ;, restore blocks.
305
355
  def split_sql_statements(sql, delimiter = ";")
356
+ # Normalize smart/curly quotes to straight ASCII first, so SQL pasted from
357
+ # an editor/doc (which converts " → “ ” and ' → ‘ ’) actually runs.
358
+ sql = normalize_quotes(sql)
359
+
306
360
  blocks = []
307
361
 
308
362
  # Extract $$ ... $$ blocks (stored procedures, triggers, etc.)
@@ -311,8 +365,12 @@ module Tina4
311
365
  "__BLOCK_#{blocks.length - 1}__"
312
366
  end
313
367
 
314
- # Extract // ... // blocks
315
- processed = processed.gsub(/\/\/(.*?)\/\//m) do
368
+ # Extract // ... // blocks (stored procedures, triggers, etc.). The `//`
369
+ # delimiters must NOT be preceded by a colon, so a URL scheme
370
+ # (`https://…`) or other `://` literal inside a migration is never
371
+ # captured as an opaque stored-proc block (it would otherwise swallow
372
+ # everything between two `//` occurrences and skip statement splitting).
373
+ processed = processed.gsub(/(?<!:)\/\/(.*?)(?<!:)\/\//m) do
316
374
  blocks << $~.to_s
317
375
  "__BLOCK_#{blocks.length - 1}__"
318
376
  end
@@ -348,10 +406,11 @@ module Tina4
348
406
  sql = File.read(file)
349
407
  statements = split_sql_statements(sql)
350
408
  statements.each do |stmt|
351
- # Firebird lacks IF NOT EXISTS for ALTER TABLE ADD.
352
- # Pre-check the system catalogue so duplicate columns are
353
- # silently skipped instead of raising an error.
354
- skip_reason = should_skip_for_firebird(stmt)
409
+ # Idempotency on engines lacking IF NOT EXISTS: Firebird ALTER-TABLE-ADD
410
+ # (pre-check the system catalogue for a duplicate column), and CREATE
411
+ # TABLE on Firebird/MSSQL (pre-check the table exists). Only a genuine
412
+ # already-exists is skipped — every other error still raises.
413
+ skip_reason = should_skip_for_firebird(stmt) || should_skip_create_table(stmt)
355
414
  if skip_reason
356
415
  Tina4::Log.info("Migration #{File.basename(file)}: #{skip_reason}")
357
416
  next
@@ -398,6 +457,34 @@ module Tina4
398
457
  end
399
458
  end
400
459
 
460
+ # CREATE TABLE <name> — name may be quoted ("x"), bracketed ([x] MSSQL), or bare.
461
+ CREATE_TABLE_RE = /\A\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:"([^"]+)"|\[([^\]]+)\]|(\w+))/i
462
+
463
+ # Make CREATE TABLE idempotent on engines lacking IF NOT EXISTS.
464
+ #
465
+ # Firebird and MSSQL do not support `CREATE TABLE IF NOT EXISTS`, so a raw
466
+ # CREATE in a re-run migration raises "object already exists". When the
467
+ # target table already exists on those engines, returns a skip reason so the
468
+ # statement is skipped (mirrors the Firebird ALTER-TABLE-ADD idempotency
469
+ # guard). SQLite/MySQL/PostgreSQL support IF NOT EXISTS and are left to the
470
+ # engine. Only a genuine already-exists is skipped — every other error still
471
+ # raises. Returns nil if the statement should execute normally.
472
+ def should_skip_create_table(stmt)
473
+ engine = @db.get_database_type rescue nil
474
+ return nil unless %w[firebird mssql].include?(engine)
475
+
476
+ m = stmt.match(CREATE_TABLE_RE)
477
+ return nil unless m
478
+
479
+ table = m[1] || m[2] || m[3]
480
+ begin
481
+ return "Table #{table} already exists, skipping CREATE TABLE" if @db.table_exists?(table)
482
+ rescue
483
+ return nil
484
+ end
485
+ nil
486
+ end
487
+
401
488
  def _record_migration(name, batch, passed: 1)
402
489
  # Extract description from filename (strip numeric prefix and extension)
403
490
  stem = File.basename(name, File.extname(name))
data/lib/tina4/orm.rb CHANGED
@@ -13,6 +13,26 @@ module Tina4
13
13
  name.to_s.gsub(/([A-Z])/) { "_#{$1.downcase}" }.sub(/^_/, "")
14
14
  end
15
15
 
16
+ # Singularize a plural relationship name to derive a class name.
17
+ #
18
+ # has_many :posts → "Post", has_many :categories → "Category". The old
19
+ # derivation was a naive sub(/s$/) that turned "categories" into
20
+ # "Categorie" — a NameError waiting to happen. This handles the common
21
+ # English plural endings before falling back to stripping a trailing "s".
22
+ # (When class_name: is passed explicitly, this is never consulted.)
23
+ def self.singularize(word)
24
+ s = word.to_s
25
+ if s =~ /ies\z/i
26
+ s.sub(/ies\z/i, "y")
27
+ elsif s =~ /(ss|sh|ch|x|z)es\z/i
28
+ s.sub(/es\z/i, "")
29
+ elsif s =~ /s\z/i && s !~ /ss\z/i
30
+ s.sub(/s\z/i, "")
31
+ else
32
+ s
33
+ end
34
+ end
35
+
16
36
  class ORM
17
37
  include Tina4::FieldTypes
18
38
 
@@ -144,7 +164,12 @@ module Tina4
144
164
  def has_many(name, class_name: nil, foreign_key: nil)
145
165
  relationship_definitions[name] = {
146
166
  type: :has_many,
147
- class_name: class_name || name.to_s.sub(/s$/, "").split("_").map(&:capitalize).join,
167
+ # Derive the target class from the (plural) relationship name via a
168
+ # proper singularizer — "posts" → "Post", "categories" → "Category"
169
+ # — instead of the naive sub(/s$/) that produced "Categorie". The FK
170
+ # auto-wire path (foreign_key_field) always passes class_name:
171
+ # explicitly, so this default only applies to a hand-written has_many.
172
+ class_name: class_name || Tina4.singularize(name).split("_").map(&:capitalize).join,
148
173
  foreign_key: foreign_key
149
174
  }
150
175
 
@@ -326,9 +351,17 @@ module Tina4
326
351
  result[:cnt].to_i
327
352
  end
328
353
 
354
+ # Create a new instance, save it, and return it.
355
+ #
356
+ # Returns the saved instance on success. v3.13.39: if the underlying #save
357
+ # fails (validation errors or a driver error), create returns false — it
358
+ # does NOT hand back a possibly-unsaved instance, so a failed insert can
359
+ # never masquerade as a success. The failure cause is logged and available
360
+ # on the (discarded) instance's #get_error via the same path save uses.
361
+ # Parity with the Python master.
329
362
  def create(attributes = {})
330
363
  instance = new(attributes)
331
- instance.save
364
+ return false if instance.save == false
332
365
  instance
333
366
  end
334
367
 
@@ -473,6 +506,17 @@ module Tina4
473
506
  select_one(sql, [id])
474
507
  end
475
508
 
509
+ # Return true if a record with the given primary key exists.
510
+ #
511
+ # Cross-framework parity with Python's MyModel.exists(pk_value),
512
+ # PHP's Model::exists($id), and Node's Model.exists(pk). Honours the
513
+ # soft-delete filter the same way find_by_id does (it routes through it).
514
+ # Used by #save to decide INSERT vs UPDATE for natural (non-auto-increment)
515
+ # primary keys — see the note on #save.
516
+ def exists(id)
517
+ !find_by_id(id).nil?
518
+ end
519
+
476
520
  # Clear the relationship cache on all loaded instances (class-level helper).
477
521
  # Useful after bulk operations when you want to force relationship re-loads.
478
522
  def clear_rel_cache # -> nil
@@ -536,6 +580,11 @@ module Tina4
536
580
  def initialize(attributes = {})
537
581
  @persisted = false
538
582
  @errors = []
583
+ # Cause of the most recent failed #save (validation message or DB error).
584
+ # nil when the most recent save succeeded. Mirrors db.get_error so a caller
585
+ # that checks `return false unless model.save` can still recover the real
586
+ # cause via #get_error / #last_error — the failure never vanishes silently.
587
+ @last_error = nil
539
588
  @relationship_cache = {}
540
589
  # Accept a JSON object string (parity with Python/PHP/Node):
541
590
  # Widget.new('{"id":1,"name":"alpha"}')
@@ -566,42 +615,137 @@ module Tina4
566
615
  end
567
616
  end
568
617
 
618
+ # Insert or update. Returns self on success (fluent), false on failure.
619
+ #
620
+ # Fails loud, never silent (the same principle db.execute already follows
621
+ # by raising). On *any* failure path save returns false — keeping the
622
+ # contract callers rely on (`return false unless model.save`) — but it also
623
+ # (a) logs the real cause via Tina4::Log.error with model/table context and
624
+ # (b) records the cause on a retrievable per-model error (#last_error /
625
+ # #get_error, mirroring db.get_error) plus #errors, so a caller can recover
626
+ # it after the fact. It never raises and never changes the self/false
627
+ # return shape. On success it returns self (was `true` pre-v3.13.39 — Ruby
628
+ # was the sole framework returning a bare boolean here) and clears the
629
+ # error.
630
+ #
631
+ # Two distinct failure paths, both loud:
632
+ #
633
+ # * Validation (v3.13.39): #validate runs FIRST. If it returns errors,
634
+ # save records them on @errors + @last_error, logs them, and returns
635
+ # false WITHOUT touching the database — an invalid model never reaches
636
+ # the driver. (Ruby already enforced validate-on-save; this adds the
637
+ # loud log + recoverable last_error to the failure path.)
638
+ # * Database (v3.13.39): a driver error (NOT NULL, duplicate PK, missing
639
+ # table, …) is rolled back by db.transaction, then captured (db.get_error
640
+ # falling back to the exception text) onto @last_error, logged with
641
+ # model/table context, and returns false — the cause is no longer
642
+ # swallowed silently.
643
+ #
644
+ # INSERT vs UPDATE (bug B, parity with the Python master): for a NATURAL
645
+ # (non-auto-increment) primary key that is set, the decision is made on
646
+ # whether the ROW EXISTS (via self.class.exists), not on @persisted alone.
647
+ # Pre-v3.13.39 a re-save of a manually-PK'd record that had @persisted set
648
+ # would UPDATE — but a freshly built (not-yet-persisted) natural-key record
649
+ # whose row already existed could double-INSERT, or a `new`-then-`save` of a
650
+ # natural key would INSERT then a second save UPDATE a phantom. Probing
651
+ # existence makes the choice correct regardless of @persisted. Auto-increment
652
+ # PKs keep the legacy @persisted-based decision (a nil PK means "new row,
653
+ # let the engine assign an id").
569
654
  def save
570
655
  @errors = []
571
656
  @relationship_cache = {} # Clear relationship cache on save
572
- validate_fields
573
- return false unless @errors.empty?
657
+
658
+ # ── validate() is ENFORCED. An invalid model never reaches the driver —
659
+ # fail loud (record + log), return false. ──
660
+ validation_errors = validate
661
+ unless validation_errors.empty?
662
+ @errors = validation_errors
663
+ @last_error = validation_errors.join("; ")
664
+ Tina4::Log.error(
665
+ "#{self.class.name}.save refused: validation failed — #{@last_error}"
666
+ )
667
+ return false
668
+ end
574
669
 
575
670
  data = to_db_hash(exclude_nil: true)
576
671
  pk = self.class.primary_key_field || :id
577
672
  pk_value = __send__(pk)
673
+ pk_opts = self.class.field_definitions[pk] || {}
674
+ auto_increment = pk_opts[:auto_increment]
578
675
 
579
- self.class.db.transaction do |db|
580
- if @persisted && pk_value
581
- filter = { pk => pk_value }
582
- data.delete(pk)
583
- # Remove mapped primary key too
584
- mapped_pk = self.class.field_mapping[pk.to_s]
585
- data.delete(mapped_pk.to_sym) if mapped_pk
586
- db.update(self.class.table_name, data, filter)
676
+ # Decide INSERT vs UPDATE.
677
+ is_update =
678
+ if pk_value.nil?
679
+ false
680
+ elsif auto_increment
681
+ # Auto-increment: legacy behaviour — a set PK on a persisted instance
682
+ # means UPDATE.
683
+ @persisted ? true : false
587
684
  else
588
- result = db.insert(self.class.table_name, data)
589
- if result[:last_id] && respond_to?("#{pk}=")
590
- __send__("#{pk}=", result[:last_id])
685
+ # Natural key: probe row existence so a re-save never double-inserts
686
+ # and a first save of a never-seen key still inserts. If the probe
687
+ # itself fails (e.g. table missing), fall back to INSERT so the caller
688
+ # sees the real driver error rather than a silent no-op UPDATE.
689
+ begin
690
+ self.class.exists(pk_value)
691
+ rescue StandardError
692
+ false
591
693
  end
592
- @persisted = true
593
694
  end
695
+
696
+ begin
697
+ self.class.db.transaction do |db|
698
+ if is_update
699
+ filter = { pk => pk_value }
700
+ data.delete(pk)
701
+ # Remove mapped primary key too
702
+ mapped_pk = self.class.field_mapping[pk.to_s]
703
+ data.delete(mapped_pk.to_sym) if mapped_pk
704
+ db.update(self.class.table_name, data, filter)
705
+ else
706
+ result = db.insert(self.class.table_name, data)
707
+ # Only adopt the engine-assigned id for an auto-increment PK. A
708
+ # natural-key PK was set by the caller; don't overwrite it with the
709
+ # driver's last_insert_id (which may be a sequence value that
710
+ # doesn't apply here).
711
+ if auto_increment && result[:last_id] && respond_to?("#{pk}=")
712
+ __send__("#{pk}=", result[:last_id])
713
+ end
714
+ end
715
+ end
716
+ rescue => e
717
+ # ── Fail loud, never silent. db.transaction already rolled back and
718
+ # re-raised. Keep the false return contract, but capture the REAL cause
719
+ # (prefer db.get_error, which insert/update/execute populate, falling
720
+ # back to the exception text) on @last_error + @errors so it survives,
721
+ # and log it with model/table context. ──
722
+ cause = (self.class.db.get_error rescue nil) || e.message
723
+ @last_error = cause
724
+ @errors = [cause]
725
+ Tina4::Log.error(
726
+ "#{self.class.name}.save failed for table " \
727
+ "'#{self.class.table_name}': #{cause}"
728
+ )
729
+ return false
594
730
  end
595
- true
596
- rescue => e
597
- @errors << e.message
598
- false
731
+
732
+ @persisted = true
733
+ @last_error = nil
734
+ self
599
735
  end
600
736
 
737
+ # Delete this record (soft or hard).
738
+ #
739
+ # v3.13.39 (bug D): RAISES on a missing primary key, matching #force_delete
740
+ # (which already raised). Previously delete returned false on a nil PK while
741
+ # force_delete raised — an inconsistent contract where "couldn't delete" and
742
+ # "deleted nothing" were indistinguishable on one path but loud on the other.
743
+ # Both now fail loud: deleting a record with no PK is a programmer error, not
744
+ # a quiet no-op. Returns true on a successful delete.
601
745
  def delete
602
746
  pk = self.class.primary_key_field || :id
603
747
  pk_value = __send__(pk)
604
- return false unless pk_value
748
+ raise "Cannot delete: no primary key value" unless pk_value
605
749
 
606
750
  self.class.db.transaction do |db|
607
751
  if self.class.soft_delete
@@ -703,6 +847,23 @@ module Tina4
703
847
  @errors
704
848
  end
705
849
 
850
+ # Cause of the most recent failed #save (validation message or DB error),
851
+ # or nil when the last save succeeded.
852
+ def last_error
853
+ @last_error
854
+ end
855
+
856
+ # Return the cause of the most recent failed #save, or nil.
857
+ #
858
+ # Mirrors db.get_error. After save returns false — whether from validation
859
+ # or a driver error — the real cause is retrievable here (and on
860
+ # #last_error) so a caller using the `return false unless model.save`
861
+ # contract can still surface it. Cleared to nil on a successful save.
862
+ # Cross-framework parity with Python/PHP/Node get_error().
863
+ def get_error
864
+ @last_error
865
+ end
866
+
706
867
  # Convert to hash using Ruby attribute names.
707
868
  # Optionally include relationships via the include keyword.
708
869
  # case: "camel" converts snake_case keys to camelCase (parity with