Skip to main content

How to Build a Custom Database Adapter using the Hybrid MX Pattern

This guide walks you through building a custom database adapter (such as framework-mx-snowflake) by inheriting from the standard SQLAlchemy adapters in framework-m-standard.

The Hybrid MX Pattern maximizes code reuse by inheriting standard CRUD, repository, and transaction management logic, only overriding the dialect-specific parts (like data type mappings, sequence generation, and sync engine DDL statements).


1. Package Structure

To create a hybrid adapter, structure your package as follows:

framework-mx-snowflake/
├── pyproject.toml
└── src/
└── framework_mx_snowflake/
├── __init__.py
├── repository.py
├── schema_mapper.py
└── sync_engine.py

2. Inheriting and Overriding the Schema Mapper

Different databases have specific column constraints and type representations. To customize these, subclass the standard SchemaMapper and override _get_column_type:

# src/framework_mx_snowflake/schema_mapper.py
from typing import Any
from pydantic.fields import FieldInfo
from sqlalchemy import Numeric
from sqlalchemy.types import TypeEngine
from framework_m_standard.adapters.db.schema_mapper import SchemaMapper

class SnowflakeSchemaMapper(SchemaMapper):
"""Custom SchemaMapper that resolves Snowflake-specific database types."""

def _get_column_type(
self,
_field_name: str,
python_type: type,
field_info: FieldInfo,
) -> type[TypeEngine[Any]] | TypeEngine[Any]:
# Customize type mapping if needed (e.g. mapping bool to Numeric(1) for certain dialects)
if python_type is bool:
return Numeric(precision=1)

# Fallback to standard SQLAlchemy mappings for other types
return super()._get_column_type(_field_name, python_type, field_info)

3. Customizing Naming Sequences

To retrieve values from a custom sequence (which might use a custom dialect syntax), subclass the DocumentNamingService and override the sequence retrieval hook:

# src/framework_mx_snowflake/naming.py
from typing import Any
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from framework_m_standard.adapters.db.naming import DocumentNamingService

class SnowflakeNamingService(DocumentNamingService[Any]):
"""Custom document naming service utilizing Snowflake sequences."""

async def _fetch_sequence_value(
self, session: AsyncSession, sequence_name: str
) -> int:
# Override standard nextval() logic with Snowflake-specific syntax
stmt = select(text(f"SELECT {sequence_name}.NEXTVAL"))
result = await session.execute(stmt)
val = result.scalar()
if val is None:
raise ValueError(f"Sequence {sequence_name} returned None")
return int(val)

4. Customizing Schema Migration DDL

The DeclarativeSyncEngine handles runtime database DDL synchronization. Subclass it to configure custom DDL locks, schemas, and table alteration commands for your database:

# src/framework_mx_snowflake/sync_engine.py
from typing import Any
from sqlalchemy.engine import Engine
from framework_m_standard.adapters.db.declarative_sync_engine import DeclarativeSyncEngine

class SnowflakeDeclarativeSyncEngine(DeclarativeSyncEngine):
"""Synchronizer that executes Snowflake-compatible DDL modifications."""

def _apply_lock_timeout(
self, conn: Any, dialect_name: str, max_lock_time: int | None
) -> None:
# Override to set custom lock timeouts if needed
pass

def _create_schema(
self, conn: Any, dialect_name: str, schema_name: str | None
) -> None:
# Custom schema creation DDL
if schema_name:
conn.execute(text(f"CREATE SCHEMA IF NOT EXISTS {schema_name}"))

def _get_add_column_sql(
self,
engine: Engine,
table_name: str,
column_name: str,
col_type: str,
nullable: str,
default: str,
) -> str:
# Generate custom ALTER TABLE ADD syntax
return (
f"ALTER TABLE {table_name} "
f"ADD COLUMN {column_name} {col_type} {nullable}{default}"
)

5. Registering the Custom Adapter

To hook your custom classes into the framework's dynamic database resolver, register them in your package's pyproject.toml project entry points using the dialect name (e.g. snowflake) as the entry point key:

# pyproject.toml
[project.entry-points."framework_m.adapters.schema_mapper"]
snowflake = "framework_mx_snowflake.schema_mapper:SnowflakeSchemaMapper"

[project.entry-points."framework_m.adapters.naming_service"]
snowflake = "framework_mx_snowflake.naming:SnowflakeNamingService"

[project.entry-points."framework_m.adapters.declarative_sync_engine"]
snowflake = "framework_mx_snowflake.sync_engine:SnowflakeDeclarativeSyncEngine"

When your package (framework-mx-snowflake) is installed in the target environment, the framework automatically detects these entry points and binds your custom classes dynamically at runtime whenever it connects to a database using the snowflake dialect, ensuring compatibility even in dynamic multi-db tenant architectures.