Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .coverage
Binary file not shown.
5 changes: 0 additions & 5 deletions HOW_IT_WORKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -523,8 +523,3 @@ PyMongoSQL provides specialized support for Apache Superset with a **two-stage e

- **Benefits**: Native MongoDB support in Superset dashboards with full SQL capability
- **Limitations**: Single collection queries, session-scoped caching, performance depends on result set size

---

Generated: 2024
Architecture Version: 2.0 (Post-INSERT VALUES implementation with Superset integration)
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -412,8 +412,8 @@ try:
connection.begin() # Start transaction

cursor = connection.cursor()
cursor.execute('UPDATE accounts SET balance = balance - 100 WHERE id = ?', [1])
cursor.execute('UPDATE accounts SET balance = balance + 100 WHERE id = ?', [2])
cursor.execute('UPDATE accounts SET balance = 100 WHERE id = ?', [1])
cursor.execute('UPDATE accounts SET balance = 200 WHERE id = ?', [2])

connection.commit() # Commit all changes
print("Transaction committed successfully")
Expand Down
2 changes: 1 addition & 1 deletion pymongosql/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
if TYPE_CHECKING:
from .connection import Connection

__version__: str = "0.3.2"
__version__: str = "0.3.3"

# Globals https://www.python.org/dev/peps/pep-0249/#globals
apilevel: str = "2.0"
Expand Down
50 changes: 41 additions & 9 deletions pymongosql/cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,25 +142,57 @@ def execute(self: _T, operation: str, parameters: Optional[Any] = None) -> _T:
def executemany(
self,
operation: str,
seq_of_parameters: List[Optional[Dict[str, Any]]],
seq_of_parameters: List[Optional[Any]],
) -> None:
"""Execute a SQL statement multiple times with different parameters

Note: This is not yet fully implemented for MongoDB operations
This method executes the operation once for each parameter set in
seq_of_parameters. It's particularly useful for bulk INSERT, UPDATE,
or DELETE operations.

Args:
operation: SQL statement to execute
seq_of_parameters: Sequence of parameter sets. Each element should be
a sequence (list/tuple) for positional parameters with ? placeholders,
or a dict for named parameters with :name placeholders.

Returns:
None (executemany does not produce a result set)

Note: The rowcount property will reflect the total number of rows affected
across all executions.
"""
self._check_closed()

# For now, just execute once and ignore parameters
_logger.warning("executemany not fully implemented, executing once without parameters")
self.execute(operation)
if not seq_of_parameters:
return

total_rowcount = 0

try:
# Execute the operation for each parameter set
for params in seq_of_parameters:
self.execute(operation, params)
# Accumulate rowcount from each execution
if self.rowcount > 0:
total_rowcount += self.rowcount

# Update the final result set with accumulated rowcount
if self._result_set:
self._result_set._rowcount = total_rowcount

except (SqlSyntaxError, DatabaseError, OperationalError, ProgrammingError):
# Re-raise known errors
raise
except Exception as e:
_logger.error(f"Unexpected error during executemany: {e}")
raise DatabaseError(f"executemany failed: {e}")

def execute_transaction(self) -> None:
"""Execute transaction (MongoDB has limited transaction support)"""
"""Execute transaction - not yet implemented"""
self._check_closed()

# MongoDB transactions are complex and require specific setup
# For now, this is a placeholder
raise NotImplementedError("Transaction support not yet implemented")
raise NotImplementedError("Transaction using this function not yet implemented")

def flush(self) -> None:
"""Flush any pending operations"""
Expand Down
21 changes: 21 additions & 0 deletions tests/test_cursor_delete.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,24 @@ def test_delete_followed_by_insert(self, conn):
assert len(list(db[self.TEST_COLLECTION].find())) == 1
doc = list(db[self.TEST_COLLECTION].find())[0]
assert doc["title"] == "New Song"

def test_delete_executemany_with_parameters(self, conn):
"""Test executemany for bulk delete operations with parameters."""
cursor = conn.cursor()
sql = f"DELETE FROM {self.TEST_COLLECTION} WHERE artist = '?'"

# Delete multiple artists using executemany
params = [["Alice"], ["Charlie"], ["Eve"]]

cursor.executemany(sql, params)

# Verify specified artists were deleted
db = conn.database
remaining = list(db[self.TEST_COLLECTION].find())
assert len(remaining) == 2 # Only Bob and Diana remain

remaining_artists = {doc["artist"] for doc in remaining}
assert remaining_artists == {"Bob", "Diana"}
assert "Alice" not in remaining_artists
assert "Charlie" not in remaining_artists
assert "Eve" not in remaining_artists
25 changes: 23 additions & 2 deletions tests/test_cursor_insert.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
# -*- coding: utf-8 -*-
"""Test suite for INSERT statement execution via Cursor."""

import pytest

from pymongosql.error import ProgrammingError, SqlSyntaxError
Expand Down Expand Up @@ -212,3 +210,26 @@ def test_insert_followed_by_select(self, conn):
col_names = [desc[0] for desc in cursor.result_set.description]
assert "name" in col_names
assert "score" in col_names

def test_insert_executemany_with_parameters(self, conn):
"""Test executemany for bulk insert operations with parameters."""
sql = f"INSERT INTO {self.TEST_COLLECTION} {{'name': '?', 'age': '?', 'instrument': '?'}}"
cursor = conn.cursor()

# Multiple parameter sets for bulk insert
params = [["Frank", 28, "Guitar"], ["Grace", 32, "Piano"], ["Henry", 27, "Drums"], ["Iris", 30, "Violin"]]

cursor.executemany(sql, params)

# Verify all documents were inserted
db = conn.database
docs = list(db[self.TEST_COLLECTION].find())
assert len(docs) == 4

names = {doc["name"] for doc in docs}
assert names == {"Frank", "Grace", "Henry", "Iris"}

# Verify specific document
frank = db[self.TEST_COLLECTION].find_one({"name": "Frank"})
assert frank["age"] == 28
assert frank["instrument"] == "Guitar"
24 changes: 24 additions & 0 deletions tests/test_cursor_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,3 +210,27 @@ def test_update_set_null(self, conn):
book_b = db[self.TEST_COLLECTION].find_one({"title": "Book B"})
assert book_b is not None
assert book_b["stock"] is None

def test_update_executemany_with_parameters(self, conn):
"""Test executemany for bulk update operations with parameters."""
cursor = conn.cursor()
sql = f"UPDATE {self.TEST_COLLECTION} SET price = '?' WHERE title = '?'"

# Update prices for multiple books using executemany
params = [[25.99, "Book A"], [35.99, "Book B"], [45.99, "Book D"]]

cursor.executemany(sql, params)

# Verify all specified books were updated
db = conn.database
book_a = db[self.TEST_COLLECTION].find_one({"title": "Book A"})
book_b = db[self.TEST_COLLECTION].find_one({"title": "Book B"})
book_d = db[self.TEST_COLLECTION].find_one({"title": "Book D"})

assert book_a["price"] == 25.99
assert book_b["price"] == 35.99
assert book_d["price"] == 45.99

# Verify other books remain unchanged
book_c = db[self.TEST_COLLECTION].find_one({"title": "Book C"})
assert book_c["price"] == 19.99 # Original price unchanged
145 changes: 145 additions & 0 deletions tests/test_delete_builder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
# -*- coding: utf-8 -*-
import pytest

from pymongosql.sql.delete_builder import DeleteExecutionPlan, MongoDeleteBuilder


class TestDeleteExecutionPlan:
"""Test DeleteExecutionPlan dataclass."""

def test_to_dict(self):
"""Test to_dict conversion."""
plan = DeleteExecutionPlan(collection="users", filter_conditions={"age": {"$lt": 18}})

result = plan.to_dict()
assert result["collection"] == "users"
assert result["filter"] == {"age": {"$lt": 18}}

def test_to_dict_empty_filter(self):
"""Test to_dict with empty filter."""
plan = DeleteExecutionPlan(collection="logs", filter_conditions={})

result = plan.to_dict()
assert result["collection"] == "logs"
assert result["filter"] == {}

def test_validate_success(self):
"""Test validate returns True for valid plan."""
plan = DeleteExecutionPlan(collection="products", filter_conditions={"status": "inactive"})

assert plan.validate() is True

def test_validate_empty_filter_allowed(self):
"""Test validate allows empty filter (delete all)."""
plan = DeleteExecutionPlan(collection="temp_data", filter_conditions={})

assert plan.validate() is True

def test_copy(self):
"""Test copy creates independent copy."""
original = DeleteExecutionPlan(collection="orders", filter_conditions={"status": "cancelled", "year": 2020})

copy = original.copy()

# Verify all fields copied
assert copy.collection == original.collection
assert copy.filter_conditions == original.filter_conditions

# Verify it's independent
copy.collection = "new_collection"
copy.filter_conditions["new_field"] = "value"
assert original.collection == "orders"
assert "new_field" not in original.filter_conditions

def test_copy_with_empty_filter(self):
"""Test copy handles empty filter dict."""
original = DeleteExecutionPlan(collection="test", filter_conditions={})

copy = original.copy()
assert copy.filter_conditions == {}

# Verify it's independent
copy.filter_conditions["new"] = "value"
assert original.filter_conditions == {}

def test_parameter_style_default(self):
"""Test default parameter style is qmark."""
plan = DeleteExecutionPlan(collection="test")
assert plan.parameter_style == "qmark"


class TestMongoDeleteBuilder:
"""Test MongoDeleteBuilder class."""

def test_collection(self):
"""Test setting collection name."""
builder = MongoDeleteBuilder()
result = builder.collection("users")

assert builder._plan.collection == "users"
assert result is builder # Fluent interface

def test_filter_conditions(self):
"""Test setting filter conditions."""
builder = MongoDeleteBuilder()
builder.filter_conditions({"status": "deleted", "age": {"$gt": 100}})

assert builder._plan.filter_conditions == {"status": "deleted", "age": {"$gt": 100}}

def test_filter_conditions_empty(self):
"""Test filter_conditions with empty dict doesn't update."""
builder = MongoDeleteBuilder()
builder.filter_conditions({})

assert builder._plan.filter_conditions == {}

def test_filter_conditions_none(self):
"""Test filter_conditions with None doesn't update."""
builder = MongoDeleteBuilder()
builder._plan.filter_conditions = {"existing": "filter"}
builder.filter_conditions(None)

# Should preserve existing
assert builder._plan.filter_conditions == {"existing": "filter"}

def test_build_success(self):
"""Test build returns execution plan when valid."""
builder = MongoDeleteBuilder()
builder.collection("products").filter_conditions({"price": {"$lt": 10}})

plan = builder.build()

assert isinstance(plan, DeleteExecutionPlan)
assert plan.collection == "products"
assert plan.filter_conditions == {"price": {"$lt": 10}}

def test_build_success_empty_filter(self):
"""Test build succeeds with empty filter (delete all)."""
builder = MongoDeleteBuilder()
builder.collection("temp_logs")

plan = builder.build()

assert isinstance(plan, DeleteExecutionPlan)
assert plan.collection == "temp_logs"
assert plan.filter_conditions == {}

def test_build_validation_failure(self):
"""Test build raises ValueError when validation fails."""
builder = MongoDeleteBuilder()
# Don't set collection

with pytest.raises(ValueError) as exc_info:
builder.build()

assert "invalid delete plan" in str(exc_info.value).lower()

def test_fluent_interface_chaining(self):
"""Test all methods return self for chaining."""
builder = MongoDeleteBuilder()

result = builder.collection("orders").filter_conditions({"status": "expired"})

assert result is builder
assert builder._plan.collection == "orders"
assert builder._plan.filter_conditions == {"status": "expired"}
Loading