diff --git a/src/sqlbuild/cli/commands/_helpers/test/inspection.py b/src/sqlbuild/cli/commands/_helpers/test/inspection.py index 4ef4f5fb7f..e518269e4a 100644 --- a/src/sqlbuild/cli/commands/_helpers/test/inspection.py +++ b/src/sqlbuild/cli/commands/_helpers/test/inspection.py @@ -19,6 +19,11 @@ def write_test_plan_inspection(*, stream: TextIO, plan_output: PlanOutput) -> in _write_names(stream=stream, label="mocked refs", names=entry.mock_ref_names) _write_names(stream=stream, label="mocked seeds", names=entry.mock_seed_names) _write_names(stream=stream, label="mocked dbt refs", names=entry.mock_dbt_ref_names) + _write_names( + stream=stream, + label="mocked table functions", + names=entry.mock_table_function_names, + ) _write_names( stream=stream, label="real models", diff --git a/src/sqlbuild/compiler/compile/_helpers/assembly/project.py b/src/sqlbuild/compiler/compile/_helpers/assembly/project.py index 24296acedb..983a4efc19 100644 --- a/src/sqlbuild/compiler/compile/_helpers/assembly/project.py +++ b/src/sqlbuild/compiler/compile/_helpers/assembly/project.py @@ -1439,6 +1439,7 @@ def _assemble_compiled_sql_test( mock_source_names=model_payload.mock_source_names, mock_seed_names=model_payload.mock_seed_names, mock_dbt_ref_names=model_payload.mock_dbt_ref_names, + mock_table_function_names=model_payload.mock_table_function_names, expected_ctes=model_payload.expected_ctes, expected_model_names=model_payload.expected_model_names, assertion_ctes=model_payload.assertion_ctes, diff --git a/src/sqlbuild/compiler/compile/_helpers/attachment/sql_tests.py b/src/sqlbuild/compiler/compile/_helpers/attachment/sql_tests.py index 614e661467..8ad2f458a7 100644 --- a/src/sqlbuild/compiler/compile/_helpers/attachment/sql_tests.py +++ b/src/sqlbuild/compiler/compile/_helpers/attachment/sql_tests.py @@ -273,6 +273,7 @@ def build_test_inputs( known_model_names=known_model_names, known_seed_names=known_seed_names, known_source_names=known_source_names, + known_table_function_names=known_table_function_names, loaded_macros=loaded_macros, ) test_payload: ( @@ -387,6 +388,7 @@ def _build_test_input_payload( mock_source_names=model_payload.mock_source_names, mock_seed_names=model_payload.mock_seed_names, mock_dbt_ref_names=model_payload.mock_dbt_ref_names, + mock_table_function_names=model_payload.mock_table_function_names, expected_ctes=model_payload.expected_ctes, expected_model_names=model_payload.expected_model_names, assertion_ctes=model_payload.assertion_ctes, @@ -678,6 +680,7 @@ def validate_test_ctes( known_model_names: set[str], known_seed_names: set[str], known_source_names: set[str], + known_table_function_names: set[str], loaded_macros: dict[str, LoadedMacro], ) -> None: """Validate SQL-native test CTE targets against discovered inputs.""" @@ -705,6 +708,13 @@ def validate_test_ctes( raise CompileInputError( f"SQL test file {test_file.relative_path} mocks unknown seed '{mock_seed_name}'" ) + mock_table_function_name: str + for mock_table_function_name in model_payload.mock_table_function_names: + if mock_table_function_name not in known_table_function_names: + raise CompileInputError( + f"SQL test file {test_file.relative_path} mocks unknown table function " + f"'{mock_table_function_name}'" + ) macro_mock_name: str for macro_mock_name in model_payload.macro_mocks: if macro_mock_name not in loaded_macros: diff --git a/src/sqlbuild/compiler/compile/_helpers/sql_tests/core.py b/src/sqlbuild/compiler/compile/_helpers/sql_tests/core.py index 13aa3f5f48..838063da54 100644 --- a/src/sqlbuild/compiler/compile/_helpers/sql_tests/core.py +++ b/src/sqlbuild/compiler/compile/_helpers/sql_tests/core.py @@ -36,6 +36,7 @@ SQL_WITH_KEYWORD, TABLE_FN_ACTUAL_TEST_CTE_NAME, TABLE_FN_EXPECTED_TEST_CTE_NAME, + TABLE_FN_TEST_CTE_PREFIX, UDF_ACTUAL_TEST_CTE_NAME, UDF_EXPECTED_TEST_CTE_NAME, ) @@ -392,6 +393,7 @@ def _classify_model_sql_test_ctes( mock_source_names: list[str] = [] mock_seed_names: list[str] = [] mock_dbt_ref_names: list[str] = [] + mock_table_function_names: list[str] = [] expected_ctes: list[CompileSqlTestCte] = [] expected_model_names: list[str] = [] assertion_ctes: list[CompileSqlTestCte] = [] @@ -467,6 +469,17 @@ def _classify_model_sql_test_ctes( ) authored_ctes.append(cte) continue + if cte.name.startswith(TABLE_FN_TEST_CTE_PREFIX): + mock_table_function_names.append( + _require_prefixed_name( + cte_name=cte.name, + prefix=TABLE_FN_TEST_CTE_PREFIX, + label="__table_fn__", + file_label=file_label, + ) + ) + authored_ctes.append(cte) + continue if cte.name.startswith(EXPECTED_TEST_CTE_PREFIX): expected_model_names.append( _require_prefixed_name( @@ -501,11 +514,12 @@ def _classify_model_sql_test_ctes( *mock_source_names, *mock_seed_names, *mock_dbt_ref_names, + *mock_table_function_names, ) if not mock_target_names: raise CompileInputError( f"SQL test '{file_label}' must define at least one __ref__*, __source__*, " - "__seed__*, or __dbt_ref__* mock CTE" + "__seed__*, __dbt_ref__*, or __table_fn__* mock CTE" ) check_names: tuple[str, ...] = (*expected_model_names, *assertion_names) if not check_names: @@ -520,6 +534,7 @@ def _classify_model_sql_test_ctes( mock_source_names=tuple(mock_source_names), mock_seed_names=tuple(mock_seed_names), mock_dbt_ref_names=tuple(mock_dbt_ref_names), + mock_table_function_names=tuple(mock_table_function_names), expected_ctes=tuple(expected_ctes), expected_model_names=tuple(expected_model_names), assertion_ctes=tuple(assertion_ctes), @@ -536,6 +551,7 @@ def _is_model_mode_cte(cte_name: str) -> bool: SOURCE_TEST_CTE_PREFIX, SEED_TEST_CTE_PREFIX, DBT_REF_TEST_CTE_PREFIX, + TABLE_FN_TEST_CTE_PREFIX, EXPECTED_TEST_CTE_PREFIX, ASSERT_TEST_CTE_PREFIX, ) diff --git a/src/sqlbuild/compiler/compile/constants.py b/src/sqlbuild/compiler/compile/constants.py index 6e90fd0463..59ce976c07 100644 --- a/src/sqlbuild/compiler/compile/constants.py +++ b/src/sqlbuild/compiler/compile/constants.py @@ -111,6 +111,7 @@ SOURCE_TEST_CTE_PREFIX: str = SqlReferenceKind.SOURCE.fixture_cte_prefix SEED_TEST_CTE_PREFIX: str = SqlReferenceKind.SEED.fixture_cte_prefix DBT_REF_TEST_CTE_PREFIX: str = SqlReferenceKind.DBT_REF.fixture_cte_prefix +TABLE_FN_TEST_CTE_PREFIX: str = SqlReferenceKind.TABLE_FUNCTION.fixture_cte_prefix MACRO_TEST_CTE_PREFIX: str = "__macro__" MACRO_ACTUAL_TEST_CTE_NAME: str = "__macro_actual__" MACRO_EXPECTED_TEST_CTE_NAME: str = "__macro_expected__" diff --git a/src/sqlbuild/compiler/compile/models.py b/src/sqlbuild/compiler/compile/models.py index cdf94a22da..f6318ad87d 100644 --- a/src/sqlbuild/compiler/compile/models.py +++ b/src/sqlbuild/compiler/compile/models.py @@ -926,6 +926,7 @@ class CompileModelSqlTestCtes: mock_source_names: tuple[str, ...] = field(default_factory=tuple) mock_seed_names: tuple[str, ...] = field(default_factory=tuple) mock_dbt_ref_names: tuple[str, ...] = field(default_factory=tuple) + mock_table_function_names: tuple[str, ...] = field(default_factory=tuple) expected_ctes: tuple[CompileSqlTestCte, ...] = field(default_factory=tuple) expected_model_names: tuple[str, ...] = field(default_factory=tuple) assertion_ctes: tuple[CompileSqlTestCte, ...] = field(default_factory=tuple) @@ -960,6 +961,7 @@ class CompileModelSqlTestInputPayload: mock_source_names: tuple[str, ...] = field(default_factory=tuple) mock_seed_names: tuple[str, ...] = field(default_factory=tuple) mock_dbt_ref_names: tuple[str, ...] = field(default_factory=tuple) + mock_table_function_names: tuple[str, ...] = field(default_factory=tuple) expected_ctes: tuple[CompileSqlTestCte, ...] = field(default_factory=tuple) expected_model_names: tuple[str, ...] = field(default_factory=tuple) assertion_ctes: tuple[CompileSqlTestCte, ...] = field(default_factory=tuple) @@ -1007,6 +1009,7 @@ class CompiledModelSqlTestPayload: mock_source_names: tuple[str, ...] = field(default_factory=tuple) mock_seed_names: tuple[str, ...] = field(default_factory=tuple) mock_dbt_ref_names: tuple[str, ...] = field(default_factory=tuple) + mock_table_function_names: tuple[str, ...] = field(default_factory=tuple) expected_ctes: tuple[CompileSqlTestCte, ...] = field(default_factory=tuple) expected_model_names: tuple[str, ...] = field(default_factory=tuple) assertion_ctes: tuple[CompileSqlTestCte, ...] = field(default_factory=tuple) diff --git a/src/sqlbuild/compiler/planner/_helpers/resolve/refs.py b/src/sqlbuild/compiler/planner/_helpers/resolve/refs.py index d7bb714ed5..2357572f21 100644 --- a/src/sqlbuild/compiler/planner/_helpers/resolve/refs.py +++ b/src/sqlbuild/compiler/planner/_helpers/resolve/refs.py @@ -253,6 +253,42 @@ def resolve_table_function_references( return "".join(parts) +def resolve_table_function_fixture_references( + *, query_sql: str, fixtures: dict[str, str] +) -> tuple[str, frozenset[str]]: + """Replace complete table-function invocations with model-test fixture relations.""" + + parts: list[str] = [] + last_index: int = 0 + reached: set[str] = set() + match: re.Match[str] + for match in _iter_executable_matches(sql=query_sql, pattern=_TABLE_FUNCTION_PATTERN): + if match.start() < last_index: + continue + function_name: str = match.group(1) + fixture_sql: str | None = fixtures.get(function_name) + if fixture_sql is None: + continue + call_suffix_start: int = _skip_whitespace(sql=query_sql, start=match.end()) + if ( + call_suffix_start >= len(query_sql) + or query_sql[call_suffix_start] != SQL_FUNCTION_CALL_OPEN_PAREN + ): + continue + call_suffix_end: int = find_matching_paren( + sql=query_sql, + open_paren_index=call_suffix_start, + context="SQL table function fixture call", + ) + parts.append(query_sql[last_index : match.start()]) + parts.append(fixture_sql) + last_index = call_suffix_end + 1 + reached.add(function_name) + + parts.append(query_sql[last_index:]) + return "".join(parts), frozenset(reached) + + def _iter_executable_matches(*, sql: str, pattern: re.Pattern[str]) -> Iterator[re.Match[str]]: index: int = 0 while index < len(sql): diff --git a/src/sqlbuild/compiler/planner/_helpers/sql_tests/assembly.py b/src/sqlbuild/compiler/planner/_helpers/sql_tests/assembly.py index e01b8581d2..2c9b6ca62c 100644 --- a/src/sqlbuild/compiler/planner/_helpers/sql_tests/assembly.py +++ b/src/sqlbuild/compiler/planner/_helpers/sql_tests/assembly.py @@ -13,6 +13,7 @@ REF_TEST_CTE_PREFIX, SEED_TEST_CTE_PREFIX, SOURCE_TEST_CTE_PREFIX, + TABLE_FN_TEST_CTE_PREFIX, ) from sqlbuild.compiler.compile.models import ( CompiledDirectLogicSqlTestPayload, @@ -26,6 +27,7 @@ ) from sqlbuild.compiler.compile.types import CompiledResourceType, SqlTestMode from sqlbuild.compiler.planner._helpers.resolve.refs import ( + resolve_table_function_fixture_references, resolve_table_function_references, resolve_udf_references, ) @@ -86,6 +88,7 @@ class _TextualChainResolver: mock_sources: dict[str, str] mock_seeds: dict[str, str] mock_dbt_refs: dict[str, str] + mock_table_functions: dict[str, str] helper_ctes: tuple[CompileSqlTestCte, ...] function_locations: dict[str, CompiledRelationLocation] adapter: BaseAdapter @@ -108,6 +111,7 @@ def ensure_through(self, count: int) -> None: mock_sources=self.mock_sources, mock_seeds=self.mock_seeds, mock_dbt_refs=self.mock_dbt_refs, + mock_table_functions=self.mock_table_functions, helper_ctes=self.helper_ctes, resolved_chain=self.resolved, function_locations=self.function_locations, @@ -162,6 +166,7 @@ def plan_test( mock_sources: dict[str, str] = _extract_mock_sources(test) mock_seeds: dict[str, str] = _extract_mock_seeds(test) mock_dbt_refs: dict[str, str] = _extract_mock_dbt_refs(test) + mock_table_functions: dict[str, str] = _extract_mock_table_functions(test) helper_ctes: tuple[CompileSqlTestCte, ...] = _extract_helper_ctes(test) expected_map: dict[str, str] = _extract_expected_ctes(test) assertion_map: dict[str, str] = _extract_assertion_ctes(test) @@ -202,6 +207,7 @@ def plan_test( mock_sources=mock_sources, mock_seeds=mock_seeds, mock_dbt_refs=mock_dbt_refs, + mock_table_functions=mock_table_functions, helper_ctes=helper_ctes, function_locations=function_locations, adapter=adapter, @@ -227,14 +233,24 @@ def plan_test( dep for dep in model.deps if dep.resource_type in {CompiledResourceType.UDF, CompiledResourceType.TABLE_FN} + and not ( + dep.resource_type == CompiledResourceType.TABLE_FN + and dep.name in mock_table_functions + ) ) query_sql: str = _resolve_test_model_query_sql(model=model, test=test) step_sql: str | None = None if sql_analysis_enabled: + analysis_query_sql, reached_table_functions = _resolve_table_function_fixtures( + query_sql=query_sql, + mock_table_functions=mock_table_functions, + helper_ctes=helper_ctes, + ) + reachable_mocks.update(reached_table_functions) sql_analysis_sql: SqlAnalysisResolvedTestSql | None = ( try_resolve_test_model_sql_with_sql_analysis( - query_sql=query_sql, + query_sql=analysis_query_sql, mock_refs=mock_refs, mock_sources=mock_sources, mock_seeds=mock_seeds, @@ -270,64 +286,33 @@ def plan_test( ) ) - assertion_steps: tuple[SqlTestAssertionStep, ...] = _build_assertion_steps( + assertion_steps: tuple[SqlTestAssertionStep, ...] + assertion_reached_table_functions: frozenset[str] + assertion_steps, assertion_reached_table_functions = _build_assertion_steps( assertion_map=assertion_map, test=test, function_locations=function_locations, qualified_function_locations=qualified_function_locations, helper_ctes=helper_ctes, + mock_table_functions=mock_table_functions, textual_chain=textual_chain, sql_analysis_resolved=sql_analysis_resolved, adapter=adapter, sql_analysis_enabled=sql_analysis_enabled, ) reachable_mocks.update(textual_chain.reachable_mocks) - - unreachable_ref: str - for unreachable_ref in sorted(set(mock_refs) - reachable_mocks): - warnings.append( - PlanWarning( - model_name=None, - severity=WarningSeverity.WARNING, - message=( - f"test '{test.name}' mock __ref__{unreachable_ref}" - f" is unreachable because a downstream model is" - f" also in the expected chain" - ), - ) - ) - - unreachable_source: str - for unreachable_source in sorted(set(mock_sources) - reachable_mocks): - warnings.append( - PlanWarning( - model_name=None, - severity=WarningSeverity.WARNING, - message=(f"test '{test.name}' mock __source__{unreachable_source} is unreachable"), - ) - ) - - unreachable_seed: str - for unreachable_seed in sorted(set(mock_seeds) - reachable_mocks): - warnings.append( - PlanWarning( - model_name=None, - severity=WarningSeverity.WARNING, - message=(f"test '{test.name}' mock __seed__{unreachable_seed} is unreachable"), - ) - ) - - unreachable_dbt_ref: str - for unreachable_dbt_ref in sorted(set(mock_dbt_refs) - reachable_mocks): - warnings.append( - PlanWarning( - model_name=None, - severity=WarningSeverity.WARNING, - message=( - f"test '{test.name}' mock __dbt_ref__{unreachable_dbt_ref} is unreachable" - ), - ) + reachable_mocks.update(assertion_reached_table_functions) + warnings.extend( + _unreachable_mock_warnings( + test_name=test.name, + reachable_mocks=reachable_mocks, + mock_refs=mock_refs, + mock_sources=mock_sources, + mock_seeds=mock_seeds, + mock_dbt_refs=mock_dbt_refs, + mock_table_functions=mock_table_functions, ) + ) entry: SqlTestPlanEntry = SqlTestPlanEntry( key=test.key, @@ -344,6 +329,7 @@ def plan_test( mock_source_names=tuple(sorted(mock_sources)), mock_seed_names=tuple(sorted(mock_seeds)), mock_dbt_ref_names=tuple(sorted(mock_dbt_refs)), + mock_table_function_names=tuple(sorted(mock_table_functions)), chain=tuple(chain_steps), assertions=assertion_steps, scope_deps=test.scope_deps, @@ -353,6 +339,43 @@ def plan_test( return entry, tuple(warnings) +def _unreachable_mock_warnings( + *, + test_name: str, + reachable_mocks: set[str], + mock_refs: dict[str, str], + mock_sources: dict[str, str], + mock_seeds: dict[str, str], + mock_dbt_refs: dict[str, str], + mock_table_functions: dict[str, str], +) -> tuple[PlanWarning, ...]: + warnings: list[PlanWarning] = [] + messages_by_mock: tuple[tuple[set[str], str, str], ...] = ( + ( + set(mock_refs), + "__ref__", + " is unreachable because a downstream model is also in the expected chain", + ), + (set(mock_sources), "__source__", " is unreachable"), + (set(mock_seeds), "__seed__", " is unreachable"), + (set(mock_dbt_refs), "__dbt_ref__", " is unreachable"), + (set(mock_table_functions), "__table_fn__", " is unreachable"), + ) + names: set[str] + prefix: str + suffix: str + for names, prefix, suffix in messages_by_mock: + for name in sorted(names - reachable_mocks): + warnings.append( + PlanWarning( + model_name=None, + severity=WarningSeverity.WARNING, + message=f"test '{test_name}' mock {prefix}{name}{suffix}", + ) + ) + return tuple(warnings) + + def resolve_test_model_chain_names( *, test: CompiledSqlTest, @@ -387,24 +410,32 @@ def _build_assertion_steps( function_locations: dict[str, CompiledRelationLocation], qualified_function_locations: dict[str, str], helper_ctes: tuple[CompileSqlTestCte, ...], + mock_table_functions: dict[str, str], textual_chain: _TextualChainResolver, sql_analysis_resolved: dict[str, SqlAnalysisResolvedTestSql], adapter: BaseAdapter, sql_analysis_enabled: bool, -) -> tuple[SqlTestAssertionStep, ...]: +) -> tuple[tuple[SqlTestAssertionStep, ...], frozenset[str]]: mock_refs: dict[str, str] = _extract_mock_refs(test) mock_sources: dict[str, str] = _extract_mock_sources(test) mock_seeds: dict[str, str] = _extract_mock_seeds(test) mock_dbt_refs: dict[str, str] = _extract_mock_dbt_refs(test) assertion_steps: list[SqlTestAssertionStep] = [] + reached_table_functions: set[str] = set() assertion_name: str assertion_sql: str for assertion_name, assertion_sql in assertion_map.items(): + fixture_resolved_assertion_sql, reached = _resolve_table_function_fixtures( + query_sql=assertion_sql, + mock_table_functions=mock_table_functions, + helper_ctes=helper_ctes, + ) + reached_table_functions.update(reached) resolved_assertion_sql: str | None = None if sql_analysis_enabled: analyzed_assertion_sql: SqlAnalysisResolvedTestSql | None = ( try_resolve_test_model_sql_with_sql_analysis( - query_sql=assertion_sql, + query_sql=fixture_resolved_assertion_sql, mock_refs=mock_refs, mock_sources=mock_sources, mock_seeds=mock_seeds, @@ -422,7 +453,7 @@ def _build_assertion_steps( resolved_assertion_sql = analyzed_assertion_sql.resolved_sql if resolved_assertion_sql is None: resolved_assertion_sql = _resolve_assertion_sql( - sql=assertion_sql, + sql=fixture_resolved_assertion_sql, resolved_chain=textual_chain.resolve_all(), mock_refs=mock_refs, mock_sources=mock_sources, @@ -438,7 +469,7 @@ def _build_assertion_steps( resolved_sql=resolved_assertion_sql, ) ) - return tuple(assertion_steps) + return tuple(assertion_steps), frozenset(reached_table_functions) def _plan_direct_logic_test( @@ -561,6 +592,7 @@ def _resolve_assertion_sql( mock_sources=mock_sources, mock_seeds=mock_seeds, mock_dbt_refs=mock_dbt_refs, + mock_table_functions={}, helper_ctes=helper_ctes, resolved_chain=assertion_resolved_chain, function_locations=function_locations, @@ -635,6 +667,7 @@ def _resolve_test_model_sql( mock_sources: dict[str, str], mock_seeds: dict[str, str], mock_dbt_refs: dict[str, str], + mock_table_functions: dict[str, str], helper_ctes: tuple[CompileSqlTestCte, ...], resolved_chain: dict[str, str], function_locations: dict[str, CompiledRelationLocation], @@ -723,6 +756,12 @@ def _replace_dbt_ref(match: re.Match[str]) -> str: result = replace_uncommented_pattern( pattern=_DBT_REF_PATTERN, replacement=_replace_dbt_ref, sql=result ) + result, reached_table_functions = _resolve_table_function_fixtures( + query_sql=result, + mock_table_functions=mock_table_functions, + helper_ctes=helper_ctes, + ) + reachable_mocks.update(reached_table_functions) result = resolve_udf_references( query_sql=result, function_locations=function_locations, @@ -847,6 +886,20 @@ def _wrap_mock_with_helpers( return f"({mock_body})" +def _resolve_table_function_fixtures( + *, + query_sql: str, + mock_table_functions: dict[str, str], + helper_ctes: tuple[CompileSqlTestCte, ...], +) -> tuple[str, frozenset[str]]: + helper_with: str = _build_helper_with_clause(helper_ctes) + fixtures: dict[str, str] = { + name: _wrap_mock_with_helpers(mock_body=body, helper_with=helper_with) + for name, body in mock_table_functions.items() + } + return resolve_table_function_fixture_references(query_sql=query_sql, fixtures=fixtures) + + def _build_helper_with_clause( helper_ctes: tuple[CompileSqlTestCte, ...], ) -> str: @@ -973,6 +1026,20 @@ def _extract_mock_dbt_refs(test: CompiledSqlTest) -> dict[str, str]: return result +def _extract_mock_table_functions(test: CompiledSqlTest) -> dict[str, str]: + """Extract mock table-function CTE bodies keyed by function name.""" + + result: dict[str, str] = {} + if not isinstance(test.payload, CompiledModelSqlTestPayload): + return result + cte: CompileSqlTestCte + for cte in test.payload.authored_ctes: + if cte.name.startswith(TABLE_FN_TEST_CTE_PREFIX): + name: str = cte.name.removeprefix(TABLE_FN_TEST_CTE_PREFIX) + result[name] = cte.sql_body + return result + + def _dbt_ref_fixture_name(*, package_name: str, model_name: str | None) -> str: if model_name is None: return package_name @@ -997,6 +1064,8 @@ def _extract_helper_ctes( continue if cte.name.startswith(DBT_REF_TEST_CTE_PREFIX): continue + if cte.name.startswith(TABLE_FN_TEST_CTE_PREFIX): + continue if cte.name.startswith(ASSERT_TEST_CTE_PREFIX): continue helpers.append(cte) diff --git a/src/sqlbuild/compiler/planner/models.py b/src/sqlbuild/compiler/planner/models.py index f838b88f59..e333d83627 100644 --- a/src/sqlbuild/compiler/planner/models.py +++ b/src/sqlbuild/compiler/planner/models.py @@ -1167,6 +1167,7 @@ class SqlTestPlanEntry: mock_source_names: tuple[str, ...] = field(default_factory=tuple) mock_seed_names: tuple[str, ...] = field(default_factory=tuple) mock_dbt_ref_names: tuple[str, ...] = field(default_factory=tuple) + mock_table_function_names: tuple[str, ...] = field(default_factory=tuple) chain: tuple[ChainStep, ...] = field(default_factory=tuple) assertions: tuple[SqlTestAssertionStep, ...] = field(default_factory=tuple) scope_deps: tuple[CompiledObjectKey, ...] = field(default_factory=tuple) diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/test/_test_types.py b/tests/e2e/src/sqlbuild/cli/commands/main/test/_test_types.py index 2fa6da7eba..2530b99812 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/test/_test_types.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/test/_test_types.py @@ -94,3 +94,21 @@ class SqlTestInspectConflictE2ETestCase: description: str expected_exit_code: int expected_stderr_fragment: str + + +@dataclass(frozen=True) +class TableFunctionFixtureE2ETestCase: + """Test case for model-mode table-function fixture execution.""" + + description: str + expected_stdout_fragment: str + expected_artifact_fragments: tuple[str, ...] + unexpected_artifact_fragments: tuple[str, ...] + + +@dataclass(frozen=True) +class UnknownTableFunctionFixtureE2ETestCase: + """Test case for rejecting an unknown model-mode table-function fixture.""" + + description: str + expected_stderr_fragment: str diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/test/helpers.py b/tests/e2e/src/sqlbuild/cli/commands/main/test/helpers.py index 37b716b766..09b323204c 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/test/helpers.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/test/helpers.py @@ -101,6 +101,38 @@ def build_parameterized_test_project_files() -> dict[str, str]: } +def build_table_function_fixture_project_files() -> dict[str, str]: + """Build a model test whose table function is replaced by a relation fixture.""" + + return { + "sqlbuild_project.toml": ( + 'name = "table_function_fixture_demo"\n' + 'adapter = "duckdb"\n\n' + "[connection]\n" + 'database = "table_function_fixture_demo.duckdb"\n' + ), + "functions/sql/customer_orders.sql": ( + "FUNCTION (\n" + " arguments (customer_id INTEGER),\n" + " returns table (order_id INTEGER)\n" + ");\n\n" + "SELECT customer_id * 100 AS order_id\n" + ), + "models/orders.sql": ( + "MODEL (columns (order_id (type INTEGER)));\n\n" + 'SELECT order_id FROM __table_fn("customer_orders")(7)\n' + ), + "tests/unit/test_orders.sql": ( + "TEST();\n\n" + "WITH\n" + "fixture_rows AS (SELECT 7 AS order_id),\n" + "__table_fn__customer_orders AS (SELECT order_id FROM fixture_rows),\n" + "__expected__orders AS (SELECT 7 AS order_id)\n" + "SELECT 1\n" + ), + } + + def build_mock_boundary_test_project_files() -> dict[str, str]: """Build a test whose ref mock intentionally replaces one real model.""" diff --git a/tests/e2e/src/sqlbuild/cli/commands/main/test/test_test.py b/tests/e2e/src/sqlbuild/cli/commands/main/test/test_test.py index b055992f03..4e0760859d 100644 --- a/tests/e2e/src/sqlbuild/cli/commands/main/test/test_test.py +++ b/tests/e2e/src/sqlbuild/cli/commands/main/test/test_test.py @@ -17,6 +17,8 @@ SqlTestFixtureValidationE2ETestCase, SqlTestInspectConflictE2ETestCase, SqlTestPlanInspectionE2ETestCase, + TableFunctionFixtureE2ETestCase, + UnknownTableFunctionFixtureE2ETestCase, ) from tests.e2e.src.sqlbuild.cli.commands.main.test.helpers import ( build_assertion_test_project_files, @@ -44,6 +46,7 @@ build_recursive_ref_fixture_project_files, build_star_mock_fixture_project_files, build_star_partial_fixture_project_files, + build_table_function_fixture_project_files, build_transformed_collection_project_files, build_unsatisfied_leaf_test_project_files, build_unspecified_nullability_fixture_project_files, @@ -811,3 +814,81 @@ def test_given_assertion_only_sql_test_when_assertion_returns_rows_then_it_fails expected_fragment: str for expected_fragment in test_case.expected_stdout_fragments: assert expected_fragment in result.stdout + + +@pytest.mark.parametrize( + "test_case", + ( + TableFunctionFixtureE2ETestCase( + description="model test replaces complete table function invocation", + expected_stdout_fragment="PASS=1", + expected_artifact_fragments=( + "WITH fixture_rows AS", + "FROM fixture_rows", + "__actual__orders", + ), + unexpected_artifact_fragments=("customer_orders(7)", "customer_id * 100"), + ), + ), + ids=lambda case: case.description, +) +def test_given_table_function_fixture_when_testing_model_then_fixture_replaces_invocation( + test_case: TableFunctionFixtureE2ETestCase, + tmp_path: Path, +) -> None: + project_dir: Path = prepare_inline_project( + tmp_path=tmp_path, + project_name="table_function_fixture_project", + repo_files=build_table_function_fixture_project_files(), + ) + + result: subprocess.CompletedProcess[str] = run_sqb( + command=("--no-color", "test"), + project_dir=project_dir, + ) + + assert result.returncode == 0, result.stdout + result.stderr + assert test_case.expected_stdout_fragment in result.stdout + artifact_paths: tuple[Path, ...] = tuple( + (project_dir / "target" / "run" / "tests").rglob("*.sql") + ) + assert len(artifact_paths) == 1 + artifact_sql: str = artifact_paths[0].read_text(encoding="utf-8") + for expected_fragment in test_case.expected_artifact_fragments: + assert expected_fragment in artifact_sql + for unexpected_fragment in test_case.unexpected_artifact_fragments: + assert unexpected_fragment not in artifact_sql + + +@pytest.mark.parametrize( + "test_case", + ( + UnknownTableFunctionFixtureE2ETestCase( + description="unknown table function fixture is rejected", + expected_stderr_fragment="mocks unknown table function 'missing_orders'", + ), + ), + ids=lambda case: case.description, +) +def test_given_unknown_table_function_fixture_when_testing_model_then_compile_fails( + test_case: UnknownTableFunctionFixtureE2ETestCase, + tmp_path: Path, +) -> None: + repo_files: dict[str, str] = build_table_function_fixture_project_files() + repo_files["tests/unit/test_orders.sql"] = repo_files["tests/unit/test_orders.sql"].replace( + "__table_fn__customer_orders", "__table_fn__missing_orders" + ) + project_dir: Path = prepare_inline_project( + tmp_path=tmp_path, + project_name="unknown_table_function_fixture_project", + repo_files=repo_files, + ) + + result: subprocess.CompletedProcess[str] = run_sqb( + command=("--no-color", "test"), + project_dir=project_dir, + ) + + assert result.returncode == 1, result.stdout + result.stderr + assert test_case.expected_stderr_fragment in result.stderr + assert "Connecting to" not in result.stdout diff --git a/tests/unit/src/sqlbuild/compiler/compile/_helpers/_test_types.py b/tests/unit/src/sqlbuild/compiler/compile/_helpers/_test_types.py index 28a99f7382..40ff3f00dd 100644 --- a/tests/unit/src/sqlbuild/compiler/compile/_helpers/_test_types.py +++ b/tests/unit/src/sqlbuild/compiler/compile/_helpers/_test_types.py @@ -341,6 +341,7 @@ class ExtractSqlTestCtesTestCase: expected_expected_model_names: tuple[str, ...] expected_mock_seed_names: tuple[str, ...] = () expected_mock_dbt_ref_names: tuple[str, ...] = () + expected_mock_table_function_names: tuple[str, ...] = () expected_assertion_names: tuple[str, ...] = () expected_macro_mocks: dict[str, str] = field(default_factory=dict) mode: SqlTestMode = SqlTestMode.MODEL diff --git a/tests/unit/src/sqlbuild/compiler/compile/_helpers/test_tests.py b/tests/unit/src/sqlbuild/compiler/compile/_helpers/test_tests.py index 911e9ac565..339344ac15 100644 --- a/tests/unit/src/sqlbuild/compiler/compile/_helpers/test_tests.py +++ b/tests/unit/src/sqlbuild/compiler/compile/_helpers/test_tests.py @@ -35,6 +35,20 @@ expected_expected_model_names=("fact_orders",), expected_mock_dbt_ref_names=("orders", "stripe__payments"), ), + ExtractSqlTestCtesTestCase( + description="extracts table function fixtures from model tests", + sql=""" + WITH + __table_fn__customer_orders AS (SELECT 1 AS order_id), + __expected__orders AS (SELECT 1 AS order_id) + SELECT 1 + """.strip(), + expected_authored_cte_names=("__table_fn__customer_orders",), + expected_mock_model_names=(), + expected_mock_source_names=(), + expected_expected_model_names=("orders",), + expected_mock_table_function_names=("customer_orders",), + ), ExtractSqlTestCtesTestCase( description="extracts zero-row assertion ctes", sql=""" @@ -320,6 +334,10 @@ def test_given_model_sql_test_cte_variants_when_extracting_then_it_returns_expec assert extracted_ctes.payload.mock_source_names == test_case.expected_mock_source_names assert extracted_ctes.payload.mock_seed_names == test_case.expected_mock_seed_names assert extracted_ctes.payload.mock_dbt_ref_names == test_case.expected_mock_dbt_ref_names + assert ( + extracted_ctes.payload.mock_table_function_names + == test_case.expected_mock_table_function_names + ) assert extracted_ctes.payload.expected_model_names == test_case.expected_expected_model_names assert extracted_ctes.payload.assertion_names == test_case.expected_assertion_names assert extracted_ctes.payload.macro_mocks == test_case.expected_macro_mocks diff --git a/tests/unit/src/sqlbuild/compiler/planner/_helpers/resolve/_test_types.py b/tests/unit/src/sqlbuild/compiler/planner/_helpers/resolve/_test_types.py index b670e4d119..e33269b97f 100644 --- a/tests/unit/src/sqlbuild/compiler/planner/_helpers/resolve/_test_types.py +++ b/tests/unit/src/sqlbuild/compiler/planner/_helpers/resolve/_test_types.py @@ -116,6 +116,15 @@ class RefResolutionTestCase: cursor_bounds: CursorBounds | None = None +@dataclass(frozen=True) +class TableFunctionFixtureResolutionTestCase: + description: str + query_sql: str + fixtures: dict[str, str] + expected_sql: str + expected_reached: frozenset[str] + + @dataclass(frozen=True) class ApplyDeferredTargetsTestCase: description: str diff --git a/tests/unit/src/sqlbuild/compiler/planner/_helpers/resolve/test_refs.py b/tests/unit/src/sqlbuild/compiler/planner/_helpers/resolve/test_refs.py index 76115fd470..f210414fb5 100644 --- a/tests/unit/src/sqlbuild/compiler/planner/_helpers/resolve/test_refs.py +++ b/tests/unit/src/sqlbuild/compiler/planner/_helpers/resolve/test_refs.py @@ -15,6 +15,7 @@ apply_deferred_locations, resolve_dbt_ref_references, resolve_ref_references, + resolve_table_function_fixture_references, resolve_table_function_references, resolve_udf_references, ) @@ -27,6 +28,7 @@ from tests.unit.src.sqlbuild.compiler.planner._helpers.resolve._test_types import ( ApplyDeferredTargetsTestCase, RefResolutionTestCase, + TableFunctionFixtureResolutionTestCase, ) from tests.unit.src.sqlbuild.compiler.planner._helpers.resolve.helpers import ( BracketTableFunctionCallAdapter, @@ -336,6 +338,64 @@ def test_given_table_function_marker_when_resolving_then_returns_expected_sql( assert result == test_case.expected_sql +@pytest.mark.parametrize( + "test_case", + ( + TableFunctionFixtureResolutionTestCase( + description="replaces the complete nested argument invocation", + query_sql=( + 'SELECT * FROM __table_fn("customer_orders")(' + "COALESCE((SELECT MAX(customer_id) FROM customers), 42)) AS orders" + ), + fixtures={"customer_orders": "(SELECT 7 AS order_id)"}, + expected_sql="SELECT * FROM (SELECT 7 AS order_id) AS orders", + expected_reached=frozenset({"customer_orders"}), + ), + TableFunctionFixtureResolutionTestCase( + description="outer fixture consumes nested table function arguments", + query_sql=( + 'SELECT * FROM __table_fn("customer_orders")(' + '(SELECT MAX(id) FROM __table_fn("customer_profiles")(1))) AS orders' + ), + fixtures={ + "customer_orders": "(SELECT 7 AS order_id)", + "customer_profiles": "(SELECT 1 AS id)", + }, + expected_sql="SELECT * FROM (SELECT 7 AS order_id) AS orders", + expected_reached=frozenset({"customer_orders"}), + ), + TableFunctionFixtureResolutionTestCase( + description="ignores comments literals and unmocked functions", + query_sql=( + "SELECT '__table_fn(\"customer_orders\")(42)' AS example " + 'FROM __table_fn("other_orders")(42) ' + '-- __table_fn("customer_orders")(42)' + ), + fixtures={"customer_orders": "(SELECT 7 AS order_id)"}, + expected_sql=( + "SELECT '__table_fn(\"customer_orders\")(42)' AS example " + 'FROM __table_fn("other_orders")(42) ' + '-- __table_fn("customer_orders")(42)' + ), + expected_reached=frozenset(), + ), + ), + ids=lambda case: case.description, +) +def test_given_table_function_fixture_when_resolving_then_replaces_only_executable_call( + test_case: TableFunctionFixtureResolutionTestCase, +) -> None: + result: str + reached: frozenset[str] + result, reached = resolve_table_function_fixture_references( + query_sql=test_case.query_sql, + fixtures=test_case.fixtures, + ) + + assert result == test_case.expected_sql + assert reached == test_case.expected_reached + + @pytest.mark.parametrize( "test_case", [ diff --git a/tests/unit/src/sqlbuild/compiler/planner/_helpers/sql_test_assembly/_test_types.py b/tests/unit/src/sqlbuild/compiler/planner/_helpers/sql_test_assembly/_test_types.py index 0cff2fbbc0..bf49f19ee5 100644 --- a/tests/unit/src/sqlbuild/compiler/planner/_helpers/sql_test_assembly/_test_types.py +++ b/tests/unit/src/sqlbuild/compiler/planner/_helpers/sql_test_assembly/_test_types.py @@ -14,18 +14,24 @@ class PlanTestChainTestCase: expected_chain_length: int mock_seed_ctes: dict[str, str] = field(default_factory=dict) mock_dbt_ref_ctes: dict[str, str] = field(default_factory=dict) + mock_table_function_ctes: dict[str, str] = field(default_factory=dict) macro_mocks: dict[str, str] = field(default_factory=dict) loaded_macro_outputs: dict[str, str] = field(default_factory=dict) function_locations: dict[str, str] = field(default_factory=dict) + table_function_locations: dict[str, str] = field(default_factory=dict) model_macro_source_queries: dict[str, str] = field(default_factory=dict) model_query_overrides: dict[str, str] = field(default_factory=dict) expected_sql_fragments: dict[str, str] = field(default_factory=dict) + unexpected_sql_fragments: dict[str, tuple[str, ...]] = field(default_factory=dict) expected_warning_count: int = 0 expected_warning_severity: WarningSeverity | None = None expected_error_fragments: tuple[str, ...] = field(default_factory=tuple) sql_body: str = "" expected_cte_bodies: dict[str, str] = field(default_factory=dict) assertion_ctes: dict[str, str] = field(default_factory=dict) + expected_assertion_fragments: dict[str, str] = field(default_factory=dict) + expected_function_deps: tuple[str, ...] = () + expected_mock_table_function_names: tuple[str, ...] = () @dataclass(frozen=True) diff --git a/tests/unit/src/sqlbuild/compiler/planner/_helpers/sql_test_assembly/helpers.py b/tests/unit/src/sqlbuild/compiler/planner/_helpers/sql_test_assembly/helpers.py index 0097a80b8c..57a8b62f4c 100644 --- a/tests/unit/src/sqlbuild/compiler/planner/_helpers/sql_test_assembly/helpers.py +++ b/tests/unit/src/sqlbuild/compiler/planner/_helpers/sql_test_assembly/helpers.py @@ -15,6 +15,7 @@ REF_TEST_CTE_PREFIX, SEED_TEST_CTE_PREFIX, SOURCE_TEST_CTE_PREFIX, + TABLE_FN_TEST_CTE_PREFIX, ) from sqlbuild.compiler.compile.models import ( CompiledFunction, @@ -96,6 +97,13 @@ def build_test_and_project( sql_body=body, ) ) + for name, body in test_case.mock_table_function_ctes.items(): + authored_ctes.append( + CompileSqlTestCte( + name=f"{TABLE_FN_TEST_CTE_PREFIX}{name}", + sql_body=body, + ) + ) for name, body in test_case.helper_ctes.items(): authored_ctes.append(CompileSqlTestCte(name=name, sql_body=body)) @@ -104,6 +112,7 @@ def build_test_and_project( test_case.mock_source_ctes, test_case.mock_seed_ctes, test_case.mock_dbt_ref_ctes, + test_case.mock_table_function_ctes, test_case.helper_ctes, test_case.expected_cte_bodies, ) @@ -134,6 +143,7 @@ def build_test_and_project( mock_source_names=tuple(test_case.mock_source_ctes.keys()), mock_seed_names=tuple(test_case.mock_seed_ctes.keys()), mock_dbt_ref_names=tuple(test_case.mock_dbt_ref_ctes.keys()), + mock_table_function_names=tuple(test_case.mock_table_function_ctes.keys()), expected_model_names=test_case.expected_model_names, assertion_ctes=tuple( CompileSqlTestCte( @@ -157,12 +167,21 @@ def build_test_and_project( name=model_name, ), deps=tuple( - CompiledObjectKey( - resource_type=CompiledResourceType.MODEL, - name=dependency_name, - ) - for dependency_name in _model_dependencies( - query_sql=query_sql, + ( + *( + CompiledObjectKey( + resource_type=CompiledResourceType.MODEL, + name=dependency_name, + ) + for dependency_name in _model_dependencies(query_sql=query_sql) + ), + *( + CompiledObjectKey( + resource_type=CompiledResourceType.TABLE_FN, + name=dependency_name, + ) + for dependency_name in _table_function_dependencies(query_sql=query_sql) + ), ) ), name=model_name, @@ -208,6 +227,33 @@ def build_test_and_project( ), ) ) + for function_name, qualified_name in test_case.table_function_locations.items(): + functions.append( + CompiledFunction( + key=CompiledObjectKey( + resource_type=CompiledResourceType.TABLE_FN, + name=function_name, + ), + deps=(), + name=function_name, + relative_path=Path(f"functions/sql/{function_name}.sql"), + arguments=(), + returns="TABLE", + body_sql="SELECT 1 AS order_id", + destination=CompiledRelationLocation( + database=None, + schema="main", + name=function_name, + qualified_name=qualified_name, + ), + fingerprint_destination=CompiledRelationLocation( + database=None, + schema="main", + name=function_name, + qualified_name=qualified_name, + ), + ) + ) project: CompiledProject = CompiledProject( run_id="test_run", @@ -232,6 +278,17 @@ def _model_dependencies(*, query_sql: str) -> tuple[str, ...]: ) +def _table_function_dependencies(*, query_sql: str) -> tuple[str, ...]: + return tuple( + dict.fromkeys( + match.group(1) + for match in uncommented_pattern_matches( + pattern=re.compile(r'__table_fn\("([^"]+)"\)'), sql=query_sql + ) + ) + ) + + def _build_model_query_overrides( *, test_case: PlanTestChainTestCase, @@ -306,6 +363,7 @@ def _build_test_sql_body( mock_sources: dict[str, str], mock_seeds: dict[str, str], mock_dbt_refs: dict[str, str], + mock_table_functions: dict[str, str], helpers: dict[str, str], expected_bodies: dict[str, str], ) -> str: @@ -322,6 +380,8 @@ def _build_test_sql_body( parts.append(f"{SEED_TEST_CTE_PREFIX}{name} AS ({body})") for name, body in mock_dbt_refs.items(): parts.append(f"{DBT_REF_TEST_CTE_PREFIX}{name} AS ({body})") + for name, body in mock_table_functions.items(): + parts.append(f"{TABLE_FN_TEST_CTE_PREFIX}{name} AS ({body})") for name, body in helpers.items(): parts.append(f"{name} AS ({body})") for name, body in expected_bodies.items(): diff --git a/tests/unit/src/sqlbuild/compiler/planner/_helpers/sql_test_assembly/test_plan_test.py b/tests/unit/src/sqlbuild/compiler/planner/_helpers/sql_test_assembly/test_plan_test.py index 6cb0ba6a3e..f45b2658fe 100644 --- a/tests/unit/src/sqlbuild/compiler/planner/_helpers/sql_test_assembly/test_plan_test.py +++ b/tests/unit/src/sqlbuild/compiler/planner/_helpers/sql_test_assembly/test_plan_test.py @@ -246,10 +246,57 @@ helper_ctes={}, expected_model_names=("orders",), expected_chain_length=1, - function_locations={"customer_orders": "main.customer_orders"}, + table_function_locations={"customer_orders": "main.customer_orders"}, expected_sql_fragments={ "orders": "FROM main.customer_orders(42)", }, + expected_function_deps=("customer_orders",), + ), + PlanTestChainTestCase( + description="model table function fixture replaces the complete invocation", + model_queries={ + "orders": ( + 'SELECT order_id FROM __table_fn("customer_orders")(' + "COALESCE((SELECT MAX(customer_id) FROM customers), 42))" + ), + }, + mock_ref_ctes={}, + mock_source_ctes={}, + mock_table_function_ctes={ + "customer_orders": "SELECT 7 AS order_id", + }, + helper_ctes={}, + expected_model_names=("orders",), + expected_chain_length=1, + table_function_locations={"customer_orders": "main.customer_orders"}, + expected_sql_fragments={"orders": "SELECT 7 AS order_id"}, + unexpected_sql_fragments={ + "orders": ("main.customer_orders", "COALESCE", "customer_id"), + }, + expected_mock_table_function_names=("customer_orders",), + expected_function_deps=(), + ), + PlanTestChainTestCase( + description="assertion-only table function fixture is reachable", + model_queries={}, + mock_ref_ctes={}, + mock_source_ctes={}, + mock_table_function_ctes={ + "customer_orders": "SELECT 7 AS order_id", + }, + helper_ctes={}, + expected_model_names=(), + expected_chain_length=0, + table_function_locations={"customer_orders": "main.customer_orders"}, + assertion_ctes={ + "positive_orders": ( + 'SELECT * FROM __table_fn("customer_orders")(7) WHERE order_id < 0' + ) + }, + expected_assertion_fragments={ + "positive_orders": "SELECT 7 AS order_id", + }, + expected_mock_table_function_names=("customer_orders",), ), PlanTestChainTestCase( description="two model chain resolves in dependency order", @@ -533,6 +580,19 @@ def test_given_test_and_project_when_planning_then_produces_expected_chain( assert len(chain_by_model_name) == len(entry.chain) for model_name, expected_fragment in test_case.expected_sql_fragments.items(): assert expected_fragment in chain_by_model_name[model_name].resolved_sql + for model_name, unexpected_fragments in test_case.unexpected_sql_fragments.items(): + for unexpected_fragment in unexpected_fragments: + assert unexpected_fragment not in chain_by_model_name[model_name].resolved_sql + assertions_by_name: dict[str, str] = { + assertion.name: assertion.resolved_sql for assertion in entry.assertions + } + for assertion_name, expected_fragment in test_case.expected_assertion_fragments.items(): + assert expected_fragment in assertions_by_name[assertion_name] + + assert entry.mock_table_function_names == test_case.expected_mock_table_function_names + assert tuple(dependency.name for dependency in entry.function_deps) == ( + test_case.expected_function_deps + ) assert len(warnings) == test_case.expected_warning_count expected_sev: WarningSeverity | None = test_case.expected_warning_severity