SQLAlchemy#
SQLAlchemy is how Test Data Workbench finds out what is actually in a database it has never seen before. Nothing else in the pipeline has an opinion about schema until SQLAlchemy’s reflection reports one back.
Why SQLAlchemy#
The tool’s entire premise is that it does not know your schema ahead of time: point it at a connection string, and it has to discover tables, columns, types, primary keys, and foreign keys on its own, across whatever database engine is on the other end. Hand writing that discovery per database dialect is not a reasonable ask, and getting it wrong quietly (missing a foreign key, misreading a type) would corrupt everything downstream that depends on the discovery being correct.
SQLAlchemy solves exactly this. Its Core layer and reflection Inspector
give one consistent API for reading a database’s own structure back,
whether that database is the SQLite file tdw demo builds for you or a
production PostgreSQL instance. It is listed as a direct dependency in
pyproject.toml (sqlalchemy>=2.0.23), not an optional one, and it is
imported in exactly one file in the whole project,
src/test_data_workbench/adaptation/schema_analyzer.py. Once that file
has done its job, nothing downstream needs SQLAlchemy again.
The idea underneath#
Relational data and Python objects do not fit together naturally. The mismatch is usually described along three axes:
Identity. A row’s identity in a relational database is its primary key value, nothing more. Two separate queries for the same row can hand back two distinct Python objects with no relationship to each other, unless something keeps track of which object already represents which row.
Navigation. A relational database has no pointers. Getting from an order to the user who placed it means a join or a second query keyed by a foreign key value, not attribute access. Anything that makes
order.userbehave like a normal object reference is doing work the relational model does not provide for free.Lifetime. An in-memory object’s lifetime is scoped to the process that created it. A row persists independently of any object representing it, and something else in the source database can change the row while your object still holds a value that is now stale.
Martin Fowler names two patterns in Patterns of Enterprise Application Architecture that address these gaps, and SQLAlchemy’s ORM implements both: the Identity Map, which keeps one Python object per row identity within a session so that two queries for the same primary key return the same object, and the Unit of Work, which tracks every pending insert, update, and delete and flushes them together as one coordinated batch rather than as they happen.
This project uses neither. It never opens a Session, never declares a
mapped class, and never writes a row back through SQLAlchemy at all, so
there is no object identity to keep consistent and no pending write to
batch. Once Inspector methods return, the result is translated straight
into this project’s own plain dataclasses, Table, Column, and
Relationship in src/test_data_workbench/core/models.py, which know
nothing about SQLAlchemy.
What this project actually leans on is the other half of the idea:
reflection, treating a database’s own catalog (the system tables or
views where it describes its own structure, such as PostgreSQL’s
information_schema and pg_catalog, or SQLite’s sqlite_master) as
a machine-readable source of truth, rather than declaring a schema in
Python and hoping it matches reality. This project’s own test suite makes
that catalog boundary concrete: tests/test_metadata_only.py captures
every SQL statement issued during an analysis and explicitly excludes
lookups against sqlite_master and sqlite_temp_master from the “did
this read table data” check, because those are catalog reads describing
structure, not row reads.
How it fits this project#
SQLAlchemy’s job begins and ends inside
SchemaAnalyzer.analyze_production_schema, in
src/test_data_workbench/adaptation/schema_analyzer.py. That one method
builds the engine, builds the inspector, and turns the result into a
SchemaInfo made entirely of this project’s own dataclasses. Everything
downstream, the GeneratorFactory in
src/test_data_workbench/adaptation/generator_factory.py, entity
classification, dependency ordering, and code emission, operates purely on
those dataclasses and imports no SQLAlchemy at all. Structure comes in from
SQLAlchemy at the front of the pipeline and nothing further back ever
touches it again.
Core and the Inspector, not the ORM#
The analyzer uses SQLAlchemy Core and its reflection Inspector, never
the ORM:
engine = sa.create_engine(connection_string, pool_pre_ping=True)
inspector = inspect(engine)
db_name = engine.url.database or "unknown"
table_names = inspector.get_table_names()
There is no declarative_base(), no mapped class, no Session
anywhere in this file, which is the correct choice for what it is doing:
the ORM maps known Python classes onto tables declared ahead of time, and
this project has nothing to declare ahead of time. Every Inspector call
returns a plain dict or list of dicts, which _analyze_table and
_detect_relationships translate directly into this project’s own
dataclasses:
Inspector call |
What it becomes |
|---|---|
|
The starting table list, capped at the first 20 entries per run. |
|
One |
|
Marks the matching columns with |
|
Marks the matching columns with |
Connection pooling and pool_pre_ping#
create_engine(connection_string, pool_pre_ping=True) asks SQLAlchemy to
test every pooled connection with a lightweight probe immediately before
handing it to the caller, rather than handing back whatever connection is
next in the pool and finding out it is dead only when a real query fails on
it.
The failure mode this guards against is ordinary and easy to hit: a
connection can go stale while sitting idle in the pool, because the
database restarted, a firewall or load balancer closed an idle connection
underneath it, or the server enforced its own idle timeout. Without
pool_pre_ping, the first sign of trouble is a driver-level error such as
“server closed the connection unexpectedly” surfacing on whatever query
happened to draw the dead connection, which looks like a query problem when
it is really a pool-hygiene problem. With it, SQLAlchemy discards the dead
connection and opens a fresh one transparently, at the cost of one small
probe per checkout.
For a tool whose first move against an unfamiliar database is “connect and reflect,” that small cost is worth it: a connection string that is wrong should fail loudly and immediately, but a connection that was fine a moment ago and has since gone stale should not be mistaken for the same thing.
Metadata-only mode: the PII heuristic still runs#
metadata_only=True (exposed as tdw analyze --metadata-only and
tdw deploy --metadata-only, see CLI Reference and
Privacy and Data Access) is implemented as guards around the three call
sites in the analyzer that issue a SELECT against table data:
_get_sample_values and _estimate_row_count, both skipped inside
_analyze_table, plus _detect_business_rules, which returns
immediately instead of ever reaching its own query:
query = text(f'SELECT DISTINCT "{column_name}" FROM "{table_name}" LIMIT {limit}')
...
query = text(f'SELECT COUNT(*) FROM "{table_name}" LIMIT 1000')
When metadata_only is set, _analyze_table never calls either
helper: sample_values stays an empty list and row_count is set to
None, deliberately distinct from 0, since None means “not
counted” and 0 would claim to know the table is empty.
_detect_business_rules is skipped in the same mode for the same reason:
its rule detection samples a dataframe of up to 100 rows per table, so
there is nothing left for it to do once row reads are off the table. This
is not just a docstring’s promise: tests/test_metadata_only.py attaches
a SQLAlchemy before_cursor_execute event listener across every engine
created during a run and asserts that none of the captured statements are
SELECT or COUNT statements touching the analyzed tables.
PII column flagging is different. _detect_pii_columns calls
classify_column_pii(column_name) for every column already collected,
and that function takes a bare string and matches it against
PII_NAME_PATTERNS with a regular expression. It never receives the
engine, the inspector, or a connection, so it has no SQL to skip in the
first place. It runs identically whether metadata_only is True or
False, which is why the flagging survives metadata-only mode intact:
it was never something metadata-only mode needed to guard against.
Foreign keys as a dependency graph#
Foreign keys discovered during reflection describe a directed graph:
table A depends on table B if a row in A references a row in B. Generating
data in a valid order (parents before the children that reference them) is
a correctness property, not a nicety, and generator_factory.py
implements it twice, once in _sort_by_dependencies (used to order the
tables that get generator code emitted for them) and once in
_calculate_dependency_order (used to order the actual generation run
via RelationshipManager.dependency_order):
def visit(table: str):
if table in visited:
return
if table in in_stack:
return # Cycle detected - break it
in_stack.add(table)
for dep in deps.get(table, []):
visit(dep)
in_stack.discard(table)
visited.add(table)
order.append(table)
Both are the same depth-first topological sort, and the cycle handling is
the interesting part: in_stack marks vertices that are currently on the
recursion stack, the classic “grey” state in a depth-first search.
Revisiting a vertex that is still grey means the traversal has found a back
edge, which is exactly what a cycle in the graph looks like, and the code
breaks it by returning immediately instead of recursing further.
A self-referential foreign key, such as categories.parent_id ->
categories.id (exercised directly in
tests/test_postgres_integration.py), is the simplest possible case of
this: it is a cycle of length one, caught by the same table in in_stack
check on the very first recursive call. The ordering layer does not treat
it as a special case; it just drops the back edge like any other and moves
on, leaving the actual self-reference handling (drawing a value already
generated earlier in the same batch, in _self_reference_id) to a
different layer entirely.
Drivers and the DB-API layer: psycopg2#
SQLAlchemy Core builds SQL, manages the connection pool, and coordinates
transactions, but it does not speak PostgreSQL’s or SQLite’s wire protocol
itself. That work is delegated to a driver module, and every driver
SQLAlchemy talks to implements PEP 249, the Python Database API
Specification: a connect() function, a Connection object, a
Cursor with execute() and the fetch methods.
For a postgresql:// connection string, SQLAlchemy’s PostgreSQL dialect
defaults to psycopg2 as that driver;
written out fully, the same URL reads postgresql+psycopg2://, exactly
the form tests/test_postgres_integration.py uses. Nothing in
src/ imports psycopg2 directly, and it does not need to: SQLAlchemy
loads it by name when a Postgres engine is created, the same way any DBAPI
driver is loaded for its matching dialect. psycopg2-binary is listed in
pyproject.toml only under [project.optional-dependencies] as the
postgres extra, not in the base dependencies list, so a plain
install gets you SQLAlchemy and SQLite support (sqlite3 ships in the
Python standard library, no driver install needed there) but not Postgres
support until you install test-data-workbench[postgres] as well.
Learn more resources#
Official documentation#
SQLAlchemy 2.0 Unified Tutorial: the starting point for Core concepts (engines, connections, reflection) at the 2.x API level this project pins.
Reflecting Database Objects: the reflection API
SchemaAnalyzeris built on.Runtime Inspection API: the
Inspectorinterface and the exact methods this project calls,get_table_names,get_columns,get_pk_constraint, andget_foreign_keys.Connection Pooling: the pooling model
create_enginesits on, including whatpool_pre_pingdoes.Dealing with Disconnects: the FAQ chapter walking through the stale-connection failure mode
pool_pre_pingexists to guard against.ORM Session Basics: the
Sessionobject that implements the identity map and unit of work patterns this project deliberately does not use.PostgreSQL dialect: psycopg2: what SQLAlchemy’s PostgreSQL dialect expects from the psycopg2 driver.
Psycopg2 documentation: the driver’s own documentation, relevant once the project’s optional
postgresextra is installed.PEP 249, Python Database API Specification v2.0: the interface every DBAPI driver SQLAlchemy loads, including psycopg2, implements.
Tutorials and blogs#
Martin Fowler, Identity Map: the pattern from Patterns of Enterprise Application Architecture that SQLAlchemy’s
Sessionimplements and this project has no use for, since it never maps a row to an object.Martin Fowler, Unit of Work: the companion pattern for batching and flushing writes together, same book, same reason this project does not need it.
Talk Python to Me, episode 344, “SQLAlchemy 2.0”: an interview-format walkthrough of what changed in the 2.0 API line this project’s
sqlalchemy>=2.0.23pin targets.SQLAlchemy Library: the project’s own curated index of talks, deep-dives, and community writing, useful once you know which sub-topic you want more of.
Videos#
“SQLAlchemy 2.0 - The One-Point-Four-Ening 2021” by Mike Bayer, Six Feet Up: SQLAlchemy’s creator on the 1.4-to-2.0 API transition, direct context for why the Core API used in this project looks the way it does.