tina4ruby 3.13.69 → 3.13.71
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/tina4/dev_admin.rb +5 -0
- data/lib/tina4/drivers/firebird_driver.rb +40 -1
- data/lib/tina4/field_types.rb +18 -2
- data/lib/tina4/mcp.rb +12 -5
- data/lib/tina4/orm.rb +79 -12
- data/lib/tina4/test_client.rb +2 -0
- data/lib/tina4/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9f7c750568ef4a0318f99b67d4d92a5a9ce7b8ba4acbb6ac44be312713f8bced
|
|
4
|
+
data.tar.gz: 0a206bb1d8e813073e5d2914d5f90f450ab5bb52f74fd60fe5cdc27f11ee1733
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a188e4d9926366ddc6152711722f9bfaddba8740353abb1b616e931c4ebec67ef26a798b5cb54248f95ea9987510bae5b931627da5b7e9aa6ca58967528bb6df
|
|
7
|
+
data.tar.gz: a554393a76905b3bd88304c7d70e76b59e29c678d7248ff880d0754ec2261e23c7699536b8a29916c9855f21973de43e60fe044fa428c0f4107048a972f810b7
|
data/lib/tina4/dev_admin.rb
CHANGED
|
@@ -969,6 +969,11 @@ module Tina4
|
|
|
969
969
|
return { error: "No database configured" } unless db
|
|
970
970
|
|
|
971
971
|
begin
|
|
972
|
+
# Force-load the seeder: Tina4.seed_table is defined in
|
|
973
|
+
# lib/tina4/seeder.rb, reachable only via the Tina4::FakeData autoload,
|
|
974
|
+
# which a bare Tina4.seed_table call never trips (mirrors Python's
|
|
975
|
+
# explicit `from tina4_python.seeder import seed_table`).
|
|
976
|
+
require_relative "seeder"
|
|
972
977
|
# Delegate to the shared resilient seed_table helper so the endpoint
|
|
973
978
|
# gets the exact same per-row wrap (P1) — no unhandled row failure can
|
|
974
979
|
# crash the endpoint — plus clear/seed/strict (P2/P3). _normalize_columns
|
|
@@ -76,7 +76,42 @@ module Tina4
|
|
|
76
76
|
decoded.start_with?("/") ? decoded : "/#{decoded}"
|
|
77
77
|
end
|
|
78
78
|
|
|
79
|
-
|
|
79
|
+
# Resolve the Firebird connection charset (#160, mirrors php #160 /
|
|
80
|
+
# the Python master's _resolve_firebird_charset).
|
|
81
|
+
#
|
|
82
|
+
# The driver used to pass NO charset to the `fb` gem, leaving it to the
|
|
83
|
+
# gem's own (non-UTF8) default — which double-encodes UTF-8 bytes stored
|
|
84
|
+
# under a legacy NONE database and diverges from the other frameworks.
|
|
85
|
+
# The charset is now resolved from, in precedence order:
|
|
86
|
+
#
|
|
87
|
+
# 1. the connection URL query — firebird://host:port/path?charset=NONE
|
|
88
|
+
# 2. an explicit charset: kwarg passed to #connect
|
|
89
|
+
# 3. the TINA4_DATABASE_CHARSET environment variable
|
|
90
|
+
# 4. the UTF8 default (canonical across all four frameworks)
|
|
91
|
+
#
|
|
92
|
+
# Pure config resolution over its inputs (URL string, kwarg, env) — it
|
|
93
|
+
# opens NO connection, so it is unit-testable without a live server. A
|
|
94
|
+
# blank value at any level is treated as absent (matching the Python
|
|
95
|
+
# master's falsy-string semantics) so `?charset=` / an empty env var falls
|
|
96
|
+
# through rather than connecting with an empty charset.
|
|
97
|
+
def self.resolve_charset(connection_string, kwarg_charset = nil)
|
|
98
|
+
require "uri"
|
|
99
|
+
url_charset = nil
|
|
100
|
+
query = begin
|
|
101
|
+
URI.parse(connection_string.to_s).query
|
|
102
|
+
rescue URI::InvalidURIError
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
if query && !query.empty?
|
|
106
|
+
pair = URI.decode_www_form(query).find { |k, _| k == "charset" }
|
|
107
|
+
url_charset = pair[1] if pair && !pair[1].to_s.empty?
|
|
108
|
+
end
|
|
109
|
+
kwarg = kwarg_charset.to_s.empty? ? nil : kwarg_charset
|
|
110
|
+
env = ENV["TINA4_DATABASE_CHARSET"].to_s.empty? ? nil : ENV["TINA4_DATABASE_CHARSET"]
|
|
111
|
+
url_charset || kwarg || env || "UTF8"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def connect(connection_string, username: nil, password: nil, charset: nil)
|
|
80
115
|
require "fb"
|
|
81
116
|
require "uri"
|
|
82
117
|
uri = URI.parse(connection_string)
|
|
@@ -115,6 +150,10 @@ module Tina4
|
|
|
115
150
|
@connect_opts = { database: database }
|
|
116
151
|
@connect_opts[:username] = db_user if db_user
|
|
117
152
|
@connect_opts[:password] = db_pass if db_pass
|
|
153
|
+
# #160: honour ?charset= in the URL, an explicit charset: kwarg, and
|
|
154
|
+
# TINA4_DATABASE_CHARSET so a legacy NONE database isn't force-connected
|
|
155
|
+
# with the gem's default charset (double-encoding). Defaults to UTF8.
|
|
156
|
+
@connect_opts[:charset] = self.class.resolve_charset(connection_string, charset)
|
|
118
157
|
|
|
119
158
|
open_connection
|
|
120
159
|
rescue LoadError
|
data/lib/tina4/field_types.rb
CHANGED
|
@@ -187,8 +187,24 @@ module Tina4
|
|
|
187
187
|
field_definitions[name] = { type: type }.merge(options)
|
|
188
188
|
@primary_key_field = name if options[:primary_key]
|
|
189
189
|
|
|
190
|
-
#
|
|
191
|
-
|
|
190
|
+
# Getter (plain reader) + an assignment-tracking setter.
|
|
191
|
+
#
|
|
192
|
+
# #165: save() must tell a column the caller EXPLICITLY set to nil
|
|
193
|
+
# (write it — an explicit nil becomes SQL NULL) from one left UNSET
|
|
194
|
+
# (omit it from the INSERT so a NOT NULL DEFAULT column gets its DB
|
|
195
|
+
# default instead of an explicit NULL). The setter records the field
|
|
196
|
+
# name in @assigned_fields on every caller assignment — via the
|
|
197
|
+
# constructor's attribute application, a from_hash/load populate, or a
|
|
198
|
+
# direct `model.field = x`. Field DEFAULTS are seeded in #initialize
|
|
199
|
+
# with instance_variable_set (bypassing this setter), so a default is
|
|
200
|
+
# never counted as a caller assignment. Mirrors the Python master's
|
|
201
|
+
# __setattr__ + object.__setattr__ default-seeding.
|
|
202
|
+
attr_reader name
|
|
203
|
+
define_method("#{name}=") do |value|
|
|
204
|
+
fields = (@assigned_fields ||= [])
|
|
205
|
+
fields << name unless fields.include?(name)
|
|
206
|
+
instance_variable_set("@#{name}", value)
|
|
207
|
+
end
|
|
192
208
|
end
|
|
193
209
|
end
|
|
194
210
|
end
|
data/lib/tina4/mcp.rb
CHANGED
|
@@ -906,7 +906,9 @@ module Tina4
|
|
|
906
906
|
# ── Template Tools ────────────────────────────────
|
|
907
907
|
server.register_tool("template_render", lambda { |template:, data: "{}"|
|
|
908
908
|
ctx = data.is_a?(String) ? JSON.parse(data) : data
|
|
909
|
-
Tina4::Template
|
|
909
|
+
# render_string is an INSTANCE method of Frond (Tina4::Template has none).
|
|
910
|
+
# Mirrors Python tools.py: Frond("src/templates").render_string(...).
|
|
911
|
+
Tina4::Frond.new(template_dir: "src/templates").render_string(template, ctx)
|
|
910
912
|
}, "Render a template string with data")
|
|
911
913
|
|
|
912
914
|
# ── File Tools ────────────────────────────────────
|
|
@@ -1023,9 +1025,9 @@ module Tina4
|
|
|
1023
1025
|
q = Tina4::Queue.new(topic: topic)
|
|
1024
1026
|
{
|
|
1025
1027
|
"topic" => topic,
|
|
1026
|
-
"pending" => q.size("pending"),
|
|
1027
|
-
"completed" => q.size("completed"),
|
|
1028
|
-
"failed" => q.size("failed")
|
|
1028
|
+
"pending" => q.size(status: "pending"),
|
|
1029
|
+
"completed" => q.size(status: "completed"),
|
|
1030
|
+
"failed" => q.size(status: "failed")
|
|
1029
1031
|
}
|
|
1030
1032
|
rescue => e
|
|
1031
1033
|
{ "error" => e.message }
|
|
@@ -1107,10 +1109,15 @@ module Tina4
|
|
|
1107
1109
|
# ── Data Tools ────────────────────────────────────
|
|
1108
1110
|
server.register_tool("seed_table", lambda { |table:, count: 10|
|
|
1109
1111
|
begin
|
|
1112
|
+
# Tina4.seed_table lives in lib/tina4/seeder.rb, reachable only via the
|
|
1113
|
+
# autoload of Tina4::FakeData — a bare Tina4.seed_table call never trips
|
|
1114
|
+
# it. Force-load the seeder first (mirrors Python's explicit
|
|
1115
|
+
# `from tina4_python.seeder import seed_table`).
|
|
1116
|
+
require_relative "seeder"
|
|
1110
1117
|
db = Tina4.database
|
|
1111
1118
|
return { "error" => "No database connection" } if db.nil?
|
|
1112
1119
|
inserted = Tina4.seed_table(table, db.columns(table), count: count.to_i)
|
|
1113
|
-
{ "table" => table, "inserted" => inserted }
|
|
1120
|
+
{ "table" => table, "inserted" => inserted.seeded, "failed" => inserted.failed }
|
|
1114
1121
|
rescue => e
|
|
1115
1122
|
{ "error" => e.message }
|
|
1116
1123
|
end
|
data/lib/tina4/orm.rb
CHANGED
|
@@ -625,6 +625,13 @@ module Tina4
|
|
|
625
625
|
# cause via #get_error / #last_error — the failure never vanishes silently.
|
|
626
626
|
@last_error = nil
|
|
627
627
|
@relationship_cache = {}
|
|
628
|
+
# #165: field names the caller EXPLICITLY assigned (via the attribute loop
|
|
629
|
+
# below, a from_hash/load populate, or a later `model.field = x`). save()
|
|
630
|
+
# reads this to OMIT an unset column from an INSERT (so a NOT NULL DEFAULT
|
|
631
|
+
# column gets its DB default) while still writing NULL for a field the
|
|
632
|
+
# caller set to nil. The defaults seeded below use instance_variable_set,
|
|
633
|
+
# bypassing the tracking setter so they are NOT counted as assignments.
|
|
634
|
+
@assigned_fields = []
|
|
628
635
|
# Accept a JSON object string (parity with Python/PHP/Node):
|
|
629
636
|
# Widget.new('{"id":1,"name":"alpha"}')
|
|
630
637
|
attributes = JSON.parse(attributes) if attributes.is_a?(String)
|
|
@@ -654,7 +661,10 @@ module Tina4
|
|
|
654
661
|
# a.meta must not leak into b.meta). Parity with the Python master,
|
|
655
662
|
# which deepcopies a JSONField's dict/list default per instance.
|
|
656
663
|
d = Marshal.load(Marshal.dump(d)) if d.is_a?(Hash) || d.is_a?(Array)
|
|
657
|
-
|
|
664
|
+
# #165: seed the default straight into the ivar, BYPASSING the
|
|
665
|
+
# tracking setter, so a default is not recorded as a caller
|
|
666
|
+
# assignment (mirrors the Python master's object.__setattr__).
|
|
667
|
+
instance_variable_set("@#{name}", d)
|
|
658
668
|
end
|
|
659
669
|
end
|
|
660
670
|
end
|
|
@@ -737,12 +747,15 @@ module Tina4
|
|
|
737
747
|
end
|
|
738
748
|
|
|
739
749
|
begin
|
|
740
|
-
#
|
|
741
|
-
#
|
|
742
|
-
#
|
|
743
|
-
|
|
750
|
+
# The column hashes are built INSIDE this begin (via the transaction
|
|
751
|
+
# block below) so a JSON column that can't be serialised (to_db_hash /
|
|
752
|
+
# insert_db_hash raises JSON::GeneratorError) fails loud through the
|
|
753
|
+
# same path as a driver error — rolled back, false, cause recorded.
|
|
744
754
|
self.class.db.transaction do |db|
|
|
745
755
|
if is_update
|
|
756
|
+
# UPDATE is unchanged (#165 targets INSERT only): keep excluding nil
|
|
757
|
+
# so a save never nulls a column the caller didn't touch.
|
|
758
|
+
data = to_db_hash(exclude_nil: true)
|
|
746
759
|
filter = { pk => pk_value }
|
|
747
760
|
data.delete(pk)
|
|
748
761
|
# Remove mapped primary key too
|
|
@@ -750,13 +763,32 @@ module Tina4
|
|
|
750
763
|
data.delete(mapped_pk.to_sym) if mapped_pk
|
|
751
764
|
db.update(self.class.table_name, data, filter)
|
|
752
765
|
else
|
|
753
|
-
|
|
754
|
-
#
|
|
755
|
-
#
|
|
756
|
-
#
|
|
757
|
-
|
|
758
|
-
if
|
|
759
|
-
|
|
766
|
+
# #165: OMIT a column the caller left unset (value nil, never
|
|
767
|
+
# assigned) so a NOT NULL DEFAULT column gets its DB default rather
|
|
768
|
+
# than an explicit NULL; a column the caller set to nil is KEPT and
|
|
769
|
+
# written as NULL (see #insert_db_hash).
|
|
770
|
+
insert_data = insert_db_hash
|
|
771
|
+
if insert_data.empty?
|
|
772
|
+
# Every insertable column is unset — let the engine apply ALL its
|
|
773
|
+
# column defaults instead of emitting explicit NULLs. DEFAULT
|
|
774
|
+
# VALUES is valid on SQLite / PostgreSQL / MSSQL / Firebird; MySQL
|
|
775
|
+
# spells the all-defaults insert () VALUES ().
|
|
776
|
+
table = self.class.table_name
|
|
777
|
+
engine = (self.class.db.respond_to?(:get_database_type) ? self.class.db.get_database_type : "").to_s.downcase
|
|
778
|
+
db.execute(engine == "mysql" ? "INSERT INTO #{table} () VALUES ()" : "INSERT INTO #{table} DEFAULT VALUES")
|
|
779
|
+
if auto_increment && respond_to?("#{pk}=")
|
|
780
|
+
last = db.get_last_id
|
|
781
|
+
__send__("#{pk}=", last) if last
|
|
782
|
+
end
|
|
783
|
+
else
|
|
784
|
+
result = db.insert(self.class.table_name, insert_data)
|
|
785
|
+
# Only adopt the engine-assigned id for an auto-increment PK. A
|
|
786
|
+
# natural-key PK was set by the caller; don't overwrite it with the
|
|
787
|
+
# driver's last_insert_id (which may be a sequence value that
|
|
788
|
+
# doesn't apply here).
|
|
789
|
+
if auto_increment && result[:last_id] && respond_to?("#{pk}=")
|
|
790
|
+
__send__("#{pk}=", result[:last_id])
|
|
791
|
+
end
|
|
760
792
|
end
|
|
761
793
|
end
|
|
762
794
|
end
|
|
@@ -1007,6 +1039,41 @@ module Tina4
|
|
|
1007
1039
|
|
|
1008
1040
|
private
|
|
1009
1041
|
|
|
1042
|
+
# Build the column=>value hash for an INSERT (#165).
|
|
1043
|
+
#
|
|
1044
|
+
# Distinguishes a column the caller left UNSET from one the caller
|
|
1045
|
+
# explicitly set to nil (tracked in @assigned_fields, populated by the field
|
|
1046
|
+
# setters):
|
|
1047
|
+
#
|
|
1048
|
+
# * value nil AND field NOT assigned -> OMITTED (a DB DEFAULT applies, so
|
|
1049
|
+
# a NOT NULL DEFAULT column succeeds instead of taking an explicit NULL)
|
|
1050
|
+
# * value nil AND field assigned -> KEPT as nil (written as SQL NULL;
|
|
1051
|
+
# a NOT NULL column rightly rejects it, so an explicit nil fails loud)
|
|
1052
|
+
# * any non-nil value (incl. a resolved ORM default) -> KEPT
|
|
1053
|
+
#
|
|
1054
|
+
# An unset auto-increment PK is skipped so the engine assigns the id. JSON
|
|
1055
|
+
# columns serialise their Hash/Array exactly as #to_db_hash does. An empty
|
|
1056
|
+
# result signals save() to emit the engine's all-defaults INSERT. Mirrors
|
|
1057
|
+
# the Python master's INSERT omission (tina4_python.orm.model.save).
|
|
1058
|
+
def insert_db_hash
|
|
1059
|
+
hash = {}
|
|
1060
|
+
mapping = self.class.field_mapping
|
|
1061
|
+
assigned = @assigned_fields || []
|
|
1062
|
+
self.class.field_definitions.each do |name, opts|
|
|
1063
|
+
value = __send__(name)
|
|
1064
|
+
# Skip an unset auto-increment PK — let the engine assign it.
|
|
1065
|
+
next if opts[:auto_increment] && value.nil?
|
|
1066
|
+
# Omit an unset-nil column so the DB DEFAULT applies.
|
|
1067
|
+
next if value.nil? && !assigned.include?(name)
|
|
1068
|
+
if opts[:type] == :json && !value.nil? && !value.is_a?(String)
|
|
1069
|
+
value = JSON.generate(value)
|
|
1070
|
+
end
|
|
1071
|
+
db_col = mapping[name.to_s] || name
|
|
1072
|
+
hash[db_col.to_sym] = value
|
|
1073
|
+
end
|
|
1074
|
+
hash
|
|
1075
|
+
end
|
|
1076
|
+
|
|
1010
1077
|
# Convert to hash using DB column names (with field_mapping applied)
|
|
1011
1078
|
def to_db_hash(exclude_nil: false)
|
|
1012
1079
|
hash = {}
|
data/lib/tina4/test_client.rb
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
#
|
|
14
14
|
# response = client.get("/api/users/1", headers: { "Authorization" => "Bearer token123" })
|
|
15
15
|
#
|
|
16
|
+
require "stringio" # rack.input is a StringIO; require it here so a clean `require "tina4"` boot never NameErrors
|
|
17
|
+
|
|
16
18
|
module Tina4
|
|
17
19
|
class TestResponse
|
|
18
20
|
attr_reader :status, :body, :headers, :content_type
|
data/lib/tina4/version.rb
CHANGED
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.
|
|
4
|
+
version: 3.13.71
|
|
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-
|
|
11
|
+
date: 2026-07-11 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rack
|