tina4ruby 3.13.52 → 3.13.54

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b6a859f05cd2ac97b5e38ab9ee678cd8419f91be1aca0dedaed1e1d63b268968
4
- data.tar.gz: 4454362a269ef77b5992a0ec4d2e30e6dd64134ed64265d1e8fd6f9432fcbc0f
3
+ metadata.gz: cd2180527148137de7bd985ed3951aefcae89d9fee30032de3c34434b3cc209e
4
+ data.tar.gz: 78e32b6e6021f24c455bb002339a59c54a8a134a1b219493c6dc994d66ebaff5
5
5
  SHA512:
6
- metadata.gz: 595d84b72e3b75a263f54ff93859a9f92f52fc76434b7633d149d11a2fa8a172a72478a13b9aa649ea0189a1f975d0a98daf7afa7cbceeb5bec9636433c95dad
7
- data.tar.gz: 66634157b45125bb796277f54a9b93d12ab721c237b0dec39450d78d69ba4f3162972a178ef742607d45e4cc33d572c3460d7d336e0133e7c29ea902e21b68ae
6
+ metadata.gz: 8f422139294a6d51b34507f116dd21621e5299829b02f7ec613083bd2bc9d051a4c832c01dab8120978708fa637302b35b6b05d2f463a188f91dfc9d89d2879b
7
+ data.tar.gz: 96ebc4a47de1674a828dc5913c4586fc82170f0c04159724d69a1cd6a99d15596bc5f217aa76ed4e119675559fc48d7b22cbd71eccd10e231de32d1ab2bc61da
@@ -1688,8 +1688,9 @@ module Tina4
1688
1688
 
1689
1689
  # Native MCP JSON-RPC endpoint (POST /__dev/mcp[/message]). Real MCP
1690
1690
  # clients POST a JSON-RPC 2.0 request; we hand the parsed body straight
1691
- # to the default server's handle_message and echo the response. A
1692
- # notification (no id) yields an empty 204, mirroring Python.
1691
+ # to the default server's dispatch_http and echo the response. A
1692
+ # notification (no id) yields an empty 202 Accepted (the MCP Streamable
1693
+ # HTTP contract), mirroring the Python master, PHP and Node.
1693
1694
  # Streamable HTTP POST /__dev/mcp — the current MCP transport. initialize
1694
1695
  # issues an Mcp-Session-Id; an unknown session on a non-initialize request
1695
1696
  # is a 404; a notification is 202; anything else answers inline (200).
@@ -229,19 +229,34 @@ module Tina4
229
229
  end
230
230
  end
231
231
 
232
+ # Return a result row keyed by lower-case symbols. Firebird returns rows keyed
233
+ # by UPPERCASE string column names while SQLite/others return lower-case symbol
234
+ # keys, so the tracking-table reads below are normalised to one shape. Without
235
+ # this, `row[:migration_name]` is nil on Firebird and every applied migration
236
+ # is treated as pending and re-run.
237
+ def normalize_row(row)
238
+ return {} if row.nil?
239
+
240
+ source = row.respond_to?(:to_h) ? row.to_h : row
241
+ source.each_with_object({}) { |(k, v), h| h[k.to_s.downcase.to_sym] = v }
242
+ end
243
+
232
244
  def completed_migrations
233
245
  result = @db.fetch("SELECT migration_name FROM #{TRACKING_TABLE} WHERE passed = 1 ORDER BY id")
234
- result.map { |r| r[:migration_name] }
246
+ result.map { |r| normalize_row(r)[:migration_name] }
235
247
  end
236
248
 
237
249
  def completed_migrations_with_batch
238
250
  result = @db.fetch("SELECT id, migration_name, batch FROM #{TRACKING_TABLE} WHERE passed = 1 ORDER BY id")
239
- result.map { |r| { id: r[:id], migration_name: r[:migration_name], batch: r[:batch] } }
251
+ result.map do |r|
252
+ row = normalize_row(r)
253
+ { id: row[:id], migration_name: row[:migration_name], batch: row[:batch] }
254
+ end
240
255
  end
241
256
 
242
257
  def next_batch_number
243
- result = @db.fetch_one("SELECT MAX(batch) as max_batch FROM #{TRACKING_TABLE} WHERE passed = 1")
244
- (result && result[:max_batch] ? result[:max_batch].to_i : 0) + 1
258
+ result = normalize_row(@db.fetch_one("SELECT MAX(batch) as max_batch FROM #{TRACKING_TABLE} WHERE passed = 1"))
259
+ (result[:max_batch] ? result[:max_batch].to_i : 0) + 1
245
260
  end
246
261
 
247
262
  def pending_migrations
@@ -479,12 +494,22 @@ module Tina4
479
494
  next
480
495
  end
481
496
 
482
- # Statement delimiter — only reached outside blocks/comments/strings.
497
+ # Statement delimiter — only reached outside blocks/comments/strings. A
498
+ # SET TERM directive switches the active terminator and is consumed
499
+ # (never emitted); any other completed statement is collected.
483
500
  if !delimiter.empty? && sql[i, dlen] == delimiter
501
+ i += dlen
484
502
  stmt = current.strip
485
- statements << stmt unless stmt.empty?
486
503
  current = +""
487
- i += dlen
504
+ unless stmt.empty?
505
+ new_term = parse_set_term(stmt)
506
+ if new_term
507
+ delimiter = new_term
508
+ dlen = delimiter.length
509
+ else
510
+ statements << stmt
511
+ end
512
+ end
488
513
  next
489
514
  end
490
515
 
@@ -492,11 +517,30 @@ module Tina4
492
517
  i += 1
493
518
  end
494
519
 
520
+ # Trailing statement (may not end with a delimiter). A trailing SET TERM
521
+ # directive is a no-op — consume it, don't emit it.
495
522
  stmt = current.strip
496
- statements << stmt unless stmt.empty?
523
+ statements << stmt unless stmt.empty? || parse_set_term(stmt)
497
524
  statements
498
525
  end
499
526
 
527
+ # Return the new terminator from a `SET TERM <new> <current>` directive, or
528
+ # nil when +statement+ is not a SET TERM directive.
529
+ #
530
+ # SET TERM is a script-level directive (recognised by isql and other
531
+ # InterBase/Firebird tooling, not run by the engine) that changes the
532
+ # terminator separating statements. Recognising it lets a statement whose own
533
+ # body contains the default ";" terminator — a trigger, stored procedure or
534
+ # EXECUTE BLOCK — be kept intact rather than split on those inner ";". The
535
+ # terminator may be more than one character (e.g. "!!").
536
+ #
537
+ # @param statement [String] a single, already-stripped statement
538
+ # @return [String, nil] the new terminator, or nil when not a SET TERM directive
539
+ def parse_set_term(statement)
540
+ m = statement.strip.match(/\ASET\s+TERM\s+(\S+)\z/i)
541
+ m && m[1]
542
+ end
543
+
500
544
  def execute_sql_file(file)
501
545
  sql = File.read(file)
502
546
  statements = split_sql_statements(sql)
data/lib/tina4/orm.rb CHANGED
@@ -419,6 +419,20 @@ module Tina4
419
419
  else "DATETIME"
420
420
  end
421
421
 
422
+ # Engine-aware JSON column type (parity with the Python master's
423
+ # JSONField DDL). PostgreSQL gets native JSONB (indexable, canonical);
424
+ # MySQL native JSON; MSSQL stores JSON as NVARCHAR(MAX) (its JSON
425
+ # functions read that); Firebird has no TEXT type so it uses a text
426
+ # BLOB; SQLite and everything else store the JSON text in TEXT
427
+ # (queryable via json1).
428
+ json_sql = case engine
429
+ when "postgres", "postgresql" then "JSONB"
430
+ when "mysql" then "JSON"
431
+ when "mssql", "sqlserver" then "NVARCHAR(MAX)"
432
+ when "firebird" then "BLOB SUB_TYPE TEXT"
433
+ else "TEXT"
434
+ end
435
+
422
436
  type_map = {
423
437
  integer: "INTEGER",
424
438
  string: "VARCHAR(255)",
@@ -430,7 +444,7 @@ module Tina4
430
444
  datetime: datetime_sql,
431
445
  timestamp: "TIMESTAMP",
432
446
  blob: "BLOB",
433
- json: "TEXT"
447
+ json: json_sql
434
448
  }
435
449
 
436
450
  col_defs = []
@@ -444,7 +458,11 @@ module Tina4
444
458
  parts << "PRIMARY KEY" if opts[:primary_key]
445
459
  parts << "AUTOINCREMENT" if opts[:auto_increment]
446
460
  parts << "NOT NULL" if !opts[:nullable] && !opts[:primary_key]
447
- if opts[:default] && !opts[:auto_increment]
461
+ # A JSON column carries no DDL DEFAULT (parity with the Python master):
462
+ # a dict/list default is an application-level concern, applied per
463
+ # instance, not a portable SQL literal (PG needs a ::jsonb cast, MySQL
464
+ # an expression default). The instance still gets its default at new.
465
+ if opts[:default] && !opts[:auto_increment] && opts[:type] != :json
448
466
  parts << "DEFAULT #{default_literal(opts[:default], opts[:type], bool_sql)}"
449
467
  end
450
468
  col_defs << parts.join(" ")
@@ -486,6 +504,21 @@ module Tina4
486
504
  hash.each do |key, value|
487
505
  # Apply field mapping (db_col => ruby_attr)
488
506
  attr_name = mapping_reverse[key.to_s] || key
507
+ # A JSON column comes back from the driver as a JSON string (SQLite
508
+ # TEXT, MySQL JSON, PostgreSQL JSONB via the text protocol, MSSQL
509
+ # NVARCHAR). Decode it to the Hash/Array the attribute expects
510
+ # (parity with the Python master's JSONField parse-on-read). A value
511
+ # already a Hash/Array is left untouched; nil stays nil; a
512
+ # non-decodable string keeps its raw form rather than crashing a
513
+ # normal read.
514
+ fdef = field_definitions[attr_name.to_sym]
515
+ if fdef && fdef[:type] == :json && value.is_a?(String)
516
+ begin
517
+ value = JSON.parse(value)
518
+ rescue JSON::ParserError
519
+ # leave the raw string in place
520
+ end
521
+ end
489
522
  setter = "#{attr_name}="
490
523
  instance.__send__(setter, value) if instance.respond_to?(setter)
491
524
  end
@@ -610,6 +643,11 @@ module Tina4
610
643
  if __send__(name).nil? && opts[:default]
611
644
  d = opts[:default]
612
645
  d = d.call if d.respond_to?(:call) && !d.is_a?(Class)
646
+ # Deep-copy a mutable Hash/Array default so two instances never alias
647
+ # the same object (e.g. `json_field :meta, default: {}` — mutating
648
+ # a.meta must not leak into b.meta). Parity with the Python master,
649
+ # which deepcopies a JSONField's dict/list default per instance.
650
+ d = Marshal.load(Marshal.dump(d)) if d.is_a?(Hash) || d.is_a?(Array)
613
651
  __send__("#{name}=", d)
614
652
  end
615
653
  end
@@ -667,7 +705,6 @@ module Tina4
667
705
  return false
668
706
  end
669
707
 
670
- data = to_db_hash(exclude_nil: true)
671
708
  pk = self.class.primary_key_field || :id
672
709
  pk_value = __send__(pk)
673
710
  pk_opts = self.class.field_definitions[pk] || {}
@@ -694,6 +731,10 @@ module Tina4
694
731
  end
695
732
 
696
733
  begin
734
+ # Built here (inside the rescue's reach) so a JSON column that can't be
735
+ # serialised (to_db_hash raises JSON::GeneratorError) fails loud through
736
+ # the same path as a driver error — rolled back, false, cause recorded.
737
+ data = to_db_hash(exclude_nil: true)
697
738
  self.class.db.transaction do |db|
698
739
  if is_update
699
740
  filter = { pk => pk_value }
@@ -941,9 +982,19 @@ module Tina4
941
982
  def to_db_hash(exclude_nil: false)
942
983
  hash = {}
943
984
  mapping = self.class.field_mapping
944
- self.class.field_definitions.each_key do |name|
985
+ self.class.field_definitions.each do |name, opts|
945
986
  value = __send__(name)
946
987
  next if exclude_nil && value.nil?
988
+ # A JSON column serialises its Hash/Array to a JSON string for the
989
+ # driver (parity with the Python master's JSONField.to_db). A value
990
+ # that can't be encoded (e.g. Float::NAN) raises JSON::GeneratorError;
991
+ # save() computes this hash inside its rescue, so it fails loud —
992
+ # rolls back, returns false, records the cause. A value already a
993
+ # String is left as-is (a caller who pre-encoded stays in control);
994
+ # nil passes through untouched.
995
+ if opts[:type] == :json && !value.nil? && !value.is_a?(String)
996
+ value = JSON.generate(value)
997
+ end
947
998
  db_col = mapping[name.to_s] || name
948
999
  hash[db_col.to_sym] = value
949
1000
  end
data/lib/tina4/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.13.52"
4
+ VERSION = "3.13.54"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.13.52
4
+ version: 3.13.54
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-07-04 00:00:00.000000000 Z
11
+ date: 2026-07-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack