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()
ENABLE_TABLE_FEATURE = 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()
SET_COLUMN_TAG = auto()
UNSET_COLUMN_TAG = auto()
DROP_COLUMN = 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; all four current unresolvable
differences are rejected by the default policy. Successful plan_changes
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 _compile_update_comment(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 a relation-correct
target.alter_clause without exposing relation kind. The latter is
pre-rendered with ALTER TABLE ... or ALTER STREAMING TABLE ... as
appropriate. Planning has already accepted the action before compilation.
Actions carry only the inputs their operation needs: for example, adding a
foreign key carries its complete declaration, while dropping one carries its
concrete catalog name. The compiler owns the Databricks syntax for those
inputs; it does not recover state or make planning decisions.
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 difference_entries so the action shows up in reports. Return one or more
DiffEntry values — each tags the line with a DiffCategory (columns, keys,
clustering, partitioning, features, properties, tags, comments) and a
DiffOperation (rendered +/-/~), names the target in subject, and
carries each descriptive phrase as a separate detail element so the text
renderer can align them into columns:
@difference_entries.register
def _(action: UpdateComment) -> tuple[DiffEntry, ...]:
return (
DiffEntry(
DiffCategory.COMMENTS,
DiffOperation.CHANGE,
subject=f"column {action.column_name}",
detail=(f"'{action.desired_comment}'",),
),
)
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 eligibility checks run before
any safety rule and short-circuit the safety stage on failure, 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 import TableDrift
from delta_engine.domain.plan.actions import UpdateComment
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_SAFETY_RULES in the same file. plan_changes 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.