diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 30b3b5a..a5aacac 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,7 +33,7 @@ jobs: run: | pip install -r requirements.txt pip install -r requirements-dev.txt - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/logging-execution" # NOSONAR pip install git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17#egg=rhosocial-activerecord-testsuite - name: Install package @@ -84,7 +84,7 @@ jobs: run: | pip install -r requirements.txt pip install -r requirements-dev.txt - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/logging-execution" # NOSONAR pip install git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17#egg=rhosocial-activerecord-testsuite - name: Install package @@ -117,7 +117,7 @@ jobs: run: | pip install -r requirements.txt pip install -r requirements-dev.txt - pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/logging-execution" # NOSONAR pip install git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17#egg=rhosocial-activerecord-testsuite - name: Install package diff --git a/changelog.d/11.changed.md b/changelog.d/11.changed.md new file mode 100644 index 0000000..fb085a3 --- /dev/null +++ b/changelog.d/11.changed.md @@ -0,0 +1 @@ +Adapted this backend to the core execution logging contract: structured execution summaries, shared sync/async logger ownership, redacted SQL logging, and bridges for the shared relation/query/basic contracts. diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/async_transaction.py b/src/rhosocial/activerecord/backend/impl/snowflake/async_transaction.py index 88ad030..fc58737 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/async_transaction.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/async_transaction.py @@ -1,9 +1,9 @@ +# src/rhosocial/activerecord/backend/impl/snowflake/async_transaction.py """Snowflake asynchronous transaction manager implementation. This module provides async transaction management for the Snowflake backend. Snowflake only supports READ COMMITTED isolation level. """ -import logging from typing import TYPE_CHECKING from rhosocial.activerecord.backend.transaction import AsyncTransactionManager @@ -41,5 +41,5 @@ async def _do_begin(self) -> None: READ COMMITTED. """ sql, params = self._build_begin_sql() - self.log(logging.DEBUG, f"Executing: {sql}") + # The backend execution path owns the canonical DEBUG SQL record. await self._backend.execute(sql, params) diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/backend/async_backend.py b/src/rhosocial/activerecord/backend/impl/snowflake/backend/async_backend.py index a5d3dd6..ca0d5d9 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/backend/async_backend.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/backend/async_backend.py @@ -1,3 +1,4 @@ +# src/rhosocial/activerecord/backend/impl/snowflake/backend/async_backend.py """Snowflake asynchronous backend implementation. This module provides the async Snowflake backend using a thread pool @@ -26,7 +27,7 @@ import asyncio import logging from concurrent.futures import Executor -from typing import Any, Dict, Optional, Tuple +from typing import Optional, Tuple from rhosocial.activerecord.backend.base import AsyncStorageBackend from rhosocial.activerecord.backend.errors import ( @@ -39,7 +40,6 @@ from rhosocial.activerecord.backend.introspection.backend_mixin import IntrospectorBackendMixin from ..config import SnowflakeConnectionConfig from ..dialect import SnowflakeDialect -from ..async_transaction import AsyncSnowflakeTransactionManager from ..mixins import SnowflakeBackendMixin, AsyncSnowflakeConcurrencyMixin @@ -190,19 +190,19 @@ async def _handle_error(self, error: Exception) -> None: category = self._classify_error(error) error_msg = str(error) if category == 'connection': - self.log(logging.ERROR, f"Connection error: {error_msg}") + self.log(logging.DEBUG, f"Connection error: {error_msg}") raise ConnectionError(error_msg) from error elif category == 'integrity': - self.log(logging.ERROR, f"Integrity error: {error_msg}") + self.log(logging.DEBUG, f"Integrity error: {error_msg}") raise IntegrityError(error_msg) from error elif category == 'query': - self.log(logging.ERROR, f"Query error: {error_msg}") + self.log(logging.DEBUG, f"Query error: {error_msg}") raise QueryError(error_msg) from error elif category == 'operational': - self.log(logging.ERROR, f"Operational error: {error_msg}") + self.log(logging.DEBUG, f"Operational error: {error_msg}") raise OperationalError(error_msg) from error else: - self.log(logging.ERROR, f"Database error: {error_msg}") + self.log(logging.DEBUG, f"Database error: {error_msg}") raise DatabaseError(error_msg) from error async def get_server_version(self) -> Tuple[int, ...]: @@ -260,7 +260,7 @@ async def _process_result_set(self, cursor, is_select, column_adapters=None, col final_results.append(final_row) return final_results except Exception as e: - self.logger.error(f"Error processing async result set: {str(e)}", exc_info=True) + self.log(logging.DEBUG, f"Error processing async result set: {str(e)}") raise def _create_introspector(self): diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/backend/backend.py b/src/rhosocial/activerecord/backend/impl/snowflake/backend/backend.py index a333393..c2cf257 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/backend/backend.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/backend/backend.py @@ -1,3 +1,4 @@ +# src/rhosocial/activerecord/backend/impl/snowflake/backend/backend.py """Snowflake synchronous backend implementation. This module provides the concrete implementation for interacting with Snowflake databases, @@ -5,7 +6,7 @@ specific behaviors and SQL dialect. """ import logging -from typing import Any, Dict, List, Optional, Tuple +from typing import Tuple from rhosocial.activerecord.backend.base import StorageBackend from rhosocial.activerecord.backend.errors import ( @@ -15,11 +16,9 @@ OperationalError, QueryError, ) -from rhosocial.activerecord.backend.result import QueryResult from rhosocial.activerecord.backend.introspection.backend_mixin import IntrospectorBackendMixin from ..config import SnowflakeConnectionConfig from ..dialect import SnowflakeDialect -from ..transaction import SnowflakeTransactionManager from ..mixins import SnowflakeBackendMixin, SnowflakeConcurrencyMixin @@ -158,19 +157,19 @@ def _handle_error(self, error: Exception) -> None: category = self._classify_error(error) error_msg = str(error) if category == 'connection': - self.log(logging.ERROR, f"Connection error: {error_msg}") + self.log(logging.DEBUG, f"Connection error: {error_msg}") raise ConnectionError(error_msg) from error elif category == 'integrity': - self.log(logging.ERROR, f"Integrity error: {error_msg}") + self.log(logging.DEBUG, f"Integrity error: {error_msg}") raise IntegrityError(error_msg) from error elif category == 'query': - self.log(logging.ERROR, f"Query error: {error_msg}") + self.log(logging.DEBUG, f"Query error: {error_msg}") raise QueryError(error_msg) from error elif category == 'operational': - self.log(logging.ERROR, f"Operational error: {error_msg}") + self.log(logging.DEBUG, f"Operational error: {error_msg}") raise OperationalError(error_msg) from error else: - self.log(logging.ERROR, f"Database error: {error_msg}") + self.log(logging.DEBUG, f"Database error: {error_msg}") raise DatabaseError(error_msg) from error def get_server_version(self) -> Tuple[int, ...]: @@ -296,7 +295,7 @@ def _process_result_set(self, cursor, is_select, column_adapters=None, column_ma final_results.append(final_row) return final_results except Exception as e: - self.logger.error(f"Error processing result set: {str(e)}", exc_info=True) + self.log(logging.DEBUG, f"Error processing result set: {str(e)}") raise def _create_introspector(self): diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/cli/info.py b/src/rhosocial/activerecord/backend/impl/snowflake/cli/info.py index 6224d35..147dd93 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/cli/info.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/cli/info.py @@ -8,7 +8,6 @@ import argparse import inspect import json -import logging from typing import Any, Dict, List, Tuple from rhosocial.activerecord.backend.dialect.protocols import ( @@ -28,11 +27,12 @@ SnowflakeCloneSupport, SnowflakeStageSupport, ) +from rhosocial.activerecord.logging.defaults import get_logger from .connection import add_connection_args, add_version_arg, resolve_connection_config_from_args from .output import INFO_OUTPUT_CHOICES, create_provider, RICH_AVAILABLE -logger = logging.getLogger(__name__) +logger = get_logger("rhosocial.activerecord.backend.snowflake") DIALECT_SPECIFIC_GROUPS = {"Snowflake-specific"} @@ -112,7 +112,7 @@ def create_parser(subparsers): def handle(args): """Handle the info subcommand.""" - provider = create_provider(args.output, ascii_borders=args.rich_ascii) + create_provider(args.output, ascii_borders=args.rich_ascii) is_connected = False dialect = None @@ -349,7 +349,10 @@ def _display_info_rich(info: Dict, verbose: int, version_display: str, is_connec if is_connected: console.print(f"[bold]Snowflake Version:[/bold] {version_display} [dim](from actual connection)[/dim]\n") else: - console.print(f"[bold]Snowflake Version:[/bold] {version_display} [yellow](default value - no database connection)[/yellow]\n") + console.print( + f"[bold]Snowflake Version:[/bold] {version_display} " + "[yellow](default value - no database connection)[/yellow]\n" + ) label = "Detailed" if verbose >= 2 else "Family Overview" console.print(f"[bold green]Protocol Support ({label}):[/bold green]") diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/backend.py b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/backend.py index 32f4f5b..bfb4ff5 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/mixins/backend.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/mixins/backend.py @@ -27,6 +27,14 @@ class SnowflakeBackendMixin: and dialect property management. """ + def _get_logger_name(self) -> str: + """Return the canonical Snowflake backend logger name. + + Both the synchronous and asynchronous backends use the same library + logger, so application logging configuration has one name to target. + """ + return "rhosocial.activerecord.backend.snowflake" + def _register_snowflake_adapters(self) -> None: """Register Snowflake-specific type adapters.""" adapters = [ diff --git a/src/rhosocial/activerecord/backend/impl/snowflake/transaction.py b/src/rhosocial/activerecord/backend/impl/snowflake/transaction.py index 3c2e756..8bf0460 100644 --- a/src/rhosocial/activerecord/backend/impl/snowflake/transaction.py +++ b/src/rhosocial/activerecord/backend/impl/snowflake/transaction.py @@ -1,9 +1,9 @@ +# src/rhosocial/activerecord/backend/impl/snowflake/transaction.py """Snowflake synchronous transaction manager implementation. This module provides Snowflake-specific transaction management. Snowflake only supports READ COMMITTED isolation level. """ -import logging from typing import TYPE_CHECKING from rhosocial.activerecord.backend.transaction import TransactionManager @@ -41,5 +41,5 @@ def _do_begin(self) -> None: READ COMMITTED. """ sql, params = self._build_begin_sql() - self.log(logging.DEBUG, f"Executing: {sql}") + # The backend execution path owns the canonical DEBUG SQL record. self._backend.execute(sql, params) diff --git a/tests/providers/query.py b/tests/providers/query.py index ebf8145..b524d6d 100644 --- a/tests/providers/query.py +++ b/tests/providers/query.py @@ -96,7 +96,12 @@ def __init__(self): def get_test_scenarios(self) -> List[str]: return list(get_enabled_scenarios().keys()) - def _reset_table_sync(self, model_class, table_name: str) -> None: + def _reset_table_sync( + self, + model_class, + table_name: str, + schema_filename: str = None, + ) -> None: from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType @@ -106,11 +111,16 @@ def _reset_table_sync(self, model_class, table_name: str) -> None: except Exception: pass - schema_sql = self._load_snowflake_schema(f"{table_name}.sql") + schema_sql = self._load_snowflake_schema(schema_filename or f"{table_name}.sql") model_class.__backend__.execute(schema_sql, (), options=ExecutionOptions(stmt_type=StatementType.DDL)) - async def _reset_table_async(self, model_class, table_name: str) -> None: + async def _reset_table_async( + self, + model_class, + table_name: str, + schema_filename: str = None, + ) -> None: from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType @@ -120,11 +130,17 @@ async def _reset_table_async(self, model_class, table_name: str) -> None: except Exception: pass - schema_sql = self._load_snowflake_schema(f"{table_name}.sql") + schema_sql = self._load_snowflake_schema(schema_filename or f"{table_name}.sql") await model_class.__backend__.execute(schema_sql, (), options=ExecutionOptions(stmt_type=StatementType.DDL)) - def _setup_model(self, model_class: Type[ActiveRecord], scenario_name: str, table_name: str) -> Type[ActiveRecord]: + def _setup_model( + self, + model_class: Type[ActiveRecord], + scenario_name: str, + table_name: str, + schema_filename: str = None, + ) -> Type[ActiveRecord]: backend_class, config = get_scenario(scenario_name) model_class.configure(config, backend_class) @@ -132,7 +148,7 @@ def _setup_model(self, model_class: Type[ActiveRecord], scenario_name: str, tabl if backend_instance not in self._active_backends: self._active_backends.append(backend_instance) - self._reset_table_sync(model_class, table_name) + self._reset_table_sync(model_class, table_name, schema_filename) return model_class def _setup_multiple_models(self, model_classes: List[Tuple[Type[ActiveRecord], str]], scenario_name: str) -> Tuple[Type[ActiveRecord], ...]: @@ -142,7 +158,13 @@ def _setup_multiple_models(self, model_classes: List[Tuple[Type[ActiveRecord], s result.append(configured_model) return tuple(result) - async def _setup_model_async(self, model_class: Type[ActiveRecord], scenario_name: str, table_name: str) -> Type[ActiveRecord]: + async def _setup_model_async( + self, + model_class: Type[ActiveRecord], + scenario_name: str, + table_name: str, + schema_filename: str = None, + ) -> Type[ActiveRecord]: from rhosocial.activerecord.backend.impl.snowflake.backend.async_backend import ( AsyncSnowflakeBackend, ) @@ -154,7 +176,7 @@ async def _setup_model_async(self, model_class: Type[ActiveRecord], scenario_nam if backend_instance not in self._active_backends: self._active_backends.append(backend_instance) - await self._reset_table_async(model_class, table_name) + await self._reset_table_async(model_class, table_name, schema_filename) return model_class async def _setup_multiple_models_async(self, model_classes: List[Tuple[Type[ActiveRecord], str]], scenario_name: str) -> Tuple[Type[ActiveRecord], ...]: @@ -234,7 +256,12 @@ def setup_order_item_model(self, scenario_name: str) -> Type[ActiveRecord]: from rhosocial.activerecord.testsuite.feature.basic.fixtures.models import ( OrderItem as CompositeOrderItemBase, ) - return self._setup_model(CompositeOrderItemBase, scenario_name, "order_items") + return self._setup_model( + CompositeOrderItemBase, + scenario_name, + "order_items", + "composite_order_items.sql", + ) async def setup_async_order_fixtures(self, scenario_name: str) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: from rhosocial.activerecord.testsuite.feature.query.fixtures.async_models import AsyncUser, AsyncOrder, AsyncOrderItem @@ -303,12 +330,17 @@ async def setup_async_profile_fixtures(self, scenario_name: str) -> Tuple[Type[A (AsyncProfile, "profiles"), ], scenario_name) - async def setup_order_item_model(self, scenario_name: str) -> Type[ActiveRecord]: + async def setup_async_order_item_model(self, scenario_name: str) -> Type[ActiveRecord]: """Set up the composite-PK AsyncOrderItem model for the query feature tests.""" from rhosocial.activerecord.testsuite.feature.basic.fixtures.models import ( AsyncOrderItem as AsyncCompositeOrderItemBase, ) - return await self._setup_model_async(AsyncCompositeOrderItemBase, scenario_name, "order_items") + return await self._setup_model_async( + AsyncCompositeOrderItemBase, + scenario_name, + "order_items", + "composite_order_items.sql", + ) async def cleanup_after_test_async(self, scenario_name: str) -> None: tables_to_drop = [ @@ -419,3 +451,45 @@ def _get_schema_sql_for_fixture_type(self, fixture_type: str) -> dict: for table in tables: schemas[table] = self._load_snowflake_schema(f'{table}.sql') return schemas + + +class AsyncQueryProvider(QueryProvider): + """Async query provider exposing the method names the testsuite awaits. + + ``QueryProvider`` carries both sync and async setup helpers; the async + testsuite fixtures call the same method names as their sync counterparts, + so this adapter maps them onto the ``setup_async_*`` implementations. + """ + + async def setup_order_fixtures(self, scenario_name: str): + return await self.setup_async_order_fixtures(scenario_name) + + async def setup_blog_fixtures(self, scenario_name: str): + return await self.setup_async_blog_fixtures(scenario_name) + + async def setup_json_user_fixtures(self, scenario_name: str): + return await self.setup_async_json_user_fixtures(scenario_name) + + async def setup_tree_fixtures(self, scenario_name: str): + return await self.setup_async_tree_fixtures(scenario_name) + + async def setup_extended_order_fixtures(self, scenario_name: str): + return await self.setup_async_extended_order_fixtures(scenario_name) + + async def setup_combined_fixtures(self, scenario_name: str): + return await self.setup_async_combined_fixtures(scenario_name) + + async def setup_annotated_query_fixtures(self, scenario_name: str): + return await self.setup_async_annotated_query_fixtures(scenario_name) + + async def setup_mapped_models(self, scenario_name: str): + return await self.setup_async_mapped_models(scenario_name) + + async def setup_profile_fixtures(self, scenario_name: str): + return await self.setup_async_profile_fixtures(scenario_name) + + async def setup_order_item_model(self, scenario_name: str): + return await self.setup_async_order_item_model(scenario_name) + + async def cleanup_after_test(self, scenario_name: str) -> None: + await self.cleanup_after_test_async(scenario_name) diff --git a/tests/providers/registry.py b/tests/providers/registry.py index 5db894b..c872a28 100644 --- a/tests/providers/registry.py +++ b/tests/providers/registry.py @@ -17,10 +17,10 @@ from .basic_connection import BasicConnectionProvider provider_registry.register("feature.basic.connection.IBasicConnectionProvider", BasicConnectionProvider) -from .query import QueryProvider +from .query import QueryProvider, AsyncQueryProvider provider_registry.register("feature.query.IQueryProvider", QueryProvider) provider_registry.register("feature.query.IQuerySyncProvider", QueryProvider) -provider_registry.register("feature.query.IQueryAsyncProvider", QueryProvider) +provider_registry.register("feature.query.IQueryAsyncProvider", AsyncQueryProvider) from .events import EventsProvider provider_registry.register("feature.events.IEventsProvider", EventsProvider) @@ -34,3 +34,8 @@ from .query_connection import QueryConnectionProvider provider_registry.register("feature.query.connection.IQueryConnectionProvider", QueryConnectionProvider) + +from .relation import RelationSyncProvider, RelationAsyncProvider +provider_registry.register("feature.relation.IRelationProvider", RelationSyncProvider) +provider_registry.register("feature.relation.IRelationSyncProvider", RelationSyncProvider) +provider_registry.register("feature.relation.IRelationAsyncProvider", RelationAsyncProvider) diff --git a/tests/providers/relation.py b/tests/providers/relation.py new file mode 100644 index 0000000..11d787c --- /dev/null +++ b/tests/providers/relation.py @@ -0,0 +1,544 @@ +# tests/providers/relation.py +"""Relation test provider for the Snowflake backend. + +Implements ``IRelationSyncProvider`` and ``IRelationAsyncProvider`` against +fakesnow (the DuckDB-based Snowflake emulator) or a real Snowflake account. + +The ordered-HasMany contract only needs the User/Post/Comment models, but the +provider implements the whole relation interface so the relation feature +directory has one coherent setup and cleanup path. +""" +from typing import Dict, List, Tuple, Type + +from rhosocial.activerecord.backend.impl.snowflake.backend.async_backend import ( + AsyncSnowflakeBackend, +) +from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord +from rhosocial.activerecord.testsuite.feature.relation.fixtures.models import ( + Author, + AsyncBoundaryOwner, + AsyncBoundaryPost, + AsyncBoundaryProfile, + AsyncComment, + AsyncPost, + AsyncUser, + Book, + BoundaryOwner, + BoundaryPost, + BoundaryProfile, + Chapter, + Comment, + Department, + Employee, + Post, + Profile, + User, +) +from rhosocial.activerecord.testsuite.feature.relation.interfaces import ( + IRelationAsyncProvider, + IRelationSyncProvider, +) +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType + +from .scenarios import get_enabled_scenarios, get_scenario + + +USER_POST_COMMENT_SCHEMA = """ + CREATE TABLE users ( + id INTEGER AUTOINCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + email VARCHAR(255), + settings VARCHAR + ); + CREATE TABLE posts ( + id INTEGER AUTOINCREMENT PRIMARY KEY, + title VARCHAR(255) NOT NULL, + body VARCHAR NOT NULL, + user_id INTEGER NOT NULL, + view_count INTEGER NOT NULL DEFAULT 0, + metadata VARCHAR + ); + CREATE TABLE comments ( + id INTEGER AUTOINCREMENT PRIMARY KEY, + body VARCHAR NOT NULL, + post_id INTEGER NOT NULL, + meta VARCHAR + ); +""" + +EMPLOYEE_DEPARTMENT_SCHEMA = """ + CREATE TABLE departments ( + id INTEGER AUTOINCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description VARCHAR + ); + CREATE TABLE employees ( + id INTEGER AUTOINCREMENT PRIMARY KEY, + username VARCHAR(255) NOT NULL, + department_id INTEGER NOT NULL + ); +""" + +AUTHOR_BOOK_SCHEMA = """ + CREATE TABLE authors ( + id INTEGER AUTOINCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL + ); + CREATE TABLE books ( + id INTEGER AUTOINCREMENT PRIMARY KEY, + title VARCHAR(255) NOT NULL, + author_id INTEGER NOT NULL + ); + CREATE TABLE chapters ( + id INTEGER AUTOINCREMENT PRIMARY KEY, + title VARCHAR(255) NOT NULL, + book_id INTEGER NOT NULL + ); + CREATE TABLE profiles ( + id INTEGER AUTOINCREMENT PRIMARY KEY, + bio VARCHAR NOT NULL, + author_id INTEGER NOT NULL + ); +""" + +RELATION_BOUNDARY_SCHEMA = """ + CREATE TABLE relation_boundary_owners ( + id INTEGER AUTOINCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL + ); + CREATE TABLE relation_boundary_profiles ( + id INTEGER AUTOINCREMENT PRIMARY KEY, + bio VARCHAR NOT NULL, + owner_id INTEGER + ); + CREATE TABLE relation_boundary_posts ( + id INTEGER AUTOINCREMENT PRIMARY KEY, + title VARCHAR(255) NOT NULL, + owner_id INTEGER + ); +""" + + +def _statements(sql: str) -> List[str]: + """Split one schema script into executable statements.""" + return [statement.strip() for statement in sql.split(";") if statement.strip()] + + +def _ddl_options() -> ExecutionOptions: + return ExecutionOptions(stmt_type=StatementType.DDL) + + +class RelationProviderBase: + """Shared, non-I/O helpers for the sync and async relation providers.""" + + def __init__(self) -> None: + self._created_tables: List[str] = [] + + def get_test_scenarios(self) -> List[str]: + return list(get_enabled_scenarios().keys()) + + def _remember_tables(self, *table_names: str) -> None: + for table_name in table_names: + if table_name not in self._created_tables: + self._created_tables.append(table_name) + + def _load_relation_boundary_dataset( + self, + owner_class, + profile_class, + post_class, + dataset_name: str, + ) -> Dict[str, int]: + if dataset_name == "null_foreign_key": + profile = profile_class(bio="No owner", owner_id=None) + profile.save() + return {"profile_id": profile.id} + + if dataset_name == "orphan_foreign_key": + missing_owner_id = 999999 + post = post_class(title="Orphan post", owner_id=missing_owner_id) + post.save() + return {"post_id": post.id, "missing_owner_id": missing_owner_id} + + if dataset_name == "owner_without_children": + owner = owner_class(name="Owner without children") + owner.save() + return {"owner_id": owner.id} + + if dataset_name == "multiple_has_one_matches": + owner = owner_class(name="Owner with duplicate profiles") + owner.save() + first = profile_class(bio="First profile", owner_id=owner.id) + first.save() + second = profile_class(bio="Second profile", owner_id=owner.id) + second.save() + return { + "owner_id": owner.id, + "first_profile_id": first.id, + "second_profile_id": second.id, + } + + raise ValueError("Unknown relation boundary dataset: %s" % dataset_name) + + +class RelationSyncProvider(RelationProviderBase, IRelationSyncProvider): + """Synchronous Snowflake implementation of the relation test provider.""" + + def __init__(self) -> None: + super().__init__() + self._active_backends: List = [] + self._user_post_comment_setup = False + self._relation_boundary_setup = False + + def _configure(self, model_class, config, backend_class, backend): + model_class.__connection_config__ = config + model_class.__backend_class__ = backend_class + model_class.__backend__ = backend + + def _create_schema(self, backend, schema_sql: str, *tables: str) -> None: + for statement in _statements(schema_sql): + backend.execute(statement, (), options=_ddl_options()) + self._remember_tables(*tables) + + def _setup_employee_department(self, scenario_name: str): + backend_class, config = get_scenario(scenario_name) + Employee.configure(config, backend_class) + backend = Employee.backend() + backend.connect() + self._active_backends.append(backend) + self._create_schema( + backend, + EMPLOYEE_DEPARTMENT_SCHEMA, + "departments", + "employees", + ) + self._configure(Department, config, backend_class, backend) + return Employee, Department + + def _setup_author_book(self, scenario_name: str): + backend_class, config = get_scenario(scenario_name) + Author.configure(config, backend_class) + backend = Author.backend() + backend.connect() + self._active_backends.append(backend) + self._create_schema( + backend, + AUTHOR_BOOK_SCHEMA, + "authors", + "books", + "chapters", + "profiles", + ) + for model_class in (Book, Chapter, Profile): + self._configure(model_class, config, backend_class, backend) + return Author, Book, Chapter, Profile + + def _setup_user_post_comment(self, scenario_name: str) -> None: + if self._user_post_comment_setup: + return + backend_class, config = get_scenario(scenario_name) + User.configure(config, backend_class) + backend = User.backend() + backend.connect() + self._active_backends.append(backend) + self._create_schema( + backend, + USER_POST_COMMENT_SCHEMA, + "comments", + "posts", + "users", + ) + for model_class in (Post, Comment): + self._configure(model_class, config, backend_class, backend) + self._user_post_comment_setup = True + + def _setup_relation_boundary(self, scenario_name: str) -> None: + if self._relation_boundary_setup: + return + backend_class, config = get_scenario(scenario_name) + BoundaryOwner.configure(config, backend_class) + backend = BoundaryOwner.backend() + backend.connect() + self._active_backends.append(backend) + self._create_schema( + backend, + RELATION_BOUNDARY_SCHEMA, + "relation_boundary_owners", + "relation_boundary_profiles", + "relation_boundary_posts", + ) + for model_class in (BoundaryProfile, BoundaryPost): + self._configure(model_class, config, backend_class, backend) + self._relation_boundary_setup = True + + def setup_employee_department_fixtures( + self, + scenario_name: str, + ) -> Tuple[Type[ActiveRecord], Type[ActiveRecord]]: + return self._setup_employee_department(scenario_name) + + def setup_author_book_fixtures( + self, + scenario_name: str, + ) -> Tuple[ + Type[ActiveRecord], + Type[ActiveRecord], + Type[ActiveRecord], + Type[ActiveRecord], + ]: + return self._setup_author_book(scenario_name) + + def setup_user_model(self, scenario_name: str) -> Type[ActiveRecord]: + self._setup_user_post_comment(scenario_name) + return User + + def setup_post_model(self, scenario_name: str) -> Type[ActiveRecord]: + self._setup_user_post_comment(scenario_name) + return Post + + def setup_comment_model(self, scenario_name: str) -> Type[ActiveRecord]: + self._setup_user_post_comment(scenario_name) + return Comment + + def setup_relation_boundary_fixtures( + self, + scenario_name: str, + ) -> Tuple[Type[ActiveRecord], Type[ActiveRecord], Type[ActiveRecord]]: + self._setup_relation_boundary(scenario_name) + return BoundaryOwner, BoundaryProfile, BoundaryPost + + def load_relation_boundary_dataset( + self, + scenario_name: str, + dataset_name: str, + ) -> Dict[str, int]: + self._setup_relation_boundary(scenario_name) + return self._load_relation_boundary_dataset( + BoundaryOwner, + BoundaryProfile, + BoundaryPost, + dataset_name, + ) + + def cleanup_after_test(self, scenario_name: str) -> None: + for backend in self._active_backends: + try: + for table_name in reversed(self._created_tables): + try: + backend.execute( + "DROP TABLE IF EXISTS %s" % table_name, + (), + options=_ddl_options(), + ) + except Exception: + pass + finally: + try: + backend.disconnect() + except Exception: + pass + self._active_backends.clear() + self._created_tables.clear() + + +class RelationAsyncProvider(RelationProviderBase, IRelationAsyncProvider): + """Asynchronous Snowflake implementation of the relation test provider.""" + + def __init__(self) -> None: + super().__init__() + self._active_async_backends: List = [] + self._user_post_comment_setup = False + self._relation_boundary_setup = False + + def _configure(self, model_class, config, backend_class, backend): + model_class.__connection_config__ = config + model_class.__backend_class__ = backend_class + model_class.__backend__ = backend + + async def _create_schema(self, backend, schema_sql: str, *tables: str) -> None: + for statement in _statements(schema_sql): + await backend.execute(statement, (), options=_ddl_options()) + self._remember_tables(*tables) + + async def _setup_employee_department(self, scenario_name: str): + _, config = get_scenario(scenario_name) + await Employee.configure(config, AsyncSnowflakeBackend) + backend = Employee.backend() + await backend.connect() + self._active_async_backends.append(backend) + await self._create_schema( + backend, + EMPLOYEE_DEPARTMENT_SCHEMA, + "departments", + "employees", + ) + self._configure(Department, config, AsyncSnowflakeBackend, backend) + return Employee, Department + + async def _setup_author_book(self, scenario_name: str): + _, config = get_scenario(scenario_name) + await Author.configure(config, AsyncSnowflakeBackend) + backend = Author.backend() + await backend.connect() + self._active_async_backends.append(backend) + await self._create_schema( + backend, + AUTHOR_BOOK_SCHEMA, + "authors", + "books", + "chapters", + "profiles", + ) + for model_class in (Book, Chapter, Profile): + self._configure(model_class, config, AsyncSnowflakeBackend, backend) + return Author, Book, Chapter, Profile + + async def _setup_user_post_comment(self, scenario_name: str) -> None: + if self._user_post_comment_setup: + return + _, config = get_scenario(scenario_name) + await AsyncUser.configure(config, AsyncSnowflakeBackend) + backend = AsyncUser.backend() + await backend.connect() + self._active_async_backends.append(backend) + await self._create_schema( + backend, + USER_POST_COMMENT_SCHEMA, + "comments", + "posts", + "users", + ) + for model_class in (AsyncPost, AsyncComment): + self._configure(model_class, config, AsyncSnowflakeBackend, backend) + self._user_post_comment_setup = True + + async def _setup_relation_boundary(self, scenario_name: str) -> None: + if self._relation_boundary_setup: + return + _, config = get_scenario(scenario_name) + await AsyncBoundaryOwner.configure(config, AsyncSnowflakeBackend) + backend = AsyncBoundaryOwner.backend() + await backend.connect() + self._active_async_backends.append(backend) + await self._create_schema( + backend, + RELATION_BOUNDARY_SCHEMA, + "relation_boundary_owners", + "relation_boundary_profiles", + "relation_boundary_posts", + ) + for model_class in (AsyncBoundaryProfile, AsyncBoundaryPost): + self._configure(model_class, config, AsyncSnowflakeBackend, backend) + self._relation_boundary_setup = True + + async def setup_employee_department_fixtures( + self, + scenario_name: str, + ) -> Tuple[Type[AsyncActiveRecord], Type[AsyncActiveRecord]]: + return await self._setup_employee_department(scenario_name) + + async def setup_author_book_fixtures( + self, + scenario_name: str, + ) -> Tuple[ + Type[AsyncActiveRecord], + Type[AsyncActiveRecord], + Type[AsyncActiveRecord], + Type[AsyncActiveRecord], + ]: + return await self._setup_author_book(scenario_name) + + async def setup_user_model(self, scenario_name: str) -> Type[AsyncActiveRecord]: + await self._setup_user_post_comment(scenario_name) + return AsyncUser + + async def setup_post_model(self, scenario_name: str) -> Type[AsyncActiveRecord]: + await self._setup_user_post_comment(scenario_name) + return AsyncPost + + async def setup_comment_model(self, scenario_name: str) -> Type[AsyncActiveRecord]: + await self._setup_user_post_comment(scenario_name) + return AsyncComment + + async def setup_relation_boundary_fixtures( + self, + scenario_name: str, + ) -> Tuple[ + Type[AsyncActiveRecord], + Type[AsyncActiveRecord], + Type[AsyncActiveRecord], + ]: + await self._setup_relation_boundary(scenario_name) + return AsyncBoundaryOwner, AsyncBoundaryProfile, AsyncBoundaryPost + + async def load_relation_boundary_dataset( + self, + scenario_name: str, + dataset_name: str, + ) -> Dict[str, int]: + await self._setup_relation_boundary(scenario_name) + return await self._load_relation_boundary_dataset_async( + AsyncBoundaryOwner, + AsyncBoundaryProfile, + AsyncBoundaryPost, + dataset_name, + ) + + async def _load_relation_boundary_dataset_async( + self, + owner_class, + profile_class, + post_class, + dataset_name: str, + ) -> Dict[str, int]: + if dataset_name == "null_foreign_key": + profile = profile_class(bio="No owner", owner_id=None) + await profile.save() + return {"profile_id": profile.id} + + if dataset_name == "orphan_foreign_key": + missing_owner_id = 999999 + post = post_class(title="Orphan post", owner_id=missing_owner_id) + await post.save() + return {"post_id": post.id, "missing_owner_id": missing_owner_id} + + if dataset_name == "owner_without_children": + owner = owner_class(name="Owner without children") + await owner.save() + return {"owner_id": owner.id} + + if dataset_name == "multiple_has_one_matches": + owner = owner_class(name="Owner with duplicate profiles") + await owner.save() + first = profile_class(bio="First profile", owner_id=owner.id) + await first.save() + second = profile_class(bio="Second profile", owner_id=owner.id) + await second.save() + return { + "owner_id": owner.id, + "first_profile_id": first.id, + "second_profile_id": second.id, + } + + raise ValueError("Unknown relation boundary dataset: %s" % dataset_name) + + async def cleanup_after_test(self, scenario_name: str) -> None: + for backend in self._active_async_backends: + try: + for table_name in reversed(self._created_tables): + try: + await backend.execute( + "DROP TABLE IF EXISTS %s" % table_name, + (), + options=_ddl_options(), + ) + except Exception: + pass + finally: + try: + await backend.disconnect() + except Exception: + pass + self._active_async_backends.clear() + self._created_tables.clear() diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/basic/nopk/test_keyless_models.py b/tests/rhosocial/activerecord_snowflake_test/feature/basic/nopk/test_keyless_models.py new file mode 100644 index 0000000..9442d03 --- /dev/null +++ b/tests/rhosocial/activerecord_snowflake_test/feature/basic/nopk/test_keyless_models.py @@ -0,0 +1,4 @@ +# tests/rhosocial/activerecord_snowflake_test/feature/basic/nopk/test_keyless_models.py +"""Shared contract wrapper for keyless models on Snowflake.""" +from rhosocial.activerecord.testsuite.feature.basic.nopk.test_keyless_models import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.basic.nopk.test_keyless_models_async import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/basic/readonly/test_readonly_models.py b/tests/rhosocial/activerecord_snowflake_test/feature/basic/readonly/test_readonly_models.py new file mode 100644 index 0000000..e999239 --- /dev/null +++ b/tests/rhosocial/activerecord_snowflake_test/feature/basic/readonly/test_readonly_models.py @@ -0,0 +1,4 @@ +# tests/rhosocial/activerecord_snowflake_test/feature/basic/readonly/test_readonly_models.py +"""Shared contract wrapper for read-only models on Snowflake.""" +from rhosocial.activerecord.testsuite.feature.basic.readonly.test_readonly_models import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.basic.readonly.test_readonly_models_async import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/logging/test_execution_logging_unit.py b/tests/rhosocial/activerecord_snowflake_test/feature/logging/test_execution_logging_unit.py new file mode 100644 index 0000000..4c8176a --- /dev/null +++ b/tests/rhosocial/activerecord_snowflake_test/feature/logging/test_execution_logging_unit.py @@ -0,0 +1,297 @@ +# tests/rhosocial/activerecord_snowflake_test/feature/logging/test_execution_logging_unit.py +"""Database-free coverage of the Snowflake execution logging contract. + +The execution-logging change is mostly a set of small branches: the error +classifier's DEBUG records on both backends, the result-set failure records, +the canonical logger name shared by the sync and async backends, and the CLI +module logger. None of that needs a warehouse, so this module builds backends +with dummy connection configurations, swaps only the classifier, and asserts +what is logged. + +Contract under test: + +* ``SnowflakeBackend`` and ``AsyncSnowflakeBackend`` resolve to the single + library logger ``rhosocial.activerecord.backend.snowflake``; the transaction + managers do not emit a second "Executing: ..." record because the backend + execution path owns the canonical DEBUG SQL record. +* Every ``_handle_error`` category logs at DEBUG (not ERROR) before raising the + typed backend error. +* A failing result-set conversion logs at DEBUG and re-raises the original + exception. + +No ``snowflake.connector`` import is required: the classifier is replaced on +the instance, and connection state stays ``None`` throughout. +""" + +import logging + +import pytest + +from rhosocial.activerecord.backend.errors import ( + ConnectionError as BackendConnectionError, +) +from rhosocial.activerecord.backend.errors import ( + DatabaseError as BackendDatabaseError, +) +from rhosocial.activerecord.backend.errors import ( + IntegrityError as BackendIntegrityError, +) +from rhosocial.activerecord.backend.errors import ( + OperationalError as BackendOperationalError, +) +from rhosocial.activerecord.backend.errors import ( + QueryError as BackendQueryError, +) +from rhosocial.activerecord.backend.impl.snowflake.async_transaction import ( + AsyncSnowflakeTransactionManager, +) +from rhosocial.activerecord.backend.impl.snowflake.backend.async_backend import ( + AsyncSnowflakeBackend, +) +from rhosocial.activerecord.backend.impl.snowflake.backend.backend import ( + SnowflakeBackend, +) +from rhosocial.activerecord.backend.impl.snowflake.cli import info as cli_info +from rhosocial.activerecord.backend.impl.snowflake.config import ( + SnowflakeConnectionConfig, +) +from rhosocial.activerecord.backend.impl.snowflake.dialect import SnowflakeDialect +from rhosocial.activerecord.backend.impl.snowflake.mixins import SnowflakeBackendMixin +from rhosocial.activerecord.backend.impl.snowflake.transaction import ( + SnowflakeTransactionManager, +) +from rhosocial.activerecord.logging.defaults import get_logger + +#: The one library logger both backend halves and the CLI must resolve to. +CANONICAL_LOGGER = "rhosocial.activerecord.backend.snowflake" + +#: ``(classifier category, raised error type, log label)`` for every branch. +ERROR_CASES = [ + ("connection", BackendConnectionError, "Connection"), + ("integrity", BackendIntegrityError, "Integrity"), + ("query", BackendQueryError, "Query"), + ("operational", BackendOperationalError, "Operational"), + ("unknown", BackendDatabaseError, "Database"), +] + + +def _config() -> SnowflakeConnectionConfig: + """Build a connection config with dummy credentials; nothing connects.""" + return SnowflakeConnectionConfig( + account="unit-test-account", + username="unit-test-user", + password="unit-test-password", + database="unit-test-database", + ) + + +def _logged_messages(caplog) -> list: + """Return the captured records' formatted messages.""" + return [record.getMessage() for record in caplog.records] + + +class _FakeExecutor: + """Placeholder executor; the async backend only stores it during ``init``.""" + + +class _FailingCursor: + """Cursor whose ``fetchall`` fails after a statement has run.""" + + description = () + + def fetchall(self): + raise RuntimeError("result set boom") + + +class _FailingAsyncCursor: + """Async counterpart of :class:`_FailingCursor`.""" + + description = () + + async def fetchall(self): + raise RuntimeError("result set boom") + + +class _RecordingBackend: + """Minimal sync backend that records the SQL the transaction manager runs.""" + + dialect = SnowflakeDialect() + + def __init__(self): + self.calls = [] + + def execute(self, sql, params=None): + self.calls.append((sql, tuple(params or ()))) + + +class _RecordingAsyncBackend: + """Async counterpart of :class:`_RecordingBackend`.""" + + dialect = SnowflakeDialect() + + def __init__(self): + self.calls = [] + + async def execute(self, sql, params=None): + self.calls.append((sql, tuple(params or ()))) + + +class TestExecutionLoggingContract: + """Synchronous half of the execution logging contract.""" + + def test_backend_uses_canonical_logger(self): + backend = SnowflakeBackend(connection_config=_config()) + assert backend.logger.name == CANONICAL_LOGGER + assert backend._get_logger_name() == CANONICAL_LOGGER + + def test_mixin_declares_canonical_logger_name(self): + assert SnowflakeBackendMixin()._get_logger_name() == CANONICAL_LOGGER + + def test_backend_builds_config_from_keyword_arguments(self): + backend = SnowflakeBackend( + account="unit-test-account", + username="unit-test-user", + password="unit-test-password", + database="unit-test-database", + version=(8, 0, 0), + ) + assert backend.config.account == "unit-test-account" + assert backend.get_server_version() == (8, 0, 0) + + def test_ping_does_not_reconnect_offline(self): + backend = SnowflakeBackend(connection_config=_config()) + assert backend._connection is None + assert backend.ping(reconnect=False) is False + + @pytest.mark.parametrize("category, error_type, label", ERROR_CASES) + def test_handle_error_logs_debug_and_raises_typed_error( + self, category, error_type, label, caplog, monkeypatch + ): + caplog.set_level(logging.DEBUG, logger=CANONICAL_LOGGER) + backend = SnowflakeBackend(connection_config=_config()) + monkeypatch.setattr(backend, "_classify_error", lambda error: category) + + with pytest.raises(error_type, match="boom"): + backend._handle_error(RuntimeError("boom")) + + assert f"{label} error: boom" in _logged_messages(caplog) + assert all( + record.levelno == logging.DEBUG + for record in caplog.records + if " error: boom" in record.getMessage() + ) + + def test_process_result_set_logs_debug_and_reraises(self, caplog): + caplog.set_level(logging.DEBUG, logger=CANONICAL_LOGGER) + backend = SnowflakeBackend(connection_config=_config()) + + with pytest.raises(RuntimeError, match="result set boom"): + backend._process_result_set(_FailingCursor(), is_select=True) + + assert "Error processing result set: result set boom" in _logged_messages(caplog) + assert all( + record.levelno == logging.DEBUG + for record in caplog.records + if "Error processing result set" in record.getMessage() + ) + + def test_begin_and_commit_delegate_to_backend(self): + backend = _RecordingBackend() + manager = SnowflakeTransactionManager( + backend, logger=logging.getLogger("test.snowflake.transaction") + ) + + manager.begin() + manager.commit() + + assert backend.calls == [("BEGIN", ()), ("COMMIT", ())] + assert manager.is_active is False + + +class TestAsyncExecutionLoggingContract: + """Async counterpart of :class:`TestExecutionLoggingContract`.""" + + @pytest.mark.asyncio + async def test_backend_uses_canonical_logger(self): + backend = AsyncSnowflakeBackend(connection_config=_config()) + assert backend.logger.name == CANONICAL_LOGGER + assert backend._get_logger_name() == CANONICAL_LOGGER + + @pytest.mark.asyncio + async def test_backend_builds_config_from_keyword_arguments(self): + executor = _FakeExecutor() + backend = AsyncSnowflakeBackend( + executor=executor, + account="unit-test-account", + username="unit-test-user", + password="unit-test-password", + database="unit-test-database", + version=(8, 0, 0), + ) + assert backend.config.account == "unit-test-account" + assert backend._executor is executor + assert await backend.get_server_version() == (8, 0, 0) + + @pytest.mark.asyncio + async def test_ping_does_not_reconnect_offline(self): + backend = AsyncSnowflakeBackend(connection_config=_config()) + assert backend._connection is None + assert await backend.ping(reconnect=False) is False + + @pytest.mark.asyncio + @pytest.mark.parametrize("category, error_type, label", ERROR_CASES) + async def test_handle_error_logs_debug_and_raises_typed_error( + self, category, error_type, label, caplog, monkeypatch + ): + caplog.set_level(logging.DEBUG, logger=CANONICAL_LOGGER) + backend = AsyncSnowflakeBackend(connection_config=_config()) + monkeypatch.setattr(backend, "_classify_error", lambda error: category) + + with pytest.raises(error_type, match="boom"): + await backend._handle_error(RuntimeError("boom")) + + assert f"{label} error: boom" in _logged_messages(caplog) + assert all( + record.levelno == logging.DEBUG + for record in caplog.records + if " error: boom" in record.getMessage() + ) + + @pytest.mark.asyncio + async def test_process_result_set_logs_debug_and_reraises(self, caplog): + caplog.set_level(logging.DEBUG, logger=CANONICAL_LOGGER) + backend = AsyncSnowflakeBackend(connection_config=_config()) + + with pytest.raises(RuntimeError, match="result set boom"): + await backend._process_result_set(_FailingAsyncCursor(), is_select=True) + + assert ( + "Error processing async result set: result set boom" + in _logged_messages(caplog) + ) + assert all( + record.levelno == logging.DEBUG + for record in caplog.records + if "Error processing async result set" in record.getMessage() + ) + + @pytest.mark.asyncio + async def test_begin_and_commit_delegate_to_backend(self): + backend = _RecordingAsyncBackend() + manager = AsyncSnowflakeTransactionManager( + backend, logger=logging.getLogger("test.snowflake.async_transaction") + ) + + await manager.begin() + await manager.commit() + + assert backend.calls == [("BEGIN", ()), ("COMMIT", ())] + assert manager.is_active is False + + +class TestCliInfoLogger: + """The CLI module uses the canonical logger, not its module namespace.""" + + def test_info_uses_canonical_logger(self): + assert cli_info.logger.name == CANONICAL_LOGGER + assert cli_info.logger is get_logger(CANONICAL_LOGGER) diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/query/basic/test_find_one_or_create.py b/tests/rhosocial/activerecord_snowflake_test/feature/query/basic/test_find_one_or_create.py new file mode 100644 index 0000000..fff4618 --- /dev/null +++ b/tests/rhosocial/activerecord_snowflake_test/feature/query/basic/test_find_one_or_create.py @@ -0,0 +1,4 @@ +# tests/rhosocial/activerecord_snowflake_test/feature/query/basic/test_find_one_or_create.py +"""Shared contract wrapper for find_one_or_create on Snowflake.""" +from rhosocial.activerecord.testsuite.feature.query.basic.test_find_one_or_create import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.query.basic.test_find_one_or_create_async import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/query/schema/composite_order_items.sql b/tests/rhosocial/activerecord_snowflake_test/feature/query/schema/composite_order_items.sql new file mode 100644 index 0000000..c4c4f62 --- /dev/null +++ b/tests/rhosocial/activerecord_snowflake_test/feature/query/schema/composite_order_items.sql @@ -0,0 +1,7 @@ +CREATE TABLE order_items ( + order_id INTEGER NOT NULL, + product_id INTEGER NOT NULL, + quantity INTEGER NOT NULL DEFAULT 1, + unit_price DECIMAL(10,2) NOT NULL DEFAULT 0.00, + PRIMARY KEY (order_id, product_id) +) diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/relation/conftest.py b/tests/rhosocial/activerecord_snowflake_test/feature/relation/conftest.py new file mode 100644 index 0000000..ffd491b --- /dev/null +++ b/tests/rhosocial/activerecord_snowflake_test/feature/relation/conftest.py @@ -0,0 +1,7 @@ +# tests/rhosocial/activerecord_snowflake_test/feature/relation/conftest.py +"""Pytest configuration for relation feature tests. + +Imports the testsuite fixtures; the Snowflake relation provider is registered +in ``tests/providers/registry.py``. +""" +from rhosocial.activerecord.testsuite.feature.relation.conftest import * # noqa: F403 diff --git a/tests/rhosocial/activerecord_snowflake_test/feature/relation/ordering/test_has_many_order_by.py b/tests/rhosocial/activerecord_snowflake_test/feature/relation/ordering/test_has_many_order_by.py new file mode 100644 index 0000000..c72fb0f --- /dev/null +++ b/tests/rhosocial/activerecord_snowflake_test/feature/relation/ordering/test_has_many_order_by.py @@ -0,0 +1,4 @@ +# tests/rhosocial/activerecord_snowflake_test/feature/relation/ordering/test_has_many_order_by.py +"""Shared contract wrapper for HasMany(order_by=...) on Snowflake.""" +from rhosocial.activerecord.testsuite.feature.relation.ordering.test_has_many_order_by import * # noqa: F403 +from rhosocial.activerecord.testsuite.feature.relation.ordering.test_has_many_order_by_async import * # noqa: F403