tina4-python 0.2.198__tar.gz → 0.2.200__tar.gz
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.
- {tina4_python-0.2.198 → tina4_python-0.2.200}/PKG-INFO +1 -1
- {tina4_python-0.2.198 → tina4_python-0.2.200}/pyproject.toml +1 -1
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/CLAUDE.md +28 -1
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/Database.py +343 -20
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/DatabaseTypes.py +3 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/Migration.py +52 -0
- tina4_python-0.2.200/tina4_python/SQLToMongo.py +635 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/.gitignore +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/README.md +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/Api.py +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/Auth.py +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/CRUD.py +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/Constant.py +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/DatabaseResult.py +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/Debug.py +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/DevReload.py +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/Env.py +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/FieldTypes.py +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/HtmlElement.py +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/Localization.py +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/Messages.py +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/MiddleWare.py +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/ORM.py +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/Queue.py +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/Request.py +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/Response.py +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/Router.py +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/Session.py +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/ShellColors.py +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/Swagger.py +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/Template.py +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/Testing.py +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/WSDL.py +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/Webserver.py +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/Websocket.py +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/__init__.py +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/cli.py +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/messages.pot +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/public/css/readme.md +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/public/favicon.ico +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/public/images/403.png +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/public/images/404.png +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/public/images/500.png +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/public/images/logo.png +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/public/images/readme.md +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/public/js/readme.md +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/public/js/reconnecting-websocket.js +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/public/js/tina4helper.js +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/public/swagger/index.html +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/templates/components/crud.twig +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/templates/errors/403.twig +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/templates/errors/404.twig +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/templates/errors/500.twig +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/templates/readme.md +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
|
@@ -280,6 +280,7 @@ Producer(Queue(topic="tasks")).produce({"action": "send_email"})
|
|
|
280
280
|
4. **fetch_one()**: Returns a plain dict (or None), NOT a DatabaseResult
|
|
281
281
|
5. **Dict access**: All query results use dict access `row["column"]` not attribute access `row.column`
|
|
282
282
|
6. **Firebird connection string**: `firebird.driver:<host>/<port>:<database_path>` — note the `firebird.driver:` prefix (the module name), NOT `firebird:`
|
|
283
|
+
6b. **MongoDB connection string**: `pymongo:<host>/<port>:<database_name>` — uses the same SQL API as all other engines (SQL is translated to MongoDB queries internally). JOINs are not supported. Install: `pip install pymongo`
|
|
283
284
|
7. **Running the app**: `uv run python app.py <port> <name>` — port and name are CLI args handled by tina4_python
|
|
284
285
|
8. **SCSS**: Files in `src/scss/` are auto-compiled to `src/public/css/` on startup
|
|
285
286
|
|
|
@@ -663,9 +664,35 @@ Every `send_request()` returns:
|
|
|
663
664
|
from tina4_python.Database import Database
|
|
664
665
|
|
|
665
666
|
db = Database("sqlite3:app.db") # SQLite
|
|
666
|
-
db = Database("
|
|
667
|
+
db = Database("psycopg2:localhost/5432:mydb", "user", "password") # PostgreSQL
|
|
668
|
+
db = Database("mysql.connector:localhost/3306:mydb", "user", "password") # MySQL
|
|
669
|
+
db = Database("firebird.driver:localhost/3050:/path/to/db", "SYSDBA", "masterkey") # Firebird
|
|
670
|
+
db = Database("pymssql:localhost/1433:mydb", "sa", "password") # MSSQL
|
|
671
|
+
db = Database("pymongo:localhost/27017:mydb") # MongoDB
|
|
672
|
+
db = Database("pymongo:localhost/27017:mydb", "user", "password") # MongoDB with auth
|
|
667
673
|
```
|
|
668
674
|
|
|
675
|
+
### MongoDB support
|
|
676
|
+
|
|
677
|
+
MongoDB uses the same SQL API as all other engines. The `SQLToMongo` module translates SQL to MongoDB queries transparently:
|
|
678
|
+
|
|
679
|
+
```python
|
|
680
|
+
db = Database("pymongo:localhost/27017:mydb")
|
|
681
|
+
|
|
682
|
+
# All standard operations work — SQL is translated to MongoDB internally
|
|
683
|
+
db.execute("CREATE TABLE users (id INTEGER)") # creates collection
|
|
684
|
+
db.insert("users", {"id": 1, "name": "Alice", "email": "alice@test.com"})
|
|
685
|
+
result = db.fetch("SELECT * FROM users WHERE name = ?", ["Alice"])
|
|
686
|
+
db.execute("UPDATE users SET name = ? WHERE id = ?", ["Bob", 1])
|
|
687
|
+
db.execute("DELETE FROM users WHERE id = ?", [1])
|
|
688
|
+
|
|
689
|
+
# WHERE operators: =, !=, <>, >, >=, <, <=, LIKE, IN, NOT IN, IS NULL, IS NOT NULL, BETWEEN, AND, OR
|
|
690
|
+
# Pagination, search, fetch_one, table_exists, get_next_id all work
|
|
691
|
+
# RETURNING is emulated (returns affected documents)
|
|
692
|
+
```
|
|
693
|
+
|
|
694
|
+
**Limitations**: MongoDB is document-based — JOINs are not supported. Use embedded documents or application-level joins instead. Migrations (`CREATE TABLE`) map to collection creation (column definitions are ignored — MongoDB is schema-less).
|
|
695
|
+
|
|
669
696
|
### CRUD operations
|
|
670
697
|
|
|
671
698
|
```python
|
|
@@ -51,6 +51,7 @@ from tina4_python import Debug
|
|
|
51
51
|
from tina4_python.DatabaseResult import DatabaseResult
|
|
52
52
|
from tina4_python.DatabaseTypes import *
|
|
53
53
|
from tina4_python.FieldTypes import get_field_type_values
|
|
54
|
+
from tina4_python.SQLToMongo import SQLToMongo
|
|
54
55
|
import datetime
|
|
55
56
|
|
|
56
57
|
class Database:
|
|
@@ -95,6 +96,8 @@ class Database:
|
|
|
95
96
|
install_message = Messages.MSG_DB_MISSING_FIREBIRD.format(install_cmd=FIREBIRD_INSTALL)
|
|
96
97
|
elif params[0] == MSSQL:
|
|
97
98
|
install_message = Messages.MSG_DB_MISSING_MSSQL.format(install_cmd=MSSQL_INSTALL)
|
|
99
|
+
elif params[0] == MONGODB:
|
|
100
|
+
install_message = f"Please install pymongo: {MONGODB_INSTALL}"
|
|
98
101
|
|
|
99
102
|
sys.exit(Messages.MSG_DB_DRIVER_NOT_FOUND.format(driver=params[0]) + "\n" + install_message + "\n" + str(e))
|
|
100
103
|
|
|
@@ -195,6 +198,16 @@ class Database:
|
|
|
195
198
|
database=self.database_path
|
|
196
199
|
)
|
|
197
200
|
self.dba.autocommit(False)
|
|
201
|
+
elif self.database_engine == MONGODB:
|
|
202
|
+
# pymongo: MongoClient → Database
|
|
203
|
+
mongo_args = {"host": self.host, "port": self.port}
|
|
204
|
+
if self.username:
|
|
205
|
+
mongo_args["username"] = self.username
|
|
206
|
+
if self.password:
|
|
207
|
+
mongo_args["password"] = self.password
|
|
208
|
+
self._mongo_client = self.database_module.MongoClient(**mongo_args)
|
|
209
|
+
self.dba = self._mongo_client[self.database_path]
|
|
210
|
+
self._mongo_session = None
|
|
198
211
|
else:
|
|
199
212
|
sys.exit("Could not load database driver for " + params[0])
|
|
200
213
|
|
|
@@ -207,7 +220,9 @@ class Database:
|
|
|
207
220
|
:return: bool : True if table exists, else False
|
|
208
221
|
"""
|
|
209
222
|
|
|
210
|
-
if self.database_engine ==
|
|
223
|
+
if self.database_engine == MONGODB:
|
|
224
|
+
return table_name in self.dba.list_collection_names()
|
|
225
|
+
elif self.database_engine == MSSQL:
|
|
211
226
|
sql = "select count(*) as count_table from sys.tables WHERE name = '" + table_name.upper() + "'"
|
|
212
227
|
elif self.database_engine == SQLITE:
|
|
213
228
|
sql = "SELECT count(*) as count_table FROM sqlite_master WHERE type='table' AND name='" + table_name + "'"
|
|
@@ -244,6 +259,12 @@ class Database:
|
|
|
244
259
|
:return: int : The next id in the sequence
|
|
245
260
|
"""
|
|
246
261
|
try:
|
|
262
|
+
if self.database_engine == MONGODB:
|
|
263
|
+
coll = self.dba[table_name]
|
|
264
|
+
doc = coll.find_one(sort=[(column_name, -1)], projection={column_name: 1})
|
|
265
|
+
max_val = doc[column_name] if doc and column_name in doc else 0
|
|
266
|
+
return int(max_val) + 1
|
|
267
|
+
|
|
247
268
|
sql = "select max(" + column_name + ") as \"max_id\" from " + table_name
|
|
248
269
|
record = self.fetch_one(sql)
|
|
249
270
|
if record["max_id"] is None:
|
|
@@ -306,6 +327,12 @@ class Database:
|
|
|
306
327
|
"""
|
|
307
328
|
if self.database_engine == MYSQL:
|
|
308
329
|
self.dba.ping(reconnect=True, attempts=1, delay=0)
|
|
330
|
+
elif self.database_engine == MONGODB:
|
|
331
|
+
# pymongo auto-reconnects; ping to confirm
|
|
332
|
+
try:
|
|
333
|
+
self._mongo_client.admin.command('ping')
|
|
334
|
+
except Exception:
|
|
335
|
+
pass
|
|
309
336
|
else:
|
|
310
337
|
# implement other database requirements if needed
|
|
311
338
|
pass
|
|
@@ -330,6 +357,10 @@ class Database:
|
|
|
330
357
|
|
|
331
358
|
self.check_connected()
|
|
332
359
|
|
|
360
|
+
# --- MongoDB: translate SQL to MongoDB queries ---
|
|
361
|
+
if self.database_engine == MONGODB:
|
|
362
|
+
return self._mongo_fetch(sql, params, limit, skip, search, search_columns)
|
|
363
|
+
|
|
333
364
|
final_sql = sql
|
|
334
365
|
final_params = params
|
|
335
366
|
|
|
@@ -365,14 +396,15 @@ class Database:
|
|
|
365
396
|
where_clause = " WHERE (" + " OR ".join(conditions) + ")"
|
|
366
397
|
final_sql = sql + where_clause
|
|
367
398
|
|
|
368
|
-
# 3. TOTAL COUNT (
|
|
369
|
-
|
|
399
|
+
# 3. TOTAL COUNT — strip ORDER BY (doesn't affect count, breaks MSSQL subqueries)
|
|
400
|
+
count_inner = re.sub(r"(?i)\s+order\s+by\s+.+?$", "", final_sql, flags=re.DOTALL).strip()
|
|
401
|
+
count_sql = f"SELECT COUNT(*) AS count_records FROM ({count_inner}) AS t"
|
|
370
402
|
counter = self.dba.cursor()
|
|
371
403
|
try:
|
|
372
404
|
counter.execute(self.parse_place_holders(count_sql), final_params)
|
|
373
405
|
total = counter.fetchone()[0]
|
|
374
406
|
except Exception as e:
|
|
375
|
-
Debug.error
|
|
407
|
+
Debug.error("COUNT ERROR", count_sql, final_params, str(e))
|
|
376
408
|
try:
|
|
377
409
|
self.dba.rollback()
|
|
378
410
|
except Exception:
|
|
@@ -381,35 +413,30 @@ class Database:
|
|
|
381
413
|
finally:
|
|
382
414
|
counter.close()
|
|
383
415
|
|
|
384
|
-
# 4.
|
|
416
|
+
# 4. PAGINATED DATA query
|
|
385
417
|
if self.database_engine == FIREBIRD:
|
|
386
418
|
final_sql = f"SELECT FIRST {limit} SKIP {skip} * FROM ({final_sql}) AS t"
|
|
387
|
-
elif self.database_engine in (MYSQL, SQLITE):
|
|
388
|
-
final_sql = f"SELECT * FROM ({final_sql}) AS t LIMIT {limit} OFFSET {skip}"
|
|
389
|
-
elif self.database_engine == POSTGRES:
|
|
390
|
-
final_sql = f"SELECT * FROM ({final_sql}) AS t LIMIT {limit} OFFSET {skip}"
|
|
391
419
|
elif self.database_engine == MSSQL:
|
|
392
420
|
inner = final_sql.strip()
|
|
393
|
-
# Detect and extract ORDER BY if present
|
|
394
421
|
order_by_match = re.search(r"(?i)\border\s+by\s+.+?$", inner, re.DOTALL)
|
|
395
422
|
has_order_by = order_by_match is not None
|
|
396
|
-
|
|
397
|
-
# Clean inner query: remove trailing ORDER BY if it exists
|
|
398
423
|
if has_order_by:
|
|
399
424
|
inner_clean = re.sub(r"(?i)\s+order\s+by\s+.+?$", "", inner, flags=re.DOTALL).strip()
|
|
400
425
|
order_by_part = order_by_match.group(0)
|
|
426
|
+
# Strip table aliases from ORDER BY columns (e.g. "e.salary" -> "salary")
|
|
427
|
+
# since the inner query is wrapped as subquery "t"
|
|
428
|
+
order_by_part = re.sub(r'(\b\w+)\.(\w+)', r'\2', order_by_part)
|
|
401
429
|
else:
|
|
402
430
|
inner_clean = inner
|
|
403
431
|
order_by_part = "ORDER BY (SELECT NULL)"
|
|
404
|
-
|
|
405
|
-
# Build final paginated query
|
|
406
432
|
final_sql = f"SELECT * FROM ({inner_clean}) AS t {order_by_part} OFFSET {skip} ROWS FETCH NEXT {limit} ROWS ONLY"
|
|
407
433
|
else:
|
|
434
|
+
# SQLite, MySQL, PostgreSQL
|
|
408
435
|
final_sql = f"SELECT * FROM ({final_sql}) AS t LIMIT {limit} OFFSET {skip}"
|
|
409
436
|
|
|
410
437
|
final_sql = self.parse_place_holders(final_sql)
|
|
411
438
|
|
|
412
|
-
# 5. Execute the
|
|
439
|
+
# 5. Execute the data query
|
|
413
440
|
cursor = self.dba.cursor()
|
|
414
441
|
try:
|
|
415
442
|
cursor.execute(final_sql, final_params)
|
|
@@ -460,11 +487,272 @@ class Database:
|
|
|
460
487
|
:param sql:
|
|
461
488
|
:return:
|
|
462
489
|
"""
|
|
463
|
-
if self.database_engine ==
|
|
490
|
+
if self.database_engine == MONGODB:
|
|
491
|
+
return sql # MongoDB doesn't use SQL placeholders
|
|
492
|
+
elif self.database_engine == MYSQL or self.database_engine == POSTGRES or self.database_engine == MSSQL:
|
|
464
493
|
return sql.replace("?", "%s")
|
|
465
494
|
else:
|
|
466
495
|
return sql.replace("%s", "?")
|
|
467
496
|
|
|
497
|
+
@staticmethod
|
|
498
|
+
def _has_returning_clause(sql):
|
|
499
|
+
"""Detect a SQL RETURNING clause without false positives.
|
|
500
|
+
|
|
501
|
+
Strips string literals and comments first, then checks that RETURNING
|
|
502
|
+
appears as a standalone keyword at the end of an INSERT, UPDATE, or
|
|
503
|
+
DELETE statement. This avoids matching column names, table names, or
|
|
504
|
+
values that happen to contain the word "returning".
|
|
505
|
+
"""
|
|
506
|
+
# Strip single-quoted string literals ('' escapes inside)
|
|
507
|
+
cleaned = re.sub(r"'(?:[^']|'')*'", "''", sql)
|
|
508
|
+
# Strip double-quoted identifiers
|
|
509
|
+
cleaned = re.sub(r'"(?:[^"]|"")*"', '""', cleaned)
|
|
510
|
+
# Strip block comments
|
|
511
|
+
cleaned = re.sub(r'/\*.*?\*/', ' ', cleaned, flags=re.DOTALL)
|
|
512
|
+
# Strip line comments
|
|
513
|
+
cleaned = re.sub(r'--[^\n]*', ' ', cleaned)
|
|
514
|
+
|
|
515
|
+
# Check the statement type is INSERT, UPDATE, or DELETE
|
|
516
|
+
stripped = cleaned.strip()
|
|
517
|
+
if not re.match(r'(?i)^(INSERT|UPDATE|DELETE)\b', stripped):
|
|
518
|
+
return False
|
|
519
|
+
|
|
520
|
+
# Look for RETURNING as a standalone keyword (word boundary on both sides)
|
|
521
|
+
# It should appear after the main clause, not as part of an identifier
|
|
522
|
+
return bool(re.search(r'\bRETURNING\b', cleaned, re.IGNORECASE))
|
|
523
|
+
|
|
524
|
+
def _emulate_returning(self, sql, params, cursor):
|
|
525
|
+
"""Emulate RETURNING for engines that don't support it natively.
|
|
526
|
+
|
|
527
|
+
MySQL and MSSQL don't support the RETURNING clause directly.
|
|
528
|
+
- For INSERT: uses lastrowid to fetch the inserted row back.
|
|
529
|
+
- For UPDATE/DELETE: extracts the RETURNING columns and the WHERE
|
|
530
|
+
clause to SELECT the affected rows before executing the mutation.
|
|
531
|
+
|
|
532
|
+
Returns a DatabaseResult, or None if emulation isn't needed/possible.
|
|
533
|
+
"""
|
|
534
|
+
cleaned_sql = sql.strip()
|
|
535
|
+
upper_sql = cleaned_sql.upper()
|
|
536
|
+
|
|
537
|
+
# Extract the RETURNING column list
|
|
538
|
+
ret_match = re.search(r'\bRETURNING\b\s+(.+)$', cleaned_sql, re.IGNORECASE | re.DOTALL)
|
|
539
|
+
if not ret_match:
|
|
540
|
+
return None
|
|
541
|
+
returning_cols = ret_match.group(1).strip().rstrip(';')
|
|
542
|
+
|
|
543
|
+
# SQL without the RETURNING clause
|
|
544
|
+
base_sql = cleaned_sql[:ret_match.start()].strip()
|
|
545
|
+
|
|
546
|
+
if upper_sql.startswith('INSERT'):
|
|
547
|
+
# Execute the INSERT (without RETURNING)
|
|
548
|
+
base_parsed = self.parse_place_holders(base_sql)
|
|
549
|
+
cursor.execute(base_parsed, params)
|
|
550
|
+
|
|
551
|
+
# Fetch the inserted row using lastrowid
|
|
552
|
+
last_id = cursor.lastrowid
|
|
553
|
+
if last_id is not None and last_id > 0:
|
|
554
|
+
# Extract table name from INSERT INTO <table>
|
|
555
|
+
table_match = re.match(r'(?i)INSERT\s+INTO\s+(\S+)', base_sql)
|
|
556
|
+
if table_match:
|
|
557
|
+
table_name = table_match.group(1)
|
|
558
|
+
# For MSSQL use different ID retrieval
|
|
559
|
+
if self.database_engine == MSSQL:
|
|
560
|
+
select_sql = f"SELECT {returning_cols} FROM {table_name} WHERE id = @@IDENTITY"
|
|
561
|
+
cursor.execute(select_sql)
|
|
562
|
+
else:
|
|
563
|
+
select_sql = self.parse_place_holders(
|
|
564
|
+
f"SELECT {returning_cols} FROM {table_name} WHERE id = ?"
|
|
565
|
+
)
|
|
566
|
+
cursor.execute(select_sql, [last_id])
|
|
567
|
+
return self.get_database_result(cursor, 1, 1, 0, sql)
|
|
568
|
+
|
|
569
|
+
# Fallback: return lastrowid as "id"
|
|
570
|
+
return DatabaseResult([{"id": last_id}], [], None, 1, 1, 0, sql, self)
|
|
571
|
+
|
|
572
|
+
elif upper_sql.startswith('UPDATE') or upper_sql.startswith('DELETE'):
|
|
573
|
+
# For UPDATE/DELETE: extract WHERE clause, SELECT matching rows first,
|
|
574
|
+
# then execute the mutation
|
|
575
|
+
where_match = re.search(r'\bWHERE\b\s+(.+)$', base_sql, re.IGNORECASE | re.DOTALL)
|
|
576
|
+
|
|
577
|
+
if upper_sql.startswith('UPDATE'):
|
|
578
|
+
table_match = re.match(r'(?i)UPDATE\s+(\S+)', base_sql)
|
|
579
|
+
else:
|
|
580
|
+
table_match = re.match(r'(?i)DELETE\s+FROM\s+(\S+)', base_sql)
|
|
581
|
+
|
|
582
|
+
if table_match and where_match:
|
|
583
|
+
table_name = table_match.group(1)
|
|
584
|
+
where_clause = where_match.group(1).strip()
|
|
585
|
+
# SELECT the rows that will be affected
|
|
586
|
+
select_sql = self.parse_place_holders(
|
|
587
|
+
f"SELECT {returning_cols} FROM {table_name} WHERE {where_clause}"
|
|
588
|
+
)
|
|
589
|
+
cursor.execute(select_sql, params)
|
|
590
|
+
rows = cursor.fetchall()
|
|
591
|
+
columns = [col[0].lower() for col in cursor.description]
|
|
592
|
+
result_rows = [dict(zip(columns, row)) for row in rows]
|
|
593
|
+
|
|
594
|
+
# Now execute the actual mutation
|
|
595
|
+
base_parsed = self.parse_place_holders(base_sql)
|
|
596
|
+
cursor.execute(base_parsed, params)
|
|
597
|
+
|
|
598
|
+
return DatabaseResult(result_rows, columns, None, len(result_rows), len(result_rows), 0, sql, self)
|
|
599
|
+
|
|
600
|
+
return None
|
|
601
|
+
|
|
602
|
+
# -------------------------------------------------------------------
|
|
603
|
+
# MongoDB helpers
|
|
604
|
+
# -------------------------------------------------------------------
|
|
605
|
+
|
|
606
|
+
def _mongo_fetch(self, sql, params, limit, skip, search, search_columns):
|
|
607
|
+
"""Execute a SQL SELECT against MongoDB via SQLToMongo translation."""
|
|
608
|
+
try:
|
|
609
|
+
op = SQLToMongo.translate(sql, params)
|
|
610
|
+
except Exception as e:
|
|
611
|
+
Debug.error("MONGO SQL PARSE ERROR", sql, str(e))
|
|
612
|
+
return DatabaseResult(None, [], str(e))
|
|
613
|
+
|
|
614
|
+
collection = self.dba[op["collection"]]
|
|
615
|
+
mongo_filter = op.get("filter", {})
|
|
616
|
+
|
|
617
|
+
# Add search conditions
|
|
618
|
+
if search and search.strip():
|
|
619
|
+
search_filter = self._mongo_search_filter(search, search_columns, op)
|
|
620
|
+
if search_filter:
|
|
621
|
+
if mongo_filter:
|
|
622
|
+
mongo_filter = {"$and": [mongo_filter, search_filter]}
|
|
623
|
+
else:
|
|
624
|
+
mongo_filter = search_filter
|
|
625
|
+
|
|
626
|
+
try:
|
|
627
|
+
if op["type"] == "count":
|
|
628
|
+
total = collection.count_documents(mongo_filter)
|
|
629
|
+
return DatabaseResult([{"count_records": total}], ["count_records"], None, 1, 1, 0, sql, self)
|
|
630
|
+
|
|
631
|
+
# Total count
|
|
632
|
+
total = collection.count_documents(mongo_filter)
|
|
633
|
+
|
|
634
|
+
# Build find kwargs
|
|
635
|
+
find_kwargs = {}
|
|
636
|
+
projection = op.get("projection")
|
|
637
|
+
if projection:
|
|
638
|
+
find_kwargs["projection"] = projection
|
|
639
|
+
# Always exclude _id unless explicitly requested
|
|
640
|
+
if "_id" not in projection:
|
|
641
|
+
find_kwargs["projection"]["_id"] = 0
|
|
642
|
+
else:
|
|
643
|
+
find_kwargs["projection"] = {"_id": 0}
|
|
644
|
+
|
|
645
|
+
cursor = collection.find(mongo_filter, **find_kwargs)
|
|
646
|
+
|
|
647
|
+
sort = op.get("sort")
|
|
648
|
+
if sort:
|
|
649
|
+
cursor = cursor.sort(list(sort.items()))
|
|
650
|
+
|
|
651
|
+
# Use pagination from the op if present, otherwise use method params
|
|
652
|
+
actual_skip = op.get("skip", skip)
|
|
653
|
+
actual_limit = op.get("limit", limit)
|
|
654
|
+
cursor = cursor.skip(actual_skip).limit(actual_limit)
|
|
655
|
+
|
|
656
|
+
rows = list(cursor)
|
|
657
|
+
columns = list(rows[0].keys()) if rows else []
|
|
658
|
+
|
|
659
|
+
return DatabaseResult(rows, columns, None, total, actual_limit, actual_skip, sql, self)
|
|
660
|
+
|
|
661
|
+
except Exception as e:
|
|
662
|
+
Debug.error("MONGO FETCH ERROR", sql, str(e))
|
|
663
|
+
return DatabaseResult(None, [], str(e))
|
|
664
|
+
|
|
665
|
+
def _mongo_execute(self, sql, params):
|
|
666
|
+
"""Execute a SQL INSERT/UPDATE/DELETE/CREATE/DROP against MongoDB."""
|
|
667
|
+
try:
|
|
668
|
+
op = SQLToMongo.translate(sql, params)
|
|
669
|
+
except Exception as e:
|
|
670
|
+
Debug.error("MONGO SQL PARSE ERROR", sql, str(e))
|
|
671
|
+
return DatabaseResult(None, [], str(e))
|
|
672
|
+
|
|
673
|
+
try:
|
|
674
|
+
op_type = op["type"]
|
|
675
|
+
collection_name = op["collection"]
|
|
676
|
+
|
|
677
|
+
if op_type == "insert":
|
|
678
|
+
coll = self.dba[collection_name]
|
|
679
|
+
doc = op["document"]
|
|
680
|
+
result = coll.insert_one(doc)
|
|
681
|
+
# Build a result that includes the inserted doc
|
|
682
|
+
return_doc = dict(doc)
|
|
683
|
+
if "_id" in return_doc:
|
|
684
|
+
return_doc["_id"] = str(return_doc["_id"])
|
|
685
|
+
columns = list(return_doc.keys())
|
|
686
|
+
return DatabaseResult([return_doc], columns, None, 1, 1, 0, sql, self)
|
|
687
|
+
|
|
688
|
+
elif op_type == "update":
|
|
689
|
+
coll = self.dba[collection_name]
|
|
690
|
+
mongo_filter = op.get("filter", {})
|
|
691
|
+
update_doc = op["update"]
|
|
692
|
+
|
|
693
|
+
if op.get("returning"):
|
|
694
|
+
# Fetch matching docs before update for RETURNING emulation
|
|
695
|
+
pre_docs = list(coll.find(mongo_filter, {"_id": 0}))
|
|
696
|
+
|
|
697
|
+
result = coll.update_many(mongo_filter, update_doc)
|
|
698
|
+
|
|
699
|
+
if op.get("returning"):
|
|
700
|
+
columns = list(pre_docs[0].keys()) if pre_docs else []
|
|
701
|
+
return DatabaseResult(pre_docs, columns, None, len(pre_docs), len(pre_docs), 0, sql, self)
|
|
702
|
+
|
|
703
|
+
return DatabaseResult(None, [], None, result.modified_count, 0, 0, sql, self)
|
|
704
|
+
|
|
705
|
+
elif op_type == "delete":
|
|
706
|
+
coll = self.dba[collection_name]
|
|
707
|
+
mongo_filter = op.get("filter", {})
|
|
708
|
+
|
|
709
|
+
if op.get("returning"):
|
|
710
|
+
# Fetch matching docs before delete for RETURNING emulation
|
|
711
|
+
pre_docs = list(coll.find(mongo_filter, {"_id": 0}))
|
|
712
|
+
|
|
713
|
+
result = coll.delete_many(mongo_filter)
|
|
714
|
+
|
|
715
|
+
if op.get("returning"):
|
|
716
|
+
columns = list(pre_docs[0].keys()) if pre_docs else []
|
|
717
|
+
return DatabaseResult(pre_docs, columns, None, len(pre_docs), len(pre_docs), 0, sql, self)
|
|
718
|
+
|
|
719
|
+
return DatabaseResult(None, [], None, result.deleted_count, 0, 0, sql, self)
|
|
720
|
+
|
|
721
|
+
elif op_type == "create_collection":
|
|
722
|
+
if collection_name not in self.dba.list_collection_names():
|
|
723
|
+
self.dba.create_collection(collection_name)
|
|
724
|
+
return DatabaseResult(None, [], None, 0, 0, 0, sql, self)
|
|
725
|
+
|
|
726
|
+
elif op_type == "drop_collection":
|
|
727
|
+
if collection_name in self.dba.list_collection_names():
|
|
728
|
+
self.dba.drop_collection(collection_name)
|
|
729
|
+
return DatabaseResult(None, [], None, 0, 0, 0, sql, self)
|
|
730
|
+
|
|
731
|
+
else:
|
|
732
|
+
return DatabaseResult(None, [], f"Unsupported MongoDB operation: {op_type}")
|
|
733
|
+
|
|
734
|
+
except Exception as e:
|
|
735
|
+
Debug.error("MONGO EXECUTE ERROR", sql, str(e))
|
|
736
|
+
return DatabaseResult(None, [], str(e))
|
|
737
|
+
|
|
738
|
+
def _mongo_search_filter(self, search, search_columns, op):
|
|
739
|
+
"""Build a MongoDB $or filter for full-text search across columns."""
|
|
740
|
+
cols = search_columns
|
|
741
|
+
if not cols:
|
|
742
|
+
# Try to get columns from the projection
|
|
743
|
+
proj = op.get("projection", {})
|
|
744
|
+
cols = [k for k in proj if k != "_id" and proj[k] == 1]
|
|
745
|
+
|
|
746
|
+
if not cols:
|
|
747
|
+
# Fallback: search all fields (not ideal but workable)
|
|
748
|
+
return {"$where": f"JSON.stringify(this).toLowerCase().indexOf('{search.lower()}') !== -1"}
|
|
749
|
+
|
|
750
|
+
conditions = []
|
|
751
|
+
for col in cols:
|
|
752
|
+
conditions.append({col: {"$regex": re.escape(search), "$options": "i"}})
|
|
753
|
+
|
|
754
|
+
return {"$or": conditions} if conditions else {}
|
|
755
|
+
|
|
468
756
|
def execute(self, sql, params=None):
|
|
469
757
|
"""
|
|
470
758
|
Execute a query based on a SQL statement
|
|
@@ -476,6 +764,11 @@ class Database:
|
|
|
476
764
|
params = []
|
|
477
765
|
|
|
478
766
|
self.check_connected()
|
|
767
|
+
|
|
768
|
+
# --- MongoDB: route through SQLToMongo ---
|
|
769
|
+
if self.database_engine == MONGODB:
|
|
770
|
+
return self._mongo_execute(sql, params)
|
|
771
|
+
|
|
479
772
|
if params != []:
|
|
480
773
|
sql = self.parse_place_holders(sql)
|
|
481
774
|
cursor = self.dba.cursor()
|
|
@@ -483,8 +776,19 @@ class Database:
|
|
|
483
776
|
try:
|
|
484
777
|
if params != []:
|
|
485
778
|
params = get_field_type_values(params)
|
|
779
|
+
|
|
780
|
+
has_returning = self._has_returning_clause(sql)
|
|
781
|
+
|
|
782
|
+
if has_returning and self.database_engine in (MYSQL, MSSQL):
|
|
783
|
+
# Emulate RETURNING for engines that don't support it natively
|
|
784
|
+
result = self._emulate_returning(sql, params, cursor)
|
|
785
|
+
if result is not None:
|
|
786
|
+
return result
|
|
787
|
+
|
|
486
788
|
cursor.execute(sql, params)
|
|
487
|
-
|
|
789
|
+
|
|
790
|
+
if has_returning:
|
|
791
|
+
# Native RETURNING support (PostgreSQL, SQLite, Firebird)
|
|
488
792
|
return self.get_database_result(cursor, 1, 1, 0, sql)
|
|
489
793
|
else:
|
|
490
794
|
# see if we are mysql and if we are insert statement to get the last record
|
|
@@ -555,6 +859,9 @@ class Database:
|
|
|
555
859
|
self.dba.execute("BEGIN TRANSACTION")
|
|
556
860
|
elif self.database_engine == POSTGRES:
|
|
557
861
|
self.dba.rollback() # start fresh
|
|
862
|
+
elif self.database_engine == MONGODB:
|
|
863
|
+
self._mongo_session = self._mongo_client.start_session()
|
|
864
|
+
self._mongo_session.start_transaction()
|
|
558
865
|
else:
|
|
559
866
|
Debug.error("START TRANSACTION ERROR:", "Database engine unrecognised/not supported")
|
|
560
867
|
except Exception as e:
|
|
@@ -566,7 +873,14 @@ class Database:
|
|
|
566
873
|
:return:
|
|
567
874
|
"""
|
|
568
875
|
try:
|
|
569
|
-
self.
|
|
876
|
+
if self.database_engine == MONGODB:
|
|
877
|
+
if self._mongo_session:
|
|
878
|
+
self._mongo_session.commit_transaction()
|
|
879
|
+
self._mongo_session.end_session()
|
|
880
|
+
self._mongo_session = None
|
|
881
|
+
# MongoDB auto-commits individual operations; no-op otherwise
|
|
882
|
+
else:
|
|
883
|
+
self.dba.commit()
|
|
570
884
|
except Exception as e:
|
|
571
885
|
Debug.error("COMMIT TRANSACTION ERROR:", str(e))
|
|
572
886
|
|
|
@@ -576,7 +890,13 @@ class Database:
|
|
|
576
890
|
:return:
|
|
577
891
|
"""
|
|
578
892
|
try:
|
|
579
|
-
self.
|
|
893
|
+
if self.database_engine == MONGODB:
|
|
894
|
+
if self._mongo_session:
|
|
895
|
+
self._mongo_session.abort_transaction()
|
|
896
|
+
self._mongo_session.end_session()
|
|
897
|
+
self._mongo_session = None
|
|
898
|
+
else:
|
|
899
|
+
self.dba.rollback()
|
|
580
900
|
except Exception as e:
|
|
581
901
|
Debug.error("ROLLBACK TRANSACTION ERROR:", str(e))
|
|
582
902
|
|
|
@@ -586,7 +906,10 @@ class Database:
|
|
|
586
906
|
:return:
|
|
587
907
|
"""
|
|
588
908
|
try:
|
|
589
|
-
self.
|
|
909
|
+
if self.database_engine == MONGODB:
|
|
910
|
+
self._mongo_client.close()
|
|
911
|
+
else:
|
|
912
|
+
self.dba.close()
|
|
590
913
|
except Exception as e:
|
|
591
914
|
Debug.error("DATABASE CLOSE ERROR:", str(e))
|
|
592
915
|
|
|
@@ -16,6 +16,7 @@ __all__ = [
|
|
|
16
16
|
"MYSQL", "MYSQL_INSTALL",
|
|
17
17
|
"POSTGRES", "POSTGRES_INSTALL",
|
|
18
18
|
"MSSQL", "MSSQL_INSTALL",
|
|
19
|
+
"MONGODB", "MONGODB_INSTALL",
|
|
19
20
|
]
|
|
20
21
|
|
|
21
22
|
SQLITE = "sqlite3"
|
|
@@ -27,3 +28,5 @@ POSTGRES = "psycopg2"
|
|
|
27
28
|
POSTGRES_INSTALL = "pip install psycopg2-binary or poetry add psycopg2-binary"
|
|
28
29
|
MSSQL = "pymssql"
|
|
29
30
|
MSSQL_INSTALL = "pip install pymssql or poetry add pymssql"
|
|
31
|
+
MONGODB = "pymongo"
|
|
32
|
+
MONGODB_INSTALL = "pip install pymongo or poetry add pymongo"
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
#
|
|
6
6
|
# flake8: noqa: E501
|
|
7
7
|
import os
|
|
8
|
+
import re
|
|
8
9
|
import sys
|
|
9
10
|
|
|
10
11
|
from tina4_python import ShellColors
|
|
@@ -15,6 +16,54 @@ from tina4_python.Database import MSSQL, POSTGRES, FIREBIRD, MYSQL
|
|
|
15
16
|
import tina4_python
|
|
16
17
|
|
|
17
18
|
|
|
19
|
+
def _firebird_column_exists(dba, table_name, column_name):
|
|
20
|
+
"""Check if a column already exists in a Firebird table via RDB$RELATION_FIELDS.
|
|
21
|
+
|
|
22
|
+
Uses a raw cursor to bypass the pagination layer (which wraps queries in
|
|
23
|
+
subqueries with COUNT(*) OVER()) — system catalogue queries break when wrapped.
|
|
24
|
+
"""
|
|
25
|
+
cursor = dba.dba.cursor()
|
|
26
|
+
try:
|
|
27
|
+
cursor.execute(
|
|
28
|
+
"SELECT 1 FROM RDB$RELATION_FIELDS "
|
|
29
|
+
"WHERE TRIM(RDB$RELATION_NAME) = ? AND TRIM(RDB$FIELD_NAME) = ?",
|
|
30
|
+
[table_name.upper(), column_name.upper()],
|
|
31
|
+
)
|
|
32
|
+
row = cursor.fetchone()
|
|
33
|
+
return row is not None
|
|
34
|
+
finally:
|
|
35
|
+
cursor.close()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _is_idempotent_skip(dba, script):
|
|
39
|
+
"""
|
|
40
|
+
Check if a DDL statement can be safely skipped because the change already exists.
|
|
41
|
+
Returns True if the statement should be skipped (already applied), False otherwise.
|
|
42
|
+
Currently handles:
|
|
43
|
+
- Firebird: ALTER TABLE ... ADD <column> (no IF NOT EXISTS support)
|
|
44
|
+
"""
|
|
45
|
+
if dba.database_engine != FIREBIRD:
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
stripped = script.strip()
|
|
49
|
+
# Match: ALTER TABLE <table> ADD <column> <datatype>...
|
|
50
|
+
match = re.match(
|
|
51
|
+
r"(?i)ALTER\s+TABLE\s+(\S+)\s+ADD\s+(\S+)\s+",
|
|
52
|
+
stripped,
|
|
53
|
+
)
|
|
54
|
+
if match:
|
|
55
|
+
table_name = match.group(1).strip('"')
|
|
56
|
+
column_name = match.group(2).strip('"')
|
|
57
|
+
if _firebird_column_exists(dba, table_name, column_name):
|
|
58
|
+
Debug.info(
|
|
59
|
+
ShellColors.bright_yellow,
|
|
60
|
+
f" Skipping (column already exists): ALTER TABLE {table_name} ADD {column_name}",
|
|
61
|
+
ShellColors.end,
|
|
62
|
+
)
|
|
63
|
+
return True
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
|
|
18
67
|
def migrate(dba, delimiter=";", migration_folder="migrations"):
|
|
19
68
|
"""
|
|
20
69
|
Migrates the database from the migrate folder
|
|
@@ -69,6 +118,9 @@ def migrate(dba, delimiter=";", migration_folder="migrations"):
|
|
|
69
118
|
error_message = ""
|
|
70
119
|
for script in script_content:
|
|
71
120
|
if script.strip() != "":
|
|
121
|
+
# Skip DDL that is already applied (e.g. Firebird ALTER TABLE ADD)
|
|
122
|
+
if _is_idempotent_skip(dba, script):
|
|
123
|
+
continue
|
|
72
124
|
result = dba.execute(script)
|
|
73
125
|
if result.error is not None:
|
|
74
126
|
error = True
|