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 +4 -4
- data/lib/tina4/auto_crud.rb +4 -2
- data/lib/tina4/database.rb +8 -1
- data/lib/tina4/database_result.rb +6 -1
- data/lib/tina4/drivers/mongodb_driver.rb +561 -0
- data/lib/tina4/drivers/odbc_driver.rb +191 -0
- data/lib/tina4/orm.rb +37 -0
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4.rb +2 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 25b4d9017318ffe796da35f53d7865bd5ce5cad601a25d6dbffa512c649cd019
|
|
4
|
+
data.tar.gz: 6ee84f47f431b714ff0b4ac84ff606c3406ca5e2896d17a0835b98f23ac9385f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: eb0387a9321fb64193433c5d975bce3f7cfb2d25af20dcf9786cf361f6471cfecbbecad9a026e60f95f34446aa1fef65d77d93d19acb14ca7b1b6e615924cbb4
|
|
7
|
+
data.tar.gz: 73a21f6cd92dda7b2a747d824b68685e827d87b4e42ae92fb2313e69af5fef4e3704e7bca70674045ed5f149926ff4a9fc2ae8a1de2ea2a6bd41e70d4de14aa6
|
data/lib/tina4/auto_crud.rb
CHANGED
|
@@ -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
|
-
|
|
59
|
-
|
|
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/database.rb
CHANGED
|
@@ -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
|
-
|
|
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
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.
|
|
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
|