tina4ruby 3.13.38 → 3.13.40
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/auth.rb +5 -1
- data/lib/tina4/cli.rb +4 -0
- data/lib/tina4/database.rb +51 -6
- data/lib/tina4/dev_admin.rb +92 -9
- data/lib/tina4/docstore.rb +753 -0
- data/lib/tina4/env.rb +7 -2
- data/lib/tina4/field_types.rb +5 -2
- data/lib/tina4/log.rb +86 -10
- data/lib/tina4/mcp.rb +101 -9
- 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 +22 -5
- data/lib/tina4/router.rb +34 -4
- data/lib/tina4/swagger.rb +166 -32
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/websocket.rb +105 -4
- data/lib/tina4.rb +81 -3
- metadata +3 -2
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
|
data/lib/tina4/query_builder.rb
CHANGED
|
@@ -155,6 +155,14 @@ module Tina4
|
|
|
155
155
|
|
|
156
156
|
# Execute the query and return the database result.
|
|
157
157
|
#
|
|
158
|
+
# v3.13.39: with no `.limit()` set, get returns ALL matching rows. It
|
|
159
|
+
# previously applied a silent default `LIMIT 100` — a data-loss-on-read
|
|
160
|
+
# footgun where the 101st row vanished without a trace. An explicit
|
|
161
|
+
# `.limit(n)` is still honoured; `to_sql` never injects a default LIMIT
|
|
162
|
+
# either. When no limit was requested we pass `limit: nil` to db.fetch —
|
|
163
|
+
# its "no truncation" sentinel (fetch only appends LIMIT when `limit` is
|
|
164
|
+
# truthy and the SQL doesn't already carry one).
|
|
165
|
+
#
|
|
158
166
|
# @return [Object] The result from db.fetch.
|
|
159
167
|
def get
|
|
160
168
|
ensure_db!
|
|
@@ -164,7 +172,7 @@ module Tina4
|
|
|
164
172
|
@db.fetch(
|
|
165
173
|
sql,
|
|
166
174
|
all_params.empty? ? [] : all_params,
|
|
167
|
-
limit: @limit_val
|
|
175
|
+
limit: @limit_val,
|
|
168
176
|
offset: @offset_val || 0
|
|
169
177
|
)
|
|
170
178
|
end
|
|
@@ -322,8 +330,19 @@ module Tina4
|
|
|
322
330
|
return [{ field => { mongo_op => val } }, param_index + 1]
|
|
323
331
|
end
|
|
324
332
|
|
|
325
|
-
#
|
|
326
|
-
|
|
333
|
+
# v3.13.39: no silent $where fallback. Previously an unparseable
|
|
334
|
+
# condition was wrapped as `{ "$where" => <raw condition string> }` — a
|
|
335
|
+
# raw-JS sink that is both injection-shaped (the WHERE string runs as
|
|
336
|
+
# JavaScript on the server) and silently different semantics from the
|
|
337
|
+
# SQL the caller wrote. Fail loud instead: name the clause so the caller
|
|
338
|
+
# fixes it rather than shipping a surprise $where.
|
|
339
|
+
raise ArgumentError,
|
|
340
|
+
"QueryBuilder#to_mongo: cannot translate WHERE clause to a " \
|
|
341
|
+
"MongoDB filter: #{cond.inspect}. Supported forms: " \
|
|
342
|
+
"'<field> <op> ?' (=, !=, <>, >, >=, <, <=), '<field> LIKE ?', " \
|
|
343
|
+
"'<field> [NOT] IN (?)', '<field> IS [NOT] NULL'. " \
|
|
344
|
+
"Rewrite the condition in one of those forms (to_mongo will not " \
|
|
345
|
+
"silently emit a raw $where JavaScript expression)."
|
|
327
346
|
end
|
|
328
347
|
|
|
329
348
|
# Merge multiple single-field mongo condition hashes into one.
|
|
@@ -8,9 +8,11 @@ module Tina4
|
|
|
8
8
|
@brokers = options[:brokers] || "localhost:9092"
|
|
9
9
|
@group_id = options[:group_id] || "tina4_consumer_group"
|
|
10
10
|
|
|
11
|
+
security = self.class._security_config
|
|
12
|
+
|
|
11
13
|
producer_config = {
|
|
12
14
|
"bootstrap.servers" => @brokers
|
|
13
|
-
}
|
|
15
|
+
}.merge(security)
|
|
14
16
|
@producer = Rdkafka::Config.new(producer_config).producer
|
|
15
17
|
|
|
16
18
|
consumer_config = {
|
|
@@ -18,13 +20,48 @@ module Tina4
|
|
|
18
20
|
"group.id" => @group_id,
|
|
19
21
|
"auto.offset.reset" => "earliest",
|
|
20
22
|
"enable.auto.commit" => "false"
|
|
21
|
-
}
|
|
23
|
+
}.merge(security)
|
|
22
24
|
@consumer = Rdkafka::Config.new(consumer_config).consumer
|
|
23
25
|
@subscribed_topics = []
|
|
24
26
|
rescue LoadError
|
|
25
27
|
raise "Kafka backend requires the 'rdkafka' gem. Install with: gem install rdkafka"
|
|
26
28
|
end
|
|
27
29
|
|
|
30
|
+
# Build SSL/SASL client config from env (for a TLS broker/proxy).
|
|
31
|
+
#
|
|
32
|
+
# Mirrors tina4_python KafkaConnector._security_config: each setting is
|
|
33
|
+
# read from the Tina4-namespaced env var first (TINA4_KAFKA_<NAME>) and
|
|
34
|
+
# falls back to the bare librdkafka-convention name (KAFKA_<NAME>) that
|
|
35
|
+
# many Kafka deployments already set. Honours security.protocol (e.g. SSL,
|
|
36
|
+
# SASL_SSL), ssl.ca.location, and optional SASL (mechanism / username /
|
|
37
|
+
# password). Unset keys are omitted, leaving librdkafka's PLAINTEXT default.
|
|
38
|
+
def self._security_config
|
|
39
|
+
# rdkafka key -> env suffix (read as TINA4_KAFKA_<suffix>, then KAFKA_<suffix>)
|
|
40
|
+
mapping = {
|
|
41
|
+
"security.protocol" => "SECURITY_PROTOCOL",
|
|
42
|
+
"ssl.ca.location" => "SSL_CA_LOCATION",
|
|
43
|
+
"sasl.mechanism" => "SASL_MECHANISM",
|
|
44
|
+
"sasl.username" => "SASL_USERNAME",
|
|
45
|
+
"sasl.password" => "SASL_PASSWORD"
|
|
46
|
+
}
|
|
47
|
+
config = {}
|
|
48
|
+
mapping.each do |rdk, suffix|
|
|
49
|
+
value = env_value("TINA4_KAFKA_#{suffix}") || env_value("KAFKA_#{suffix}")
|
|
50
|
+
config[rdk] = value if value
|
|
51
|
+
end
|
|
52
|
+
config
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Read an env var, treating empty/blank values as unset (parity with
|
|
56
|
+
# Python's `os.environ.get(...) or ...` truthiness).
|
|
57
|
+
def self.env_value(name)
|
|
58
|
+
value = ENV[name]
|
|
59
|
+
return nil if value.nil? || value.empty?
|
|
60
|
+
|
|
61
|
+
value
|
|
62
|
+
end
|
|
63
|
+
private_class_method :env_value
|
|
64
|
+
|
|
28
65
|
def enqueue(message)
|
|
29
66
|
@producer.produce(
|
|
30
67
|
topic: message.topic,
|
data/lib/tina4/rack_app.rb
CHANGED
|
@@ -454,17 +454,26 @@ module Tina4
|
|
|
454
454
|
end
|
|
455
455
|
|
|
456
456
|
def serve_swagger_ui
|
|
457
|
+
# Honour the documented production on/off switch (TINA4_SWAGGER_ENABLED,
|
|
458
|
+
# else TINA4_DEBUG) — before v3.13.40 this was never checked and /swagger
|
|
459
|
+
# (the full API surface) was served unconditionally in production.
|
|
460
|
+
return [404, { "content-type" => "text/plain" }, ["Not Found"]] unless Tina4::Swagger.enabled?
|
|
461
|
+
|
|
462
|
+
# The UI assets load from a CDN by default (keeps the framework
|
|
463
|
+
# zero-dependency — no vendored ~1.4MB swagger-ui-dist). Air-gapped
|
|
464
|
+
# deployments point TINA4_SWAGGER_UI_CDN at a self-hosted mirror.
|
|
465
|
+
cdn = (ENV["TINA4_SWAGGER_UI_CDN"] || "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5").sub(%r{/+\z}, "")
|
|
457
466
|
html = <<~HTML
|
|
458
467
|
<!DOCTYPE html>
|
|
459
468
|
<html lang="en">
|
|
460
469
|
<head>
|
|
461
470
|
<meta charset="UTF-8">
|
|
462
471
|
<title>API Documentation</title>
|
|
463
|
-
<link rel="stylesheet" href="
|
|
472
|
+
<link rel="stylesheet" href="#{cdn}/swagger-ui.css">
|
|
464
473
|
</head>
|
|
465
474
|
<body>
|
|
466
475
|
<div id="swagger-ui"></div>
|
|
467
|
-
<script src="
|
|
476
|
+
<script src="#{cdn}/swagger-ui-bundle.js"></script>
|
|
468
477
|
<script>
|
|
469
478
|
SwaggerUIBundle({ url: '/swagger/openapi.json', dom_id: '#swagger-ui' });
|
|
470
479
|
</script>
|
|
@@ -475,8 +484,11 @@ module Tina4
|
|
|
475
484
|
end
|
|
476
485
|
|
|
477
486
|
def serve_openapi_json
|
|
478
|
-
|
|
479
|
-
|
|
487
|
+
return [404, { "content-type" => "application/json; charset=utf-8" }, ['{"error":"Not Found"}']] unless Tina4::Swagger.enabled?
|
|
488
|
+
|
|
489
|
+
# Regenerate per request — DevReload adds routes in-process (same PID), so
|
|
490
|
+
# a memoized spec would go stale until restart. Swagger.generate is cheap.
|
|
491
|
+
[200, { "content-type" => "application/json; charset=utf-8" }, [JSON.generate(Tina4::Swagger.generate)]]
|
|
480
492
|
end
|
|
481
493
|
|
|
482
494
|
def handle_403(path = "")
|
|
@@ -920,7 +932,12 @@ module Tina4
|
|
|
920
932
|
Tina4::Log.error("WebSocket error on #{ws_route.path}: #{error.message}")
|
|
921
933
|
end
|
|
922
934
|
|
|
923
|
-
|
|
935
|
+
# Per-route auth on the upgrade: a secured WS route (auth_required) needs a
|
|
936
|
+
# valid JWT or the handshake is rejected (401, not accepted) by
|
|
937
|
+
# handle_upgrade — after the origin allow-list, before the handshake. The
|
|
938
|
+
# dev-reload channel is always public.
|
|
939
|
+
auth_required = !dev_reload && ws_route.respond_to?(:auth_required) && ws_route.auth_required
|
|
940
|
+
ws.handle_upgrade(env, socket, manager: manager, auth_required: auth_required)
|
|
924
941
|
|
|
925
942
|
# Return async response (-1 signals Rack the response is handled via hijack)
|
|
926
943
|
[-1, {}, []]
|