tina4ruby 3.10.54 → 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: 5f97899d98f7c34c914f158fc57c2b0253b79e544ad9feacdca10d6a6479209f
4
- data.tar.gz: 8370cfeefc28525f4cd918543e51e5f444c7da5845c80e58c2dc548d513f26de
3
+ metadata.gz: 25b4d9017318ffe796da35f53d7865bd5ce5cad601a25d6dbffa512c649cd019
4
+ data.tar.gz: 6ee84f47f431b714ff0b4ac84ff606c3406ca5e2896d17a0835b98f23ac9385f
5
5
  SHA512:
6
- metadata.gz: dde7f333c0474ff7add07c7693ebbb27b8a11d565f6f031ee0aeee106cd46e3e696a70bed388ceeebd40640b7022eb68e7e12aee55668a8914c3d5ac205cee0b
7
- data.tar.gz: 0da6d2ab9ea1bb916f305f5f1dc2426dd92e1a54b3ac601c5b9cc607587ca64e7b40e0141e9cee6d7889bf001647c35b52ba2d19441027fe14bba2727d53f235
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
@@ -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
@@ -649,5 +649,42 @@ module Tina4
649
649
 
650
650
  @relationship_cache[name] = klass.find(fk_value)
651
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
652
689
  end
653
690
  end
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.54"
4
+ VERSION = "3.10.55"
5
5
  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.54
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