Contributing a New Database Adapter¶
Thank you for your interest in expanding the database support of db-graphql-gateway! Our goal is to maintain a truly database-agnostic GraphQL interface. This means that users should be able to swap adapters without modifying their GraphQL schemas, authorization policies, or application code.
This guide explains the DatabaseAdapter protocol, how to build a new adapter, and how to prove it works using the Cross-Adapter Conformance Suite.
The DatabaseAdapter Protocol¶
To integrate a new database engine (e.g., SQL Server, DuckDB, CockroachDB), you must implement the DatabaseAdapter interface (found in src/db_graphql_gateway/database/adapters/interfaces.py).
The adapter acts as a bridge between the engine-agnostic core (GraphQLSchemaBuilder, AuthorizationEngine) and the underlying database dialect.
Key Components¶
DatabaseAdapter: Manages the connection pool, executes queries, and returns raw data.SchemaInspector: Introspects the database to dynamically generate the canonical schema.TypeMapper: Translates dialect-specific types (e.g., MySQL'sTINYINT(1)) into abstract GraphQL IR types (GraphQLType.BOOLEAN).QueryCompiler: Usually inherits fromBaseQueryCompilerand overrides dialect-specific AST-to-SQL logic.
Capability Flags¶
When configuring your QueryCompiler or Adapter, you may need to define capability flags that instruct the core gateway on how to handle the dialect's quirks:
supports_returning(bool): Set toTrueif your engine supportsRETURNINGclauses (like Postgres). IfFalse(like SQLite/MySQL), the gateway will automatically use a "SELECT-after-write" pattern.supports_upsert_on_conflict(bool): Set toTrueif your engine supportsON CONFLICT DO UPDATEorON DUPLICATE KEY UPDATE.
SELECT-after-write
If supports_returning is False, the BaseQueryCompiler will dynamically set fetch_after_write = True on the CompiledQuery returned during mutations. Your adapter's execute() method must intercept this and perform the secondary select using cursor.lastrowid or the known primary key.
placeholder_style(enum/string): e.g.,?for SQLite,%sfor MySQL,$1for Postgres. Ensures parameterized queries match the underlying driver's expectations.
Building a New Adapter: Worked Example (SQLite)¶
Let's look at how SQLite was implemented.
1. Connection & Execution¶
class SQLiteAdapter(DatabaseAdapter):
async def execute_query(self, query: str, params: list[Any]) -> list[dict[str, Any]]:
async with self.pool.acquire() as conn:
cursor = await conn.execute(query, params)
rows = await cursor.fetchall()
return [dict(row) for row in rows]
2. Query Compilation¶
Inherit from BaseQueryCompiler to get 90% of ANSI SQL behavior for free.
class SQLiteQueryCompiler(BaseQueryCompiler):
def __init__(self):
super().__init__()
self.placeholder = "?"
self.supports_returning = False
3. Engine Gotchas (Checklist)¶
Before building, verify the following against your driver:
- [ ] Placeholder Style: Does the driver use ?, %s, :name, or $1?
- [ ] Identifier Quoting: Does it use "" (Postgres/SQLite) or \`(MySQL)? Overridequote_identifier()in your compiler.
- [ ] **Boolean Types**: Does the engine lack a nativeBOOLEAN? Ensure yourTypeMapperintercepts it (e.g.,TINYINT(1) -> BOOLEAN).
- [ ] **Upsert Syntax**: Does it useON CONFLICTorON DUPLICATE KEY UPDATE`?
Explicit Non-Goals¶
The DatabaseAdapter protocol does not need to support highly proprietary, engine-specific extensions (e.g., Postgres PostGIS, Oracle XMLType) if there is no cross-engine equivalent. Expose these via adapter-level opt-in configuration, not by muddying the core protocol.
Passing the Conformance Suite¶
Your PR will not be merged unless it passes the Conformance Suite. This suite runs identical GraphQL queries against all adapters to guarantee 100% behavioral parity (including pagination, relationships, and authorization pushdowns).
How to hook into the Conformance Suite:¶
- Open
tests/conformance/conftest.py. - Add your engine to the parameter matrix:
@pytest_asyncio.fixture(params=["sqlite", "mysql", "postgres", "your_engine"]) - Update the
db_adapterfixture to provision your engine (usetestcontainersif it requires a daemon). The fixture must use the genericget_ddl("your_engine")string to instantiate the canonical schema:elif engine == "your_engine": # Setup your engine here for stmt in get_ddl("your_engine"): await conn.execute(stmt) - Run the suite:
uv run pytest tests/conformance/ -k "db_adapter[your_engine]"
If your compiler and type mapper are correct, all tests will pass without writing a single line of test code!
Passing the Docker Integration Suite¶
In addition to the Conformance Suite, if you make changes to the core execution engine, GraphQL builder, or authentication flows, you must run the End-to-End Integration Suite.
This suite spins up a real PostgreSQL container, seeds a relational schema with data, mounts a FastAPI gateway, and runs advanced HTTP assertions (Deep Nesting limits, N+1 parallelization boundaries, and Row-Level Authentication).
- Change directory:
cd integration_tests/ - Run the suite:
./run_all.sh
The script will automatically manage the Docker containers, run all tests, and clean up afterwards.
Entry Points & Packaging¶
To keep the core package lightweight, third-party adapters should be published as separate packages (e.g., db-graphql-gateway-duckdb).
Register your adapter in your pyproject.toml so the gateway can auto-discover it:
[project.entry-points."db_graphql_gateway.adapters"]
duckdb = "my_duckdb_package.adapter:DuckDBAdapter"
engine="duckdb" and the plugin system will handle the rest.