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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e96ce1bc6835280fdee7d6acc8d22af2fd4d013fbd658501aa189cf01266dabc
4
- data.tar.gz: 236dbc434352d4210956b45e2eedcfc5129b849d47b636edc915084695098c3f
3
+ metadata.gz: 9f7c750568ef4a0318f99b67d4d92a5a9ce7b8ba4acbb6ac44be312713f8bced
4
+ data.tar.gz: 0a206bb1d8e813073e5d2914d5f90f450ab5bb52f74fd60fe5cdc27f11ee1733
5
5
  SHA512:
6
- metadata.gz: 39d2f2a93cbee0c2b8d95fb8f4da308e1809e8ffb30100320a39143f07f88fcaa020649c802f7cf753289884f4984940f666cc81c4e0e9ab274a1fcbf5aca3ec
7
- data.tar.gz: 057a277a69d9768542103f8459d3765819e9c6f5d2418c676977dbb59d865126f21d490d8de7b6a36f55f0849b376a71fac45df3946448f62d33a94e1e5c1d12
6
+ metadata.gz: a188e4d9926366ddc6152711722f9bfaddba8740353abb1b616e931c4ebec67ef26a798b5cb54248f95ea9987510bae5b931627da5b7e9aa6ca58967528bb6df
7
+ data.tar.gz: a554393a76905b3bd88304c7d70e76b59e29c678d7248ff880d0754ec2261e23c7699536b8a29916c9855f21973de43e60fe044fa428c0f4107048a972f810b7
@@ -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
- def connect(connection_string, username: nil, password: nil)
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
@@ -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
- # Define getter/setter
191
- attr_accessor name
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.render_string(template, ctx)
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
- __send__("#{name}=", d)
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
- # Built here (inside the rescue's reach) so a JSON column that can't be
741
- # serialised (to_db_hash raises JSON::GeneratorError) fails loud through
742
- # the same path as a driver error — rolled back, false, cause recorded.
743
- data = to_db_hash(exclude_nil: true)
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
- result = db.insert(self.class.table_name, data)
754
- # Only adopt the engine-assigned id for an auto-increment PK. A
755
- # natural-key PK was set by the caller; don't overwrite it with the
756
- # driver's last_insert_id (which may be a sequence value that
757
- # doesn't apply here).
758
- if auto_increment && result[:last_id] && respond_to?("#{pk}=")
759
- __send__("#{pk}=", result[:last_id])
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 = {}
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.13.69"
4
+ VERSION = "3.13.71"
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.69
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-10 00:00:00.000000000 Z
11
+ date: 2026-07-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack