tina4ruby 3.11.13 → 3.11.15
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/CHANGELOG.md +80 -80
- data/LICENSE.txt +21 -21
- data/README.md +137 -137
- data/exe/tina4ruby +5 -5
- data/lib/tina4/ai.rb +696 -696
- data/lib/tina4/api.rb +189 -189
- data/lib/tina4/auth.rb +305 -305
- data/lib/tina4/auto_crud.rb +244 -244
- data/lib/tina4/cache.rb +154 -154
- data/lib/tina4/cli.rb +1449 -1449
- data/lib/tina4/constants.rb +46 -46
- data/lib/tina4/container.rb +74 -74
- data/lib/tina4/cors.rb +74 -74
- data/lib/tina4/crud.rb +692 -692
- data/lib/tina4/database/sqlite3_adapter.rb +165 -165
- data/lib/tina4/database.rb +625 -625
- data/lib/tina4/database_result.rb +208 -208
- data/lib/tina4/debug.rb +8 -8
- data/lib/tina4/dev.rb +14 -14
- data/lib/tina4/dev_admin.rb +935 -935
- data/lib/tina4/dev_mailbox.rb +191 -191
- data/lib/tina4/drivers/firebird_driver.rb +124 -110
- data/lib/tina4/drivers/mongodb_driver.rb +561 -561
- data/lib/tina4/drivers/mssql_driver.rb +112 -112
- data/lib/tina4/drivers/mysql_driver.rb +90 -90
- data/lib/tina4/drivers/odbc_driver.rb +191 -191
- data/lib/tina4/drivers/postgres_driver.rb +116 -106
- data/lib/tina4/drivers/sqlite_driver.rb +122 -122
- data/lib/tina4/env.rb +95 -95
- data/lib/tina4/error_overlay.rb +252 -252
- data/lib/tina4/events.rb +109 -109
- data/lib/tina4/field_types.rb +154 -154
- data/lib/tina4/frond.rb +2025 -2025
- data/lib/tina4/gallery/auth/meta.json +1 -1
- data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -114
- data/lib/tina4/gallery/database/meta.json +1 -1
- data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -43
- data/lib/tina4/gallery/error-overlay/meta.json +1 -1
- data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -17
- data/lib/tina4/gallery/orm/meta.json +1 -1
- data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -16
- data/lib/tina4/gallery/queue/meta.json +1 -1
- data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +325 -325
- data/lib/tina4/gallery/rest-api/meta.json +1 -1
- data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -14
- data/lib/tina4/gallery/templates/meta.json +1 -1
- data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -12
- data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -257
- data/lib/tina4/graphql.rb +966 -966
- data/lib/tina4/health.rb +39 -39
- data/lib/tina4/html_element.rb +170 -170
- data/lib/tina4/job.rb +80 -80
- data/lib/tina4/localization.rb +168 -168
- data/lib/tina4/log.rb +203 -203
- data/lib/tina4/mcp.rb +696 -696
- data/lib/tina4/messenger.rb +587 -587
- data/lib/tina4/metrics.rb +793 -793
- data/lib/tina4/middleware.rb +445 -445
- data/lib/tina4/migration.rb +451 -451
- data/lib/tina4/orm.rb +790 -790
- data/lib/tina4/public/css/tina4.css +2463 -2463
- data/lib/tina4/public/css/tina4.min.css +1 -1
- data/lib/tina4/public/images/logo.svg +5 -5
- data/lib/tina4/public/js/frond.min.js +2 -2
- data/lib/tina4/public/js/tina4-dev-admin.js +565 -565
- data/lib/tina4/public/js/tina4-dev-admin.min.js +480 -480
- data/lib/tina4/public/js/tina4.min.js +92 -92
- data/lib/tina4/public/js/tina4js.min.js +48 -48
- data/lib/tina4/public/swagger/index.html +90 -90
- data/lib/tina4/public/swagger/oauth2-redirect.html +63 -63
- data/lib/tina4/query_builder.rb +380 -380
- data/lib/tina4/queue.rb +366 -366
- data/lib/tina4/queue_backends/kafka_backend.rb +80 -80
- data/lib/tina4/queue_backends/lite_backend.rb +298 -298
- data/lib/tina4/queue_backends/mongo_backend.rb +126 -126
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -73
- data/lib/tina4/rack_app.rb +817 -817
- data/lib/tina4/rate_limiter.rb +130 -130
- data/lib/tina4/request.rb +268 -255
- data/lib/tina4/response.rb +346 -346
- data/lib/tina4/response_cache.rb +551 -551
- data/lib/tina4/router.rb +406 -406
- data/lib/tina4/scss/tina4css/_alerts.scss +34 -34
- data/lib/tina4/scss/tina4css/_badges.scss +22 -22
- data/lib/tina4/scss/tina4css/_buttons.scss +69 -69
- data/lib/tina4/scss/tina4css/_cards.scss +49 -49
- data/lib/tina4/scss/tina4css/_forms.scss +156 -156
- data/lib/tina4/scss/tina4css/_grid.scss +81 -81
- data/lib/tina4/scss/tina4css/_modals.scss +84 -84
- data/lib/tina4/scss/tina4css/_nav.scss +149 -149
- data/lib/tina4/scss/tina4css/_reset.scss +94 -94
- data/lib/tina4/scss/tina4css/_tables.scss +54 -54
- data/lib/tina4/scss/tina4css/_typography.scss +55 -55
- data/lib/tina4/scss/tina4css/_utilities.scss +197 -197
- data/lib/tina4/scss/tina4css/_variables.scss +117 -117
- data/lib/tina4/scss/tina4css/base.scss +1 -1
- data/lib/tina4/scss/tina4css/colors.scss +48 -48
- data/lib/tina4/scss/tina4css/tina4.scss +17 -17
- data/lib/tina4/scss_compiler.rb +178 -178
- data/lib/tina4/seeder.rb +567 -567
- data/lib/tina4/service_runner.rb +303 -303
- data/lib/tina4/session.rb +297 -297
- data/lib/tina4/session_handlers/database_handler.rb +72 -72
- data/lib/tina4/session_handlers/file_handler.rb +67 -67
- data/lib/tina4/session_handlers/mongo_handler.rb +49 -49
- data/lib/tina4/session_handlers/redis_handler.rb +43 -43
- data/lib/tina4/session_handlers/valkey_handler.rb +43 -43
- data/lib/tina4/shutdown.rb +84 -84
- data/lib/tina4/sql_translation.rb +158 -158
- data/lib/tina4/swagger.rb +124 -124
- data/lib/tina4/template.rb +894 -894
- data/lib/tina4/templates/base.twig +26 -26
- data/lib/tina4/templates/errors/302.twig +14 -14
- data/lib/tina4/templates/errors/401.twig +9 -9
- data/lib/tina4/templates/errors/403.twig +29 -29
- data/lib/tina4/templates/errors/404.twig +29 -29
- data/lib/tina4/templates/errors/500.twig +38 -38
- data/lib/tina4/templates/errors/502.twig +9 -9
- data/lib/tina4/templates/errors/503.twig +12 -12
- data/lib/tina4/templates/errors/base.twig +37 -37
- data/lib/tina4/test_client.rb +159 -159
- data/lib/tina4/testing.rb +340 -340
- data/lib/tina4/validator.rb +174 -174
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +312 -312
- data/lib/tina4/websocket.rb +343 -343
- data/lib/tina4/websocket_backplane.rb +190 -190
- data/lib/tina4/wsdl.rb +564 -564
- data/lib/tina4.rb +458 -458
- data/lib/tina4ruby.rb +4 -4
- metadata +3 -3
|
@@ -1,561 +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
|
|
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
|