Engines¶
TL;DR BaseEngine[DF] is a generic ABC that abstracts read / write /
merge / transform / maintenance across DataFrame libraries. All format-aware
methods take a fmt= parameter so Delta and Iceberg share the same surface.
The DF type parameter¶
from datacoolie.engines.base import BaseEngine
class SparkEngine(BaseEngine[pyspark.sql.DataFrame]): ...
class PolarsEngine(BaseEngine[polars.LazyFrame]): ...
Sources, destinations, and transformers are parameterised by the same DF, so
the type system prevents you from passing a Polars DataFrame to a Spark
destination.
Method sections¶
BaseEngine organises its API into sections that match the abstraction in
src/datacoolie/engines/base.py:
| Section | Methods |
|---|---|
| Construction | __init__, platform, set_platform |
| Read | read_parquet, read_delta, read_iceberg, read_csv, read_json, read_jsonl, read_avro, read_excel, read_path, read_database, read_table, create_dataframe, execute_sql |
| Write | write_to_path, write_to_table |
| Merge | merge_to_path, merge_overwrite_to_path, scd2_to_path, merge_to_table, merge_overwrite_to_table, scd2_to_table |
| Transform | add_column, drop_columns, select_columns, rename_column, filter_rows, apply_watermark_filter, deduplicate, deduplicate_by_rank, cast_column |
| System columns | add_system_columns, add_file_info_columns, remove_system_columns, convert_timestamp_ntz_to_timestamp |
| Metrics | count_rows, is_empty, get_columns, get_schema, get_max_values, get_count_and_max_values |
| Maintenance | table_exists_by_path, table_exists_by_name, get_history_*, compact_*, cleanup_* |
| Navigation (concrete dispatch) | read, write, merge, merge_overwrite, scd2, exists, get_history, compact, cleanup |
The navigation group (read, write, ...) is concrete on BaseEngine — it
dispatches on connection type and format to the right abstract method. You
rarely override it.
Engine implementations accept an optional execution ID when adding system columns:
When supplied, it must be added as the string column __dataflow_run_id.
The fmt contract¶
Format-aware methods take a fmt string ("delta", "iceberg", "parquet",
"csv", ...). This single parameter unifies lakehouse formats across engines:
engine.read_table("`cat`.`db`.`sales`.`orders`", fmt="iceberg")
engine.merge_to_table(df, table, keys=["id"], fmt="delta", options={"overwriteSchema": "true"})
engine.table_exists_by_name(table, fmt="iceberg")
Rules:
fmtdefaults to"delta"where it would otherwise be required — legacy code that predates Iceberg keeps working.merge_to_table/read_table/table_exists_by_nameall acceptfmt.table_exists_by_nameuses keyword-onlyfmt(*, fmt="delta").- Engines raise
EngineErrorfor unsupportedfmtvalues rather than silently falling back.
See ADR-0001 for history.
Qualified SQL relations in Polars¶
PolarsEngine can discover Delta tables from a path or Iceberg tables from a
catalog namespace, then expose the same logical SQL naming behavior for both.
Install the SQL resolver separately when you need this feature:
Registration is lazy by default. register_delta_tables and
register_iceberg_tables enumerate and index table descriptors, but do not
create scans or bind frames to SQLContext. The first query that references a
table creates its LazyFrame and registers one private alias. Later queries on
the same engine reuse that registration; data is still read lazily when the
query result is collected.
engine.register_delta_tables(
"s3://lake/database_B",
logical_prefix=("catalog_A", "database_B"),
recursive=True,
include="database_B.**.d_*",
exclude=("**.tmp.**", "**.*_backup"),
)
result = engine.execute_sql("""
SELECT *
FROM database_B.sales.d_orders
""")
One canonical name contains at most four components. A query may omit only leading components, so all unique suffixes are valid:
| Indexed name | Valid references when unique |
|---|---|
catalog.database.schema.table |
4, 3, 2, or 1 trailing components |
database.schema.table |
3, 2, or 1 trailing components |
schema.table |
2 or 1 trailing components |
table |
1 component |
If a suffix matches multiple tables, execution raises an ambiguity error and lists the candidates. Qualify the reference further; the engine never chooses one table implicitly.
Delta and Iceberg roots¶
For Delta, base_path is the physical discovery root and each table's relative
folders are appended to logical_prefix. Recursive discovery stops at a
directory containing _delta_log.
For Iceberg catalog mode, namespace narrows catalog enumeration. By default,
the catalog name and root namespace form the logical prefix; supplying
logical_prefix replaces that root mapping. Use logical_prefix=() when only
the identifier below the selected namespace should appear in the canonical
name.
engine.register_iceberg_tables(
namespace=("database_B",),
logical_prefix=("catalog_A", "database_B"),
recursive=True,
)
Choose the narrowest physical root first for performance. Use patterns for logical selection:
| Intent | Pattern |
|---|---|
| Everything below catalog A / database B | catalog_A.database_B.** |
| Database B under any catalog | database_B.** |
d_ tables below database B, with or without schema levels |
database_B.**.d_* |
d_ tables anywhere |
d_* |
* stays within one name component; ** crosses zero or more components.
Exclude patterns win over include patterns.
Use preload=True only when callers must execute directly through
engine.sql_context. Set on_error="skip" for observable best-effort
discovery and inspect engine.last_registration_report; the default is
fail-fast. Use structured logical_prefix for root mapping; there is no flat
prefix or physical-separator configuration.
See ADR-0005 for the decision.
Driver connection keys¶
BaseEngine.DRIVER_CONNECTION_KEYS is a frozenset of JDBC-specific option keys
(encrypt, trustServerCertificate, hostNameInCertificate) that must not
leak into higher-level connection APIs (connectorx, SQLAlchemy). Spark folds
them into the JDBC URL; Polars strips them before handing options to
connectorx. Extend the set when adding new driver-specific keys.
Platform attachment¶
An engine is useless without a platform. There are three valid states:
- Construct engine with platform:
SparkEngine(spark_session=spark, platform=p). - Attach later:
engine.set_platform(p)before the driver runs. - Let the driver attach: pass
platform=toDataCoolieDriver(...).
The Spark constructor parameter is named spark_session; a positional call
also works.
The driver guards against mismatched platform types to prevent silent "works on my laptop, fails in Fabric" bugs.
Case-insensitive column resolution¶
BaseEngine._resolve_column_name and _resolve_column_names do a
case-insensitive lookup against an actual DataFrame schema. Most transformers
use these helpers so users can write amount in metadata even when Spark
inferred AMOUNT.