diff --git a/js/packages/ui/src/api/lineagecheck.ts b/js/packages/ui/src/api/lineagecheck.ts index b2cbd8e48..58fc44b75 100644 --- a/js/packages/ui/src/api/lineagecheck.ts +++ b/js/packages/ui/src/api/lineagecheck.ts @@ -13,7 +13,7 @@ import type { CllInput } from "./cll"; // ============================================================================ export interface LineageDiffViewOptions { - view_mode?: "changed_models" | "all"; + view_mode?: "changed_models" | "all" | "body_changes"; node_ids?: string[]; packages?: string[]; select?: string; diff --git a/js/packages/ui/src/api/schemacheck.ts b/js/packages/ui/src/api/schemacheck.ts index f3944ed63..d0a4947bd 100644 --- a/js/packages/ui/src/api/schemacheck.ts +++ b/js/packages/ui/src/api/schemacheck.ts @@ -15,7 +15,7 @@ export interface SchemaDiffViewParams { node_id?: string | string[]; select?: string; exclude?: string; - view_mode?: "all" | "changed_models"; + view_mode?: "all" | "changed_models" | "body_changes"; packages?: string[]; } diff --git a/js/packages/ui/src/api/select.ts b/js/packages/ui/src/api/select.ts index de5978bfd..f67d143d2 100644 --- a/js/packages/ui/src/api/select.ts +++ b/js/packages/ui/src/api/select.ts @@ -6,7 +6,7 @@ export interface SelectInput { select?: string; exclude?: string; packages?: string[]; - view_mode?: "all" | "changed_models"; + view_mode?: "all" | "changed_models" | "body_changes"; } export interface SelectOutput { diff --git a/js/packages/ui/src/api/types/run.ts b/js/packages/ui/src/api/types/run.ts index 9e363a46f..c46c8ee72 100644 --- a/js/packages/ui/src/api/types/run.ts +++ b/js/packages/ui/src/api/types/run.ts @@ -119,8 +119,8 @@ export interface SchemaDiffParams { exclude?: string; /** Package names to include */ packages?: string[]; - /** View mode - show all models or only changed ones */ - view_mode?: "all" | "changed_models"; + /** View mode - 'all', 'changed_models', or 'body_changes' (SQL body/macro/contract only) */ + view_mode?: "all" | "changed_models" | "body_changes"; } /** @@ -134,8 +134,8 @@ export interface LineageDiffParams { exclude?: string; /** Package names to include */ packages?: string[]; - /** View mode - show all models or only changed ones */ - view_mode?: "all" | "changed_models"; + /** View mode - 'all', 'changed_models', or 'body_changes' (SQL body/macro/contract only) */ + view_mode?: "all" | "changed_models" | "body_changes"; } // ============================================================================ diff --git a/js/packages/ui/src/components/check/LineageDiffView.tsx b/js/packages/ui/src/components/check/LineageDiffView.tsx index 3bf7d5ff2..b2b419c0a 100644 --- a/js/packages/ui/src/components/check/LineageDiffView.tsx +++ b/js/packages/ui/src/components/check/LineageDiffView.tsx @@ -10,7 +10,7 @@ import { LineageView, type LineageViewRef } from "../lineage/LineageView"; * View options for lineage diff checks */ export interface LineageDiffViewOptions { - view_mode?: "changed_models" | "all"; + view_mode?: "changed_models" | "all" | "body_changes"; node_ids?: string[]; select?: string; exclude?: string; diff --git a/js/packages/ui/src/components/check/SchemaDiffView.tsx b/js/packages/ui/src/components/check/SchemaDiffView.tsx index 275cf589e..c6e0210c4 100644 --- a/js/packages/ui/src/components/check/SchemaDiffView.tsx +++ b/js/packages/ui/src/components/check/SchemaDiffView.tsx @@ -28,7 +28,7 @@ export interface SchemaDiffParams { node_id?: string | string[]; select?: string; exclude?: string; - view_mode?: "all" | "changed_models"; + view_mode?: "all" | "changed_models" | "body_changes"; packages?: string[]; } diff --git a/js/packages/ui/src/components/lineage/LineageView.tsx b/js/packages/ui/src/components/lineage/LineageView.tsx index 21b3a4f9b..8e6043421 100644 --- a/js/packages/ui/src/components/lineage/LineageView.tsx +++ b/js/packages/ui/src/components/lineage/LineageView.tsx @@ -43,7 +43,7 @@ export interface LineageViewProps { * View options for lineage diff visualization */ viewOptions?: { - view_mode?: "changed_models" | "all"; + view_mode?: "changed_models" | "all" | "body_changes"; node_ids?: string[]; select?: string; exclude?: string; @@ -201,8 +201,11 @@ export const LineageView = forwardRef( if (viewOptions?.node_ids && viewOptions.node_ids.length > 0) { // Explicit node selection selectedNodeIds = viewOptions.node_ids; - } else if (viewOptions?.view_mode === "changed_models") { - // Only changed models + } else if ( + viewOptions?.view_mode === "changed_models" || + viewOptions?.view_mode === "body_changes" + ) { + // Changed models or body changes (filtering done server-side via selector) selectedNodeIds = lineageGraph.modifiedSet; } diff --git a/js/packages/ui/src/components/lineage/topbar/LineageViewTopBar.tsx b/js/packages/ui/src/components/lineage/topbar/LineageViewTopBar.tsx index 7b39a7ff1..4540512f9 100644 --- a/js/packages/ui/src/components/lineage/topbar/LineageViewTopBar.tsx +++ b/js/packages/ui/src/components/lineage/topbar/LineageViewTopBar.tsx @@ -209,7 +209,12 @@ const ViewModeSelectMenu = ({ const open = Boolean(anchorEl); const viewMode = viewOptions.view_mode ?? "changed_models"; - const label = viewMode === "changed_models" ? "Changed Models" : "All"; + const label = + viewMode === "body_changes" + ? "Body Changes" + : viewMode === "changed_models" + ? "Changed Models" + : "All"; const handleClick = (event: MouseEvent) => { setAnchorEl(event.currentTarget); @@ -256,6 +261,14 @@ const ViewModeSelectMenu = ({ sx={{ m: 0 }} /> + handleSelect("body_changes")}> + } + label="Body Changes" + sx={{ m: 0 }} + /> + handleSelect("all")}> { expect(modeButton).toBeDefined(); }); + it("shows Body Changes when view mode is body_changes", () => { + mockUseLineageViewContextSafe.mockReturnValue( + createMockLineageViewContext({ + viewOptions: { view_mode: "body_changes" }, + }), + ); + + render(); + + expect( + screen.getByRole("button", { name: /Body Changes/i }), + ).toBeInTheDocument(); + }); + it("opens mode menu when clicked", async () => { render(); @@ -395,12 +409,43 @@ describe("LineageViewTopBar", () => { expect( screen.getByRole("radio", { name: /Changed Models/i }), ).toBeInTheDocument(); + expect( + screen.getByRole("radio", { name: /Body Changes/i }), + ).toBeInTheDocument(); expect( screen.getByRole("radio", { name: /^All$/i }), ).toBeInTheDocument(); }); }); + it("calls onViewOptionsChanged when mode is changed to body_changes", async () => { + const mockOnViewOptionsChanged = vi.fn(); + mockUseLineageViewContextSafe.mockReturnValue( + createMockLineageViewContext({ + viewOptions: { view_mode: "changed_models" }, + onViewOptionsChanged: mockOnViewOptionsChanged, + }), + ); + + render(); + + const modeButton = screen.getByRole("button", { + name: /Changed Models/i, + }); + fireEvent.click(modeButton); + + await waitFor(() => { + const bodyChangesMenuItem = screen.getByRole("menuitem", { + name: /Body Changes/i, + }); + fireEvent.click(bodyChangesMenuItem); + }); + + expect(mockOnViewOptionsChanged).toHaveBeenCalledWith( + expect.objectContaining({ view_mode: "body_changes" }), + ); + }); + it("calls onViewOptionsChanged when mode is changed to all", async () => { const mockOnViewOptionsChanged = vi.fn(); mockUseLineageViewContextSafe.mockReturnValue( diff --git a/recce/adapter/base.py b/recce/adapter/base.py index 9cbd41d42..e17b70fa7 100644 --- a/recce/adapter/base.py +++ b/recce/adapter/base.py @@ -54,7 +54,7 @@ def select_nodes( select: Optional[str] = None, exclude: Optional[str] = None, packages: Optional[list[str]] = None, - view_mode: Optional[Literal["all", "changed_models"]] = None, + view_mode: Optional[Literal["all", "changed_models", "body_changes"]] = None, ) -> Set[str]: raise NotImplementedError() diff --git a/recce/adapter/dbt_adapter/__init__.py b/recce/adapter/dbt_adapter/__init__.py index e24ee14ec..a5efb3b9a 100644 --- a/recce/adapter/dbt_adapter/__init__.py +++ b/recce/adapter/dbt_adapter/__init__.py @@ -1857,7 +1857,7 @@ def select_nodes( select: Optional[str] = None, exclude: Optional[str] = None, packages: Optional[list[str]] = None, - view_mode: Optional[Literal["all", "changed_models"]] = None, + view_mode: Optional[Literal["all", "changed_models", "body_changes"]] = None, ) -> Set[str]: import dbt.compilation from dbt.compilation import Compiler @@ -1891,8 +1891,14 @@ def _parse_difference(include, exclude): if packages is not None: package_spec = SelectionUnion([_parse_difference([f"package:{p}"], None) for p in packages]) specs.append(package_spec) - if view_mode and view_mode == "changed_models": + if view_mode == "changed_models": specs.append(_parse_difference(["1+state:modified+"], None)) + elif view_mode == "body_changes": + specs.append( + _parse_difference( + ["1+state:modified.body+", "1+state:modified.macros+", "1+state:modified.contract+"], None + ) + ) spec = SelectionIntersection(specs) manifest = Manifest() diff --git a/recce/mcp_server.py b/recce/mcp_server.py index 39726b1b3..def772660 100644 --- a/recce/mcp_server.py +++ b/recce/mcp_server.py @@ -241,9 +241,9 @@ async def list_tools() -> List[Tool]: }, "view_mode": { "type": "string", - "enum": ["changed_models", "all"], + "enum": ["changed_models", "all", "body_changes"], "default": "changed_models", - "description": "View mode: 'changed_models' for only changed models (default), 'all' for all models", + "description": "View mode: 'changed_models' for only changed models (default), 'all' for all models, 'body_changes' for SQL body/macro/contract changes only", }, }, }, @@ -362,8 +362,8 @@ async def list_tools() -> List[Tool]: }, "view_mode": { "type": "string", - "enum": ["all", "changed_models"], - "description": "View mode: 'all' for all models, 'changed_models' for only changed models (optional)", + "enum": ["all", "changed_models", "body_changes"], + "description": "View mode: 'all' for all models, 'changed_models' for only changed models (optional), 'body_changes' for SQL body/macro/contract changes only", }, }, }, diff --git a/recce/server.py b/recce/server.py index 45ee6a416..e42a10479 100644 --- a/recce/server.py +++ b/recce/server.py @@ -663,7 +663,7 @@ class SelectNodesInput(BaseModel): select: Optional[str] = None exclude: Optional[str] = None packages: Optional[list[str]] = None - view_mode: Optional[Literal["all", "changed_models"]] = None + view_mode: Optional[Literal["all", "changed_models", "body_changes"]] = None class SelectNodesOutput(BaseModel): diff --git a/recce/tasks/core.py b/recce/tasks/core.py index 77f2c5af1..d472cbc11 100644 --- a/recce/tasks/core.py +++ b/recce/tasks/core.py @@ -77,7 +77,7 @@ def get_node_ids_by_selector( select: Optional[str] = None, exclude: Optional[str] = None, packages: Optional[list[str]] = None, - view_mode: Optional[Literal["all", "changed_models"]] = None, + view_mode: Optional[Literal["all", "changed_models", "body_changes"]] = None, ) -> List[str]: nodes = default_context().adapter.select_nodes( select=select, exclude=exclude, packages=packages, view_mode=view_mode diff --git a/recce/tasks/lineage.py b/recce/tasks/lineage.py index f27f177cc..8132a9f1c 100644 --- a/recce/tasks/lineage.py +++ b/recce/tasks/lineage.py @@ -10,7 +10,7 @@ class LineageDiffParams(BaseModel): select: Optional[str] = None exclude: Optional[str] = None packages: Optional[list[str]] = None - view_mode: Optional[Literal["all", "changed_models"]] = None + view_mode: Optional[Literal["all", "changed_models", "body_changes"]] = None class LineageDiffCheckValidator(CheckValidator): diff --git a/recce/tasks/rowcount.py b/recce/tasks/rowcount.py index 5d4b7b678..811acb7ef 100644 --- a/recce/tasks/rowcount.py +++ b/recce/tasks/rowcount.py @@ -253,7 +253,7 @@ class RowCountDiffParams(BaseModel): select: Optional[str] = None exclude: Optional[str] = None packages: Optional[list[str]] = None - view_mode: Optional[Literal["all", "changed_models"]] = None + view_mode: Optional[Literal["all", "changed_models", "body_changes"]] = None class RowCountDiffTask(Task, QueryMixin): diff --git a/recce/tasks/schema.py b/recce/tasks/schema.py index 74a419fd6..359068373 100644 --- a/recce/tasks/schema.py +++ b/recce/tasks/schema.py @@ -56,7 +56,7 @@ class SchemaDiffParams(BaseModel): select: Optional[str] = None exclude: Optional[str] = None packages: Optional[list[str]] = None - view_mode: Optional[Literal["all", "changed_models"]] = None + view_mode: Optional[Literal["all", "changed_models", "body_changes"]] = None class SchemaDiffCheckValidator(CheckValidator): diff --git a/tests/adapter/dbt_adapter/test_selector.py b/tests/adapter/dbt_adapter/test_selector.py index 2f4fd6402..0a5cf7a9a 100644 --- a/tests/adapter/dbt_adapter/test_selector.py +++ b/tests/adapter/dbt_adapter/test_selector.py @@ -190,6 +190,10 @@ def test_select_with_pacakage_mode_include_exclude(dbt_test_helper): node_ids = adapter.select_nodes(view_mode="changed_models") assert len(node_ids) == 3 + # body_changes: only body/macros/contract changes (same result here since the change is a body change) + node_ids = adapter.select_nodes(view_mode="body_changes") + assert len(node_ids) == 3 + node_ids = adapter.select_nodes(view_mode="changed_models", packages=["other_package"]) assert len(node_ids) == 1 diff --git a/tests/tasks/test_lineage.py b/tests/tasks/test_lineage.py index 3d5d305fb..167745b69 100644 --- a/tests/tasks/test_lineage.py +++ b/tests/tasks/test_lineage.py @@ -36,7 +36,7 @@ def validate(params: dict): } ) - # view_mode should be 'all' or 'changed_models' + # view_mode should be 'all', 'changed_models', or 'body_changes' validate( { "view_mode": None, diff --git a/tests/tasks/test_row_count.py b/tests/tasks/test_row_count.py index 2e59e7f4a..99becca3c 100644 --- a/tests/tasks/test_row_count.py +++ b/tests/tasks/test_row_count.py @@ -130,7 +130,7 @@ def validate(params: dict): } ) - # view_mode should be 'all' or 'changed_models' + # view_mode should be 'all', 'changed_models', or 'body_changes' validate( { "view_mode": None, diff --git a/tests/tasks/test_schema.py b/tests/tasks/test_schema.py index edf799e16..c4e04b667 100644 --- a/tests/tasks/test_schema.py +++ b/tests/tasks/test_schema.py @@ -56,7 +56,7 @@ def validate(params: dict): } ) - # view_mode should be 'all' or 'changed_models' + # view_mode should be 'all', 'changed_models', or 'body_changes' validate( { "view_mode": None,