tina4ruby 3.13.68 → 3.13.70
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/api.rb +429 -41
- 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: 87f4e1923c2fc20dbfdce286957c5e0505ccc37091e51ea6b0286041a342dacc
|
|
4
|
+
data.tar.gz: be4a0fc97b935313a27f8b5f53941cff0e939c10d73a5fbca081409a010db210
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 44806d41e9bcbc45e4666a484a026ac7bb7c90babb8d7e5e031cd5316995f5faa7f9b303760e3339d67a2540aaddcd1fc29f1ca553593f15788c86048c8f8c8b
|
|
7
|
+
data.tar.gz: a4c84561d96968f9ca8c5eafaebb4cfd480a3693a88b63d7c694d64bedc9a4fa232e87c405f1a0e72693751c8a273d5969606fe115dfd879e9e8b60d7033e6af
|
data/lib/tina4/api.rb
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
|
|
2
3
|
require "net/http"
|
|
3
4
|
require "openssl"
|
|
4
5
|
require "uri"
|
|
5
6
|
require "json"
|
|
6
7
|
require "base64"
|
|
8
|
+
require "securerandom"
|
|
7
9
|
|
|
8
10
|
module Tina4
|
|
9
11
|
# Statuses that warrant an automatic retry when max_retries > 0: rate-limit
|
|
@@ -12,6 +14,51 @@ module Tina4
|
|
|
12
14
|
# master's _RETRY_STATUSES.
|
|
13
15
|
API_RETRY_STATUSES = [429, 500, 502, 503, 504].freeze
|
|
14
16
|
|
|
17
|
+
# Streaming download reads/writes this many bytes per chunk so a multi-megabyte
|
|
18
|
+
# body never lands in memory in one piece. Parity with the Python master's
|
|
19
|
+
# _DOWNLOAD_CHUNK_SIZE.
|
|
20
|
+
API_DOWNLOAD_CHUNK_SIZE = 64 * 1024
|
|
21
|
+
|
|
22
|
+
# Redirect codes we follow (301/302/303/307/308). Net::HTTP does NOT auto-follow,
|
|
23
|
+
# so the client follows them itself in a bounded loop (see #network_call).
|
|
24
|
+
API_REDIRECT_STATUSES = [301, 302, 303, 307, 308].freeze
|
|
25
|
+
|
|
26
|
+
# Upper bound on redirect hops before we stop following and return the last
|
|
27
|
+
# 3xx response. Matches urllib's default (Python master).
|
|
28
|
+
API_MAX_REDIRECTS = 10
|
|
29
|
+
|
|
30
|
+
# Headers dropped when a redirect crosses to a different origin — a bearer
|
|
31
|
+
# token or a session cookie must never be handed to a host you didn't
|
|
32
|
+
# authenticate to. Compared case-insensitively. Parity with the Python
|
|
33
|
+
# master's _STRIP_ON_CROSS_ORIGIN.
|
|
34
|
+
API_STRIP_ON_CROSS_ORIGIN = %w[authorization cookie].freeze
|
|
35
|
+
|
|
36
|
+
# A curated extension -> MIME map for guessing a multipart part's Content-Type
|
|
37
|
+
# from its filename (Ruby core ships no mimetypes db, and pulling one in would
|
|
38
|
+
# break the zero-dependency promise). tina4: a small built-in map covering the
|
|
39
|
+
# common upload types; anything unlisted falls back to application/octet-stream.
|
|
40
|
+
# Mirrors the guess-with-fallback the Python master gets from mimetypes.
|
|
41
|
+
API_MIME_BY_EXTENSION = {
|
|
42
|
+
"png" => "image/png", "jpg" => "image/jpeg", "jpeg" => "image/jpeg",
|
|
43
|
+
"gif" => "image/gif", "webp" => "image/webp", "svg" => "image/svg+xml",
|
|
44
|
+
"bmp" => "image/bmp", "ico" => "image/x-icon", "tif" => "image/tiff",
|
|
45
|
+
"tiff" => "image/tiff",
|
|
46
|
+
"pdf" => "application/pdf", "json" => "application/json",
|
|
47
|
+
"xml" => "application/xml", "zip" => "application/zip",
|
|
48
|
+
"gz" => "application/gzip", "tar" => "application/x-tar",
|
|
49
|
+
"csv" => "text/csv", "txt" => "text/plain", "html" => "text/html",
|
|
50
|
+
"htm" => "text/html", "css" => "text/css", "js" => "text/javascript",
|
|
51
|
+
"md" => "text/markdown",
|
|
52
|
+
"mp3" => "audio/mpeg", "wav" => "audio/wav", "ogg" => "audio/ogg",
|
|
53
|
+
"mp4" => "video/mp4", "mov" => "video/quicktime", "webm" => "video/webm",
|
|
54
|
+
"doc" => "application/msword",
|
|
55
|
+
"docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
56
|
+
"xls" => "application/vnd.ms-excel",
|
|
57
|
+
"xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
58
|
+
"ppt" => "application/vnd.ms-powerpoint",
|
|
59
|
+
"pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
|
60
|
+
}.freeze
|
|
61
|
+
|
|
15
62
|
class API
|
|
16
63
|
attr_reader :base_url, :headers
|
|
17
64
|
|
|
@@ -32,9 +79,33 @@ module Tina4
|
|
|
32
79
|
# (429/5xx). A retried non-idempotent request (POST/PUT/PATCH/DELETE) may be
|
|
33
80
|
# re-sent — retries are opt-in for exactly that reason. Parity with the
|
|
34
81
|
# Python master.
|
|
82
|
+
#
|
|
83
|
+
# 3.13.69: +transport (an injectable seam) and +cookies (an opt-in per-client
|
|
84
|
+
# cookie jar), both parity with the Python master.
|
|
85
|
+
#
|
|
86
|
+
# `transport` (default nil = the real Net::HTTP network path) is an injectable
|
|
87
|
+
# seam so that APPLICATION developers can unit-test their own code without a
|
|
88
|
+
# live server. When supplied it must respond to #call with the signature
|
|
89
|
+
# `call(method, url, headers, body, timeout)` and return a Hash shaped like
|
|
90
|
+
# `{http_code:/status:, body:, headers:, error:}` (string or symbol keys). It
|
|
91
|
+
# fully REPLACES the network call. NOTE: Tina4's own suite must NEVER inject a
|
|
92
|
+
# fake/canned transport — the no-mock rule stands, so framework tests always
|
|
93
|
+
# exercise the real network path against a real local server. The seam exists
|
|
94
|
+
# purely for application-developer testing.
|
|
95
|
+
#
|
|
96
|
+
# `cookies` (default false = off, zero behaviour change) turns on a
|
|
97
|
+
# per-client, in-memory cookie jar: `Set-Cookie` headers on responses are
|
|
98
|
+
# parsed (leading name=value only, last write wins) and the accumulated
|
|
99
|
+
# `Cookie` header is sent on subsequent requests. Not persisted; scoped to
|
|
100
|
+
# this instance.
|
|
35
101
|
def initialize(base_url, headers: {}, timeout: 30,
|
|
36
102
|
bearer_token: nil, username: nil, password: nil,
|
|
37
|
-
verify_ssl: nil, max_retries: 0, retry_backoff: 0.5
|
|
103
|
+
verify_ssl: nil, max_retries: 0, retry_backoff: 0.5,
|
|
104
|
+
transport: nil, cookies: false)
|
|
105
|
+
if !transport.nil? && !transport.respond_to?(:call)
|
|
106
|
+
raise ArgumentError, "transport must respond to #call(method, url, headers, body, timeout)"
|
|
107
|
+
end
|
|
108
|
+
|
|
38
109
|
@base_url = base_url.chomp("/")
|
|
39
110
|
@headers = {
|
|
40
111
|
"Content-Type" => "application/json",
|
|
@@ -44,6 +115,9 @@ module Tina4
|
|
|
44
115
|
@verify_ssl = verify_ssl
|
|
45
116
|
@max_retries = [0, max_retries.to_i].max
|
|
46
117
|
@retry_backoff = retry_backoff.to_f
|
|
118
|
+
@transport = transport
|
|
119
|
+
@cookies_enabled = cookies ? true : false
|
|
120
|
+
@cookies = {}
|
|
47
121
|
|
|
48
122
|
# Bearer wins over basic-auth when both passed
|
|
49
123
|
if bearer_token
|
|
@@ -101,19 +175,102 @@ module Tina4
|
|
|
101
175
|
execute(uri, request)
|
|
102
176
|
end
|
|
103
177
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
178
|
+
# POST a multipart/form-data body — a file plus optional text fields.
|
|
179
|
+
#
|
|
180
|
+
# BREAKING (3.13.69): reconciled to the canonical cross-framework shape.
|
|
181
|
+
# `file_path` was a REQUIRED positional; it is now a keyword. Two ways to
|
|
182
|
+
# supply the file, so a caller never needs a temp file:
|
|
183
|
+
#
|
|
184
|
+
# - file_path: — a file on disk. filename: defaults to its basename.
|
|
185
|
+
# - file_bytes: + filename: — an in-memory payload (String bytes).
|
|
186
|
+
#
|
|
187
|
+
# field_name: is the form field the file is sent under (default "file").
|
|
188
|
+
# extra_fields: (Hash) become additional text parts. headers: (Hash) are
|
|
189
|
+
# extra per-call headers merged onto the request. The part's Content-Type is
|
|
190
|
+
# guessed from the filename (falling back to application/octet-stream) — this
|
|
191
|
+
# replaces the old hard-coded application/octet-stream. The client's default
|
|
192
|
+
# headers (including any Authorization) are sent too, unlike the old upload().
|
|
193
|
+
#
|
|
194
|
+
# Returns an APIResponse. A missing file or no source given returns a clean
|
|
195
|
+
# error response (status 0, error set) — it does NOT raise, and nothing is
|
|
196
|
+
# sent over the wire.
|
|
197
|
+
#
|
|
198
|
+
# api.upload("/avatars", file_path: "/tmp/me.png")
|
|
199
|
+
# api.upload("/avatars", file_bytes: raw, filename: "me.png",
|
|
200
|
+
# extra_fields: { "user_id" => "42" })
|
|
201
|
+
def upload(path, file_path: nil, field_name: "file", extra_fields: {},
|
|
202
|
+
headers: {}, file_bytes: nil, filename: nil)
|
|
203
|
+
unless file_bytes.nil?
|
|
204
|
+
content = file_bytes.is_a?(String) ? file_bytes : file_bytes.to_s
|
|
205
|
+
upload_name = filename || "upload.bin"
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
if content.nil? && file_path
|
|
209
|
+
return error_response("file not found: #{file_path}") unless File.file?(file_path)
|
|
210
|
+
|
|
211
|
+
begin
|
|
212
|
+
content = File.binread(file_path)
|
|
213
|
+
rescue SystemCallError => e
|
|
214
|
+
return error_response(e.message)
|
|
215
|
+
end
|
|
216
|
+
upload_name = filename || File.basename(file_path)
|
|
217
|
+
end
|
|
107
218
|
|
|
108
|
-
|
|
219
|
+
return error_response("upload requires file_path or file_bytes") if content.nil?
|
|
109
220
|
|
|
221
|
+
part_content_type = guess_content_type(upload_name)
|
|
222
|
+
boundary = "----Tina4Boundary#{SecureRandom.hex(16)}"
|
|
223
|
+
body = build_multipart_body(boundary, field_name, upload_name, content,
|
|
224
|
+
part_content_type, extra_fields)
|
|
225
|
+
|
|
226
|
+
uri = build_uri(path)
|
|
110
227
|
request = Net::HTTP::Post.new(uri)
|
|
111
228
|
request.body = body
|
|
229
|
+
# Apply the client default headers (auth, etc.) FIRST, then force the
|
|
230
|
+
# multipart Content-Type so it wins over the default application/json,
|
|
231
|
+
# then any per-call headers last (parity with the Python master's order).
|
|
232
|
+
apply_headers(request, {})
|
|
112
233
|
request["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
|
|
113
|
-
headers.each { |
|
|
234
|
+
headers.each { |key, value| request[key] = value }
|
|
114
235
|
execute(uri, request)
|
|
115
236
|
end
|
|
116
237
|
|
|
238
|
+
# Stream a GET response body to dest_path in chunks.
|
|
239
|
+
#
|
|
240
|
+
# The body is written to disk API_DOWNLOAD_CHUNK_SIZE bytes at a time instead
|
|
241
|
+
# of being buffered whole in memory — safe for large payloads. Follows
|
|
242
|
+
# redirects (with the same cross-origin auth/cookie strip as every verb) and
|
|
243
|
+
# honours verify_ssl.
|
|
244
|
+
#
|
|
245
|
+
# Returns an APIResponse carrying `path` (and no body — it went to disk).
|
|
246
|
+
# `path` is dest_path on success and nil on any error (missing dest, HTTP
|
|
247
|
+
# error status, or a transport failure); the destination file is not written
|
|
248
|
+
# on a pre-flight or HTTP-status error. status is 0 on a transport failure.
|
|
249
|
+
def download(path, dest_path: nil, params: {})
|
|
250
|
+
return error_response("download requires dest_path", path: nil) unless dest_path
|
|
251
|
+
|
|
252
|
+
uri = build_uri(path, params)
|
|
253
|
+
headers = @headers.dup
|
|
254
|
+
cookie = cookie_header
|
|
255
|
+
headers["Cookie"] = cookie if @cookies_enabled && cookie
|
|
256
|
+
|
|
257
|
+
return download_via_transport(uri, headers, dest_path) if @transport
|
|
258
|
+
|
|
259
|
+
begin
|
|
260
|
+
result = network_call("GET", uri, headers, nil, stream_to: dest_path)
|
|
261
|
+
code = result[:status]
|
|
262
|
+
if result[:path]
|
|
263
|
+
APIResponse.new(status: code, body: nil, headers: result[:headers],
|
|
264
|
+
error: nil, path: result[:path])
|
|
265
|
+
else
|
|
266
|
+
APIResponse.new(status: code, body: nil, headers: result[:headers],
|
|
267
|
+
error: "download failed (HTTP #{code})", path: nil)
|
|
268
|
+
end
|
|
269
|
+
rescue StandardError => e
|
|
270
|
+
APIResponse.new(status: 0, body: nil, headers: {}, error: e.message, path: nil)
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
117
274
|
def set_basic_auth(username, password)
|
|
118
275
|
@headers["Authorization"] = "Basic #{Base64.strict_encode64("#{username}:#{password}")}"
|
|
119
276
|
self
|
|
@@ -179,59 +336,290 @@ module Tina4
|
|
|
179
336
|
response
|
|
180
337
|
end
|
|
181
338
|
|
|
182
|
-
# A single HTTP attempt. Returns the standardized APIResponse.
|
|
339
|
+
# A single HTTP attempt. Returns the standardized APIResponse. Dispatches to
|
|
340
|
+
# the injected transport seam when one is set, else the real network path.
|
|
183
341
|
def attempt_request(uri, request)
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
http.open_timeout = @timeout
|
|
191
|
-
http.read_timeout = @timeout
|
|
192
|
-
|
|
193
|
-
response = http.request(request)
|
|
342
|
+
method = request.method
|
|
343
|
+
headers = {}
|
|
344
|
+
request.each_header { |key, value| headers[key] = value }
|
|
345
|
+
cookie = cookie_header
|
|
346
|
+
headers["cookie"] = cookie if @cookies_enabled && cookie
|
|
347
|
+
body = request.body
|
|
194
348
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
headers
|
|
199
|
-
|
|
349
|
+
if @transport
|
|
350
|
+
transport_response(method, uri.to_s, headers, body)
|
|
351
|
+
else
|
|
352
|
+
result = network_call(method, uri, headers, body)
|
|
353
|
+
APIResponse.new(status: result[:status], body: result[:body] || "",
|
|
354
|
+
headers: result[:headers], error: nil)
|
|
355
|
+
end
|
|
200
356
|
rescue StandardError => e
|
|
357
|
+
APIResponse.new(status: 0, body: "", headers: {}, error: e.message)
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Perform a real HTTP round-trip via Net::HTTP, following redirects in a
|
|
361
|
+
# bounded loop and stripping Authorization/Cookie on a cross-origin hop.
|
|
362
|
+
# When stream_to is given, a 2xx body is streamed to that path in chunks
|
|
363
|
+
# (never buffered whole) and no file is written on a non-2xx status.
|
|
364
|
+
#
|
|
365
|
+
# Returns { status:, headers:, body:, path: } (body nil when streamed; path
|
|
366
|
+
# set only when a file was actually written). Raises on a transport failure
|
|
367
|
+
# (caller turns that into a status-0 error response).
|
|
368
|
+
def network_call(method, uri, headers, body, stream_to: nil)
|
|
369
|
+
remaining = API_MAX_REDIRECTS
|
|
370
|
+
current_method = method.to_s.upcase
|
|
371
|
+
current_uri = uri.is_a?(URI) ? uri : URI.parse(uri.to_s)
|
|
372
|
+
current_headers = headers.dup
|
|
373
|
+
current_body = body
|
|
374
|
+
|
|
375
|
+
loop do
|
|
376
|
+
http = Net::HTTP.new(current_uri.host, current_uri.port)
|
|
377
|
+
http.use_ssl = current_uri.scheme == "https"
|
|
378
|
+
# Only disable verification when EXPLICITLY false — nil/true keep the
|
|
379
|
+
# secure default (OpenSSL::SSL::VERIFY_PEER).
|
|
380
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_NONE if @verify_ssl == false
|
|
381
|
+
http.open_timeout = @timeout
|
|
382
|
+
http.read_timeout = @timeout
|
|
383
|
+
|
|
384
|
+
request = build_net_request(current_method, current_uri, current_headers, current_body)
|
|
385
|
+
|
|
386
|
+
status = nil
|
|
387
|
+
resp_headers = {}
|
|
388
|
+
body_string = nil
|
|
389
|
+
wrote_file = false
|
|
390
|
+
location = nil
|
|
391
|
+
redirect_code = nil
|
|
392
|
+
|
|
393
|
+
http.start do |conn|
|
|
394
|
+
conn.request(request) do |response|
|
|
395
|
+
status = response.code.to_i
|
|
396
|
+
resp_headers = response.to_hash
|
|
397
|
+
if API_REDIRECT_STATUSES.include?(status) && remaining.positive? && response["location"]
|
|
398
|
+
response.read_body { |_chunk| } # drain (and discard) the redirect body
|
|
399
|
+
location = response["location"]
|
|
400
|
+
redirect_code = status
|
|
401
|
+
else
|
|
402
|
+
store_cookies(response.get_fields("Set-Cookie"))
|
|
403
|
+
if stream_to && (200..299).cover?(status)
|
|
404
|
+
# Stream to disk in API_DOWNLOAD_CHUNK_SIZE blocks. Net::HTTP's
|
|
405
|
+
# read_body block yields socket-buffer-sized chunks; we coalesce
|
|
406
|
+
# them and flush at the 64 KB threshold so the whole body never
|
|
407
|
+
# lands in memory at once (bounded to ~one chunk + 64 KB).
|
|
408
|
+
File.open(stream_to, "wb") do |file|
|
|
409
|
+
buffer = String.new(encoding: Encoding::BINARY)
|
|
410
|
+
response.read_body do |chunk|
|
|
411
|
+
buffer << chunk
|
|
412
|
+
if buffer.bytesize >= API_DOWNLOAD_CHUNK_SIZE
|
|
413
|
+
file.write(buffer)
|
|
414
|
+
buffer.clear
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
file.write(buffer) unless buffer.empty?
|
|
418
|
+
end
|
|
419
|
+
wrote_file = true
|
|
420
|
+
elsif stream_to
|
|
421
|
+
response.read_body { |_chunk| } # error status on a download: drain, write nothing
|
|
422
|
+
else
|
|
423
|
+
body_string = String.new(encoding: Encoding::BINARY)
|
|
424
|
+
response.read_body { |chunk| body_string << chunk }
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
if location
|
|
431
|
+
new_uri = URI.join(current_uri.to_s, location)
|
|
432
|
+
current_headers = strip_cross_origin(current_headers) unless same_origin?(current_uri, new_uri)
|
|
433
|
+
# 301/302/303 downgrade a non-GET/HEAD to GET and drop the body
|
|
434
|
+
# (matches urllib); 307/308 preserve method + body.
|
|
435
|
+
if [301, 302, 303].include?(redirect_code) && !%w[GET HEAD].include?(current_method)
|
|
436
|
+
current_method = "GET"
|
|
437
|
+
current_body = nil
|
|
438
|
+
end
|
|
439
|
+
current_uri = new_uri
|
|
440
|
+
remaining -= 1
|
|
441
|
+
next
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
return { status: status, headers: resp_headers, body: body_string,
|
|
445
|
+
path: (wrote_file ? stream_to : nil) }
|
|
446
|
+
end
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
# Build a Net::HTTP request object for one hop from a method/uri/headers/body.
|
|
450
|
+
def build_net_request(method, uri, headers, body)
|
|
451
|
+
klass = case method
|
|
452
|
+
when "GET" then Net::HTTP::Get
|
|
453
|
+
when "POST" then Net::HTTP::Post
|
|
454
|
+
when "PUT" then Net::HTTP::Put
|
|
455
|
+
when "PATCH" then Net::HTTP::Patch
|
|
456
|
+
when "DELETE" then Net::HTTP::Delete
|
|
457
|
+
when "HEAD" then Net::HTTP::Head
|
|
458
|
+
else Net::HTTP::Get
|
|
459
|
+
end
|
|
460
|
+
request = klass.new(uri)
|
|
461
|
+
headers.each do |key, value|
|
|
462
|
+
request[key] = value.is_a?(Array) ? value.first : value
|
|
463
|
+
end
|
|
464
|
+
request.body = body if body && !%w[GET HEAD].include?(method)
|
|
465
|
+
request
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# Invoke a user-injected transport and normalize its result into an
|
|
469
|
+
# APIResponse. The transport fully replaces the network call.
|
|
470
|
+
def transport_response(method, url, headers, body)
|
|
471
|
+
result = @transport.call(method, url, headers, body, @timeout)
|
|
472
|
+
result = {} unless result.is_a?(Hash)
|
|
473
|
+
store_cookies(set_cookie_values(result_value(result, :headers)))
|
|
201
474
|
APIResponse.new(
|
|
202
|
-
status: 0,
|
|
203
|
-
body: "",
|
|
204
|
-
headers: {},
|
|
205
|
-
error:
|
|
475
|
+
status: (result_value(result, :http_code, :status) || 0).to_i,
|
|
476
|
+
body: result_value(result, :body) || "",
|
|
477
|
+
headers: result_value(result, :headers) || {},
|
|
478
|
+
error: result_value(result, :error)
|
|
206
479
|
)
|
|
207
480
|
end
|
|
208
481
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
482
|
+
# download() over the transport seam: the transport returns a buffered body,
|
|
483
|
+
# so write it out (only on a 2xx) instead of streaming. Mirrors the Python
|
|
484
|
+
# master's transport branch of download().
|
|
485
|
+
def download_via_transport(uri, headers, dest_path)
|
|
486
|
+
result = @transport.call("GET", uri.to_s, headers, nil, @timeout)
|
|
487
|
+
result = {} unless result.is_a?(Hash)
|
|
488
|
+
store_cookies(set_cookie_values(result_value(result, :headers)))
|
|
489
|
+
code = (result_value(result, :http_code, :status) || 0).to_i
|
|
490
|
+
error = result_value(result, :error)
|
|
491
|
+
resp_headers = result_value(result, :headers) || {}
|
|
492
|
+
|
|
493
|
+
if error.nil? && (200..299).cover?(code)
|
|
494
|
+
data = result_value(result, :body).to_s
|
|
495
|
+
begin
|
|
496
|
+
File.binwrite(dest_path, data)
|
|
497
|
+
rescue SystemCallError => e
|
|
498
|
+
return APIResponse.new(status: code, body: nil, headers: resp_headers, error: e.message, path: nil)
|
|
499
|
+
end
|
|
500
|
+
APIResponse.new(status: code, body: nil, headers: resp_headers, error: nil, path: dest_path)
|
|
501
|
+
else
|
|
502
|
+
APIResponse.new(status: code, body: nil, headers: resp_headers,
|
|
503
|
+
error: error || "download failed (HTTP #{code})", path: nil)
|
|
215
504
|
end
|
|
505
|
+
rescue StandardError => e
|
|
506
|
+
APIResponse.new(status: 0, body: nil, headers: {}, error: e.message, path: nil)
|
|
507
|
+
end
|
|
216
508
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
509
|
+
# Assemble a multipart/form-data body as raw bytes. Text fields come first,
|
|
510
|
+
# then the file part, then the closing delimiter — byte-identical to the
|
|
511
|
+
# Python master's _build_multipart_body so every framework produces the same
|
|
512
|
+
# layout. Built as a BINARY string so raw file bytes concatenate cleanly.
|
|
513
|
+
def build_multipart_body(boundary, field_name, filename, content, content_type, extra_fields)
|
|
514
|
+
crlf = "\r\n"
|
|
515
|
+
body = String.new(encoding: Encoding::BINARY)
|
|
516
|
+
(extra_fields || {}).each do |key, value|
|
|
517
|
+
body << "--#{boundary}#{crlf}".b
|
|
518
|
+
body << %(Content-Disposition: form-data; name="#{key}"#{crlf}#{crlf}).b
|
|
519
|
+
body << "#{value}#{crlf}".b
|
|
520
|
+
end
|
|
521
|
+
body << "--#{boundary}#{crlf}".b
|
|
522
|
+
body << %(Content-Disposition: form-data; name="#{field_name}"; filename="#{filename}"#{crlf}).b
|
|
523
|
+
body << "Content-Type: #{content_type}#{crlf}#{crlf}".b
|
|
524
|
+
body << content.b
|
|
525
|
+
body << crlf.b
|
|
526
|
+
body << "--#{boundary}--#{crlf}".b
|
|
223
527
|
body
|
|
224
528
|
end
|
|
529
|
+
|
|
530
|
+
# Guess a multipart part's Content-Type from a filename extension, falling
|
|
531
|
+
# back to application/octet-stream (parity with the Python master).
|
|
532
|
+
def guess_content_type(filename)
|
|
533
|
+
ext = File.extname(filename.to_s).delete_prefix(".").downcase
|
|
534
|
+
API_MIME_BY_EXTENSION[ext] || "application/octet-stream"
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
# True when two URLs share scheme + host + (effective) port.
|
|
538
|
+
def same_origin?(uri_a, uri_b)
|
|
539
|
+
a = uri_a.is_a?(URI) ? uri_a : URI.parse(uri_a.to_s)
|
|
540
|
+
b = uri_b.is_a?(URI) ? uri_b : URI.parse(uri_b.to_s)
|
|
541
|
+
defaults = { "http" => 80, "https" => 443 }
|
|
542
|
+
port_a = a.port || defaults[a.scheme]
|
|
543
|
+
port_b = b.port || defaults[b.scheme]
|
|
544
|
+
a.scheme == b.scheme && a.host == b.host && port_a == port_b
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
def strip_cross_origin(headers)
|
|
548
|
+
headers.reject { |key, _| API_STRIP_ON_CROSS_ORIGIN.include?(key.to_s.downcase) }
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
# Fetch a value from a Hash trying each key as both a symbol and a string.
|
|
552
|
+
def result_value(result, *keys)
|
|
553
|
+
keys.each do |key|
|
|
554
|
+
return result[key] if result.key?(key)
|
|
555
|
+
|
|
556
|
+
string_key = key.to_s
|
|
557
|
+
return result[string_key] if result.key?(string_key)
|
|
558
|
+
end
|
|
559
|
+
nil
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
# ── cookie jar (opt-in, in-memory, per-client) ─────────────────────────
|
|
563
|
+
# The accumulated Cookie request header, or nil when the jar is empty.
|
|
564
|
+
def cookie_header
|
|
565
|
+
return nil if @cookies.empty?
|
|
566
|
+
|
|
567
|
+
@cookies.map { |name, value| "#{name}=#{value}" }.join("; ")
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
# Parse Set-Cookie response header values into the jar (when enabled). Only
|
|
571
|
+
# the leading name=value pair of each Set-Cookie is kept (attributes like
|
|
572
|
+
# Path/HttpOnly/Expires are ignored); a later value for the same name
|
|
573
|
+
# overwrites an earlier one. Parity with the Python master's _store_cookies.
|
|
574
|
+
def store_cookies(values)
|
|
575
|
+
return unless @cookies_enabled
|
|
576
|
+
|
|
577
|
+
Array(values).each do |raw|
|
|
578
|
+
next if raw.nil? || raw.empty?
|
|
579
|
+
|
|
580
|
+
first_pair = raw.split(";", 2).first.to_s.strip
|
|
581
|
+
name, separator, value = first_pair.partition("=")
|
|
582
|
+
next if separator.empty?
|
|
583
|
+
|
|
584
|
+
name = name.strip
|
|
585
|
+
@cookies[name] = value.strip unless name.empty?
|
|
586
|
+
end
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
# Extract Set-Cookie values from a plain headers Hash (the transport seam
|
|
590
|
+
# returns a Hash, not a Net::HTTPResponse). Values may be a String or Array.
|
|
591
|
+
def set_cookie_values(headers)
|
|
592
|
+
return [] unless headers.is_a?(Hash)
|
|
593
|
+
|
|
594
|
+
values = []
|
|
595
|
+
headers.each do |key, value|
|
|
596
|
+
next unless key.to_s.downcase == "set-cookie"
|
|
597
|
+
|
|
598
|
+
Array(value).each { |item| values << item }
|
|
599
|
+
end
|
|
600
|
+
values
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
def error_response(message, path: :__none__)
|
|
604
|
+
if path == :__none__
|
|
605
|
+
APIResponse.new(status: 0, body: "", headers: {}, error: message)
|
|
606
|
+
else
|
|
607
|
+
APIResponse.new(status: 0, body: nil, headers: {}, error: message, path: path)
|
|
608
|
+
end
|
|
609
|
+
end
|
|
225
610
|
end
|
|
226
611
|
|
|
227
612
|
class APIResponse
|
|
228
|
-
attr_reader :status, :body, :headers, :error
|
|
613
|
+
attr_reader :status, :body, :headers, :error, :path
|
|
229
614
|
|
|
230
|
-
|
|
615
|
+
# `path` (default nil) is populated by API#download — the on-disk path the
|
|
616
|
+
# streamed body was written to (nil on error). It is nil for every other verb.
|
|
617
|
+
def initialize(status:, body:, headers:, error: nil, path: nil)
|
|
231
618
|
@status = status
|
|
232
619
|
@body = body
|
|
233
620
|
@headers = headers
|
|
234
621
|
@error = error
|
|
622
|
+
@path = path
|
|
235
623
|
end
|
|
236
624
|
|
|
237
625
|
def success?
|
|
@@ -240,7 +628,7 @@ module Tina4
|
|
|
240
628
|
|
|
241
629
|
def json
|
|
242
630
|
@json ||= JSON.parse(@body)
|
|
243
|
-
rescue JSON::ParserError
|
|
631
|
+
rescue JSON::ParserError, TypeError
|
|
244
632
|
{}
|
|
245
633
|
end
|
|
246
634
|
|
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.70
|
|
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
|