diff --git a/docs/sql-transactions.md b/docs/sql-transactions.md index 3aa2a11..29accc2 100644 --- a/docs/sql-transactions.md +++ b/docs/sql-transactions.md @@ -36,8 +36,8 @@ User-defined unit-of-work wrappers can opt into the same classification through effect-contract schema v2. Unclassified schema-v1 begin contracts remain explicitly `none`; method spelling never supplies scope. -A second explicit option adds strictly lexical stage-to-boundary and -context-manager exit evidence: +A second explicit option adds strictly lexical stage-to-flush, stage-to-outcome, +and context-manager exit evidence: ```yaml analysis: @@ -47,8 +47,11 @@ analysis: sql_transaction_path_max_pairs: 1024 ``` -An ordered path requires exact audited stage and commit/rollback occurrences in -the same source file, direct function body, and lexical order. Both calls must +An ordered path requires exact audited stage and flush/commit/rollback +occurrences in the same source file, direct function body, and lexical order. +A stage-to-flush path establishes only that pending SQL may be issued before a +later transaction outcome; it never promotes the evidence to durable persistence. +Both calls must use the same finite `Name`/`Attribute` receiver expression, with no intervening assignment to that expression or one of its lexical ancestors. A nearest prior same-receiver `begin` may be attached together with its declared transaction or diff --git a/src/fastapi_endpoint_detector/analyzer/sql_transaction_paths.py b/src/fastapi_endpoint_detector/analyzer/sql_transaction_paths.py index d8faa2c..d4ce21c 100644 --- a/src/fastapi_endpoint_detector/analyzer/sql_transaction_paths.py +++ b/src/fastapi_endpoint_detector/analyzer/sql_transaction_paths.py @@ -33,7 +33,7 @@ ) _MAX_SOURCE_BYTES = 2 * 1024 * 1024 -_BOUNDARY = Literal["commit", "rollback"] +_BOUNDARY = Literal["flush", "commit", "rollback"] _REASON = Literal[ "different_source_scope", "source_call_unavailable", @@ -453,6 +453,7 @@ def build_sql_transaction_path_diagnostics( # noqa: PLR0912, PLR0915 len(item.stage_occurrence_ids) * ( len(item.begin_occurrence_ids) + + len(item.flush_occurrence_ids) + len(item.commit_occurrence_ids) + len(item.rollback_occurrence_ids) ) @@ -468,6 +469,7 @@ def build_sql_transaction_path_diagnostics( # noqa: PLR0912, PLR0915 for evidence in transaction_report.endpoint_evidence for occurrence_id in ( *evidence.stage_occurrence_ids, + *evidence.flush_occurrence_ids, *evidence.begin_occurrence_ids, *evidence.commit_occurrence_ids, *evidence.rollback_occurrence_ids, @@ -482,10 +484,9 @@ def build_sql_transaction_path_diagnostics( # noqa: PLR0912, PLR0915 paths: list[SQLTransactionOrderedPath] = [] context_paths: list[SQLTransactionContextPath] = [] diagnostics: list[SQLTransactionPathDiagnostic] = [] - limitations = ( + common_limitations = ( "Ordering proves only lexical source order in one direct function body; runtime " "execution, exceptions, aliases, and transaction identity are not established.", - "A reachable ordered commit is not proof of commit success or durable persistence.", "Receiver equality is a stable finite source expression, not runtime object identity.", ) for evidence in transaction_report.endpoint_evidence: @@ -499,9 +500,11 @@ def build_sql_transaction_path_diagnostics( # noqa: PLR0912, PLR0915 ) begins = tuple(occurrence_by_id[item] for item in evidence.begin_occurrence_ids) begin_scope_by_id = {item.occurrence_id: item.scope for item in evidence.begin_scopes} - boundaries: tuple[tuple[str, _BOUNDARY], ...] = tuple( - (item, "commit") for item in evidence.commit_occurrence_ids - ) + tuple((item, "rollback") for item in evidence.rollback_occurrence_ids) + boundaries: tuple[tuple[str, _BOUNDARY], ...] = ( + tuple((item, "flush") for item in evidence.flush_occurrence_ids) + + tuple((item, "commit") for item in evidence.commit_occurrence_ids) + + tuple((item, "rollback") for item in evidence.rollback_occurrence_ids) + ) for stage_id in evidence.stage_occurrence_ids: stage = contexts.get(stage_id) for boundary_id, boundary_kind in boundaries: @@ -621,7 +624,16 @@ def build_sql_transaction_path_diagnostics( # noqa: PLR0912, PLR0915 stage_occurrence_id=stage_id, boundary_occurrence_id=boundary_id, boundary=boundary_kind, - limitations=limitations, + limitations=( + *common_limitations, + ( + "A reachable ordered flush may issue pending SQL but is not proof " + "of transaction commit or durable persistence." + if boundary_kind == "flush" + else "A reachable ordered commit or rollback is not proof of " + "boundary success or durable persistence." + ), + ), ) ) unique_paths = {item.id: item for item in paths} diff --git a/src/fastapi_endpoint_detector/models/report.py b/src/fastapi_endpoint_detector/models/report.py index 9a43e9a..a7f75ac 100644 --- a/src/fastapi_endpoint_detector/models/report.py +++ b/src/fastapi_endpoint_detector/models/report.py @@ -511,6 +511,7 @@ def validate_sql_transaction_path_report(self) -> "AnalysisReport": # noqa: PLR for evidence in transaction_report.endpoint_evidence for stage_id in evidence.stage_occurrence_ids for boundary_id in ( + *evidence.flush_occurrence_ids, *evidence.commit_occurrence_ids, *evidence.rollback_occurrence_ids, ) @@ -522,7 +523,9 @@ def validate_sql_transaction_path_report(self) -> "AnalysisReport": # noqa: PLR raise ValueError("SQL ordered path references unknown endpoint evidence") begin_scope_by_id = {item.occurrence_id: item.scope for item in evidence.begin_scopes} expected_boundaries = ( - evidence.commit_occurrence_ids + evidence.flush_occurrence_ids + if path.boundary == "flush" + else evidence.commit_occurrence_ids if path.boundary == "commit" else evidence.rollback_occurrence_ids ) diff --git a/src/fastapi_endpoint_detector/models/sql_transaction.py b/src/fastapi_endpoint_detector/models/sql_transaction.py index 4389eca..4c5a55e 100644 --- a/src/fastapi_endpoint_detector/models/sql_transaction.py +++ b/src/fastapi_endpoint_detector/models/sql_transaction.py @@ -238,7 +238,7 @@ class SQLTransactionPathError(ValueError): class SQLTransactionOrderedPath(_StrictModel): """One same-scope, same-receiver straight-line stage-to-boundary relation.""" - schema_version: Literal[2] = 2 + schema_version: Literal[3] = 3 id: Digest endpoint_id: Digest file_path: str = Field(min_length=1) @@ -248,7 +248,7 @@ class SQLTransactionOrderedPath(_StrictModel): begin_scope: TransactionScope | None = None stage_occurrence_id: Digest boundary_occurrence_id: Digest - boundary: Literal["commit", "rollback"] + boundary: Literal["flush", "commit", "rollback"] ordering: Literal["same_scope_straight_line"] = "same_scope_straight_line" status: Literal["ordered_boundary_reachable"] = "ordered_boundary_reachable" persistence_status: Literal["not_established"] = "not_established" @@ -343,6 +343,7 @@ class SQLTransactionPathDiagnostic(_StrictModel): class SQLTransactionPathSummary(_StrictModel): ordered_paths: int = Field(ge=0) + ordered_flushes: int = Field(ge=0) ordered_commits: int = Field(ge=0) ordered_rollbacks: int = Field(ge=0) context_manager_paths: int = Field(ge=0) @@ -352,7 +353,10 @@ class SQLTransactionPathSummary(_StrictModel): @model_validator(mode="after") def validate_counts(self) -> SQLTransactionPathSummary: - if self.ordered_commits + self.ordered_rollbacks != self.ordered_paths: + if ( + self.ordered_flushes + self.ordered_commits + self.ordered_rollbacks + != self.ordered_paths + ): raise ValueError("ordered SQL path counts are inconsistent") if self.context_transactions + self.context_savepoints != self.context_manager_paths: raise ValueError("context-managed SQL path counts are inconsistent") @@ -362,7 +366,7 @@ def validate_counts(self) -> SQLTransactionPathSummary: class SQLTransactionPathReport(_StrictModel): """Content-addressed bounded straight-line and context-exit evidence.""" - schema_version: Literal[3] = 3 + schema_version: Literal[4] = 4 status: Literal["diagnostic_only"] = "diagnostic_only" effect_audit_hash: Digest transaction_report_hash: Digest @@ -400,6 +404,7 @@ def validate_report(self) -> SQLTransactionPathReport: raise ValueError("SQL path diagnostics must be sorted and unique") expected = SQLTransactionPathSummary( ordered_paths=len(self.ordered_paths), + ordered_flushes=sum(item.boundary == "flush" for item in self.ordered_paths), ordered_commits=sum(item.boundary == "commit" for item in self.ordered_paths), ordered_rollbacks=sum(item.boundary == "rollback" for item in self.ordered_paths), context_manager_paths=len(self.context_paths), @@ -431,7 +436,7 @@ def build_sql_transaction_ordered_path( receiver_hash: str, stage_occurrence_id: str, boundary_occurrence_id: str, - boundary: Literal["commit", "rollback"], + boundary: Literal["flush", "commit", "rollback"], begin_occurrence_id: str | None = None, begin_scope: TransactionScope | None = None, limitations: tuple[str, ...], @@ -535,6 +540,7 @@ def build_sql_transaction_path_report( ) summary = SQLTransactionPathSummary( ordered_paths=len(sorted_paths), + ordered_flushes=sum(item.boundary == "flush" for item in sorted_paths), ordered_commits=sum(item.boundary == "commit" for item in sorted_paths), ordered_rollbacks=sum(item.boundary == "rollback" for item in sorted_paths), context_manager_paths=len(sorted_context_paths), diff --git a/src/fastapi_endpoint_detector/output/html_output.py b/src/fastapi_endpoint_detector/output/html_output.py index 609f49c..bb06a1d 100644 --- a/src/fastapi_endpoint_detector/output/html_output.py +++ b/src/fastapi_endpoint_detector/output/html_output.py @@ -2617,7 +2617,8 @@ def format(self, report: AnalysisReport) -> str: content_lines.append( '
' "SQL Ordered Paths: " - f"{paths.summary.ordered_paths} explicit boundaries / " + f"{paths.summary.ordered_paths} explicit boundaries " + f"({paths.summary.ordered_flushes} flushes) / " f"{paths.summary.context_manager_paths} context exits / " f"{paths.summary.unresolved_pairs} unresolved pairs; " "lexical and conditional only, persistence not established
" diff --git a/src/fastapi_endpoint_detector/output/markdown_output.py b/src/fastapi_endpoint_detector/output/markdown_output.py index c201963..c222c95 100644 --- a/src/fastapi_endpoint_detector/output/markdown_output.py +++ b/src/fastapi_endpoint_detector/output/markdown_output.py @@ -89,7 +89,8 @@ def format(self, report: AnalysisReport) -> str: paths = report.sql_transaction_path_report lines.append( "- **SQL Ordered Paths:** " - f"{paths.summary.ordered_paths} explicit boundaries / " + f"{paths.summary.ordered_paths} explicit boundaries " + f"({paths.summary.ordered_flushes} flushes) / " f"{paths.summary.context_manager_paths} context exits / " f"{paths.summary.unresolved_pairs} unresolved pairs; " "lexical and conditional only, persistence not established" diff --git a/src/fastapi_endpoint_detector/output/text_output.py b/src/fastapi_endpoint_detector/output/text_output.py index 90c3efb..fd5ec0a 100644 --- a/src/fastapi_endpoint_detector/output/text_output.py +++ b/src/fastapi_endpoint_detector/output/text_output.py @@ -118,7 +118,8 @@ def format(self, report: AnalysisReport) -> str: paths = report.sql_transaction_path_report console.print( " SQL Ordered Paths: " - f"{paths.summary.ordered_paths} explicit boundaries / " + f"{paths.summary.ordered_paths} explicit boundaries " + f"({paths.summary.ordered_flushes} flushes) / " f"{paths.summary.context_manager_paths} context exits / " f"{paths.summary.unresolved_pairs} unresolved pairs " "(lexical and conditional only; persistence not established)" diff --git a/tests/integration/test_sql_transaction_semantics.py b/tests/integration/test_sql_transaction_semantics.py index 60cbca4..cc851ba 100644 --- a/tests/integration/test_sql_transaction_semantics.py +++ b/tests/integration/test_sql_transaction_semantics.py @@ -206,8 +206,11 @@ def _ordered_project(root: Path) -> tuple[Path, Path]: " def begin(self) -> None: pass\n" " def begin_nested(self) -> None: pass\n" " def add(self, value: str) -> None: pass\n" + " def flush(self) -> None: pass\n" " def commit(self) -> None: pass\n" " def rollback(self) -> None: pass\n\n" + "class Other:\n" + " def flush(self) -> None: pass\n\n" "class AsyncSession:\n" " def begin(self): return self\n" " async def __aenter__(self): return self\n" @@ -223,7 +226,14 @@ def _ordered_project(root: Path) -> tuple[Path, Path]: " session = Session()\n" " session.begin()\n" " session.add('ordered')\n" + " session.flush()\n" " session.commit()\n\n" + "@app.post('/generic-flush')\n" + "def generic_flush() -> None:\n" + " session = Session()\n" + " other = Other()\n" + " session.add('generic')\n" + " other.flush()\n\n" "@app.post('/nested')\n" "def nested() -> None:\n" " session = Session()\n" @@ -328,7 +338,14 @@ def _ordered_project(root: Path) -> tuple[Path, Path]: else {} ), } - for operation in ("add", "begin", "begin_nested", "commit", "rollback") + for operation in ( + "add", + "flush", + "begin", + "begin_nested", + "commit", + "rollback", + ) ] + [ { @@ -399,9 +416,10 @@ def test_ordered_paths_require_same_scope_receiver_and_straight_line(tmp_path: P assert configured.orphan_changes == baseline.orphan_changes paths = configured.sql_transaction_path_report assert paths is not None - assert paths.schema_version == 3 + assert paths.schema_version == 4 assert paths.summary.model_dump() == { - "ordered_paths": 3, + "ordered_paths": 4, + "ordered_flushes": 1, "ordered_commits": 3, "ordered_rollbacks": 0, "context_manager_paths": 3, @@ -415,6 +433,14 @@ def test_ordered_paths_require_same_scope_receiver_and_straight_line(tmp_path: P "nested", "ordered", } + ordered_flush = next( + item + for item in paths.ordered_paths + if item.function_name == "ordered" and item.boundary == "flush" + ) + assert ordered_flush.persistence_status == "not_established" + assert any("pending sql" in item.lower() for item in ordered_flush.limitations) + assert all(item.function_name != "generic_flush" for item in paths.ordered_paths) assert ordered.begin_occurrence_id is not None assert ordered.begin_scope is not None and ordered.begin_scope.value == "transaction" nested = next(item for item in paths.ordered_paths if item.function_name == "nested") @@ -460,6 +486,8 @@ def test_ordered_paths_require_same_scope_receiver_and_straight_line(tmp_path: P for output_format in ("json", "yaml", "text", "markdown", "html"): rendered = get_formatter(output_format).format(configured).lower() assert "sql_transaction_path_report" in rendered or "sql ordered paths" in rendered + for output_format in ("text", "markdown", "html"): + assert "flushes" in get_formatter(output_format).format(configured).lower() def test_ordered_paths_are_explicit_and_atomically_bounded(tmp_path: Path) -> None: diff --git a/tests/unit/test_sql_transaction.py b/tests/unit/test_sql_transaction.py index 798e94e..fb18a66 100644 --- a/tests/unit/test_sql_transaction.py +++ b/tests/unit/test_sql_transaction.py @@ -25,6 +25,7 @@ _HASH_B = f"sha256:{'b' * 64}" _HASH_C = f"sha256:{'c' * 64}" _HASH_D = f"sha256:{'d' * 64}" +_HASH_E = f"sha256:{'e' * 64}" def test_outcome_is_derived_from_reachable_boundaries() -> None: @@ -127,14 +128,29 @@ def test_ordered_paths_are_content_addressed_and_bounded() -> None: boundary="commit", limitations=("lexical ordering only",), ) + flush_path = build_sql_transaction_ordered_path( + endpoint_id=_HASH_A, + file_path="main.py", + function_name="handler", + receiver_hash=_HASH_B, + begin_occurrence_id=_HASH_C, + begin_scope=TransactionScope.TRANSACTION, + stage_occurrence_id=_HASH_B, + boundary_occurrence_id=_HASH_E, + boundary="flush", + limitations=("pending persistence only",), + ) report = build_sql_transaction_path_report( _HASH_A, _HASH_B, - (path,), + (path, flush_path), (), max_pairs=4, ) + assert report.schema_version == 4 + assert flush_path.schema_version == 3 + assert report.summary.ordered_flushes == 1 assert report.summary.ordered_commits == 1 assert report.max_pairs == 4 payload = report.model_dump(mode="json")