tina4ruby 3.10.50 → 3.10.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: abf3c5a919a2ef3d8045a321ff7199560cc9d984afbbfce65517728b2f12ae3c
4
- data.tar.gz: c7976150a47140a2585fd5adc19ccc66b6dbeaeeb38bdcdbf405bb239006acfe
3
+ metadata.gz: 25b4d9017318ffe796da35f53d7865bd5ce5cad601a25d6dbffa512c649cd019
4
+ data.tar.gz: 6ee84f47f431b714ff0b4ac84ff606c3406ca5e2896d17a0835b98f23ac9385f
5
5
  SHA512:
6
- metadata.gz: 8d2167a92355ee4d21810bd3999cd67993f48ab3be21ca7d2e85f83c5b51cfcc225a0d5db40213feecad93cb9548640fa3f80b06d43ede6bc0270fe06af98e07
7
- data.tar.gz: 3afbe6fa4642493a511949cbf0f324224e1fccbedaaa1edd18790bbbb8fcfaf2c29f1d8bb0a13d4ec16f94b8c4234b1e4d41603b9194763a9c3dd9dce60b0d04
6
+ metadata.gz: eb0387a9321fb64193433c5d975bce3f7cfb2d25af20dcf9786cf361f6471cfecbbecad9a026e60f95f34446aa1fef65d77d93d19acb14ca7b1b6e615924cbb4
7
+ data.tar.gz: 73a21f6cd92dda7b2a747d824b68685e827d87b4e42ae92fb2313e69af5fef4e3704e7bca70674045ed5f149926ff4a9fc2ae8a1de2ea2a6bd41e70d4de14aa6
@@ -55,8 +55,10 @@ module Tina4
55
55
  # GET /api/{table} -- list all with pagination, filtering, sorting
56
56
  Tina4::Router.add_route("GET", "#{prefix}/#{table}", proc { |req, res|
57
57
  begin
58
- limit = (req.query["limit"] || 10).to_i
59
- offset = (req.query["offset"] || 0).to_i
58
+ per_page = (req.query["per_page"] || req.query["limit"] || 10).to_i
59
+ page = (req.query["page"] || 1).to_i
60
+ limit = per_page
61
+ offset = req.query["offset"] ? req.query["offset"].to_i : (page - 1) * per_page
60
62
  order_by = parse_sort(req.query["sort"])
61
63
 
62
64
  # Filter support: ?filter[field]=value
data/lib/tina4/cli.rb CHANGED
@@ -155,7 +155,7 @@ module Tina4
155
155
  # ── start ─────────────────────────────────────────────────────────────
156
156
 
157
157
  def cmd_start(argv)
158
- options = { port: nil, host: nil, dev: false, no_browser: false, production: false }
158
+ options = { port: nil, host: nil, dev: false, no_browser: false, no_reload: false, production: false }
159
159
  parser = OptionParser.new do |opts|
160
160
  opts.banner = "Usage: tina4ruby start [options]"
161
161
  opts.on("-p", "--port PORT", Integer, "Port (default: 7147)") { |v| options[:port] = v }
@@ -163,6 +163,7 @@ module Tina4
163
163
  opts.on("-d", "--dev", "Enable dev mode with auto-reload") { options[:dev] = true }
164
164
  opts.on("--production", "Use production server (Puma)") { options[:production] = true }
165
165
  opts.on("--no-browser", "Do not open browser on start") { options[:no_browser] = true }
166
+ opts.on("--no-reload", "Disable file watcher / live-reload") { options[:no_reload] = true }
166
167
  end
167
168
  parser.parse!(argv)
168
169
 
@@ -172,6 +173,11 @@ module Tina4
172
173
  options[:no_browser] = true
173
174
  end
174
175
 
176
+ # --no-reload flag sets TINA4_NO_RELOAD so the existing env check picks it up
177
+ if options[:no_reload]
178
+ ENV["TINA4_NO_RELOAD"] = "true"
179
+ end
180
+
175
181
  # Priority: CLI flag > ENV var > default
176
182
  options[:port] = resolve_config(:port, options[:port])
177
183
  options[:host] = resolve_config(:host, options[:host])
@@ -191,7 +197,8 @@ module Tina4
191
197
  load_routes(root_dir)
192
198
 
193
199
  if options[:dev]
194
- Tina4::DevReload.start(root_dir: root_dir)
200
+ no_reload = %w[true 1 yes].include?(ENV.fetch("TINA4_NO_RELOAD", "").downcase)
201
+ Tina4::DevReload.start(root_dir: root_dir) unless no_reload
195
202
  Tina4::ScssCompiler.compile_all(root_dir)
196
203
  end
197
204
 
data/lib/tina4/cors.rb CHANGED
@@ -66,7 +66,7 @@ module Tina4
66
66
  elsif request_origin && origin_allowed?(request_origin.chomp("/"))
67
67
  request_origin.chomp("/")
68
68
  else
69
- config[:origins].split(",").first&.strip || "*"
69
+ ""
70
70
  end
71
71
  end
72
72
  end
@@ -76,7 +76,10 @@ module Tina4
76
76
  "mysql" => "Tina4::Drivers::MysqlDriver",
77
77
  "mssql" => "Tina4::Drivers::MssqlDriver",
78
78
  "sqlserver" => "Tina4::Drivers::MssqlDriver",
79
- "firebird" => "Tina4::Drivers::FirebirdDriver"
79
+ "firebird" => "Tina4::Drivers::FirebirdDriver",
80
+ "mongodb" => "Tina4::Drivers::MongodbDriver",
81
+ "mongo" => "Tina4::Drivers::MongodbDriver",
82
+ "odbc" => "Tina4::Drivers::OdbcDriver"
80
83
  }.freeze
81
84
 
82
85
  def initialize(connection_string = nil, username: nil, password: nil, driver_name: nil, pool: 0)
@@ -491,6 +494,10 @@ module Tina4
491
494
  "mssql"
492
495
  when /firebird/, /\.fdb$/
493
496
  "firebird"
497
+ when /mongodb/, /^mongo:/
498
+ "mongodb"
499
+ when /^odbc:/
500
+ "odbc"
494
501
  else
495
502
  "sqlite"
496
503
  end
@@ -86,10 +86,15 @@ module Tina4
86
86
  slice_offset = (page - 1) * per_page
87
87
  page_records = @records[slice_offset, per_page] || []
88
88
  {
89
+ records: page_records,
89
90
  data: page_records,
91
+ count: total,
92
+ total: total,
93
+ limit: per_page,
94
+ offset: (page - 1) * per_page,
90
95
  page: page,
91
96
  per_page: per_page,
92
- total: total,
97
+ totalPages: total_pages,
93
98
  total_pages: total_pages,
94
99
  has_next: page < total_pages,
95
100
  has_prev: page > 1
@@ -0,0 +1,561 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tina4
4
+ module Drivers
5
+ class MongodbDriver
6
+ attr_reader :connection, :db
7
+
8
+ def connect(connection_string, username: nil, password: nil)
9
+ begin
10
+ require "mongo"
11
+ rescue LoadError
12
+ raise LoadError,
13
+ "The 'mongo' gem is required for MongoDB connections. " \
14
+ "Install: gem install mongo"
15
+ end
16
+
17
+ uri = build_uri(connection_string, username, password)
18
+ @db_name = extract_db_name(connection_string)
19
+ @client = Mongo::Client.new(uri)
20
+ @db = @client.use(@db_name)
21
+ @connection = @db
22
+ @last_insert_id = nil
23
+ end
24
+
25
+ def close
26
+ @client&.close
27
+ @client = nil
28
+ @db = nil
29
+ @connection = nil
30
+ end
31
+
32
+ # Execute a query (SELECT-like) and return array of symbol-keyed hashes
33
+ def execute_query(sql, params = [])
34
+ parsed = parse_sql(sql, params)
35
+ collection = @db[parsed[:collection]]
36
+
37
+ case parsed[:operation]
38
+ when :find
39
+ cursor = collection.find(parsed[:filter] || {})
40
+ cursor = cursor.projection(parsed[:projection]) if parsed[:projection] && !parsed[:projection].empty?
41
+ cursor = cursor.sort(parsed[:sort]) if parsed[:sort] && !parsed[:sort].empty?
42
+ cursor = cursor.skip(parsed[:skip]) if parsed[:skip] && parsed[:skip] > 0
43
+ cursor = cursor.limit(parsed[:limit]) if parsed[:limit] && parsed[:limit] > 0
44
+ cursor.map { |doc| mongo_doc_to_hash(doc) }
45
+ else
46
+ []
47
+ end
48
+ end
49
+
50
+ # Execute a DML statement (INSERT, UPDATE, DELETE, CREATE)
51
+ def execute(sql, params = [])
52
+ parsed = parse_sql(sql, params)
53
+ collection = @db[parsed[:collection]]
54
+
55
+ case parsed[:operation]
56
+ when :insert
57
+ result = collection.insert_one(parsed[:document])
58
+ @last_insert_id = result.inserted_id.to_s
59
+ result
60
+ when :update
61
+ collection.update_many(parsed[:filter] || {}, { "$set" => parsed[:updates] })
62
+ when :delete
63
+ collection.delete_many(parsed[:filter] || {})
64
+ when :create_collection
65
+ begin
66
+ @db.command(create: parsed[:collection].to_s)
67
+ rescue Mongo::Error::OperationFailure
68
+ # Collection already exists — ignore
69
+ end
70
+ nil
71
+ when :find
72
+ execute_query(sql, params)
73
+ else
74
+ nil
75
+ end
76
+ end
77
+
78
+ def last_insert_id
79
+ @last_insert_id
80
+ end
81
+
82
+ def placeholder
83
+ "?"
84
+ end
85
+
86
+ def placeholders(count)
87
+ (["?"] * count).join(", ")
88
+ end
89
+
90
+ # MongoDB has no LIMIT clause — ignore; already handled in execute_query
91
+ def apply_limit(sql, limit, offset = 0)
92
+ sql_up = sql.upcase
93
+ return sql if sql_up.include?("LIMIT")
94
+ modified = sql.dup
95
+ modified += " LIMIT #{limit}" if limit && limit > 0
96
+ modified += " OFFSET #{offset}" if offset && offset > 0
97
+ modified
98
+ end
99
+
100
+ # MongoDB transactions require a replica set — wrap in session if available
101
+ def begin_transaction
102
+ # no-op for standalone; transaction support via session handled externally
103
+ end
104
+
105
+ def commit
106
+ # no-op
107
+ end
108
+
109
+ def rollback
110
+ # no-op
111
+ end
112
+
113
+ def tables
114
+ @db.collection_names.reject { |n| n.start_with?("system.") }
115
+ end
116
+
117
+ def columns(table_name)
118
+ collection = @db[table_name.to_s]
119
+ sample = collection.find.limit(1).first
120
+ return [] unless sample
121
+
122
+ sample.keys.map do |key|
123
+ {
124
+ name: key,
125
+ type: sample[key].class.name,
126
+ nullable: true,
127
+ default: nil,
128
+ primary_key: key == "_id"
129
+ }
130
+ end
131
+ end
132
+
133
+ private
134
+
135
+ def build_uri(connection_string, username, password)
136
+ uri = connection_string.to_s
137
+ # Normalise scheme: mongodb:// stays, mongo:// becomes mongodb://
138
+ uri = uri.sub(/^mongo:\/\//, "mongodb://")
139
+
140
+ if username || password
141
+ # Inject credentials into the URI if not already present
142
+ if uri =~ /^mongodb:\/\/([^@]+@)/
143
+ # credentials already in URI — leave as-is
144
+ else
145
+ host_part = uri.sub(/^mongodb:\/\//, "")
146
+ creds = [username, password ? ":#{password}" : nil].compact.join
147
+ uri = "mongodb://#{creds}@#{host_part}"
148
+ end
149
+ end
150
+ uri
151
+ end
152
+
153
+ def extract_db_name(connection_string)
154
+ # mongodb://host:port/dbname -> dbname
155
+ # Strip query string first
156
+ path = connection_string.to_s.split("?").first
157
+ db = path.split("/").last
158
+ db && !db.empty? ? db : "tina4"
159
+ end
160
+
161
+ # ── SQL-to-MongoDB translator ──────────────────────────────────────
162
+
163
+ def parse_sql(sql, params = [])
164
+ sql_stripped = sql.strip
165
+ upper = sql_stripped.upcase
166
+
167
+ # Bind positional ? params
168
+ bound_sql = bind_params(sql_stripped, params)
169
+
170
+ if upper.start_with?("SELECT")
171
+ parse_select(bound_sql)
172
+ elsif upper.start_with?("INSERT INTO")
173
+ parse_insert(bound_sql)
174
+ elsif upper.start_with?("UPDATE")
175
+ parse_update(bound_sql)
176
+ elsif upper.start_with?("DELETE FROM")
177
+ parse_delete(bound_sql)
178
+ elsif upper.start_with?("CREATE TABLE") || upper.start_with?("CREATE COLLECTION")
179
+ parse_create(bound_sql)
180
+ else
181
+ { operation: :unknown, collection: nil }
182
+ end
183
+ end
184
+
185
+ def bind_params(sql, params)
186
+ return sql if params.nil? || params.empty?
187
+
188
+ idx = -1
189
+ sql.gsub("?") do
190
+ idx += 1
191
+ v = params[idx]
192
+ v.is_a?(String) ? "'#{v.gsub("'", "\\\\'")}'" : v.to_s
193
+ end
194
+ end
195
+
196
+ # ── SELECT parsing ─────────────────────────────────────────────────
197
+
198
+ def parse_select(sql)
199
+ result = { operation: :find }
200
+
201
+ # Extract table name (FROM clause)
202
+ if (m = sql.match(/\bFROM\s+(\w+)/i))
203
+ result[:collection] = m[1].to_sym
204
+ else
205
+ result[:collection] = :unknown
206
+ return result
207
+ end
208
+
209
+ # Projection (columns)
210
+ result[:projection] = parse_projection(sql)
211
+
212
+ # WHERE clause
213
+ where_clause = extract_clause(sql, "WHERE", %w[ORDER GROUP LIMIT OFFSET HAVING])
214
+ result[:filter] = where_clause ? parse_where(where_clause) : {}
215
+
216
+ # ORDER BY
217
+ result[:sort] = parse_order_by(sql)
218
+
219
+ # LIMIT / OFFSET
220
+ result[:limit] = extract_limit(sql)
221
+ result[:skip] = extract_offset(sql)
222
+
223
+ result
224
+ end
225
+
226
+ def parse_projection(sql)
227
+ m = sql.match(/^SELECT\s+(.*?)\s+FROM\b/im)
228
+ return {} unless m
229
+
230
+ cols = m[1].strip
231
+ return {} if cols == "*"
232
+
233
+ proj = {}
234
+ cols.split(",").each do |col|
235
+ col = col.strip
236
+ # Handle AS aliases — use the alias as field name
237
+ field = col.split(/\s+AS\s+/i).first.strip
238
+ proj[field] = 1
239
+ end
240
+ proj
241
+ end
242
+
243
+ def parse_order_by(sql)
244
+ m = sql.match(/\bORDER\s+BY\s+(.*?)(?:\s+LIMIT|\s+OFFSET|\s*$)/im)
245
+ return {} unless m
246
+
247
+ sort = {}
248
+ m[1].split(",").each do |part|
249
+ part = part.strip
250
+ if (pm = part.match(/^(\w+)\s+(ASC|DESC)$/i))
251
+ sort[pm[1]] = pm[2].upcase == "DESC" ? -1 : 1
252
+ else
253
+ sort[part] = 1
254
+ end
255
+ end
256
+ sort
257
+ end
258
+
259
+ def extract_limit(sql)
260
+ m = sql.match(/\bLIMIT\s+(\d+)/i)
261
+ m ? m[1].to_i : nil
262
+ end
263
+
264
+ def extract_offset(sql)
265
+ m = sql.match(/\bOFFSET\s+(\d+)/i)
266
+ m ? m[1].to_i : nil
267
+ end
268
+
269
+ # ── WHERE clause parser → Mongo filter hash ───────────────────────
270
+
271
+ def parse_where(clause)
272
+ clause = clause.strip
273
+ return {} if clause.empty?
274
+
275
+ # Handle OR at top level
276
+ or_parts = split_top_level(clause, /\bOR\b/i)
277
+ if or_parts.length > 1
278
+ return { "$or" => or_parts.map { |p| parse_where(p) } }
279
+ end
280
+
281
+ # Handle AND at top level
282
+ and_parts = split_top_level(clause, /\bAND\b/i)
283
+ if and_parts.length > 1
284
+ conditions = and_parts.map { |p| parse_where(p) }
285
+ merged = {}
286
+ conditions.each { |c| merged.merge!(c) }
287
+ return merged
288
+ end
289
+
290
+ parse_condition(clause)
291
+ end
292
+
293
+ # Split a string on a regex delimiter only at top level (not inside parens)
294
+ def split_top_level(str, delimiter_re)
295
+ parts = []
296
+ depth = 0
297
+ current = ""
298
+ tokens = str.split(/(\(|\)|\s+)/m)
299
+
300
+ # Rebuild token stream and split on delimiter
301
+ rebuilt = str
302
+ # Simple approach: scan character by character
303
+ parts = []
304
+ current = ""
305
+ i = 0
306
+ while i < str.length
307
+ ch = str[i]
308
+ if ch == "("
309
+ depth += 1
310
+ current += ch
311
+ elsif ch == ")"
312
+ depth -= 1
313
+ current += ch
314
+ elsif depth == 0
315
+ # Check for delimiter match at this position
316
+ remaining = str[i..]
317
+ m = remaining.match(/\A\s*#{delimiter_re.source}\s*/i)
318
+ if m
319
+ parts << current.strip
320
+ current = ""
321
+ i += m[0].length
322
+ next
323
+ else
324
+ current += ch
325
+ end
326
+ else
327
+ current += ch
328
+ end
329
+ i += 1
330
+ end
331
+ parts << current.strip unless current.strip.empty?
332
+ parts.length > 1 ? parts : [str]
333
+ end
334
+
335
+ def parse_condition(clause)
336
+ clause = clause.strip.gsub(/^\(+/, "").gsub(/\)+$/, "").strip
337
+
338
+ # IS NULL / IS NOT NULL
339
+ if (m = clause.match(/^(\w+)\s+IS\s+NOT\s+NULL$/i))
340
+ return { m[1] => { "$ne" => nil } }
341
+ end
342
+ if (m = clause.match(/^(\w+)\s+IS\s+NULL$/i))
343
+ return { m[1] => nil }
344
+ end
345
+
346
+ # IN (...)
347
+ if (m = clause.match(/^(\w+)\s+IN\s*\((.+)\)$/i))
348
+ values = m[2].split(",").map { |v| parse_value(v.strip) }
349
+ return { m[1] => { "$in" => values } }
350
+ end
351
+
352
+ # NOT IN (...)
353
+ if (m = clause.match(/^(\w+)\s+NOT\s+IN\s*\((.+)\)$/i))
354
+ values = m[2].split(",").map { |v| parse_value(v.strip) }
355
+ return { m[1] => { "$nin" => values } }
356
+ end
357
+
358
+ # LIKE → $regex
359
+ if (m = clause.match(/^(\w+)\s+LIKE\s+'(.+)'$/i))
360
+ pattern = m[2].gsub("%", ".*").gsub("_", ".")
361
+ return { m[1] => { "$regex" => pattern, "$options" => "i" } }
362
+ end
363
+
364
+ # NOT LIKE → $not $regex
365
+ if (m = clause.match(/^(\w+)\s+NOT\s+LIKE\s+'(.+)'$/i))
366
+ pattern = m[2].gsub("%", ".*").gsub("_", ".")
367
+ return { m[1] => { "$not" => /#{pattern}/i } }
368
+ end
369
+
370
+ # Comparison operators: !=, <>, >=, <=, >, <, =
371
+ ops = [["!=", "$ne"], ["<>", "$ne"], [">=", "$gte"], ["<=", "$lte"],
372
+ [">", "$gt"], ["<", "$lt"], ["=", "$eq"]]
373
+ ops.each do |op, mongo_op|
374
+ if (m = clause.match(/^(\w+)\s*#{Regexp.escape(op)}\s*(.+)$/i))
375
+ field = m[1]
376
+ value = parse_value(m[2].strip)
377
+ if mongo_op == "$eq"
378
+ return { field => value }
379
+ else
380
+ return { field => { mongo_op => value } }
381
+ end
382
+ end
383
+ end
384
+
385
+ # Fallback — return as a raw string comment (best-effort)
386
+ {}
387
+ end
388
+
389
+ def parse_value(str)
390
+ str = str.strip
391
+ if str.start_with?("'") && str.end_with?("'")
392
+ str[1..-2]
393
+ elsif str =~ /\A-?\d+\z/
394
+ str.to_i
395
+ elsif str =~ /\A-?\d+\.\d+\z/
396
+ str.to_f
397
+ elsif str.upcase == "TRUE"
398
+ true
399
+ elsif str.upcase == "FALSE"
400
+ false
401
+ elsif str.upcase == "NULL"
402
+ nil
403
+ else
404
+ str
405
+ end
406
+ end
407
+
408
+ # ── INSERT parsing ─────────────────────────────────────────────────
409
+
410
+ def parse_insert(sql)
411
+ result = { operation: :insert }
412
+
413
+ m = sql.match(/INSERT\s+INTO\s+(\w+)\s*\(([^)]+)\)\s*VALUES\s*\(([^)]+)\)/im)
414
+ unless m
415
+ result[:collection] = :unknown
416
+ result[:document] = {}
417
+ return result
418
+ end
419
+
420
+ result[:collection] = m[1].to_sym
421
+ cols = m[2].split(",").map(&:strip)
422
+ vals = parse_value_list(m[3])
423
+
424
+ result[:document] = cols.each_with_object({}).with_index do |(col, doc), i|
425
+ doc[col] = vals[i]
426
+ end
427
+
428
+ result
429
+ end
430
+
431
+ def parse_value_list(str)
432
+ # Split on commas not inside quotes
433
+ vals = []
434
+ current = ""
435
+ in_quote = false
436
+ str.each_char do |ch|
437
+ if ch == "'" && !in_quote
438
+ in_quote = true
439
+ current += ch
440
+ elsif ch == "'" && in_quote
441
+ in_quote = false
442
+ current += ch
443
+ elsif ch == "," && !in_quote
444
+ vals << parse_value(current.strip)
445
+ current = ""
446
+ else
447
+ current += ch
448
+ end
449
+ end
450
+ vals << parse_value(current.strip) unless current.strip.empty?
451
+ vals
452
+ end
453
+
454
+ # ── UPDATE parsing ─────────────────────────────────────────────────
455
+
456
+ def parse_update(sql)
457
+ result = { operation: :update }
458
+
459
+ m = sql.match(/UPDATE\s+(\w+)\s+SET\s+(.+?)(?:\s+WHERE\s+(.+))?$/im)
460
+ unless m
461
+ result[:collection] = :unknown
462
+ result[:updates] = {}
463
+ result[:filter] = {}
464
+ return result
465
+ end
466
+
467
+ result[:collection] = m[1].to_sym
468
+
469
+ # Parse SET assignments
470
+ updates = {}
471
+ set_clause = m[2].strip
472
+ # Split on comma, skip commas inside quotes
473
+ assignments = split_assignments(set_clause)
474
+ assignments.each do |assign|
475
+ parts = assign.split("=", 2)
476
+ next unless parts.length == 2
477
+
478
+ key = parts[0].strip
479
+ val = parse_value(parts[1].strip)
480
+ updates[key] = val
481
+ end
482
+ result[:updates] = updates
483
+
484
+ # Parse WHERE
485
+ where_str = m[3]&.strip
486
+ result[:filter] = where_str && !where_str.empty? ? parse_where(where_str) : {}
487
+
488
+ result
489
+ end
490
+
491
+ def split_assignments(set_clause)
492
+ parts = []
493
+ current = ""
494
+ in_quote = false
495
+ set_clause.each_char do |ch|
496
+ if ch == "'" && !in_quote
497
+ in_quote = true
498
+ current += ch
499
+ elsif ch == "'" && in_quote
500
+ in_quote = false
501
+ current += ch
502
+ elsif ch == "," && !in_quote
503
+ parts << current.strip
504
+ current = ""
505
+ else
506
+ current += ch
507
+ end
508
+ end
509
+ parts << current.strip unless current.strip.empty?
510
+ parts
511
+ end
512
+
513
+ # ── DELETE parsing ─────────────────────────────────────────────────
514
+
515
+ def parse_delete(sql)
516
+ result = { operation: :delete }
517
+
518
+ m = sql.match(/DELETE\s+FROM\s+(\w+)(?:\s+WHERE\s+(.+))?$/im)
519
+ unless m
520
+ result[:collection] = :unknown
521
+ result[:filter] = {}
522
+ return result
523
+ end
524
+
525
+ result[:collection] = m[1].to_sym
526
+ where_str = m[2]&.strip
527
+ result[:filter] = where_str && !where_str.empty? ? parse_where(where_str) : {}
528
+
529
+ result
530
+ end
531
+
532
+ # ── CREATE TABLE parsing ───────────────────────────────────────────
533
+
534
+ def parse_create(sql)
535
+ m = sql.match(/CREATE\s+(?:TABLE|COLLECTION)\s+(?:IF\s+NOT\s+EXISTS\s+)?(\w+)/im)
536
+ {
537
+ operation: :create_collection,
538
+ collection: m ? m[1].to_sym : :unknown
539
+ }
540
+ end
541
+
542
+ # ── Extract a named clause from SQL ───────────────────────────────
543
+
544
+ def extract_clause(sql, clause_keyword, stop_keywords = [])
545
+ pattern_parts = stop_keywords.map { |kw| "\\b#{kw}\\b" }.join("|")
546
+ stop_pattern = pattern_parts.empty? ? "$" : "(?:#{pattern_parts}|$)"
547
+ m = sql.match(/\b#{clause_keyword}\s+(.*?)(?=\s*#{stop_pattern})/im)
548
+ m ? m[1].strip : nil
549
+ end
550
+
551
+ # ── Document conversion ────────────────────────────────────────────
552
+
553
+ def mongo_doc_to_hash(doc)
554
+ doc.each_with_object({}) do |(k, v), h|
555
+ key = k.to_s == "_id" ? :_id : k.to_s.to_sym
556
+ h[key] = v.is_a?(BSON::ObjectId) ? v.to_s : v
557
+ end
558
+ end
559
+ end
560
+ end
561
+ end
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tina4
4
+ module Drivers
5
+ class OdbcDriver
6
+ attr_reader :connection
7
+
8
+ # Connect to an ODBC data source.
9
+ #
10
+ # Connection string formats:
11
+ # odbc:///DSN=MyDSN
12
+ # odbc:///DSN=MyDSN;UID=user;PWD=pass
13
+ # odbc:///DRIVER={SQL Server};SERVER=host;DATABASE=db
14
+ #
15
+ # The leading scheme prefix "odbc:///" is stripped; the remainder is
16
+ # passed verbatim to ODBC::Database.new as a connection string.
17
+ # username: and password: are appended as UID/PWD if not already present
18
+ # in the connection string.
19
+ def connect(connection_string, username: nil, password: nil)
20
+ begin
21
+ require "odbc"
22
+ rescue LoadError
23
+ raise LoadError,
24
+ "The 'ruby-odbc' gem is required for ODBC connections. " \
25
+ "Install: gem install ruby-odbc"
26
+ end
27
+
28
+ dsn_string = connection_string.to_s
29
+ .sub(/^odbc:\/\/\//, "")
30
+ .sub(/^odbc:\/\//, "")
31
+ .sub(/^odbc:/, "")
32
+
33
+ # Append credentials if provided and not already embedded
34
+ if username && !dsn_string.match?(/\bUID=/i)
35
+ dsn_string = "#{dsn_string};UID=#{username}"
36
+ end
37
+ if password && !dsn_string.match?(/\bPWD=/i)
38
+ dsn_string = "#{dsn_string};PWD=#{password}"
39
+ end
40
+
41
+ @connection = ODBC::Database.new(dsn_string)
42
+ @in_transaction = false
43
+ self
44
+ end
45
+
46
+ def close
47
+ @connection&.disconnect
48
+ @connection = nil
49
+ end
50
+
51
+ def connected?
52
+ !@connection.nil?
53
+ end
54
+
55
+ # Execute a SELECT query and return rows as an array of symbol-keyed hashes.
56
+ def execute_query(sql, params = [])
57
+ stmt = if params && !params.empty?
58
+ s = @connection.prepare(sql)
59
+ s.execute(*params)
60
+ s
61
+ else
62
+ @connection.run(sql)
63
+ end
64
+
65
+ columns = stmt.columns(true).map { |c| c.name.to_s.to_sym }
66
+ rows = []
67
+ while (row = stmt.fetch)
68
+ rows << columns.zip(row).to_h
69
+ end
70
+ stmt.drop
71
+ rows
72
+ rescue => e
73
+ stmt&.drop rescue nil
74
+ raise e
75
+ end
76
+
77
+ # Execute DDL or DML without returning rows.
78
+ def execute(sql, params = [])
79
+ if params && !params.empty?
80
+ stmt = @connection.prepare(sql)
81
+ stmt.execute(*params)
82
+ stmt.drop
83
+ else
84
+ @connection.do(sql)
85
+ end
86
+ nil
87
+ end
88
+
89
+ # ODBC does not expose a universal last-insert-id API.
90
+ # Drivers that support it can be queried via execute_query after insert.
91
+ def last_insert_id
92
+ nil
93
+ end
94
+
95
+ def placeholder
96
+ "?"
97
+ end
98
+
99
+ def placeholders(count)
100
+ (["?"] * count).join(", ")
101
+ end
102
+
103
+ # Build paginated SQL.
104
+ # Tries OFFSET/FETCH NEXT (SQL Server, newer ODBC sources) first.
105
+ # Falls back to LIMIT/OFFSET for sources that support it (MySQL, PostgreSQL via ODBC).
106
+ # The caller (Database#fetch) already gates on whether LIMIT is already present.
107
+ def apply_limit(sql, limit, offset = 0)
108
+ offset ||= 0
109
+ if offset > 0
110
+ # SQL Server / ANSI syntax — requires ORDER BY; add a no-op if absent
111
+ if sql.upcase.include?("ORDER BY")
112
+ "#{sql} OFFSET #{offset} ROWS FETCH NEXT #{limit} ROWS ONLY"
113
+ else
114
+ # LIMIT/OFFSET fallback (MySQL, PostgreSQL via ODBC, SQLite via ODBC)
115
+ "#{sql} LIMIT #{limit} OFFSET #{offset}"
116
+ end
117
+ else
118
+ "#{sql} LIMIT #{limit}"
119
+ end
120
+ end
121
+
122
+ def begin_transaction
123
+ return if @in_transaction
124
+ @connection.autocommit = false
125
+ @in_transaction = true
126
+ end
127
+
128
+ def commit
129
+ return unless @in_transaction
130
+ @connection.commit
131
+ @connection.autocommit = true
132
+ @in_transaction = false
133
+ end
134
+
135
+ def rollback
136
+ return unless @in_transaction
137
+ @connection.rollback
138
+ @connection.autocommit = true
139
+ @in_transaction = false
140
+ end
141
+
142
+ # List all user tables via ODBC metadata.
143
+ def tables
144
+ stmt = @connection.tables
145
+ rows = []
146
+ while (row = stmt.fetch_hash)
147
+ type = row["TABLE_TYPE"] || row[:TABLE_TYPE] || ""
148
+ name = row["TABLE_NAME"] || row[:TABLE_NAME]
149
+ rows << name.to_s if type.to_s.upcase == "TABLE" && name
150
+ end
151
+ stmt.drop
152
+ rows
153
+ rescue => e
154
+ stmt&.drop rescue nil
155
+ raise e
156
+ end
157
+
158
+ # Return column metadata for a table via ODBC metadata.
159
+ def columns(table_name)
160
+ stmt = @connection.columns(table_name.to_s)
161
+ result = []
162
+ while (row = stmt.fetch_hash)
163
+ name = row["COLUMN_NAME"] || row[:COLUMN_NAME]
164
+ type = row["TYPE_NAME"] || row[:TYPE_NAME]
165
+ nullable_val = row["NULLABLE"] || row[:NULLABLE]
166
+ default = row["COLUMN_DEF"] || row[:COLUMN_DEF]
167
+ result << {
168
+ name: name.to_s,
169
+ type: type.to_s,
170
+ nullable: nullable_val.to_i == 1,
171
+ default: default,
172
+ primary_key: false # ODBC metadata does not reliably expose PK flag here
173
+ }
174
+ end
175
+ stmt.drop
176
+ result
177
+ rescue => e
178
+ stmt&.drop rescue nil
179
+ raise e
180
+ end
181
+
182
+ private
183
+
184
+ def symbolize_keys(hash)
185
+ hash.each_with_object({}) do |(k, v), h|
186
+ h[k.to_s.to_sym] = v if k.is_a?(String) || k.is_a?(Symbol)
187
+ end
188
+ end
189
+ end
190
+ end
191
+ end
data/lib/tina4/orm.rb CHANGED
@@ -18,7 +18,7 @@ module Tina4
18
18
 
19
19
  class << self
20
20
  def db
21
- @db || Tina4.database
21
+ @db || Tina4.database || auto_discover_db
22
22
  end
23
23
 
24
24
  # Per-model database binding
@@ -345,6 +345,13 @@ module Tina4
345
345
 
346
346
  private
347
347
 
348
+ def auto_discover_db
349
+ url = ENV["DATABASE_URL"]
350
+ return nil unless url
351
+ Tina4.database = Tina4::Database.new(url, username: ENV.fetch("DATABASE_USERNAME", ""), password: ENV.fetch("DATABASE_PASSWORD", ""))
352
+ Tina4.database
353
+ end
354
+
348
355
  def find_by_id(id)
349
356
  pk = primary_key_field || :id
350
357
  sql = "SELECT * FROM #{table_name} WHERE #{pk} = ?"
@@ -642,5 +649,42 @@ module Tina4
642
649
 
643
650
  @relationship_cache[name] = klass.find(fk_value)
644
651
  end
652
+
653
+ public
654
+
655
+ # ── Imperative relationship methods (ad-hoc, like Python/PHP/Node) ──
656
+
657
+ def query_has_one(related_class, foreign_key: nil)
658
+ pk = self.class.primary_key_field || :id
659
+ pk_value = __send__(pk)
660
+ return nil unless pk_value
661
+
662
+ fk = foreign_key || "#{self.class.name.split('::').last.downcase}_id"
663
+ result = related_class.db.fetch_one(
664
+ "SELECT * FROM #{related_class.table_name} WHERE #{fk} = ?", [pk_value]
665
+ )
666
+ result ? related_class.from_hash(result) : nil
667
+ end
668
+
669
+ def query_has_many(related_class, foreign_key: nil, limit: 100, offset: 0)
670
+ pk = self.class.primary_key_field || :id
671
+ pk_value = __send__(pk)
672
+ return [] unless pk_value
673
+
674
+ fk = foreign_key || "#{self.class.name.split('::').last.downcase}_id"
675
+ results = related_class.db.fetch(
676
+ "SELECT * FROM #{related_class.table_name} WHERE #{fk} = ?",
677
+ [pk_value], limit: limit, offset: offset
678
+ )
679
+ results.map { |row| related_class.from_hash(row) }
680
+ end
681
+
682
+ def query_belongs_to(related_class, foreign_key: nil)
683
+ fk = foreign_key || "#{related_class.name.split('::').last.downcase}_id"
684
+ fk_value = respond_to?(fk.to_sym) ? __send__(fk.to_sym) : nil
685
+ return nil unless fk_value
686
+
687
+ related_class.find(fk_value)
688
+ end
645
689
  end
646
690
  end
@@ -3,6 +3,19 @@ require "json"
3
3
  require "securerandom"
4
4
 
5
5
  module Tina4
6
+ # Middleware wrapper that tags requests arriving on the AI dev port.
7
+ # Suppresses live-reload behaviour so AI tools get stable responses.
8
+ class AiPortRackApp
9
+ def initialize(app)
10
+ @app = app
11
+ end
12
+
13
+ def call(env)
14
+ env["tina4.ai_port"] = true
15
+ @app.call(env)
16
+ end
17
+ end
18
+
6
19
  class RackApp
7
20
  STATIC_DIRS = %w[public src/public src/assets assets].freeze
8
21
 
@@ -43,6 +56,10 @@ module Tina4
43
56
 
44
57
  # Dev dashboard routes (handled before anything else)
45
58
  if path.start_with?("/__dev")
59
+ # Block live-reload endpoint on the AI port — AI tools must get stable responses
60
+ if path == "/__dev_reload" && env["tina4.ai_port"]
61
+ return [404, { "content-type" => "text/plain" }, ["Not available on AI port"]]
62
+ end
46
63
  dev_response = Tina4::DevAdmin.handle_request(env)
47
64
  return dev_response if dev_response
48
65
  end
@@ -95,7 +112,7 @@ module Tina4
95
112
  matched_pattern: matched_pattern || "(no match)",
96
113
  }
97
114
  joined = body_parts.join
98
- overlay = inject_dev_overlay(joined, request_info)
115
+ overlay = inject_dev_overlay(joined, request_info, ai_port: env["tina4.ai_port"])
99
116
  rack_response = [status, headers, [overlay]]
100
117
  end
101
118
  end
@@ -630,7 +647,7 @@ module Tina4
630
647
  [-1, {}, []]
631
648
  end
632
649
 
633
- def inject_dev_overlay(body, request_info)
650
+ def inject_dev_overlay(body, request_info, ai_port: false)
634
651
  version = Tina4::VERSION
635
652
  method = request_info[:method]
636
653
  path = request_info[:path]
@@ -638,9 +655,11 @@ module Tina4
638
655
  request_id = Tina4::Log.request_id || "-"
639
656
  route_count = Tina4::Router.routes.length
640
657
 
658
+ ai_badge = ai_port ? '<span style="background:#7c3aed;color:#fff;font-size:10px;padding:1px 6px;border-radius:3px;font-weight:bold;">AI PORT</span>' : ""
659
+
641
660
  toolbar = <<~HTML.strip
642
661
  <div id="tina4-dev-toolbar" style="position:fixed;bottom:0;left:0;right:0;background:#333;color:#fff;font-family:monospace;font-size:12px;padding:6px 16px;z-index:99999;display:flex;align-items:center;gap:16px;">
643
- <span id="tina4-ver-btn" style="color:#d32f2f;font-weight:bold;cursor:pointer;text-decoration:underline dotted;" onclick="tina4VersionModal()" title="Click to check for updates">Tina4 v#{version}</span>
662
+ #{ai_badge}<span id="tina4-ver-btn" style="color:#d32f2f;font-weight:bold;cursor:pointer;text-decoration:underline dotted;" onclick="tina4VersionModal()" title="Click to check for updates">Tina4 v#{version}</span>
644
663
  <div id="tina4-ver-modal" style="display:none;position:fixed;bottom:3rem;left:1rem;background:#1e1e2e;border:1px solid #d32f2f;border-radius:8px;padding:16px 20px;z-index:100000;min-width:320px;box-shadow:0 8px 32px rgba(0,0,0,0.5);font-family:monospace;font-size:13px;color:#cdd6f4;">
645
664
  <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
646
665
  <strong style="color:#89b4fa;">Version Info</strong>
@@ -709,6 +728,7 @@ module Tina4
709
728
  el.style.color='#f38ba8';
710
729
  });
711
730
  }
731
+ #{ai_port ? "" : "/* tina4:reload-js */"}
712
732
  </script>
713
733
  HTML
714
734
 
data/lib/tina4/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.10.50"
4
+ VERSION = "3.10.55"
5
5
  end
@@ -11,6 +11,7 @@ module Tina4
11
11
  def start
12
12
  require "webrick"
13
13
  require "stringio"
14
+ require "socket"
14
15
  Tina4.print_banner(host: @host, port: @port)
15
16
  Tina4::Log.info("Starting Tina4 WEBrick server on http://#{@host}:#{@port}")
16
17
  @server = WEBrick::HTTPServer.new(
@@ -101,10 +102,117 @@ module Tina4
101
102
  servlet.define_method(:webrick_req_port) { port }
102
103
 
103
104
  @server.mount("/", servlet, rack_app)
105
+
106
+ # AI dev port (port + 1) — no-reload, no-browser
107
+ @ai_server = nil
108
+ @ai_thread = nil
109
+ no_ai_port = %w[true 1 yes].include?(ENV.fetch("TINA4_NO_AI_PORT", "").downcase)
110
+ is_debug = %w[true 1 yes].include?(ENV.fetch("TINA4_DEBUG", "").downcase)
111
+
112
+ if is_debug && !no_ai_port
113
+ ai_port = @port + 1
114
+ begin
115
+ test = TCPServer.new("0.0.0.0", ai_port)
116
+ test.close
117
+
118
+ @ai_server = WEBrick::HTTPServer.new(
119
+ BindAddress: @host,
120
+ Port: ai_port,
121
+ Logger: WEBrick::Log.new(File::NULL),
122
+ AccessLog: []
123
+ )
124
+
125
+ # Wrap the rack app so AI-port requests are tagged
126
+ ai_rack_app = Tina4::AiPortRackApp.new(@app)
127
+
128
+ # Build a servlet identical to the main one but bound to the AI port host/port
129
+ ai_host = @host
130
+ ai_port_str = ai_port.to_s
131
+ ai_servlet = Class.new(WEBrick::HTTPServlet::AbstractServlet) do
132
+ define_method(:initialize) do |server, app|
133
+ super(server)
134
+ @app = app
135
+ end
136
+
137
+ %w[GET POST PUT DELETE PATCH HEAD OPTIONS].each do |http_method|
138
+ define_method("do_#{http_method}") do |webrick_req, webrick_res|
139
+ handle_request(webrick_req, webrick_res)
140
+ end
141
+ end
142
+
143
+ define_method(:handle_request) do |webrick_req, webrick_res|
144
+ if Tina4::Shutdown.shutting_down?
145
+ webrick_res.status = 503
146
+ webrick_res.body = '{"error":"Service shutting down"}'
147
+ webrick_res["content-type"] = "application/json"
148
+ return
149
+ end
150
+
151
+ Tina4::Shutdown.track_request do
152
+ env = build_rack_env(webrick_req)
153
+ status, headers, body = @app.call(env)
154
+
155
+ webrick_res.status = status
156
+ headers.each do |key, value|
157
+ if key.downcase == "set-cookie"
158
+ Array(value.split("\n")).each { |c| webrick_res.cookies << WEBrick::Cookie.parse_set_cookie(c) }
159
+ else
160
+ webrick_res[key] = value
161
+ end
162
+ end
163
+
164
+ response_body = ""
165
+ body.each { |chunk| response_body += chunk }
166
+ webrick_res.body = response_body
167
+ end
168
+ end
169
+
170
+ define_method(:build_rack_env) do |req|
171
+ input = StringIO.new(req.body || "")
172
+ env = {
173
+ "REQUEST_METHOD" => req.request_method,
174
+ "PATH_INFO" => req.path,
175
+ "QUERY_STRING" => req.query_string || "",
176
+ "SERVER_NAME" => webrick_req_host,
177
+ "SERVER_PORT" => webrick_req_port,
178
+ "CONTENT_TYPE" => req.content_type || "",
179
+ "CONTENT_LENGTH" => (req.content_length rescue 0).to_s,
180
+ "REMOTE_ADDR" => req.peeraddr&.last || "127.0.0.1",
181
+ "rack.input" => input,
182
+ "rack.errors" => $stderr,
183
+ "rack.url_scheme" => "http",
184
+ "rack.version" => [1, 3],
185
+ "rack.multithread" => true,
186
+ "rack.multiprocess" => false,
187
+ "rack.run_once" => false
188
+ }
189
+
190
+ req.header.each do |key, values|
191
+ env_key = "HTTP_#{key.upcase.gsub('-', '_')}"
192
+ env[env_key] = values.join(", ")
193
+ end
194
+
195
+ env
196
+ end
197
+ end
198
+
199
+ ai_servlet.define_method(:webrick_req_host) { ai_host }
200
+ ai_servlet.define_method(:webrick_req_port) { ai_port_str }
201
+
202
+ @ai_server.mount("/", ai_servlet, ai_rack_app)
203
+ @ai_thread = Thread.new { @ai_server.start }
204
+ puts " AI Port: http://localhost:#{ai_port} (no-reload)"
205
+ rescue Errno::EADDRINUSE
206
+ puts " AI Port: SKIPPED (port #{ai_port} in use)"
207
+ end
208
+ end
209
+
104
210
  @server.start
105
211
  end
106
212
 
107
213
  def stop
214
+ @ai_server&.shutdown
215
+ @ai_thread&.join(5)
108
216
  @server&.shutdown
109
217
  end
110
218
  end
data/lib/tina4.rb CHANGED
@@ -59,6 +59,8 @@ module Tina4
59
59
  autoload :MysqlDriver, File.expand_path("tina4/drivers/mysql_driver", __dir__)
60
60
  autoload :MssqlDriver, File.expand_path("tina4/drivers/mssql_driver", __dir__)
61
61
  autoload :FirebirdDriver, File.expand_path("tina4/drivers/firebird_driver", __dir__)
62
+ autoload :MongodbDriver, File.expand_path("tina4/drivers/mongodb_driver", __dir__)
63
+ autoload :OdbcDriver, File.expand_path("tina4/drivers/odbc_driver", __dir__)
62
64
  end
63
65
 
64
66
  # ── Lazy-loaded: session handlers ─────────────────────────────────────
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.10.50
4
+ version: 3.10.55
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team
@@ -294,8 +294,10 @@ files:
294
294
  - lib/tina4/dev_mailbox.rb
295
295
  - lib/tina4/dev_reload.rb
296
296
  - lib/tina4/drivers/firebird_driver.rb
297
+ - lib/tina4/drivers/mongodb_driver.rb
297
298
  - lib/tina4/drivers/mssql_driver.rb
298
299
  - lib/tina4/drivers/mysql_driver.rb
300
+ - lib/tina4/drivers/odbc_driver.rb
299
301
  - lib/tina4/drivers/postgres_driver.rb
300
302
  - lib/tina4/drivers/sqlite_driver.rb
301
303
  - lib/tina4/env.rb