How to add a new action type¶
This guide walks through the steps to add a new Action subtype to the engine — for example, UpdateComment to alter a table or column comment.
1. Define the action in the domain¶
Add a frozen dataclass to src/delta_engine/domain/plan/actions.py:
@dataclass(frozen=True, slots=True)
class UpdateComment(Action):
"""Change the comment on a column or table."""
column_name: str
desired_comment: str
observed_comment: str
aspect: ClassVar[TableAspect] = TableAspect.COLUMN_COMMENTS
phase: ClassVar[ActionPhase] = ActionPhase.SET_COLUMN_COMMENT
def __post_init__(self) -> None:
if self.desired_comment == self.observed_comment:
raise ValueError("UpdateComment carries no difference")
@property
def subject(self) -> str:
return self.column_name
Note:
SetColumnCommentis already implemented — this is a hypothetical example showing the pattern.
Every action declares its TableAspect, carries enough desired and observed
state for validation and reporting, and rejects a no-op payload when that is
representable. Name each field once, semantically (desired_* / observed_*
for transition state); compilers and renderers read those names directly.
Action.subject determines alphabetical sort order within a phase;
ActionPhase is an IntEnum — lower values run first.
2. Add a phase constant if needed¶
If the action belongs to a new execution phase, add it to the ActionPhase enum in the same file:
class ActionPhase(IntEnum):
CREATE_TABLE = auto()
SET_PROPERTY = auto()
UNSET_PROPERTY = auto()
SET_TABLE_TAG = auto()
UNSET_TABLE_TAG = auto()
DROP_FOREIGN_KEY = auto()
DROP_PRIMARY_KEY = auto()
RENAME_COLUMN = auto()
ADD_COLUMN = auto()
ALTER_COLUMN_TYPE = auto()
SET_CLUSTERING = auto()
DROP_COLUMN = auto()
SET_COLUMN_TAG = auto()
UNSET_COLUMN_TAG = auto()
SET_COLUMN_COMMENT = auto()
SET_TABLE_COMMENT = auto()
SET_COLUMN_NULLABILITY = auto()
SET_PRIMARY_KEY = auto()
SET_FOREIGN_KEY = auto()
# ADD_YOUR_NEW_PHASE = auto()
ActionPlan sorts by phase then subject automatically — no changes needed there.
When choosing a phase, mind the rename boundary: a plan may rename columns,
and RENAME_COLUMN is the point where the table’s column names switch from
observed to desired. An action phased before it may reference only observed
column names (or constraint names, which the rename does not touch); an
action phased after it must use desired names.
3. Emit the action directly from the differ¶
Update the relevant helper in src/delta_engine/domain/plan/diff.py. For
example, a matched column comment pair emits the action itself:
if desired.comment != observed.comment:
actions.append(
UpdateComment(
column_name=desired.name,
desired_comment=desired.comment,
observed_comment=observed.comment,
)
)
Actions join the diff’s actions tuple, so no union edit is needed. If the
comparison cannot be represented as an action, add a frozen difference type in
unresolvable.py, name it in Unresolvable, and emit it into the diff’s
unresolvable tuple from diff.py. Decide whether it is accepted or
rejected in application validation; the current three unresolvable
differences are rejected by the default policy. Successful plan_diff
results must contain actions only.
4. Register a SQL compiler¶
In src/delta_engine/adapters/databricks/sql/compile.py, register a singledispatch handler:
@_compile_action.register
def _(action: UpdateComment, target: _Target) -> str:
col = backtick(action.column_name)
comment = quote_literal(action.desired_comment)
return f"{target.alter_clause} ALTER COLUMN {col} COMMENT {comment}"
Each handler receives a _Target — the table as statements address it. It renders the backticked table name (target.name) and the ALTER clause whose keyword follows the plan’s relation kind (target.alter_clause — ALTER TABLE ... or ALTER STREAMING TABLE ...); ALTER-family statements start from target.alter_clause so every action follows the observed relation kind. A constraint action carries its complete constraint (named when the DesiredTable was built, or read from the catalog for an observed one), so the handler renders action.constraint.constraint_name directly rather than computing it.
Use backtick for identifiers and quote_literal for string literals (both in delta_engine/adapters/databricks/sql/dialect.py).
5. Register a diff rendering arm¶
In src/delta_engine/application/diff_entries.py, register a singledispatch
arm on action_entries so the action shows up in reports. Return one or more
DiffEntry values — each tags the line with a DiffCategory (columns, keys,
properties, tags, comments), a +/-/~ symbol, and its aligned cells:
@action_entries.register
def _(action: UpdateComment) -> tuple[DiffEntry, ...]:
text = f"column {action.column_name}: '{action.desired_comment}'"
return (DiffEntry(DiffCategory.COMMENTS, "~", (text,)),)
An action may emit several entries across categories (CreateTable lists its columns and its primary key), and category grouping in the diff is display-only — it never changes execution order. test_every_action_type_has_registered_diff_entries fails if an action has no arm.
6. Add a validation rule if needed¶
If the new action can be unsafe, add a rule in
src/delta_engine/application/validation.py. Rules receive the self-contained
drift and match concrete types from drift.actions (or drift.unresolvable
when judging unresolvable differences). The scope gate runs before any rule
and short-circuits on out-of-scope drift, so a rule only ever sees
differences the declaration manages — declare the correct TableAspect on
the action (step 1) and no scope filtering is needed here:
from typing import ClassVar
from delta_engine.application.failures import ValidationFailure
from delta_engine.domain.plan.actions import UpdateComment
from delta_engine.domain.plan.diff import TableDrift
class NoUnsafeCommentChange:
name: ClassVar[str] = "NoUnsafeCommentChange"
def evaluate(self, drift: TableDrift) -> tuple[ValidationFailure, ...]:
return tuple(
ValidationFailure(
rule_name=self.name,
message=f"Operation not allowed: ...",
)
for change in drift.actions
if isinstance(change, UpdateComment) and <condition>
)
Add it to DEFAULT_RULES in the same file. plan_diff deliberately exposes
no rules parameter: accepted plans always use this default policy.
7. Write tests¶
Add tests in:
tests/domain/plan/test_diff.py— doesdiff_tableemitUpdateCommentdirectly with both states?tests/domain/plan/test_actions.py— does the action declare its aspect, invariants, phase, and compiler properties?tests/application/test_planning.py— is the action accepted or rejected at the total planning boundary?tests/adapters/databricks/sql/test_compile.py— does the compiler produce the correct SQL?tests/application/test_rendering.py— does the action render its expected diff entries?tests/application/test_validation.py— if you added a rule, does it fire correctly?
Run:
uv run pytest tests/ -v
Expected: all tests pass and coverage stays above the configured threshold.