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.
@@ -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
- # Accept both 000001_name.sql (sequential) and YYYYMMDDHHMMSS_name.sql (timestamp) patterns
229
- Dir.glob(File.join(@migrations_dir, "*.{rb,sql}"))
230
- .reject { |f| f.end_with?(".down.sql") }
231
- .sort_by { |f| migration_sort_key(File.basename(f)) }
232
- .reject { |f| completed.include?(File.basename(f)) }
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
- # Sort key that handles both 000001_name.sql and 20240315120000_name.sql patterns.
236
- # Both are zero-padded numeric prefixes so alphabetical sorting works, but we
237
- # extract the prefix explicitly to guarantee correct ordering when mixed.
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
- processed = processed.gsub(/\/\/(.*?)\/\//m) do
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
- # Firebird lacks IF NOT EXISTS for ALTER TABLE ADD.
352
- # Pre-check the system catalogue so duplicate columns are
353
- # silently skipped instead of raising an error.
354
- skip_reason = should_skip_for_firebird(stmt)
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
- class_name: class_name || name.to_s.sub(/s$/, "").split("_").map(&:capitalize).join,
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
- validate_fields
573
- return false unless @errors.empty?
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
- self.class.db.transaction do |db|
580
- if @persisted && pk_value
581
- filter = { pk => pk_value }
582
- data.delete(pk)
583
- # Remove mapped primary key too
584
- mapped_pk = self.class.field_mapping[pk.to_s]
585
- data.delete(mapped_pk.to_sym) if mapped_pk
586
- db.update(self.class.table_name, data, filter)
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
- result = db.insert(self.class.table_name, data)
589
- if result[:last_id] && respond_to?("#{pk}=")
590
- __send__("#{pk}=", result[:last_id])
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
- true
596
- rescue => e
597
- @errors << e.message
598
- false
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
- return false unless pk_value
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
@@ -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 || 100,
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
- # Fallback
326
- [{ "$where" => cond }, param_index]
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,
@@ -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="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css">
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="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
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
- @openapi_json ||= JSON.generate(Tina4::Swagger.generate)
479
- [200, { "content-type" => "application/json; charset=utf-8" }, [@openapi_json]]
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
- ws.handle_upgrade(env, socket, manager: manager)
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, {}, []]