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.
Files changed (67) hide show
  1. {tina4_python-0.2.198 → tina4_python-0.2.200}/PKG-INFO +1 -1
  2. {tina4_python-0.2.198 → tina4_python-0.2.200}/pyproject.toml +1 -1
  3. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/CLAUDE.md +28 -1
  4. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/Database.py +343 -20
  5. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/DatabaseTypes.py +3 -0
  6. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/Migration.py +52 -0
  7. tina4_python-0.2.200/tina4_python/SQLToMongo.py +635 -0
  8. {tina4_python-0.2.198 → tina4_python-0.2.200}/.gitignore +0 -0
  9. {tina4_python-0.2.198 → tina4_python-0.2.200}/README.md +0 -0
  10. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/Api.py +0 -0
  11. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/Auth.py +0 -0
  12. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/CRUD.py +0 -0
  13. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/Constant.py +0 -0
  14. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/DatabaseResult.py +0 -0
  15. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/Debug.py +0 -0
  16. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/DevReload.py +0 -0
  17. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/Env.py +0 -0
  18. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/FieldTypes.py +0 -0
  19. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/HtmlElement.py +0 -0
  20. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/Localization.py +0 -0
  21. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/Messages.py +0 -0
  22. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/MiddleWare.py +0 -0
  23. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/ORM.py +0 -0
  24. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/Queue.py +0 -0
  25. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/Request.py +0 -0
  26. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/Response.py +0 -0
  27. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/Router.py +0 -0
  28. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/Session.py +0 -0
  29. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/ShellColors.py +0 -0
  30. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/Swagger.py +0 -0
  31. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/Template.py +0 -0
  32. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/Testing.py +0 -0
  33. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/WSDL.py +0 -0
  34. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/Webserver.py +0 -0
  35. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/Websocket.py +0 -0
  36. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/__init__.py +0 -0
  37. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/cli.py +0 -0
  38. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/messages.pot +0 -0
  39. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/public/css/readme.md +0 -0
  40. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/public/favicon.ico +0 -0
  41. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/public/images/403.png +0 -0
  42. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/public/images/404.png +0 -0
  43. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/public/images/500.png +0 -0
  44. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/public/images/logo.png +0 -0
  45. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/public/images/readme.md +0 -0
  46. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/public/js/readme.md +0 -0
  47. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/public/js/reconnecting-websocket.js +0 -0
  48. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/public/js/tina4helper.js +0 -0
  49. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/public/swagger/index.html +0 -0
  50. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  51. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/templates/components/crud.twig +0 -0
  52. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/templates/errors/403.twig +0 -0
  53. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/templates/errors/404.twig +0 -0
  54. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/templates/errors/500.twig +0 -0
  55. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/templates/readme.md +0 -0
  56. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  57. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  58. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  59. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  60. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  61. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  62. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  63. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  64. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  65. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  66. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  67. {tina4_python-0.2.198 → tina4_python-0.2.200}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tina4-python
3
- Version: 0.2.198
3
+ Version: 0.2.200
4
4
  Summary: Tina4Python - This is not another framework for Python
5
5
  Author-email: Andre van Zuydam <andrevanzuydam@gmail.com>
6
6
  Requires-Python: <4.0,>=3.12
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tina4-python"
3
- version = "0.2.198"
3
+ version = "0.2.200"
4
4
  description = "Tina4Python - This is not another framework for Python"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam",email = "andrevanzuydam@gmail.com"}
@@ -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("postgresql:host=localhost;dbname=mydb;user=me;password=secret")
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 == MSSQL:
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 (with the same filter!)
369
- count_sql = f"SELECT COUNT(*) AS count_records FROM ({final_sql}) AS t"
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 ("COUNT ERROR", count_sql, final_params, str(e))
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. FINAL PAGINATION – applied AFTER the filter
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 real query
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 == MYSQL or self.database_engine == POSTGRES or self.database_engine == MSSQL:
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
- if "returning" in sql.lower():
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.dba.commit()
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.dba.rollback()
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.dba.close()
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