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.
- checksums.yaml +4 -4
- data/README.md +7 -7
- data/lib/tina4/api.rb +43 -1
- data/lib/tina4/cli.rb +4 -0
- data/lib/tina4/database.rb +51 -6
- data/lib/tina4/dev_admin.rb +20 -4
- data/lib/tina4/field_types.rb +5 -2
- data/lib/tina4/log.rb +86 -10
- data/lib/tina4/mcp.rb +25 -5
- data/lib/tina4/metrics.rb +115 -28
- data/lib/tina4/migration.rb +107 -20
- data/lib/tina4/orm.rb +182 -21
- data/lib/tina4/query_builder.rb +22 -3
- data/lib/tina4/queue_backends/kafka_backend.rb +39 -2
- data/lib/tina4/rack_app.rb +6 -1
- data/lib/tina4/router.rb +34 -4
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/websocket.rb +105 -4
- data/lib/tina4.rb +80 -3
- metadata +2 -2
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
|
|
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 >
|
|
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
|
-
|
|
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
|
-
|
|
784
|
-
|
|
785
|
-
|
|
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
|
-
|
|
788
|
-
|
|
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
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
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
|
-
|
|
798
|
-
|
|
799
|
-
|
|
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
|
-
|
|
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
|
|
data/lib/tina4/migration.rb
CHANGED
|
@@ -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
|
-
#
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
#
|
|
236
|
-
#
|
|
237
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
352
|
-
#
|
|
353
|
-
#
|
|
354
|
-
|
|
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
|
-
|
|
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
|
-
|
|
573
|
-
|
|
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
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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
|
-
|
|
596
|
-
|
|
597
|
-
@
|
|
598
|
-
|
|
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
|
-
|
|
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
|