diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ef2bcc..0fc12ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,8 @@ jobs: matrix: python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] mongodb-version: ['7.0', '8.0'] + # Test against representative SQLAlchemy series (1.x and 2.x) + sqlalchemy-version: ['1.4.*', '2.*'] services: mongodb: @@ -41,9 +43,9 @@ jobs: uses: actions/cache@v3 with: path: ~/.cache/pip - key: ${{ runner.os }}-py${{ matrix.python-version }}-mongo${{ matrix.mongodb-version }}-pip-${{ hashFiles('**/requirements-test.txt', 'pyproject.toml') }} + key: ${{ runner.os }}-py${{ matrix.python-version }}-mongo${{ matrix.mongodb-version }}-sqlalchemy-${{ matrix.sqlalchemy-version }}-pip-${{ hashFiles('**/requirements-test.txt', 'pyproject.toml') }} restore-keys: | - ${{ runner.os }}-py${{ matrix.python-version }}-mongo${{ matrix.mongodb-version }}-pip- + ${{ runner.os }}-py${{ matrix.python-version }}-mongo${{ matrix.mongodb-version }}-sqlalchemy-${{ matrix.sqlalchemy-version }}-pip- - name: Install MongoDB shell run: | @@ -55,6 +57,9 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip + # Install the target SQLAlchemy version for this matrix entry first to ensure + # tests run against both 1.x and 2.x series. + pip install "SQLAlchemy==${{ matrix.sqlalchemy-version }}" pip install -r requirements-test.txt pip install black isort diff --git a/MANIFEST.in b/MANIFEST.in index 1a396b4..5bf1299 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -8,7 +8,6 @@ include requirements-test.txt # Include configuration files include pyproject.toml -include .flake8 # Exclude unnecessary files global-exclude *.pyc diff --git a/README.md b/README.md index b44f017..e1d501c 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,36 @@ # PyMongoSQL +[![PyPI](https://img.shields.io/pypi/v/pymongosql)](https://pypi.org/project/pymongosql/) [![Test](https://github.com/passren/PyMongoSQL/actions/workflows/ci.yml/badge.svg)](https://github.com/passren/PyMongoSQL/actions/workflows/ci.yml) [![Code Style](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) +[![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/) [![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/) -PyMongoSQL is a Python [DB API 2.0 (PEP 249)](https://www.python.org/dev/peps/pep-0249/) client for [MongoDB](https://www.mongodb.com/). It provides a familiar SQL interface to MongoDB, allowing developers to use SQL queries to interact with MongoDB collections. +PyMongoSQL is a Python [DB API 2.0 (PEP 249)](https://www.python.org/dev/peps/pep-0249/) client for [MongoDB](https://www.mongodb.com/). It provides a familiar SQL interface to MongoDB, allowing developers to use SQL to interact with MongoDB collections. ## Objectives PyMongoSQL implements the DB API 2.0 interfaces to provide SQL-like access to MongoDB. The project aims to: -- Bridge the gap between SQL and NoSQL by providing SQL query capabilities for MongoDB +- Bridge the gap between SQL and NoSQL by providing SQL capabilities for MongoDB - Support standard SQL DQL (Data Query Language) operations including SELECT statements with WHERE, ORDER BY, and LIMIT clauses - Provide seamless integration with existing Python applications that expect DB API 2.0 compliance - Enable easy migration from traditional SQL databases to MongoDB -- Support field aliasing and projection mapping for flexible result set handling -- Maintain high performance through direct `db.command()` execution instead of high-level APIs ## Features - **DB API 2.0 Compliant**: Full compatibility with Python Database API 2.0 specification +- **SQLAlchemy Integration**: Complete ORM and Core support with dedicated MongoDB dialect - **SQL Query Support**: SELECT statements with WHERE conditions, field selection, and aliases -- **MongoDB Native Integration**: Direct `db.command()` execution for optimal performance - **Connection String Support**: MongoDB URI format for easy configuration -- **Result Set Handling**: Support for `fetchone()`, `fetchmany()`, and `fetchall()` operations -- **Field Aliasing**: SQL-style field aliases with automatic projection mapping -- **Context Manager Support**: Automatic resource management with `with` statements -- **Transaction Ready**: Architecture designed for future DML operation support (INSERT, UPDATE, DELETE) ## Requirements - **Python**: 3.9, 3.10, 3.11, 3.12, 3.13+ -- **MongoDB**: 4.0+ +- **MongoDB**: 7.0+ ## Dependencies @@ -43,6 +40,11 @@ PyMongoSQL implements the DB API 2.0 interfaces to provide SQL-like access to Mo - **ANTLR4** (SQL Parser Runtime) - antlr4-python3-runtime >= 4.13.0 +### Optional Dependencies + +- **SQLAlchemy** (for ORM/Core support) + - sqlalchemy >= 1.4.0 (SQLAlchemy 1.4+ and 2.0+ supported) + ## Installation ```bash @@ -67,7 +69,7 @@ from pymongosql import connect # Connect to MongoDB connection = connect( host="mongodb://localhost:27017", - database="test_db" + database="database" ) cursor = connection.cursor() @@ -97,44 +99,19 @@ for row in cursor: ```python from pymongosql import connect -with connect(host="mongodb://localhost:27017", database="mydb") as conn: +with connect(host="mongodb://localhost:27017/database") as conn: with conn.cursor() as cursor: cursor.execute('SELECT COUNT(*) as total FROM users') result = cursor.fetchone() print(f"Total users: {result['total']}") ``` -### Field Aliases and Projections - -```python -from pymongosql import connect - -connection = connect(host="mongodb://localhost:27017", database="ecommerce") -cursor = connection.cursor() - -# Use field aliases for cleaner result sets -cursor.execute(''' - SELECT - name AS product_name, - price AS cost, - category AS product_type - FROM products - WHERE in_stock = true - ORDER BY price DESC - LIMIT 10 -''') - -products = cursor.fetchall() -for product in products: - print(f"{product['product_name']}: ${product['cost']}") -``` - ### Query with Parameters ```python from pymongosql import connect -connection = connect(host="mongodb://localhost:27017", database="blog") +connection = connect(host="mongodb://localhost:27017/database") cursor = connection.cursor() # Parameterized queries for security @@ -159,7 +136,6 @@ while users: ### SELECT Statements - Field selection: `SELECT name, age FROM users` - Wildcards: `SELECT * FROM products` -- Field aliases: `SELECT name AS user_name, age AS user_age FROM users` ### WHERE Clauses - Equality: `WHERE name = 'John'` @@ -171,15 +147,6 @@ while users: - LIMIT: `LIMIT 10` - Combined: `ORDER BY created_at DESC LIMIT 5` -## Architecture - -PyMongoSQL uses a multi-layer architecture: - -1. **SQL Parser**: Built with ANTLR4 for robust SQL parsing -2. **Query Planner**: Converts SQL AST to MongoDB query plans -3. **Command Executor**: Direct `db.command()` execution for performance -4. **Result Processor**: Handles projection mapping and result set iteration - ## Connection Options ```python @@ -201,31 +168,6 @@ print(conn.database_name) # Database name print(conn.is_connected) # Connection status ``` -## Error Handling - -```python -from pymongosql import connect -from pymongosql.error import ProgrammingError, SqlSyntaxError - -try: - connection = connect(host="mongodb://localhost:27017", database="test") - cursor = connection.cursor() - cursor.execute("INVALID SQL SYNTAX") -except SqlSyntaxError as e: - print(f"SQL syntax error: {e}") -except ProgrammingError as e: - print(f"Programming error: {e}") -``` - -## Development Status - -PyMongoSQL is currently focused on DQL (Data Query Language) operations. Future releases will include: - -- **DML Operations**: INSERT, UPDATE, DELETE statements -- **Advanced SQL Features**: JOINs, subqueries, aggregations -- **Schema Operations**: CREATE/DROP collection commands -- **Transaction Support**: Multi-document ACID transactions - ## Contributing Contributions are welcome! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change. diff --git a/pymongosql/__init__.py b/pymongosql/__init__.py index 7507f25..e404a89 100644 --- a/pymongosql/__init__.py +++ b/pymongosql/__init__.py @@ -6,7 +6,7 @@ if TYPE_CHECKING: from .connection import Connection -__version__: str = "0.1.2" +__version__: str = "0.2.0" # Globals https://www.python.org/dev/peps/pep-0249/#globals apilevel: str = "2.0" @@ -40,3 +40,15 @@ def connect(*args, **kwargs) -> "Connection": from .connection import Connection return Connection(*args, **kwargs) + + +# SQLAlchemy integration (optional) +# For SQLAlchemy functionality, import from pymongosql.sqlalchemy_mongodb: +# from pymongosql.sqlalchemy_mongodb import create_engine_url, create_engine_from_mongodb_uri +try: + from .sqlalchemy_mongodb import __sqlalchemy_version__, __supports_sqlalchemy_2x__, __supports_sqlalchemy__ +except ImportError: + # SQLAlchemy integration not available + __sqlalchemy_version__ = None + __supports_sqlalchemy__ = False + __supports_sqlalchemy_2x__ = False diff --git a/pymongosql/sqlalchemy_mongodb/__init__.py b/pymongosql/sqlalchemy_mongodb/__init__.py new file mode 100644 index 0000000..9bf4cc2 --- /dev/null +++ b/pymongosql/sqlalchemy_mongodb/__init__.py @@ -0,0 +1,171 @@ +# -*- coding: utf-8 -*- +""" +SQLAlchemy MongoDB dialect and integration for PyMongoSQL. + +This package provides SQLAlchemy integration including: +- MongoDB-specific dialect +- Version compatibility utilities +- Engine creation helpers +- MongoDB URI handling +""" + +# SQLAlchemy integration +try: + # Import and register the dialect automatically + from .sqlalchemy_compat import ( + get_sqlalchemy_version, + is_sqlalchemy_2x, + ) + + # Make compatibility info easily accessible + __sqlalchemy_version__ = get_sqlalchemy_version() + __supports_sqlalchemy__ = __sqlalchemy_version__ is not None + __supports_sqlalchemy_2x__ = is_sqlalchemy_2x() + +except ImportError: + # SQLAlchemy not available + __sqlalchemy_version__ = None + __supports_sqlalchemy__ = False + __supports_sqlalchemy_2x__ = False + + +def create_engine_url(host: str = "localhost", port: int = 27017, database: str = "test", **kwargs) -> str: + """Create a SQLAlchemy engine URL for PyMongoSQL. + + Args: + host: MongoDB host + port: MongoDB port + database: Database name + **kwargs: Additional connection parameters + + Returns: + SQLAlchemy URL string (uses mongodb:// format) + + Example: + >>> url = create_engine_url("localhost", 27017, "mydb") + >>> engine = sqlalchemy.create_engine(url) + """ + params = [] + for key, value in kwargs.items(): + params.append(f"{key}={value}") + + param_str = "&".join(params) + if param_str: + param_str = "?" + param_str + + return f"mongodb://{host}:{port}/{database}{param_str}" + + +def create_mongodb_url(mongodb_uri: str) -> str: + """Convert a standard MongoDB URI to work with PyMongoSQL SQLAlchemy dialect. + + Args: + mongodb_uri: Standard MongoDB connection string + (e.g., 'mongodb://localhost:27017/mydb' or 'mongodb+srv://...') + + Returns: + SQLAlchemy-compatible URL for PyMongoSQL + + Example: + >>> url = create_mongodb_url("mongodb://user:pass@localhost:27017/mydb") + >>> engine = sqlalchemy.create_engine(url) + """ + # Return the MongoDB URI as-is since the dialect now handles MongoDB URLs directly + return mongodb_uri + + +def create_engine_from_mongodb_uri(mongodb_uri: str, **engine_kwargs): + """Create a SQLAlchemy engine from any MongoDB connection string. + + This function handles both mongodb:// and mongodb+srv:// URIs properly. + Use this instead of create_engine() directly for mongodb+srv URIs. + + Args: + mongodb_uri: Standard MongoDB connection string + **engine_kwargs: Additional arguments passed to create_engine + + Returns: + SQLAlchemy Engine object + + Example: + >>> # For SRV records (Atlas/Cloud) + >>> engine = create_engine_from_mongodb_uri("mongodb+srv://user:pass@cluster.net/db") + >>> # For standard MongoDB + >>> engine = create_engine_from_mongodb_uri("mongodb://localhost:27017/mydb") + """ + try: + from sqlalchemy import create_engine + + if mongodb_uri.startswith("mongodb+srv://"): + # For MongoDB+SRV, convert to standard mongodb:// for SQLAlchemy compatibility + # SQLAlchemy doesn't handle the + character in scheme names well + converted_uri = mongodb_uri.replace("mongodb+srv://", "mongodb://") + + # Create engine with converted URI + engine = create_engine(converted_uri, **engine_kwargs) + + def custom_create_connect_args(url): + # Use original SRV URI for actual MongoDB connection + opts = {"host": mongodb_uri} + return [], opts + + engine.dialect.create_connect_args = custom_create_connect_args + return engine + else: + # Standard mongodb:// URLs work fine with SQLAlchemy + return create_engine(mongodb_uri, **engine_kwargs) + + except ImportError: + raise ImportError("SQLAlchemy is required for engine creation") + + +def register_dialect(): + """Register the PyMongoSQL dialect with SQLAlchemy. + + This function handles registration for both SQLAlchemy 1.x and 2.x. + Registers support for standard MongoDB connection strings only. + """ + try: + from sqlalchemy.dialects import registry + + # Register for standard MongoDB URLs + registry.register("mongodb", "pymongosql.sqlalchemy_mongodb.sqlalchemy_dialect", "PyMongoSQLDialect") + + # Try to register both SRV forms so SQLAlchemy can resolve SRV-style URLs + # (either 'mongodb+srv' or the dotted 'mongodb.srv' plugin name). + # Some SQLAlchemy versions accept '+' in scheme names; others import + # the dotted plugin name. Attempt both registrations in one block. + try: + registry.register("mongodb+srv", "pymongosql.sqlalchemy_mongodb.sqlalchemy_dialect", "PyMongoSQLDialect") + registry.register("mongodb.srv", "pymongosql.sqlalchemy_mongodb.sqlalchemy_dialect", "PyMongoSQLDialect") + except Exception: + # If registration fails we fall back to handling SRV URIs in + # create_engine_from_mongodb_uri by converting 'mongodb+srv' to 'mongodb'. + pass + + return True + except ImportError: + # Fallback for versions without registry + return False + except Exception: + # Handle other registration errors gracefully + return False + + +# Attempt registration on module import +_registration_successful = register_dialect() + +# Export all SQLAlchemy-related functionality +__all__ = [ + "create_engine_url", + "create_mongodb_url", + "create_engine_from_mongodb_uri", + "register_dialect", + "__sqlalchemy_version__", + "__supports_sqlalchemy__", + "__supports_sqlalchemy_2x__", + "_registration_successful", +] + +# Note: PyMongoSQL now uses standard MongoDB connection strings directly +# No need for PyMongoSQL-specific URL format diff --git a/pymongosql/sqlalchemy_mongodb/sqlalchemy_compat.py b/pymongosql/sqlalchemy_mongodb/sqlalchemy_compat.py new file mode 100644 index 0000000..2c1263c --- /dev/null +++ b/pymongosql/sqlalchemy_mongodb/sqlalchemy_compat.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +""" +SQLAlchemy version compatibility utilities for PyMongoSQL. + +This module provides utilities to work with different SQLAlchemy versions. +""" +import warnings +from typing import Any, Dict, Optional + +try: + import sqlalchemy + + SQLALCHEMY_VERSION = tuple(map(int, sqlalchemy.__version__.split(".")[:2])) + SQLALCHEMY_2X = SQLALCHEMY_VERSION >= (2, 0) + HAS_SQLALCHEMY = True +except ImportError: + SQLALCHEMY_VERSION = None + SQLALCHEMY_2X = False + HAS_SQLALCHEMY = False + + +def get_sqlalchemy_version() -> Optional[tuple]: + """Get the installed SQLAlchemy version as a tuple. + + Returns: + Tuple of (major, minor) version numbers, or None if not installed. + + Example: + >>> get_sqlalchemy_version() + (2, 0) + """ + return SQLALCHEMY_VERSION + + +def is_sqlalchemy_2x() -> bool: + """Check if SQLAlchemy 2.x is installed. + + Returns: + True if SQLAlchemy 2.x or later is installed, False otherwise. + """ + return SQLALCHEMY_2X + + +def check_sqlalchemy_compatibility() -> Dict[str, Any]: + """Check SQLAlchemy compatibility and return status information. + + Returns: + Dictionary with compatibility information. + """ + if not HAS_SQLALCHEMY: + return { + "installed": False, + "version": None, + "compatible": False, + "message": "SQLAlchemy not installed. Install with: pip install sqlalchemy>=1.4.0", + } + + if SQLALCHEMY_VERSION < (1, 4): + return { + "installed": True, + "version": SQLALCHEMY_VERSION, + "compatible": False, + "message": f'SQLAlchemy {".".join(map(str, SQLALCHEMY_VERSION))} is too old. Requires 1.4.0 or later.', + } + + return { + "installed": True, + "version": SQLALCHEMY_VERSION, + "compatible": True, + "is_2x": SQLALCHEMY_2X, + "message": f'SQLAlchemy {".".join(map(str, SQLALCHEMY_VERSION))} is compatible.', + } + + +def get_base_class(): + """Get the appropriate base class for ORM models. + + Returns version-appropriate base class for declarative models. + + Returns: + Base class for SQLAlchemy ORM models. + + Example: + >>> Base = get_base_class() + >>> class User(Base): + ... __tablename__ = 'users' + ... # ... model definition + """ + if not HAS_SQLALCHEMY: + raise ImportError("SQLAlchemy is required but not installed") + + if SQLALCHEMY_2X: + # SQLAlchemy 2.x style + try: + from sqlalchemy.orm import DeclarativeBase + + class Base(DeclarativeBase): + pass + + return Base + except ImportError: + # Fallback to 1.x style if DeclarativeBase not available + from sqlalchemy.ext.declarative import declarative_base + + return declarative_base() + else: + # SQLAlchemy 1.x style + from sqlalchemy.ext.declarative import declarative_base + + return declarative_base() + + +def create_pymongosql_engine(url: str, **kwargs): + """Create a PyMongoSQL engine with version-appropriate settings. + + Args: + url: Database URL (e.g., 'pymongosql://localhost:27017/mydb') + **kwargs: Additional arguments passed to create_engine + + Returns: + SQLAlchemy engine configured for PyMongoSQL. + + Example: + >>> engine = create_pymongosql_engine('pymongosql://localhost:27017/mydb') + """ + if not HAS_SQLALCHEMY: + raise ImportError("SQLAlchemy is required but not installed") + + from sqlalchemy import create_engine + + # Version-specific default configurations + if SQLALCHEMY_2X: + # SQLAlchemy 2.x defaults + defaults = { + "echo": False, + "future": True, # Use future engine interface + } + else: + # SQLAlchemy 1.x defaults + defaults = { + "echo": False, + } + + # Merge user kwargs with defaults + engine_kwargs = {**defaults, **kwargs} + + return create_engine(url, **engine_kwargs) + + +def get_session_maker(engine, **kwargs): + """Get a session maker with version-appropriate configuration. + + Args: + engine: SQLAlchemy engine + **kwargs: Additional arguments for sessionmaker + + Returns: + Configured sessionmaker class. + """ + if not HAS_SQLALCHEMY: + raise ImportError("SQLAlchemy is required but not installed") + + from sqlalchemy.orm import sessionmaker + + if SQLALCHEMY_2X: + # SQLAlchemy 2.x session configuration + defaults = {} + else: + # SQLAlchemy 1.x session configuration + defaults = {} + + session_kwargs = {**defaults, **kwargs} + + return sessionmaker(bind=engine, **session_kwargs) + + +def warn_if_incompatible(): + """Issue a warning if SQLAlchemy version is incompatible.""" + compat_info = check_sqlalchemy_compatibility() + + if not compat_info["compatible"]: + warnings.warn(f"PyMongoSQL SQLAlchemy integration: {compat_info['message']}", UserWarning, stacklevel=2) + + +# Compatibility constants for easy access +__all__ = [ + "SQLALCHEMY_VERSION", + "SQLALCHEMY_2X", + "HAS_SQLALCHEMY", + "get_sqlalchemy_version", + "is_sqlalchemy_2x", + "check_sqlalchemy_compatibility", + "get_base_class", + "create_pymongosql_engine", + "get_session_maker", + "warn_if_incompatible", +] + +# Warn on import if incompatible +if HAS_SQLALCHEMY: + warn_if_incompatible() diff --git a/pymongosql/sqlalchemy_mongodb/sqlalchemy_dialect.py b/pymongosql/sqlalchemy_mongodb/sqlalchemy_dialect.py new file mode 100644 index 0000000..3026cea --- /dev/null +++ b/pymongosql/sqlalchemy_mongodb/sqlalchemy_dialect.py @@ -0,0 +1,540 @@ +# -*- coding: utf-8 -*- +""" +SQLAlchemy dialect for PyMongoSQL. + +This module provides a SQLAlchemy dialect that allows PyMongoSQL to work +seamlessly with SQLAlchemy's ORM and core query functionality. + +Supports both SQLAlchemy 1.x and 2.x versions. +""" +import logging +from typing import Any, Dict, List, Optional, Tuple, Type + +from sqlalchemy import pool, types +from sqlalchemy.engine import default, url +from sqlalchemy.sql import compiler +from sqlalchemy.sql.sqltypes import NULLTYPE + +import pymongosql + +_logger = logging.getLogger(__name__) + +try: + import sqlalchemy + + SQLALCHEMY_VERSION = tuple(map(int, sqlalchemy.__version__.split(".")[:2])) + SQLALCHEMY_2X = SQLALCHEMY_VERSION >= (2, 0) +except ImportError: + SQLALCHEMY_VERSION = (1, 4) # Default fallback + SQLALCHEMY_2X = False + +# Version-specific imports +if SQLALCHEMY_2X: + try: + from sqlalchemy.engine.interfaces import Dialect + except ImportError: + # Fallback for different 2.x versions + from sqlalchemy.engine.default import DefaultDialect as Dialect +else: + from sqlalchemy.engine.interfaces import Dialect + + +class PyMongoSQLIdentifierPreparer(compiler.IdentifierPreparer): + """MongoDB-specific identifier preparer. + + MongoDB collection and field names have specific rules that differ + from SQL databases. + """ + + reserved_words = set( + [ + # MongoDB reserved words and operators + "$eq", + "$ne", + "$gt", + "$gte", + "$lt", + "$lte", + "$in", + "$nin", + "$and", + "$or", + "$not", + "$nor", + "$exists", + "$type", + "$mod", + "$regex", + "$text", + "$where", + "$all", + "$elemMatch", + "$size", + "$bitsAllClear", + "$bitsAllSet", + "$bitsAnyClear", + "$bitsAnySet", + ] + ) + + def __init__(self, dialect: Dialect, **kwargs: Any) -> None: + super().__init__(dialect, **kwargs) + # MongoDB allows most characters in field names - use regex pattern + import re + + self.legal_characters = re.compile(r"^[$a-zA-Z0-9_.]+$") + + +class PyMongoSQLCompiler(compiler.SQLCompiler): + """MongoDB-specific SQL compiler. + + Handles SQL compilation specific to MongoDB's query patterns. + """ + + def visit_column(self, column, **kwargs): + """Handle column references for MongoDB field names.""" + name = column.name + # Handle MongoDB-specific field name patterns + if name.startswith("_"): + # MongoDB system fields like _id + return self.preparer.quote(name) + return super().visit_column(column, **kwargs) + + +class PyMongoSQLDDLCompiler(compiler.DDLCompiler): + """MongoDB-specific DDL compiler. + + Handles Data Definition Language operations for MongoDB. + """ + + def visit_create_table(self, create, **kwargs): + """Handle CREATE TABLE - MongoDB creates collections on first insert.""" + # MongoDB collections are created implicitly + return "-- Collection will be created on first insert" + + def visit_drop_table(self, drop, **kwargs): + """Handle DROP TABLE - translates to MongoDB collection drop.""" + table = drop.element + return f"-- DROP COLLECTION {self.preparer.format_table(table)}" + + +class PyMongoSQLTypeCompiler(compiler.GenericTypeCompiler): + """MongoDB-specific type compiler. + + Handles type mapping between SQL types and MongoDB BSON types. + """ + + def visit_VARCHAR(self, type_, **kwargs): + return "STRING" + + def visit_CHAR(self, type_, **kwargs): + return "STRING" + + def visit_TEXT(self, type_, **kwargs): + return "STRING" + + def visit_INTEGER(self, type_, **kwargs): + return "INT32" + + def visit_BIGINT(self, type_, **kwargs): + return "INT64" + + def visit_FLOAT(self, type_, **kwargs): + return "DOUBLE" + + def visit_NUMERIC(self, type_, **kwargs): + return "DECIMAL128" + + def visit_DECIMAL(self, type_, **kwargs): + return "DECIMAL128" + + def visit_DATETIME(self, type_, **kwargs): + return "DATE" + + def visit_DATE(self, type_, **kwargs): + return "DATE" + + def visit_BOOLEAN(self, type_, **kwargs): + return "BOOL" + + +class PyMongoSQLDialect(default.DefaultDialect): + """SQLAlchemy dialect for PyMongoSQL. + + This dialect enables PyMongoSQL to work with SQLAlchemy by providing + the necessary interface methods and compilation logic. + + Compatible with SQLAlchemy 1.4+ and 2.x versions. + """ + + name = "mongodb" + driver = "pymongosql" + + # Version compatibility + _sqlalchemy_version = SQLALCHEMY_VERSION + _is_sqlalchemy_2x = SQLALCHEMY_2X + + # DB API 2.0 compliance + supports_alter = False # MongoDB doesn't support ALTER TABLE + supports_comments = False # No SQL comments in MongoDB + supports_default_values = True + supports_empty_inserts = True + supports_multivalues_insert = True + supports_native_decimal = True # BSON Decimal128 + supports_native_boolean = True # BSON Boolean + supports_sequences = False # No sequences in MongoDB + supports_native_enum = False # No native enums + + # MongoDB-specific features + supports_statement_cache = True + supports_server_side_cursors = True + + # Connection characteristics + poolclass = pool.StaticPool + + # Compilation + statement_compiler = PyMongoSQLCompiler + ddl_compiler = PyMongoSQLDDLCompiler + type_compiler = PyMongoSQLTypeCompiler + preparer = PyMongoSQLIdentifierPreparer + + # Default parameter style + paramstyle = "qmark" # Matches PyMongoSQL's paramstyle + + @classmethod + def dbapi(cls): + """Return the PyMongoSQL DBAPI module (SQLAlchemy 1.x compatibility).""" + return pymongosql + + @classmethod + def import_dbapi(cls): + """Return the PyMongoSQL DBAPI module (SQLAlchemy 2.x).""" + return pymongosql + + def _get_dbapi_module(self): + """Internal method to get DBAPI module for instance access.""" + return pymongosql + + def __getattribute__(self, name): + """Override getattribute to handle DBAPI access properly.""" + if name == "dbapi": + # Always return the module directly for DBAPI access + return pymongosql + return super().__getattribute__(name) + + def create_connect_args(self, url: url.URL) -> Tuple[List[Any], Dict[str, Any]]: + """Create connection arguments from SQLAlchemy URL. + + Supports standard MongoDB connection strings (mongodb://). + Note: For mongodb+srv URLs, use them directly as connection strings + rather than through SQLAlchemy create_engine due to SQLAlchemy parsing limitations. + + Args: + url: SQLAlchemy URL object with MongoDB connection string + + Returns: + Tuple of (args, kwargs) for PyMongoSQL connection + """ + opts = {} + + # For MongoDB URLs, reconstruct the full URI to pass to PyMongoSQL + # This ensures proper MongoDB connection string format + uri_parts = [] + + # Start with scheme (mongodb only - srv handled separately) + uri_parts.append(f"{url.drivername}://") + + # Add credentials if present + if url.username: + if url.password: + uri_parts.append(f"{url.username}:{url.password}@") + else: + uri_parts.append(f"{url.username}@") + + # Add host and port + if url.host: + uri_parts.append(url.host) + if url.port: + uri_parts.append(f":{url.port}") + + # Add database + if url.database: + uri_parts.append(f"/{url.database}") + + # Add query parameters + if url.query: + query_parts = [] + for key, value in url.query.items(): + query_parts.append(f"{key}={value}") + if query_parts: + uri_parts.append(f"?{'&'.join(query_parts)}") + + # Pass the full MongoDB URI to PyMongoSQL + mongodb_uri = "".join(uri_parts) + opts["host"] = mongodb_uri + + return [], opts + + def get_schema_names(self, connection, **kwargs): + """Get list of databases (schemas in SQL terms).""" + # Use MongoDB admin command directly instead of SQL SHOW DATABASES + try: + # Access the underlying MongoDB client through the connection + db_connection = connection.connection + if hasattr(db_connection, "_client"): + admin_db = db_connection._client.admin + result = admin_db.command("listDatabases") + return [db["name"] for db in result.get("databases", [])] + except Exception as e: + _logger.warning(f"Failed to get database names: {e}") + return ["default"] # Fallback to default database + + def has_table(self, connection, table_name: str, schema: Optional[str] = None, **kwargs) -> bool: + """Check if a collection (table) exists.""" + try: + # Use MongoDB listCollections command directly + db_connection = connection.connection + if hasattr(db_connection, "_client"): + if schema: + db = db_connection._client[schema] + else: + db = db_connection.database + + # Use listCollections command + collections = db.list_collection_names() + return table_name in collections + except Exception as e: + _logger.warning(f"Failed to check table existence: {e}") + return False + + def get_table_names(self, connection, schema: Optional[str] = None, **kwargs) -> List[str]: + """Get list of collections (tables).""" + try: + # Use MongoDB listCollections command directly + db_connection = connection.connection + if hasattr(db_connection, "_client"): + if schema: + db = db_connection._client[schema] + else: + db = db_connection.database + + # Use listCollections command + return db.list_collection_names() + except Exception as e: + _logger.warning(f"Failed to get table names: {e}") + return [] + + def get_columns(self, connection, table_name: str, schema: Optional[str] = None, **kwargs) -> List[Dict[str, Any]]: + """Get column information for a collection. + + MongoDB is schemaless, so this inspects documents to infer structure. + """ + columns = [] + try: + # Use direct MongoDB operations to sample documents and infer schema + db_connection = connection.connection + if hasattr(db_connection, "_client"): + if schema: + db = db_connection._client[schema] + else: + db = db_connection.database + + collection = db[table_name] + + # Sample a few documents to infer schema + sample_docs = list(collection.find().limit(10)) + if sample_docs: + # Collect all unique field names and types + field_types = {} + for doc in sample_docs: + for field_name, value in doc.items(): + if field_name not in field_types: + field_types[field_name] = self._infer_bson_type(value) + + # Convert to SQLAlchemy column format + for field_name, bson_type in field_types.items(): + columns.append( + { + "name": field_name, + "type": self._get_column_type(bson_type), + "nullable": field_name != "_id", # _id is always required + "default": None, + } + ) + else: + # Empty collection, provide minimal _id column + columns = [ + { + "name": "_id", + "type": types.String(), + "nullable": False, + "default": None, + } + ] + + except Exception as e: + _logger.warning(f"Failed to get column info for {table_name}: {e}") + # Fallback: provide minimal _id column + columns = [ + { + "name": "_id", + "type": types.String(), + "nullable": False, + "default": None, + } + ] + + return columns + + def _infer_bson_type(self, value: Any) -> str: + """Infer BSON type from a Python value.""" + from datetime import datetime + + from bson import ObjectId + + if isinstance(value, ObjectId): + return "objectId" + elif isinstance(value, str): + return "string" + elif isinstance(value, bool): + return "bool" + elif isinstance(value, int): + return "int" + elif isinstance(value, float): + return "double" + elif isinstance(value, datetime): + return "date" + elif isinstance(value, list): + return "array" + elif isinstance(value, dict): + return "object" + elif value is None: + return "null" + else: + return "string" # Default fallback + + def _get_column_type(self, mongo_type: str) -> Type[types.TypeEngine]: + """Map MongoDB/BSON types to SQLAlchemy types.""" + type_map = { + "objectId": types.String, + "string": types.String, + "int": types.Integer, + "long": types.BigInteger, + "double": types.Float, + "decimal": types.DECIMAL, + "bool": types.Boolean, + "date": types.DateTime, + "null": NULLTYPE, + "array": types.JSON, + "object": types.JSON, + "binData": types.LargeBinary, + } + return type_map.get(mongo_type.lower(), types.String) + + def get_pk_constraint(self, connection, table_name: str, schema: Optional[str] = None, **kwargs) -> Dict[str, Any]: + """Get primary key constraint info. + + MongoDB always has _id as the primary key. + """ + return {"constrained_columns": ["_id"], "name": "pk_id"} + + def get_foreign_keys( + self, connection, table_name: str, schema: Optional[str] = None, **kwargs + ) -> List[Dict[str, Any]]: + """Get foreign key constraints. + + MongoDB doesn't enforce foreign keys, return empty list. + """ + return [] + + def get_indexes(self, connection, table_name: str, schema: Optional[str] = None, **kwargs) -> List[Dict[str, Any]]: + """Get index information for a collection.""" + indexes = [] + try: + # Use direct MongoDB operations to get indexes + db_connection = connection.connection + if hasattr(db_connection, "_client"): + if schema: + db = db_connection._client[schema] + else: + db = db_connection.database + + collection = db[table_name] + + # Get index information + index_info = collection.index_information() + for index_name, index_spec in index_info.items(): + # Extract column names from key specification + column_names = [field[0] for field in index_spec.get("key", [])] + + indexes.append( + { + "name": index_name, + "column_names": column_names, + "unique": index_spec.get("unique", False), + } + ) + except Exception as e: + _logger.warning(f"Failed to get index info for {table_name}: {e}") + # Always include the default _id index as fallback + indexes = [ + { + "name": "_id_", + "column_names": ["_id"], + "unique": True, + } + ] + + return indexes + + def do_rollback(self, dbapi_connection): + """Rollback transaction. + + MongoDB has limited transaction support. + """ + # PyMongoSQL should handle this + if hasattr(dbapi_connection, "rollback"): + try: + dbapi_connection.rollback() + except Exception: + # MongoDB doesn't always support rollback - ignore errors + # This is normal behavior for MongoDB connections without active transactions + pass + + def do_commit(self, dbapi_connection): + """Commit transaction. + + MongoDB auto-commits most operations. + """ + # PyMongoSQL should handle this + if hasattr(dbapi_connection, "commit"): + try: + dbapi_connection.commit() + except Exception: + # MongoDB auto-commits most operations - ignore errors + # This is normal behavior for MongoDB connections + pass + + def do_ping(self, dbapi_connection): + """Ping the database to test connection status. + + Used by SQLAlchemy and tools like Superset for connection testing. + This avoids the need to execute "SELECT 1" queries that would fail + due to PartiQL grammar requirements. + """ + if hasattr(dbapi_connection, "test_connection") and callable(dbapi_connection.test_connection): + return dbapi_connection.test_connection() + else: + # Fallback: try to execute a simple ping command directly + try: + if hasattr(dbapi_connection, "_client"): + dbapi_connection._client.admin.command("ping") + return True + except Exception: + pass + return False + + +# Version information +__sqlalchemy_version__ = SQLALCHEMY_VERSION +__supports_sqlalchemy_2x__ = SQLALCHEMY_2X diff --git a/pyproject.toml b/pyproject.toml index 42aecb3..d98a795 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ dependencies = [ ] [project.optional-dependencies] +sqlalchemy = ["sqlalchemy>=1.4.0"] dev = [ "pytest>=7.0.0", "pytest-cov>=4.0.0", @@ -52,6 +53,10 @@ Repository = "https://github.com/passren/PyMongoSQL.git" Documentation = "https://github.com/passren/PyMongoSQL/wiki" "Bug Reports" = "https://github.com/passren/PyMongoSQL/issues" +[project.entry-points."sqlalchemy.dialects"] +mongodb = "pymongosql.sqlalchemy_mongodb.sqlalchemy_dialect:PyMongoSQLDialect" +"mongodb+srv" = "pymongosql.sqlalchemy_mongodb.sqlalchemy_dialect:PyMongoSQLDialect" + [tool.black] line-length = 120 target-version = ['py39'] diff --git a/requirements-optional.txt b/requirements-optional.txt new file mode 100644 index 0000000..7729cbc --- /dev/null +++ b/requirements-optional.txt @@ -0,0 +1,2 @@ +# SQLAlchemy support (optional) - supports 1.4+ and 2.x +sqlalchemy>=1.4.0,<3.0.0 \ No newline at end of file diff --git a/requirements-test.txt b/requirements-test.txt index c45a15f..fc68ae1 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,6 +1,5 @@ -# Main dependencies -antlr4-python3-runtime>=4.13.0 -pymongo>=4.15.0 +-r requirements.txt +-r requirements-optional.txt # Test dependencies pytest>=7.0.0 diff --git a/requirements.txt b/requirements.txt index cf2818f..7ebe8cd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ antlr4-python3-runtime>=4.13.0 -pymongo>=4.15.0 \ No newline at end of file +pymongo>=4.15.0 diff --git a/tests/test_sqlalchemy_dialect.py b/tests/test_sqlalchemy_dialect.py new file mode 100644 index 0000000..2a4e8e8 --- /dev/null +++ b/tests/test_sqlalchemy_dialect.py @@ -0,0 +1,665 @@ +#!/usr/bin/env python3 +""" +Tests for PyMongoSQL SQLAlchemy dialect. + +This test suite validates the SQLAlchemy integration functionality. +""" +import unittest +from typing import Callable +from unittest.mock import Mock, patch + +# SQLAlchemy version compatibility +try: + import sqlalchemy + + SQLALCHEMY_VERSION = tuple(map(int, sqlalchemy.__version__.split(".")[:2])) + SQLALCHEMY_2X = SQLALCHEMY_VERSION >= (2, 0) + HAS_SQLALCHEMY = True +except ImportError: + SQLALCHEMY_VERSION = None + SQLALCHEMY_2X = False + HAS_SQLALCHEMY = False + +# Version-compatible imports +if HAS_SQLALCHEMY: + from sqlalchemy import Column, Integer, String, create_engine + from sqlalchemy.engine import url + + # Handle declarative base differences + if SQLALCHEMY_2X: + try: + from sqlalchemy.orm import DeclarativeBase + + class _TestBase(DeclarativeBase): # Prefix with _ to avoid pytest collection + pass + + declarative_base: Callable[[], type[_TestBase]] = lambda: _TestBase + except ImportError: + from sqlalchemy.ext.declarative import declarative_base + else: + from sqlalchemy.ext.declarative import declarative_base + +import pymongosql +from pymongosql.sqlalchemy_mongodb import create_engine_url +from pymongosql.sqlalchemy_mongodb.sqlalchemy_dialect import ( + PyMongoSQLDDLCompiler, + PyMongoSQLDialect, + PyMongoSQLIdentifierPreparer, + PyMongoSQLTypeCompiler, +) + + +class TestPyMongoSQLDialect(unittest.TestCase): + """Test cases for the PyMongoSQL SQLAlchemy dialect.""" + + def setUp(self): + """Set up test fixtures.""" + if not HAS_SQLALCHEMY: + self.skipTest("SQLAlchemy not available") + self.dialect = PyMongoSQLDialect() + + def test_dialect_name(self): + """Test dialect name and driver.""" + self.assertEqual(self.dialect.name, "mongodb") + self.assertEqual(self.dialect.driver, "pymongosql") + + def test_dbapi(self): + """Test DBAPI module reference.""" + # Test class method + self.assertEqual(PyMongoSQLDialect.dbapi(), pymongosql) + + # Test import_dbapi class method (SQLAlchemy 2.x) + self.assertEqual(PyMongoSQLDialect.import_dbapi(), pymongosql) + + # Test instance access (should work even if SQLAlchemy interferes) + try: + result = self.dialect.dbapi() if callable(self.dialect.dbapi) else self.dialect._get_dbapi_module() + self.assertEqual(result, pymongosql) + except Exception: + # Fallback test - at least the class method should work + self.assertEqual(PyMongoSQLDialect.dbapi(), pymongosql) + + def test_create_connect_args_basic(self): + """Test basic connection argument creation.""" + test_url = url.make_url("mongodb://localhost:27017/testdb") + args, kwargs = self.dialect.create_connect_args(test_url) + + self.assertEqual(args, []) + self.assertIn("host", kwargs) + # The new implementation passes the complete MongoDB URI as host + self.assertEqual(kwargs["host"], "mongodb://localhost:27017/testdb") + + def test_create_connect_args_with_auth(self): + """Test connection args with authentication.""" + test_url = url.make_url("mongodb://user:pass@localhost:27017/testdb") + args, kwargs = self.dialect.create_connect_args(test_url) + + # The new implementation passes the complete MongoDB URI with auth as host + self.assertIn("host", kwargs) + self.assertEqual(kwargs["host"], "mongodb://user:pass@localhost:27017/testdb") + + def test_create_connect_args_with_query_params(self): + """Test connection args with query parameters.""" + test_url = url.make_url("mongodb://localhost/testdb?ssl=true&replicaSet=rs0") + args, kwargs = self.dialect.create_connect_args(test_url) + + # The new implementation passes the complete MongoDB URI with query params as host + self.assertIn("host", kwargs) + self.assertIn("ssl=true", kwargs["host"]) + self.assertIn("replicaSet=rs0", kwargs["host"]) + + def test_supports_features(self): + """Test dialect feature support flags.""" + # Features MongoDB doesn't support + self.assertFalse(self.dialect.supports_alter) + self.assertFalse(self.dialect.supports_comments) + self.assertFalse(self.dialect.supports_sequences) + self.assertFalse(self.dialect.supports_native_enum) + + # Features MongoDB does support + self.assertTrue(self.dialect.supports_default_values) + self.assertTrue(self.dialect.supports_empty_inserts) + self.assertTrue(self.dialect.supports_multivalues_insert) + self.assertTrue(self.dialect.supports_native_decimal) + self.assertTrue(self.dialect.supports_native_boolean) + + def test_has_table(self): + """Test table (collection) existence check using MongoDB operations.""" + from unittest.mock import MagicMock + + # Mock MongoDB connection structure + mock_conn = Mock() + mock_db_connection = Mock() + mock_client = MagicMock() # Use MagicMock for __getitem__ support + mock_db = Mock() + + mock_conn.connection = mock_db_connection + mock_db_connection._client = mock_client + mock_db_connection.database = mock_db + mock_db.list_collection_names.return_value = ["users", "products", "orders"] + + # Test existing table + self.assertTrue(self.dialect.has_table(mock_conn, "users")) + + # Test non-existing table + self.assertFalse(self.dialect.has_table(mock_conn, "nonexistent")) + + # Test with schema + mock_schema_db = Mock() + mock_client.__getitem__.return_value = mock_schema_db + mock_schema_db.list_collection_names.return_value = ["schema_users"] + self.assertTrue(self.dialect.has_table(mock_conn, "schema_users", schema="test_schema")) + + def test_get_table_names(self): + """Test getting collection names using MongoDB operations.""" + from unittest.mock import MagicMock + + # Mock MongoDB connection structure + mock_conn = Mock() + mock_db_connection = Mock() + mock_client = MagicMock() # Use MagicMock for __getitem__ support + mock_db = Mock() + + mock_conn.connection = mock_db_connection + mock_db_connection._client = mock_client + mock_db_connection.database = mock_db + mock_db.list_collection_names.return_value = ["users", "products", "orders"] + + tables = self.dialect.get_table_names(mock_conn) + expected = ["users", "products", "orders"] + self.assertEqual(tables, expected) + + # Test with schema + mock_schema_db = Mock() + mock_client.__getitem__.return_value = mock_schema_db + mock_schema_db.list_collection_names.return_value = ["schema_table1", "schema_table2"] + schema_tables = self.dialect.get_table_names(mock_conn, schema="test_schema") + self.assertEqual(schema_tables, ["schema_table1", "schema_table2"]) + + @patch("bson.ObjectId") + def test_get_columns(self, mock_objectid): + """Test getting column information using MongoDB document sampling.""" + from datetime import datetime + from unittest.mock import MagicMock + + # Mock MongoDB connection structure + mock_conn = Mock() + mock_db_connection = Mock() + mock_client = Mock() + mock_db = MagicMock() # Use MagicMock for __getitem__ support + mock_collection = Mock() + + mock_conn.connection = mock_db_connection + mock_db_connection._client = mock_client + mock_db_connection.database = mock_db + mock_db.__getitem__.return_value = mock_collection + + # Mock sample documents + sample_docs = [ + {"_id": "507f1f77bcf86cd799439011", "name": "John", "age": 25, "active": True}, + {"_id": "507f1f77bcf86cd799439012", "name": "Jane", "email": "jane@test.com", "score": 95.5}, + {"_id": "507f1f77bcf86cd799439013", "created_at": datetime.now(), "tags": ["python", "mongodb"]}, + ] + mock_collection.find.return_value.limit.return_value = sample_docs + + columns = self.dialect.get_columns(mock_conn, "users") + + # Should have inferred columns from sample documents + self.assertGreater(len(columns), 0) + + # Check _id is always included and not nullable + id_column = next((col for col in columns if col["name"] == "_id"), None) + self.assertIsNotNone(id_column) + self.assertFalse(id_column["nullable"]) + + # Test fallback for empty collection + mock_collection.find.return_value.limit.return_value = [] + fallback_columns = self.dialect.get_columns(mock_conn, "empty_collection") + self.assertEqual(len(fallback_columns), 1) + self.assertEqual(fallback_columns[0]["name"], "_id") + + def test_get_pk_constraint(self): + """Test primary key constraint info.""" + mock_conn = Mock() + pk_info = self.dialect.get_pk_constraint(mock_conn, "users") + + self.assertEqual(pk_info["constrained_columns"], ["_id"]) + self.assertEqual(pk_info["name"], "pk_id") + + def test_get_foreign_keys(self): + """Test foreign key constraints (should be empty for MongoDB).""" + mock_conn = Mock() + fks = self.dialect.get_foreign_keys(mock_conn, "users") + + self.assertEqual(fks, []) + + def test_get_indexes(self): + """Test getting index information using MongoDB index_information.""" + from unittest.mock import MagicMock + + # Mock MongoDB connection structure + mock_conn = Mock() + mock_db_connection = Mock() + mock_client = Mock() + mock_db = MagicMock() # Use MagicMock for __getitem__ support + mock_collection = Mock() + + mock_conn.connection = mock_db_connection + mock_db_connection._client = mock_client + mock_db_connection.database = mock_db + mock_db.__getitem__.return_value = mock_collection + + # Mock index information + mock_index_info = { + "_id_": {"key": [("_id", 1)], "unique": False}, # _id is implicit unique + "email_1": {"key": [("email", 1)], "unique": True}, + "name_text": {"key": [("name", "text")], "unique": False}, + } + mock_collection.index_information.return_value = mock_index_info + + indexes = self.dialect.get_indexes(mock_conn, "users") + + self.assertEqual(len(indexes), 3) + + # Check _id index + id_index = next((idx for idx in indexes if idx["name"] == "_id_"), None) + self.assertIsNotNone(id_index) + self.assertEqual(id_index["column_names"], ["_id"]) + + # Check email index + email_index = next((idx for idx in indexes if idx["name"] == "email_1"), None) + self.assertIsNotNone(email_index) + self.assertTrue(email_index["unique"]) + self.assertEqual(email_index["column_names"], ["email"]) + + def test_get_schema_names(self): + """Test getting database names using MongoDB listDatabases command.""" + # Mock MongoDB connection structure + mock_conn = Mock() + mock_db_connection = Mock() + mock_client = Mock() + mock_admin_db = Mock() + + mock_conn.connection = mock_db_connection + mock_db_connection._client = mock_client + mock_client.admin = mock_admin_db + + # Mock listDatabases result + mock_admin_db.command.return_value = { + "databases": [ + {"name": "admin", "sizeOnDisk": 32768}, + {"name": "config", "sizeOnDisk": 12288}, + {"name": "myapp", "sizeOnDisk": 65536}, + {"name": "test", "sizeOnDisk": 8192}, + ] + } + + schemas = self.dialect.get_schema_names(mock_conn) + expected = ["admin", "config", "myapp", "test"] + self.assertEqual(schemas, expected) + + # Verify the correct MongoDB command was called + mock_admin_db.command.assert_called_with("listDatabases") + + def test_get_schema_names_fallback(self): + """Test get_schema_names fallback when MongoDB operation fails.""" + # Mock connection that raises an exception + mock_conn = Mock() + mock_conn.connection.side_effect = Exception("Connection error") + + schemas = self.dialect.get_schema_names(mock_conn) + self.assertEqual(schemas, ["default"]) + + def test_do_ping(self): + """Test connection ping using MongoDB native ping command.""" + # Mock successful connection + mock_conn = Mock() + mock_conn.test_connection.return_value = True + + result = self.dialect.do_ping(mock_conn) + self.assertTrue(result) + + # Test fallback to direct client ping + mock_conn_no_test = Mock() + mock_conn_no_test.test_connection = None + mock_client = Mock() + mock_admin_db = Mock() + mock_conn_no_test._client = mock_client + mock_client.admin = mock_admin_db + + result_fallback = self.dialect.do_ping(mock_conn_no_test) + self.assertTrue(result_fallback) + mock_admin_db.command.assert_called_with("ping") + + def test_do_ping_failure(self): + """Test do_ping when connection fails.""" + # Mock failed connection + mock_conn = Mock() + mock_conn.test_connection.return_value = False + + result = self.dialect.do_ping(mock_conn) + self.assertFalse(result) + + # Test fallback failure - connection without test_connection method + mock_conn_error = Mock() + del mock_conn_error.test_connection # Remove the attribute entirely + mock_conn_error._client = Mock() + mock_conn_error._client.admin.command.side_effect = Exception("Connection failed") + + result_error = self.dialect.do_ping(mock_conn_error) + self.assertFalse(result_error) + + def test_infer_bson_type(self): + """Test BSON type inference from Python values.""" + from datetime import datetime + + # Test various Python types + test_cases = [ + ("test string", "string"), + (42, "int"), + (3.14, "double"), + (True, "bool"), + (False, "bool"), + (datetime.now(), "date"), + ([1, 2, 3], "array"), + ({"key": "value"}, "object"), + (None, "null"), + ] + + for value, expected_type in test_cases: + with self.subTest(value=value, expected=expected_type): + inferred_type = self.dialect._infer_bson_type(value) + self.assertEqual(inferred_type, expected_type) + + def test_error_handling(self): + """Test error handling and fallback behavior for all methods.""" + # Mock connection that fails when trying to access MongoDB operations + mock_conn = Mock() + mock_db_connection = Mock() + mock_conn.connection = mock_db_connection + + # Make hasattr check fail or make database operations fail + mock_db_connection._client = None # This makes hasattr(_client) return False + # Or we can make database operations fail by making database.list_collection_names() fail + mock_db = Mock() + mock_db_connection.database = mock_db + mock_db.list_collection_names.side_effect = Exception("MongoDB error") + + # Test has_table fallback + result = self.dialect.has_table(mock_conn, "test_table") + self.assertFalse(result) + + # Test get_table_names fallback + tables = self.dialect.get_table_names(mock_conn) + self.assertEqual(tables, []) + + # Test get_columns fallback + columns = self.dialect.get_columns(mock_conn, "test_table") + self.assertEqual(len(columns), 1) + self.assertEqual(columns[0]["name"], "_id") + + # Test get_indexes fallback + indexes = self.dialect.get_indexes(mock_conn, "test_table") + self.assertEqual(len(indexes), 1) + self.assertEqual(indexes[0]["name"], "_id_") + self.assertTrue(indexes[0]["unique"]) + + def test_schema_operations_with_schema_parameter(self): + """Test operations when schema parameter is provided.""" + from unittest.mock import MagicMock + + # Mock MongoDB connection structure + mock_conn = Mock() + mock_db_connection = Mock() + mock_client = MagicMock() # Use MagicMock for __getitem__ support + mock_schema_db = MagicMock() # Use MagicMock for __getitem__ support + mock_collection = Mock() + + mock_conn.connection = mock_db_connection + mock_db_connection._client = mock_client + mock_client.__getitem__.return_value = mock_schema_db + mock_schema_db.__getitem__.return_value = mock_collection + mock_schema_db.list_collection_names.return_value = ["table1", "table2"] + + # Test has_table with schema + result = self.dialect.has_table(mock_conn, "table1", schema="test_schema") + self.assertTrue(result) + mock_client.__getitem__.assert_called_with("test_schema") + + # Test get_table_names with schema + tables = self.dialect.get_table_names(mock_conn, schema="test_schema") + self.assertEqual(tables, ["table1", "table2"]) + + # Test get_columns with schema + mock_collection.find.return_value.limit.return_value = [{"_id": "123", "name": "test"}] + columns = self.dialect.get_columns(mock_conn, "table1", schema="test_schema") + self.assertGreater(len(columns), 0) + mock_schema_db.__getitem__.assert_called_with("table1") + + def test_superset_integration_workflow(self): + """Test the complete workflow that Apache Superset would use.""" + from unittest.mock import MagicMock + + # Mock complete MongoDB connection for Superset workflow + mock_conn = Mock() + mock_db_connection = Mock() + mock_client = MagicMock() + mock_db = MagicMock() # Use MagicMock for __getitem__ support + mock_admin_db = Mock() + mock_collection = Mock() + + # Wire up the mock chain + mock_conn.connection = mock_db_connection + mock_db_connection._client = mock_client + mock_db_connection.database = mock_db + mock_client.admin = mock_admin_db + mock_db.__getitem__.return_value = mock_collection + + # Set up realistic responses + mock_conn.test_connection = Mock(return_value=True) + mock_admin_db.command.return_value = {"databases": [{"name": "myapp"}, {"name": "analytics"}]} + mock_db.list_collection_names.return_value = ["users", "orders", "products"] + mock_collection.find.return_value.limit.return_value = [ + {"_id": "1", "name": "Test User", "email": "test@example.com", "age": 30} + ] + mock_collection.index_information.return_value = { + "_id_": {"key": [("_id", 1)], "unique": False}, + "email_1": {"key": [("email", 1)], "unique": True}, + } + + # Step 1: Connection testing (what Superset does first) + ping_success = self.dialect.do_ping(mock_conn) + self.assertTrue(ping_success, "Connection ping should succeed") + + # Step 2: Discover available databases/schemas + schemas = self.dialect.get_schema_names(mock_conn) + self.assertEqual(schemas, ["myapp", "analytics"], "Should discover databases") + + # Step 3: List tables/collections in default database + tables = self.dialect.get_table_names(mock_conn) + self.assertEqual(tables, ["users", "orders", "products"], "Should list collections") + + # Step 4: Check if specific table exists + self.assertTrue(self.dialect.has_table(mock_conn, "users"), "Should find existing table") + self.assertFalse(self.dialect.has_table(mock_conn, "logs"), "Should not find non-existing table") + + # Step 5: Get column information for table introspection + columns = self.dialect.get_columns(mock_conn, "users") + self.assertGreater(len(columns), 0, "Should discover columns from document sampling") + + # Verify required _id column exists and is not nullable + id_column = next((col for col in columns if col["name"] == "_id"), None) + self.assertIsNotNone(id_column, "_id column should exist") + self.assertFalse(id_column["nullable"], "_id should not be nullable") + + # Step 6: Get index information for performance optimization + indexes = self.dialect.get_indexes(mock_conn, "users") + self.assertGreater(len(indexes), 0, "Should discover indexes") + + # Verify _id index exists + id_index = next((idx for idx in indexes if idx["name"] == "_id_"), None) + self.assertIsNotNone(id_index, "_id index should exist") + + +class TestPyMongoSQLCompilers(unittest.TestCase): + """Test SQLAlchemy compiler components.""" + + def setUp(self): + """Set up test fixtures.""" + if not HAS_SQLALCHEMY: + self.skipTest("SQLAlchemy not available") + self.dialect = PyMongoSQLDialect() + + def test_identifier_preparer(self): + """Test MongoDB identifier preparation.""" + preparer = PyMongoSQLIdentifierPreparer(self.dialect) + + # Test reserved words + self.assertIn("$eq", preparer.reserved_words) + self.assertIn("$and", preparer.reserved_words) + + # Test legal characters regex includes MongoDB-specific ones + self.assertTrue(preparer.legal_characters.match("field.subfield")) # Dot notation + self.assertTrue(preparer.legal_characters.match("_id")) # Underscore prefix + self.assertTrue(preparer.legal_characters.match("user123")) # Alphanumeric + + def test_type_compiler(self): + """Test type compilation for MongoDB.""" + compiler = PyMongoSQLTypeCompiler(self.dialect) + + # Mock type objects + varchar_type = Mock() + varchar_type.__class__.__name__ = "VARCHAR" + + integer_type = Mock() + integer_type.__class__.__name__ = "INTEGER" + + # Test type mapping + self.assertEqual(compiler.visit_VARCHAR(varchar_type), "STRING") + self.assertEqual(compiler.visit_INTEGER(integer_type), "INT32") + self.assertEqual(compiler.visit_BOOLEAN(Mock()), "BOOL") + + def test_ddl_compiler(self): + """Test DDL compilation.""" + # Test that the compiler class is properly configured + self.assertEqual(self.dialect.ddl_compiler, PyMongoSQLDDLCompiler) + + # Test CREATE TABLE compilation concept + # Test that the methods exist on the class + self.assertTrue(hasattr(PyMongoSQLDDLCompiler, "visit_create_table")) + self.assertTrue(hasattr(PyMongoSQLDDLCompiler, "visit_drop_table")) + + # Test DDL method behavior by calling class methods directly + # This avoids the complex compiler instantiation issues + + # Create a mock compiler instance with minimal setup + mock_compiler = Mock(spec=PyMongoSQLDDLCompiler) + mock_compiler.preparer = Mock() + mock_compiler.preparer.format_table = Mock(return_value="test_table") + + # Test CREATE TABLE behavior + create_mock = Mock() + create_mock.element = Mock() + create_mock.element.name = "test_table" + + # Call the actual method from the class + create_result = PyMongoSQLDDLCompiler.visit_create_table(mock_compiler, create_mock) + self.assertIn("Collection will be created", create_result) + + # Test DROP TABLE behavior + drop_mock = Mock() + drop_mock.element = Mock() + drop_mock.element.name = "test_table" + + # Call the actual method from the class + drop_result = PyMongoSQLDDLCompiler.visit_drop_table(mock_compiler, drop_mock) + self.assertIn("DROP COLLECTION", drop_result) + + +class TestSQLAlchemyIntegration(unittest.TestCase): + """Integration tests for SQLAlchemy functionality.""" + + def test_create_engine_url_helper(self): + """Test the URL helper function.""" + url = create_engine_url("localhost", 27017, "testdb") + self.assertEqual(url, "mongodb://localhost:27017/testdb") + + # Test with additional parameters + url_with_params = create_engine_url("localhost", 27017, "testdb", ssl=True, replicaSet="rs0") + self.assertIn("mongodb://localhost:27017/testdb", url_with_params) + self.assertIn("ssl=True", url_with_params) + self.assertIn("replicaSet=rs0", url_with_params) + + @patch("pymongosql.sqlalchemy_mongodb.sqlalchemy_dialect.pymongosql.connect") + def test_engine_creation(self, mock_connect): + """Test SQLAlchemy engine creation.""" + if not HAS_SQLALCHEMY: + self.skipTest("SQLAlchemy not available") + + # Mock the connection + mock_conn = Mock() + mock_connect.return_value = mock_conn + + # This should not raise an exception + engine = create_engine("mongodb://localhost:27017/testdb") + self.assertIsNotNone(engine) + self.assertEqual(engine.dialect.name, "mongodb") + + # Test version compatibility attributes + if hasattr(engine.dialect, "_sqlalchemy_version"): + self.assertIsNotNone(engine.dialect._sqlalchemy_version) + if hasattr(engine.dialect, "_is_sqlalchemy_2x"): + self.assertIsInstance(engine.dialect._is_sqlalchemy_2x, bool) + + def test_orm_model_definition(self): + """Test ORM model definition with PyMongoSQL.""" + if not HAS_SQLALCHEMY: + self.skipTest("SQLAlchemy not available") + + Base = declarative_base() + + class TestModel(Base): + __tablename__ = "test_collection" + + id = Column("_id", String, primary_key=True) + name = Column(String) + value = Column(Integer) + + # Should not raise exceptions + self.assertEqual(TestModel.__tablename__, "test_collection") + # The column is named '_id' in the database, but 'id' in the model + self.assertIn("_id", TestModel.__table__.columns.keys()) # Actual DB column name + self.assertIn("name", TestModel.__table__.columns.keys()) + self.assertIn("value", TestModel.__table__.columns.keys()) + + # Test that the model has the expected attributes + self.assertTrue(hasattr(TestModel, "id")) # Model attribute + self.assertTrue(hasattr(TestModel, "name")) + self.assertTrue(hasattr(TestModel, "value")) + + # Test SQLAlchemy version specific features + self.assertTrue(hasattr(TestModel, "__table__")) + + +class TestDialectRegistration(unittest.TestCase): + """Test dialect registration with SQLAlchemy.""" + + def test_dialect_registration(self): + """Test that the dialect is properly registered.""" + if not HAS_SQLALCHEMY: + self.skipTest("SQLAlchemy not available") + + try: + from sqlalchemy.dialects import registry + + from pymongosql.sqlalchemy_mongodb import _registration_successful + + # The dialect should be registered + self.assertTrue(hasattr(registry, "load")) + + # Our registration should have succeeded + self.assertTrue(_registration_successful) + + except ImportError: + # Skip if SQLAlchemy registry is not available + self.skipTest("SQLAlchemy registry not available") diff --git a/tests/test_sqlalchemy_integration.py b/tests/test_sqlalchemy_integration.py new file mode 100644 index 0000000..2519d39 --- /dev/null +++ b/tests/test_sqlalchemy_integration.py @@ -0,0 +1,371 @@ +#!/usr/bin/env python3 +""" +Real Integration Tests for PyMongoSQL SQLAlchemy Dialect + +This test suite validates the SQLAlchemy dialect integration by: +1. Using real MongoDB connections (same as other tests) +2. Creating SQLAlchemy ORM models +3. Testing query operations with actual data +4. Validating object creation from query results +""" + +import os + +import pytest + +from tests.conftest import TEST_DB, TEST_URI + +# SQLAlchemy version compatibility +try: + import sqlalchemy + from sqlalchemy import JSON, Boolean, Column, Float, Integer, String, create_engine + from sqlalchemy.orm import sessionmaker + + SQLALCHEMY_VERSION = tuple(map(int, sqlalchemy.__version__.split(".")[:2])) + SQLALCHEMY_2X = SQLALCHEMY_VERSION >= (2, 0) + HAS_SQLALCHEMY = True + + # Handle declarative base differences + if SQLALCHEMY_2X: + try: + from sqlalchemy.orm import DeclarativeBase, Session + + class Base(DeclarativeBase): + pass + + except ImportError: + from sqlalchemy.ext.declarative import declarative_base + + Base = declarative_base() + from sqlalchemy.orm import Session + else: + from sqlalchemy.ext.declarative import declarative_base + from sqlalchemy.orm import Session + + Base = declarative_base() + +except ImportError: + SQLALCHEMY_VERSION = None + SQLALCHEMY_2X = False + HAS_SQLALCHEMY = False + Base = None + Session = None + +# Skip all tests if SQLAlchemy is not available +pytestmark = pytest.mark.skipif(not HAS_SQLALCHEMY, reason="SQLAlchemy not available") + + +# ORM Models +class User(Base): + """User model for testing.""" + + __tablename__ = "users" + + id = Column("_id", String, primary_key=True) + name = Column(String) + email = Column(String) + age = Column(Integer) + city = Column(String) + active = Column(Boolean) + balance = Column(Float) + tags = Column(JSON) + address = Column(JSON) + + def __repr__(self): + return f"" + + +class Product(Base): + """Product model for testing.""" + + __tablename__ = "products" + + id = Column("_id", String, primary_key=True) + name = Column(String) + price = Column(Float) + category = Column(String) + in_stock = Column(Boolean) + quantity = Column(Integer) + tags = Column(JSON) + specifications = Column(JSON) + + def __repr__(self): + return f"" + + +class Order(Base): + """Order model for testing.""" + + __tablename__ = "orders" + + id = Column("_id", String, primary_key=True) + user_id = Column(String) + total = Column(Float) + status = Column(String) + items = Column(JSON) + + def __repr__(self): + return f"" + + +# Pytest fixtures +@pytest.fixture +def sqlalchemy_engine(): + """Provide a SQLAlchemy engine connected to MongoDB. The URI is taken from environment variables + (PYMONGOSQL_TEST_URI or MONGODB_URI) or falls back to a sensible local default. + """ + uri = os.environ.get("PYMONGOSQL_TEST_URI") or os.environ.get("MONGODB_URI") or TEST_URI + db = os.environ.get("PYMONGOSQL_TEST_DB") or TEST_DB + + def _ensure_uri_has_db(uri_value: str, database: str) -> str: + if not database: + return uri_value + idx = uri_value.find("://") + if idx == -1: + return uri_value + rest = uri_value[idx + 3 :] + if "/" in rest: + after = rest.split("/", 1)[1] + if after == "" or after.startswith("?"): + return uri_value.rstrip("/") + "/" + database + return uri_value + return uri_value.rstrip("/") + "/" + database + + if uri: + uri_to_use = _ensure_uri_has_db(uri, db) + else: + uri_to_use = "mongodb://testuser:testpass@localhost:27017/test_db" + + engine = create_engine(uri_to_use) + yield engine + + +@pytest.fixture +def session_maker(sqlalchemy_engine): + """Provide a SQLAlchemy session maker.""" + return sessionmaker(bind=sqlalchemy_engine) + + +class TestSQLAlchemyIntegration: + """Test class for SQLAlchemy dialect integration with real MongoDB data.""" + + def test_engine_creation(self, sqlalchemy_engine): + """Test that SQLAlchemy engine works with real MongoDB.""" + assert sqlalchemy_engine is not None + assert sqlalchemy_engine.dialect.name == "mongodb" + + # Test that we can get a connection + with sqlalchemy_engine.connect() as connection: + assert connection is not None + + def test_read_users_data(self, sqlalchemy_engine): + """Test reading users data and creating User objects.""" + with sqlalchemy_engine.connect() as connection: + # Query real users data + result = connection.execute( + sqlalchemy.text("SELECT _id, name, email, age, city, active, balance FROM users LIMIT 5") + ) + rows = result.fetchall() + + assert len(rows) > 0, "Should have user data in test database" + + # Create User objects from query results + users = [] + for row in rows: + # Handle both SQLAlchemy 1.x and 2.x result formats + if hasattr(row, "_mapping"): + # SQLAlchemy 2.x style with mapping access + user = User( + id=row._mapping.get("_id") or str(row[0]), + name=row._mapping.get("name") or row[1] or "Unknown", + email=row._mapping.get("email") or row[2] or "unknown@example.com", + age=row._mapping.get("age") or (row[3] if len(row) > 3 and isinstance(row[3], int) else 0), + city=row._mapping.get("city") or (row[4] if len(row) > 4 else "Unknown"), + active=row._mapping.get("active", True), + balance=row._mapping.get("balance", 0.0), + ) + else: + # SQLAlchemy 1.x style with sequence access + user = User( + id=str(row[0]) if row[0] else "unknown", + name=row[1] if len(row) > 1 and row[1] else "Unknown", + email=row[2] if len(row) > 2 and row[2] else "unknown@example.com", + age=row[3] if len(row) > 3 and isinstance(row[3], int) else 0, + city=row[4] if len(row) > 4 and row[4] else "Unknown", + active=row[5] if len(row) > 5 and row[5] is not None else True, + balance=float(row[6]) if len(row) > 6 and row[6] is not None else 0.0, + ) + users.append(user) + + # Validate User objects + for user in users: + assert user.id is not None, "User should have an ID" + assert user.name is not None, "User should have a name" + assert user.email is not None, "User should have an email" + assert isinstance(user.age, int), "User age should be an integer" + assert isinstance(user.balance, (int, float)), "User balance should be numeric" + + print(f"[PASS] Successfully created {len(users)} User objects from real MongoDB data") + if users: + print(f" Sample: {users[0].name} ({users[0].email}) - Age: {users[0].age}") + + def test_read_products_data(self, sqlalchemy_engine): + """Test reading products data and creating Product objects.""" + with sqlalchemy_engine.connect() as connection: + # Query real products data + result = connection.execute( + sqlalchemy.text("SELECT _id, name, price, category, in_stock, quantity FROM products LIMIT 5") + ) + rows = result.fetchall() + + assert len(rows) > 0, "Should have product data in test database" + + # Create Product objects from query results + products = [] + for row in rows: + # Handle both SQLAlchemy 1.x and 2.x result formats + if hasattr(row, "_mapping"): + # SQLAlchemy 2.x style with mapping access + product = Product( + id=row._mapping.get("_id") or str(row[0]), + name=row._mapping.get("name") or row[1] or "Unknown Product", + price=float(row._mapping.get("price", 0) or row[2] or 0), + category=row._mapping.get("category") or row[3] or "Unknown", + in_stock=bool(row._mapping.get("in_stock", True)), + quantity=int(row._mapping.get("quantity", 0) or 0), + ) + else: + # SQLAlchemy 1.x style with sequence access + product = Product( + id=str(row[0]) if row[0] else "unknown", + name=row[1] if len(row) > 1 and row[1] else "Unknown Product", + price=float(row[2]) if len(row) > 2 and row[2] is not None else 0.0, + category=row[3] if len(row) > 3 and row[3] else "Unknown", + in_stock=bool(row[4]) if len(row) > 4 and row[4] is not None else True, + quantity=int(row[5]) if len(row) > 5 and row[5] is not None else 0, + ) + products.append(product) + + # Validate Product objects + for product in products: + assert product.id is not None, "Product should have an ID" + assert product.name is not None, "Product should have a name" + assert isinstance(product.price, float), "Product price should be a float" + assert product.category is not None, "Product should have a category" + assert isinstance(product.in_stock, bool), "Product in_stock should be a boolean" + assert isinstance(product.quantity, int), "Product quantity should be an integer" + + print(f"[PASS] Successfully created {len(products)} Product objects from real MongoDB data") + if products: + print(f" Sample: {products[0].name} - ${products[0].price} ({products[0].category})") + + def test_session_based_queries(self, session_maker): + """Test SQLAlchemy session-based operations with real data.""" + session = session_maker() + + try: + # Test session-based query execution + result = session.execute(sqlalchemy.text("SELECT _id, name, email FROM users LIMIT 3")) + rows = result.fetchall() + + assert len(rows) > 0, "Should have user data available" + + # Create objects from session query results + users = [] + for row in rows: + if hasattr(row, "_mapping"): + user = User( + id=row._mapping.get("_id") or str(row[0]), + name=row._mapping.get("name") or row[1] or "Unknown", + email=row._mapping.get("email") or row[2] or "unknown@example.com", + ) + else: + user = User( + id=str(row[0]) if row[0] else "unknown", + name=row[1] if len(row) > 1 and row[1] else "Unknown", + email=row[2] if len(row) > 2 and row[2] else "unknown@example.com", + ) + users.append(user) + + # Validate that session queries work + for user in users: + assert user.id is not None + assert user.name is not None + assert user.email is not None + assert len(user.name) > 0 + + print(f"[PASS] Session-based queries successful: {len(users)} users retrieved") + if users: + print(f" Sample: {users[0].name} ({users[0].email})") + + finally: + session.close() + + def test_complex_queries_with_filtering(self, sqlalchemy_engine): + """Test more complex SQL queries with WHERE conditions.""" + with sqlalchemy_engine.connect() as connection: + # Test filtering queries + result = connection.execute(sqlalchemy.text("SELECT _id, name, age FROM users WHERE age > 25 LIMIT 5")) + rows = result.fetchall() + + if len(rows) > 0: # Only test if we have data + # Create User objects and validate filtering worked + users = [] + for row in rows: + if hasattr(row, "_mapping"): + age = row._mapping.get("age") or row[2] or 0 + user = User( + id=row._mapping.get("_id") or str(row[0]), + name=row._mapping.get("name") or row[1] or "Unknown", + age=age, + ) + else: + age = row[2] if len(row) > 2 and isinstance(row[2], int) else 0 + user = User( + id=str(row[0]) if row[0] else "unknown", + name=row[1] if len(row) > 1 and row[1] else "Unknown", + age=age, + ) + users.append(user) + + # Validate that filtering worked (age > 25) + for user in users: + if user.age > 0: # Only check if age data is available + assert user.age > 25, f"User {user.name} should be older than 25" + + # Validate that filtering worked (age > 25) + for user in users: + if user.age > 0: # Only check if age data is available + assert user.age > 25, f"User {user.name} should be older than 25" + + print(f"[PASS] Complex filtering queries successful: {len(users)} users over 25") + if users: + print(f" Ages: {[user.age for user in users if user.age > 0]}") + + def test_multiple_table_queries(self, sqlalchemy_engine): + """Test querying multiple collections (tables).""" + with sqlalchemy_engine.connect() as connection: + # Test querying different collections + users_result = connection.execute(sqlalchemy.text("SELECT _id, name FROM users LIMIT 2")) + products_result = connection.execute(sqlalchemy.text("SELECT _id, name, price FROM products LIMIT 2")) + + users_rows = users_result.fetchall() + products_rows = products_result.fetchall() + + # Validate we can query multiple collections + if len(users_rows) > 0: + assert users_rows[0][0] is not None # User ID + assert users_rows[0][1] is not None # User name + + if len(products_rows) > 0: + assert products_rows[0][0] is not None # Product ID + assert products_rows[0][1] is not None # Product name + assert products_rows[0][2] is not None # Product price + + print("Multi-collection queries successful") + print(f"Users: {len(users_rows)}, Products: {len(products_rows)}") + + def test_mongodb_connection_available(self, conn): + """Test that MongoDB connection is available before running other tests.""" + assert conn is not None + print("MongoDB connection test successful")