From bcbd4c6175249b0f8c8cb69bd7e9f41f75fcef50 Mon Sep 17 00:00:00 2001 From: Peng Ren Date: Mon, 2 Feb 2026 13:08:29 -0500 Subject: [PATCH 1/2] Bump to 0.4.0 version --- README.md | 1 + pymongosql/__init__.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4a83ac0..0cbd73f 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ [![codecov](https://codecov.io/gh/passren/PyMongoSQL/branch/main/graph/badge.svg?token=2CTRL80NP2)](https://codecov.io/gh/passren/PyMongoSQL) [![License: MIT](https://img.shields.io/badge/License-MIT-purple.svg)](https://github.com/passren/PyMongoSQL/blob/0.1.2/LICENSE) [![Python Version](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/downloads/) +[![Downloads](https://static.pepy.tech/badge/pymongosql/month)](https://pepy.tech/projects/pymongosql) [![MongoDB](https://img.shields.io/badge/MongoDB-7.0+-green.svg)](https://www.mongodb.com/) [![SQLAlchemy](https://img.shields.io/badge/SQLAlchemy-1.4+_2.0+-darkgreen.svg)](https://www.sqlalchemy.org/) [![Superset](https://img.shields.io/badge/Apache_Superset-1.0+-blue.svg)](https://superset.apache.org/docs/6.0.0/configuration/databases) diff --git a/pymongosql/__init__.py b/pymongosql/__init__.py index 745df98..d377b5a 100644 --- a/pymongosql/__init__.py +++ b/pymongosql/__init__.py @@ -6,7 +6,7 @@ if TYPE_CHECKING: from .connection import Connection -__version__: str = "0.3.4" +__version__: str = "0.4.0" # Globals https://www.python.org/dev/peps/pep-0249/#globals apilevel: str = "2.0" From 46956c631ad1dc7ed1fc5a87796615007f3ff825 Mon Sep 17 00:00:00 2001 From: Peng Ren Date: Mon, 2 Feb 2026 15:41:37 -0500 Subject: [PATCH 2/2] Fix reserved keyword issue --- README.md | 2 +- pymongosql/sql/handler.py | 2 + tests/data/users.json | 228 ++++++++++++++++++++----- tests/run_test_server.py | 5 +- tests/test_cursor.py | 43 +++++ tests/test_cursor_aggregate.py | 1 + tests/test_sql_parser_nested_fields.py | 2 +- 7 files changed, 235 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 0cbd73f..7483d44 100644 --- a/README.md +++ b/README.md @@ -230,7 +230,7 @@ Parameters are substituted into the MongoDB filter during execution, providing p - **Array access**: `items[0].name`, `orders[1].total` - **Complex queries**: `WHERE customer.profile.age > 18 AND orders[0].status = 'paid'` -> **Note**: Avoid SQL reserved words (`user`, `data`, `value`, `count`, etc.) as unquoted field names. Use alternatives or bracket notation for arrays. +> **Note**: Avoid SQL reserved words (`user`, `data`, `value`, `count`, etc.) as unquoted field names. Use alternatives names, or wrap them in double quotes if you must use them. ### Sorting and Limiting diff --git a/pymongosql/sql/handler.py b/pymongosql/sql/handler.py index cafb08d..794c0ca 100644 --- a/pymongosql/sql/handler.py +++ b/pymongosql/sql/handler.py @@ -62,6 +62,8 @@ def normalize_field_path(path: str) -> str: s = re.sub(r"\[\s*['\"]([^'\"]+)['\"]\s*\]", r".\1", s) # Convert numeric bracket indexes [0] -> .0 s = re.sub(r"\[\s*(\d+)\s*\]", r".\1", s) + # Unquote quoted identifiers in dot notation (e.g., "date" -> date) + s = re.sub(r'"([^"]+)"', r"\1", s) # Collapse multiple dots and strip leading/trailing dots s = re.sub(r"\.{2,}", ".", s).strip(".") return s diff --git a/tests/data/users.json b/tests/data/users.json index 5169dce..337076f 100644 --- a/tests/data/users.json +++ b/tests/data/users.json @@ -2,6 +2,7 @@ { "_id": "1", "name": "John Doe", + "user": "user_1", "age": 30, "email": "john@example.com", "active": true, @@ -35,7 +36,7 @@ ], "certifications": [] }, - "created_at": "2023-01-15T09:30:00Z", + "created_at": {"$date": "2023-01-15T09:30:00Z"}, "login_count": 42, "salary": 85000.5, "is_premium": false, @@ -48,11 +49,18 @@ "p2" ], "owned_products": [], - "department_id": "dept1" + "department_id": "dept1", + "date": { + "$timestamp": { + "t": 1735776000, + "i": 0 + } + } }, { "_id": "2", "name": "Jane Smith", + "user": "user_2", "age": 25, "email": "jane@example.com", "active": true, @@ -93,7 +101,7 @@ "Adobe Certified Expert" ] }, - "created_at": "2022-03-01T14:22:30Z", + "created_at": {"$date": "2022-03-01T14:22:30Z"}, "login_count": 156, "salary": 72000.0, "is_premium": true, @@ -108,11 +116,18 @@ "owned_products": [ "p3" ], - "department_id": "dept2" + "department_id": "dept2", + "date": { + "$timestamp": { + "t": 1748736000, + "i": 0 + } + } }, { "_id": "3", "name": "Bob Johnson", + "user": "user_3", "age": 35, "email": "bob@example.com", "active": false, @@ -147,7 +162,7 @@ "CKA" ] }, - "created_at": "2020-01-15T08:00:00Z", + "created_at": {"$date": "2020-01-15T08:00:00Z"}, "login_count": 0, "salary": null, "is_premium": false, @@ -157,11 +172,18 @@ "p1", "p2" ], - "department_id": "dept3" + "department_id": "dept3", + "date": { + "$timestamp": { + "t": 1767225600, + "i": 0 + } + } }, { "_id": "4", "name": "Alice Williams", + "user": "user_4", "age": 28, "email": "alice@example.com", "active": true, @@ -207,7 +229,7 @@ "Product Management Certificate" ] }, - "created_at": "2021-09-15T10:15:45Z", + "created_at": {"$date": "2021-09-15T10:15:45Z"}, "login_count": 89, "salary": 95000.75, "is_premium": true, @@ -221,7 +243,13 @@ "owned_products": [ "p4" ], - "department_id": "dept2" + "department_id": "dept2", + "date": { + "$timestamp": { + "t": 1631700945, + "i": 0 + } + } }, { "_id": "5", @@ -264,7 +292,7 @@ "Tableau Desktop Specialist" ] }, - "created_at": "2023-02-01T12:00:00Z", + "created_at": {"$date": "2023-02-01T12:00:00Z"}, "login_count": 67, "salary": 68000.0, "is_premium": false, @@ -276,11 +304,18 @@ "p3" ], "owned_products": [], - "department_id": "dept4" + "department_id": "dept4", + "date": { + "$timestamp": { + "t": 1675252800, + "i": 0 + } + } }, { "_id": "6", "name": "Diana Prince", + "user": "user_6", "age": 32, "email": "diana@example.com", "active": true, @@ -321,7 +356,7 @@ "Security+" ] }, - "created_at": "2021-11-15T09:00:00Z", + "created_at": {"$date": "2021-11-15T09:00:00Z"}, "login_count": 234, "salary": 92000.0, "is_premium": true, @@ -333,7 +368,13 @@ "p4" ], "owned_products": [], - "department_id": "dept5" + "department_id": "dept5", + "date": { + "$timestamp": { + "t": 1636966800, + "i": 0 + } + } }, { "_id": "7", @@ -371,7 +412,7 @@ "Business Analysis Professional" ] }, - "created_at": "2019-05-20T16:30:00Z", + "created_at": {"$date": "2019-05-20T16:30:00Z"}, "login_count": 12, "salary": null, "is_premium": false, @@ -381,11 +422,18 @@ "p3" ], "owned_products": [], - "department_id": null + "department_id": null, + "date": { + "$timestamp": { + "t": 1558369800, + "i": 0 + } + } }, { "_id": "8", "name": "Fiona Green", + "user": "user_8", "age": 26, "email": "fiona@example.com", "active": true, @@ -426,7 +474,7 @@ "HubSpot Content Marketing" ] }, - "created_at": "2022-08-10T11:45:00Z", + "created_at": {"$date": "2022-08-10T11:45:00Z"}, "login_count": 145, "salary": 55000.0, "is_premium": false, @@ -438,7 +486,13 @@ "p2" ], "owned_products": [], - "department_id": "dept6" + "department_id": "dept6", + "date": { + "$timestamp": { + "t": 1660131900, + "i": 0 + } + } }, { "_id": "9", @@ -482,7 +536,7 @@ "Sales Professional" ] }, - "created_at": "2020-03-01T08:30:00Z", + "created_at": {"$date": "2020-03-01T08:30:00Z"}, "login_count": 298, "salary": 105000.0, "is_premium": true, @@ -496,7 +550,13 @@ "p4" ], "owned_products": [], - "department_id": "dept7" + "department_id": "dept7", + "date": { + "$timestamp": { + "t": 1583051400, + "i": 0 + } + } }, { "_id": "10", @@ -540,7 +600,7 @@ "PHR" ] }, - "created_at": "2021-01-15T10:00:00Z", + "created_at": {"$date": "2021-01-15T10:00:00Z"}, "login_count": 189, "salary": 75000.0, "is_premium": true, @@ -551,7 +611,13 @@ "p3" ], "owned_products": [], - "department_id": "dept8" + "department_id": "dept8", + "date": { + "$timestamp": { + "t": 1610704800, + "i": 0 + } + } }, { "_id": "11", @@ -594,7 +660,7 @@ "ISTQB Foundation Level" ] }, - "created_at": "2022-05-01T09:15:00Z", + "created_at": {"$date": "2022-05-01T09:15:00Z"}, "login_count": 156, "salary": 62000.0, "is_premium": false, @@ -606,7 +672,13 @@ "p2" ], "owned_products": [], - "department_id": "dept9" + "department_id": "dept9", + "date": { + "$timestamp": { + "t": 1651396500, + "i": 0 + } + } }, { "_id": "12", @@ -650,7 +722,7 @@ "CFA Level 1" ] }, - "created_at": "2020-09-01T08:00:00Z", + "created_at": {"$date": "2020-09-01T08:00:00Z"}, "login_count": 267, "salary": 78000.0, "is_premium": true, @@ -663,7 +735,13 @@ "p1" ], "owned_products": [], - "department_id": "dept10" + "department_id": "dept10", + "date": { + "$timestamp": { + "t": 1598947200, + "i": 0 + } + } }, { "_id": "13", @@ -705,7 +783,7 @@ ], "certifications": [] }, - "created_at": "2023-06-01T09:00:00Z", + "created_at": {"$date": "2023-06-01T09:00:00Z"}, "login_count": 89, "salary": 45000.0, "is_premium": false, @@ -715,7 +793,13 @@ ], "purchased_products": [], "owned_products": [], - "department_id": "dept11" + "department_id": "dept11", + "date": { + "$timestamp": { + "t": 1685610000, + "i": 0 + } + } }, { "_id": "14", @@ -759,7 +843,7 @@ "Bar Admission" ] }, - "created_at": "2019-11-01T10:30:00Z", + "created_at": {"$date": "2019-11-01T10:30:00Z"}, "login_count": 201, "salary": 125000.0, "is_premium": true, @@ -771,7 +855,13 @@ "p3" ], "owned_products": [], - "department_id": "dept12" + "department_id": "dept12", + "date": { + "$timestamp": { + "t": 1572604200, + "i": 0 + } + } }, { "_id": "15", @@ -807,7 +897,7 @@ "TOGAF" ] }, - "created_at": "2018-03-15T14:00:00Z", + "created_at": {"$date": "2018-03-15T14:00:00Z"}, "login_count": 5, "salary": null, "is_premium": false, @@ -816,7 +906,13 @@ "p1" ], "owned_products": [], - "department_id": null + "department_id": null, + "date": { + "$timestamp": { + "t": 1521122400, + "i": 0 + } + } }, { "_id": "16", @@ -860,7 +956,7 @@ "Supply Chain Professional" ] }, - "created_at": "2020-07-01T08:45:00Z", + "created_at": {"$date": "2020-07-01T08:45:00Z"}, "login_count": 223, "salary": 82000.0, "is_premium": true, @@ -873,7 +969,13 @@ "p3" ], "owned_products": [], - "department_id": "dept13" + "department_id": "dept13", + "date": { + "$timestamp": { + "t": 1593593100, + "i": 0 + } + } }, { "_id": "17", @@ -917,7 +1019,7 @@ "Customer Service Professional" ] }, - "created_at": "2021-12-01T10:15:00Z", + "created_at": {"$date": "2021-12-01T10:15:00Z"}, "login_count": 198, "salary": 48000.0, "is_premium": false, @@ -929,7 +1031,13 @@ "p4" ], "owned_products": [], - "department_id": "dept14" + "department_id": "dept14", + "date": { + "$timestamp": { + "t": 1638353700, + "i": 0 + } + } }, { "_id": "18", @@ -974,7 +1082,7 @@ "Board Certification" ] }, - "created_at": "2018-01-01T00:00:00Z", + "created_at": {"$date": "2018-01-01T00:00:00Z"}, "login_count": 456, "salary": 250000.0, "is_premium": true, @@ -992,7 +1100,13 @@ "p3", "p4" ], - "department_id": "dept15" + "department_id": "dept15", + "date": { + "$timestamp": { + "t": 1514764800, + "i": 0 + } + } }, { "_id": "19", @@ -1038,7 +1152,7 @@ "Google Cloud ML Engineer" ] }, - "created_at": "2022-01-10T09:30:00Z", + "created_at": {"$date": "2022-01-10T09:30:00Z"}, "login_count": 167, "salary": 135000.0, "is_premium": true, @@ -1049,7 +1163,13 @@ "p1" ], "owned_products": [], - "department_id": "dept16" + "department_id": "dept16", + "date": { + "$timestamp": { + "t": 1641807000, + "i": 0 + } + } }, { "_id": "20", @@ -1093,7 +1213,7 @@ "Instructional Design" ] }, - "created_at": "2021-04-15T11:00:00Z", + "created_at": {"$date": "2021-04-15T11:00:00Z"}, "login_count": 178, "salary": 65000.0, "is_premium": false, @@ -1104,11 +1224,18 @@ "p3" ], "owned_products": [], - "department_id": "dept17" + "department_id": "dept17", + "date": { + "$timestamp": { + "t": 1618484400, + "i": 0 + } + } }, { "_id": "21", "name": "Invalid User One", + "user": "user_21", "age": 25, "email": "invalid1@example.com", "active": true, @@ -1140,7 +1267,7 @@ ], "certifications": [] }, - "created_at": "2023-01-01T00:00:00Z", + "created_at": {"$date": "2023-01-01T00:00:00Z"}, "login_count": 1, "salary": 50000.0, "is_premium": false, @@ -1156,11 +1283,18 @@ "owned_products": [ "p44" ], - "department_id": "dept99" + "department_id": "dept99", + "date": { + "$timestamp": { + "t": 1672531200, + "i": 0 + } + } }, { "_id": "22", "name": "Broken Reference User", + "user": "user_22", "age": 30, "email": "broken@example.com", "active": false, @@ -1179,7 +1313,7 @@ "skills": [], "certifications": [] }, - "created_at": "2020-01-01T00:00:00Z", + "created_at": {"$date": "2020-01-01T00:00:00Z"}, "login_count": 0, "salary": null, "is_premium": false, @@ -1191,6 +1325,12 @@ "owned_products": [ "pZZZ" ], - "department_id": "deptXYZ" + "department_id": "deptXYZ", + "date": { + "$timestamp": { + "t": 1577836800, + "i": 0 + } + } } ] \ No newline at end of file diff --git a/tests/run_test_server.py b/tests/run_test_server.py index b22ea09..ee50b24 100644 --- a/tests/run_test_server.py +++ b/tests/run_test_server.py @@ -12,6 +12,7 @@ import time import pymongo +from bson import json_util from pymongo.errors import ServerSelectionTimeoutError @@ -217,7 +218,7 @@ def load_test_data(): test_data_path = os.path.join(script_dir, TEST_DATA_FILES["legacy"]) try: with open(test_data_path, "r", encoding="utf-8") as f: - return json.load(f) + return json_util.loads(f.read()) except FileNotFoundError: print(f"[ERROR] Test data file not found: {test_data_path}") return None @@ -230,7 +231,7 @@ def load_test_data(): full_path = os.path.join(script_dir, file_path) try: with open(full_path, "r", encoding="utf-8") as f: - test_data[collection_name] = json.load(f) + test_data[collection_name] = json_util.loads(f.read()) print(f" Loaded {len(test_data[collection_name])} {collection_name}") except FileNotFoundError: print(f"[ERROR] Test data file not found: {full_path}") diff --git a/tests/test_cursor.py b/tests/test_cursor.py index 212262b..2584f28 100644 --- a/tests/test_cursor.py +++ b/tests/test_cursor.py @@ -1,5 +1,8 @@ # -*- coding: utf-8 -*- +from datetime import datetime, timezone + import pytest +from bson.timestamp import Timestamp from pymongosql.error import DatabaseError, ProgrammingError, SqlSyntaxError from pymongosql.result_set import ResultSet @@ -424,3 +427,43 @@ def test_execute_with_named_parameters(self, conn): rows = cursor.result_set.fetchall() assert len(rows) > 0 # Should have results matching the filter assert len(rows[0]) == 2 # Should have name and email columns + + def test_execute_with_reserved_keyword_field(self, conn): + """Test executing SELECT with reserved keyword field name (quoted)""" + # "date" is a reserved keyword in PartiQL, but can be used as a field name when quoted + sql = 'SELECT name, "date" FROM users WHERE age > 25 LIMIT 5' + cursor = conn.cursor() + result = cursor.execute(sql) + + assert result == cursor # execute returns self + assert isinstance(cursor.result_set, ResultSet) + + # Check that "date" appears in cursor description + assert cursor.result_set.description is not None + col_names = [desc[0] for desc in cursor.result_set.description] + + # The quoted field name should appear in results + assert "name" in col_names + assert "date" in col_names + + rows = cursor.result_set.fetchall() + assert len(rows) == 5 + assert len(rows[0]) == 2 # Should have name and date columns + + for row in rows: + date_value = row[col_names.index("date")] + assert date_value is not None + + def test_execute_with_reserved_keyword_field_in_where(self, conn): + """Test executing WHERE clause with reserved keyword field name (quoted)""" + sql = 'SELECT name FROM users WHERE "date" > ?' + cursor = conn.cursor() + cutoff = datetime(2025, 1, 1, tzinfo=timezone.utc) + result = cursor.execute(sql, [Timestamp(int(cutoff.timestamp()), 0)]) + + assert result == cursor # execute returns self + assert isinstance(cursor.result_set, ResultSet) + + rows = cursor.result_set.fetchall() + assert len(rows) == 3 + assert len(rows[0]) == 1 diff --git a/tests/test_cursor_aggregate.py b/tests/test_cursor_aggregate.py index f9bfa3d..11165f0 100644 --- a/tests/test_cursor_aggregate.py +++ b/tests/test_cursor_aggregate.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import json + from pymongosql.result_set import ResultSet diff --git a/tests/test_sql_parser_nested_fields.py b/tests/test_sql_parser_nested_fields.py index b92cb2d..eeed246 100644 --- a/tests/test_sql_parser_nested_fields.py +++ b/tests/test_sql_parser_nested_fields.py @@ -75,7 +75,7 @@ def test_quoted_reserved_words(self): execution_plan = parser.get_execution_plan() assert execution_plan.collection == "collection" - assert execution_plan.projection_stage == {'"user"': 1} + assert execution_plan.projection_stage == {"user": 1} def test_complex_nested_query(self): """Test complex query with multiple nested field types"""