Skip to content

Commit 5ea161b

Browse files
committed
fix: keep offline modes working without a connection
`sqb test --inspect` only compiles and prints the test plan, but it resolved the connection eagerly and failed with D001 on projects without one; it now skips the connection requirement. Build validates target execution limits from the project adapter before resolving the connection, so limit errors no longer depend on a configured connection. `sqb freshness` resolves the connection only when sources are selected.
1 parent ad86917 commit 5ea161b

12 files changed

Lines changed: 212 additions & 56 deletions

File tree

‎src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/project-configuration.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,8 +150,9 @@ schema = "dev" # uses connections.local, the only named connection
150150

151151
A command that needs the warehouse, such as `build`, `plan`, `query`, `diff`, or `debug`, fails
152152
with `D001` before connecting when no connection is configured at all: no named connection, no
153-
legacy `[connection]` block, and no inline target connection. Commands that do not connect,
154-
such as `compile`, `format`, `lineage`, `scope`, `rename`, and `mv`, keep working without one.
153+
legacy `[connection]` block, and no inline target connection. Commands and modes that do not
154+
connect, such as `compile`, `format`, `lineage`, `scope`, `rename`, `mv`, `test --inspect`, and
155+
`scenario test --local`, keep working without one.
155156
SQLBuild never falls back to an implicit in-memory DuckDB database; to use one, set it
156157
explicitly:
157158

‎src/sqlbuild/cli/commands/_helpers/build_planning/invocation.py‎

Lines changed: 60 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,11 @@
66
from pathlib import Path
77
from typing import TextIO
88

9-
from sqlbuild.cli.commands._helpers.runtime.adapter_context import (
10-
resolve_adapter_connection_context,
11-
)
9+
from sqlbuild.adapter.contract.classes.base_adapter import BaseAdapter
10+
from sqlbuild.cli.commands._helpers.runtime.adapters import resolve_adapter
11+
from sqlbuild.cli.commands._helpers.runtime.connection import resolve_project_connection_config
1212
from sqlbuild.cli.commands.exceptions import CliUserError
1313
from sqlbuild.cli.commands.models import (
14-
AdapterConnectionContext,
1514
BuildCommandRequest,
1615
BuildInvocation,
1716
)
@@ -23,6 +22,9 @@
2322
from sqlbuild.compiler.discovery.main.discover import discover_project_inputs
2423
from sqlbuild.compiler.discovery.models import DiscoveredProjectInputs
2524
from sqlbuild.presentation.main.supports_color import supports_color
25+
from sqlbuild.spec.contracts.main.resolve_effective_adapter_name import (
26+
resolve_effective_adapter_name,
27+
)
2628
from sqlbuild.spec.contracts.main.resolve_target_config import resolve_target_config
2729
from sqlbuild.spec.contracts.main.resolve_target_name import resolve_target_name
2830
from sqlbuild.spec.contracts.models import ExecutionLimitsConfig, TargetConfig
@@ -44,17 +46,35 @@ def resolve_build_invocation(*, request: BuildCommandRequest) -> BuildInvocation
4446
)
4547
if request.defer_to is not None and effective_defer_clone_from is not None:
4648
raise CliUserError("--defer-clone-from cannot be used with --defer-to", code="C408")
47-
adapter_context: AdapterConnectionContext = resolve_adapter_connection_context(
49+
adapter_name: str = resolve_effective_adapter_name(
50+
project_config=discovered_inputs.project_config,
51+
local_config=discovered_inputs.local_config,
52+
)
53+
adapter: BaseAdapter = resolve_adapter(
54+
adapter_name=adapter_name, project_dir=effective_project_dir
55+
)
56+
effective_target_name: str | None = resolve_target_name(
57+
project_config=discovered_inputs.project_config,
58+
local_config=discovered_inputs.local_config,
59+
selected_target=request.selected_target,
60+
)
61+
execution_limits: ExecutionLimitsConfig = _resolve_execution_limits(
4862
discovered_inputs=discovered_inputs,
49-
effective_project_dir=effective_project_dir,
63+
target_name=effective_target_name,
64+
adapter_name=adapter_name,
65+
adapter=adapter,
66+
)
67+
connection_config: dict[str, object] = resolve_project_connection_config(
68+
discovered_inputs=discovered_inputs,
69+
project_dir=effective_project_dir,
5070
selected_target=request.selected_target,
5171
cli_vars=request.cli_vars,
5272
)
5373
machine_output: bool = request.json_output
5474
use_color: bool = not request.no_color and not machine_output and supports_color()
5575
progress_stream: TextIO = sys.stderr if request.debug or machine_output else sys.stdout
5676
reporters: CommandProgressReporters = build_command_progress_reporters(
57-
adapter_name=adapter_context.adapter_name,
77+
adapter_name=adapter_name,
5878
stream=progress_stream,
5979
use_color=use_color,
6080
)
@@ -63,59 +83,65 @@ def resolve_build_invocation(*, request: BuildCommandRequest) -> BuildInvocation
6383
if request.load_sources is not None
6484
else build_effective_settings_config(discovered_inputs=discovered_inputs).auto_load_sources
6585
)
66-
effective_target_name: str | None = resolve_target_name(
67-
project_config=discovered_inputs.project_config,
68-
local_config=discovered_inputs.local_config,
69-
selected_target=request.selected_target,
86+
return BuildInvocation(
87+
effective_project_dir=effective_project_dir,
88+
discovered_inputs=discovered_inputs,
89+
effective_defer_clone_from=effective_defer_clone_from,
90+
adapter_name=adapter_name,
91+
adapter=adapter,
92+
connection_config=connection_config,
93+
use_color=use_color,
94+
progress_stream=progress_stream,
95+
connection_progress=reporters.connection,
96+
planning_progress=reporters.planning,
97+
should_load_sources=should_load_sources,
98+
effective_target_name=effective_target_name,
99+
execution_limits=execution_limits,
70100
)
101+
102+
103+
def _resolve_execution_limits(
104+
*,
105+
discovered_inputs: DiscoveredProjectInputs,
106+
target_name: str | None,
107+
adapter_name: str,
108+
adapter: BaseAdapter,
109+
) -> ExecutionLimitsConfig:
110+
"""Resolve the target's execution limits and validate them against the project adapter."""
111+
71112
execution_limits: ExecutionLimitsConfig = (
72113
resolve_target_config(
73114
project_config=discovered_inputs.project_config,
74115
local_config=discovered_inputs.local_config,
75-
target_name=effective_target_name,
116+
target_name=target_name,
76117
).execution_limits
77-
if effective_target_name is not None
118+
if target_name is not None
78119
else ExecutionLimitsConfig()
79120
)
121+
target_label: str = target_name or "default"
80122
if (
81123
execution_limits.max_duration is not None
82-
and adapter_context.adapter.execution_duration_limit_seconds is None
124+
and adapter.execution_duration_limit_seconds is None
83125
):
84-
target_label: str = effective_target_name or "default"
85126
raise CliUserError(
86127
f"Target '{target_label}' configures max_duration, but adapter "
87-
f"'{adapter_context.adapter_name}' cannot cancel active statements safely",
128+
f"'{adapter_name}' cannot cancel active statements safely",
88129
code="C415",
89130
help=execution_limits.remediation,
90131
)
91-
maximum_duration_seconds: int | None = adapter_context.adapter.execution_duration_limit_seconds
132+
maximum_duration_seconds: int | None = adapter.execution_duration_limit_seconds
92133
if (
93134
execution_limits.max_duration_seconds is not None
94135
and maximum_duration_seconds is not None
95136
and execution_limits.max_duration_seconds > maximum_duration_seconds
96137
):
97-
target_label = effective_target_name or "default"
98138
raise CliUserError(
99139
f"Target '{target_label}' max_duration exceeds the largest duration supported by "
100-
f"adapter '{adapter_context.adapter_name}' ({maximum_duration_seconds} seconds)",
140+
f"adapter '{adapter_name}' ({maximum_duration_seconds} seconds)",
101141
code="C416",
102142
help=execution_limits.remediation,
103143
)
104-
return BuildInvocation(
105-
effective_project_dir=effective_project_dir,
106-
discovered_inputs=discovered_inputs,
107-
effective_defer_clone_from=effective_defer_clone_from,
108-
adapter_name=adapter_context.adapter_name,
109-
adapter=adapter_context.adapter,
110-
connection_config=adapter_context.connection_config,
111-
use_color=use_color,
112-
progress_stream=progress_stream,
113-
connection_progress=reporters.connection,
114-
planning_progress=reporters.planning,
115-
should_load_sources=should_load_sources,
116-
effective_target_name=effective_target_name,
117-
execution_limits=execution_limits,
118-
)
144+
return execution_limits
119145

120146

121147
def _resolve_defer_clone_from(

‎src/sqlbuild/cli/commands/_helpers/runtime/adapter_context.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ def resolve_adapter_connection_context(
2020
effective_project_dir: Path,
2121
selected_target: str | None,
2222
cli_vars: dict[str, object] | None,
23+
require_connection: bool = True,
2324
) -> AdapterConnectionContext:
2425
"""Resolve the effective adapter and project connection configuration."""
2526

@@ -36,6 +37,7 @@ def resolve_adapter_connection_context(
3637
project_dir=effective_project_dir,
3738
selected_target=selected_target,
3839
cli_vars=cli_vars,
40+
require_connection=require_connection,
3941
)
4042
return AdapterConnectionContext(
4143
adapter_name=adapter_name,

‎src/sqlbuild/cli/commands/_helpers/runtime/command_invocation.py‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,9 @@ def resolve_command_invocation[InvocationT](
5454
) -> InvocationT:
5555
"""Resolve discovery, adapter, connection, and output context for one command."""
5656

57-
context: _CommandInvocationContext = _resolve_command_invocation_context(request=request)
57+
context: _CommandInvocationContext = _resolve_command_invocation_context(
58+
request=request, require_connection=True
59+
)
5860
return invocation_type(
5961
effective_project_dir=context.effective_project_dir,
6062
discovered_inputs=context.discovered_inputs,
@@ -67,11 +69,16 @@ def resolve_command_invocation[InvocationT](
6769

6870

6971
def resolve_reported_command_invocation[InvocationT](
70-
*, request: _CommandInvocationRequest, invocation_type: Callable[..., InvocationT]
72+
*,
73+
request: _CommandInvocationRequest,
74+
invocation_type: Callable[..., InvocationT],
75+
require_connection: bool = True,
7176
) -> InvocationT:
7277
"""Resolve command context plus shared connection and planning progress reporters."""
7378

74-
context: _CommandInvocationContext = _resolve_command_invocation_context(request=request)
79+
context: _CommandInvocationContext = _resolve_command_invocation_context(
80+
request=request, require_connection=require_connection
81+
)
7582
reporters: CommandProgressReporters = build_command_progress_reporters(
7683
adapter_name=context.adapter_context.adapter_name,
7784
stream=context.progress_stream,
@@ -91,7 +98,7 @@ def resolve_reported_command_invocation[InvocationT](
9198

9299

93100
def _resolve_command_invocation_context(
94-
*, request: _CommandInvocationRequest
101+
*, request: _CommandInvocationRequest, require_connection: bool
95102
) -> _CommandInvocationContext:
96103
effective_project_dir: Path = (
97104
request.project_dir if request.project_dir is not None else Path.cwd()
@@ -104,6 +111,7 @@ def _resolve_command_invocation_context(
104111
effective_project_dir=effective_project_dir,
105112
selected_target=request.selected_target,
106113
cli_vars=request.cli_vars,
114+
require_connection=require_connection,
107115
)
108116
machine_output: bool = request.json_output
109117
use_color: bool = not request.no_color and not machine_output and supports_color()

‎src/sqlbuild/cli/commands/_helpers/runtime/connection.py‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -111,14 +111,16 @@ def resolve_project_connection_config(
111111
project_dir: Path,
112112
selected_target: str | None = None,
113113
cli_vars: dict[str, object] | None = None,
114+
require_connection: bool = True,
114115
) -> dict[str, object]:
115-
"""Resolve the effective project connection config for CLI command execution."""
116+
"""Resolve the project connection; offline modes pass ``require_connection=False``."""
116117

117-
_require_configured_connection(
118-
discovered_inputs=discovered_inputs,
119-
project_dir=project_dir,
120-
selected_target=selected_target,
121-
)
118+
if require_connection:
119+
_require_configured_connection(
120+
discovered_inputs=discovered_inputs,
121+
project_dir=project_dir,
122+
selected_target=selected_target,
123+
)
122124
return resolve_connection_config(
123125
raw_config=build_effective_connection_config(
124126
discovered_inputs=discovered_inputs,

‎src/sqlbuild/cli/commands/_helpers/test/invocation.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99

1010

1111
def resolve_test_invocation(*, request: TestCommandRequest) -> TestInvocation:
12-
"""Resolve discovery, adapter, connection, and reporters for test."""
12+
"""Resolve test context; ``--inspect`` never connects, so it needs no connection."""
1313

14-
return resolve_reported_command_invocation(request=request, invocation_type=TestInvocation)
14+
return resolve_reported_command_invocation(
15+
request=request,
16+
invocation_type=TestInvocation,
17+
require_connection=not request.inspect,
18+
)

‎src/sqlbuild/cli/commands/main/execution/_freshness.py‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,12 +67,6 @@ def run_freshness(request: FreshnessCommandRequest) -> int:
6767
adapter: BaseAdapter = resolve_adapter(
6868
adapter_name=adapter_name, project_dir=effective_project_dir
6969
)
70-
connection_config: dict[str, object] = resolve_project_connection_config(
71-
discovered_inputs=discovered_inputs,
72-
project_dir=effective_project_dir,
73-
selected_target=selected_target,
74-
cli_vars=cli_vars,
75-
)
7670
graph: ProjectGraph = build_project_graph(
7771
discovered_inputs=discovered_inputs,
7872
adapter=adapter,
@@ -91,6 +85,12 @@ def run_freshness(request: FreshnessCommandRequest) -> int:
9185
if not selected_source_names:
9286
result: FreshnessCommandResult = FreshnessCommandResult()
9387
else:
88+
connection_config: dict[str, object] = resolve_project_connection_config(
89+
discovered_inputs=discovered_inputs,
90+
project_dir=effective_project_dir,
91+
selected_target=selected_target,
92+
cli_vars=cli_vars,
93+
)
9494
connection: Any = adapter.connect(connection_config)
9595
try:
9696
previous_records_by_source_name: dict[str, SourceFreshnessRecord] | None = None

‎tests/integration/src/sqlbuild/cli/commands/main/helpers.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -791,3 +791,21 @@ def write_default_connection_project(*, project_dir: Path, project_toml: str) ->
791791
'MODEL (description "Orders.", materialized table);\n\nSELECT 1 AS order_id\n',
792792
encoding="utf-8",
793793
)
794+
795+
796+
def write_default_connection_sql_test(*, project_dir: Path) -> None:
797+
"""Add a downstream model and a SQL test that mocks its orders dependency."""
798+
799+
_ = (project_dir / "models" / "order_totals.sql").write_text(
800+
'MODEL (description "Order totals.", materialized table);\n\n'
801+
'SELECT order_id FROM __ref("orders")\n',
802+
encoding="utf-8",
803+
)
804+
(project_dir / "tests" / "unit").mkdir(parents=True)
805+
_ = (project_dir / "tests" / "unit" / "test_order_totals.sql").write_text(
806+
'TEST (name "order_totals_value");\n\n'
807+
"WITH __ref__orders AS (SELECT 1 AS order_id),\n"
808+
"__expected__order_totals AS (SELECT 1 AS order_id)\n"
809+
"SELECT 1\n",
810+
encoding="utf-8",
811+
)

‎tests/integration/src/sqlbuild/cli/commands/main/test_default_connection.py‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
execute_duckdb_sql,
1717
query_duckdb_rows,
1818
write_default_connection_project,
19+
write_default_connection_sql_test,
1920
)
2021

2122
_ONE_CONNECTION_PROJECT: str = (
@@ -123,6 +124,13 @@ def test_given_target_without_connection_when_building_then_only_named_connectio
123124
expected_exit_code=0,
124125
expected_output_fragments=("Project compiled",),
125126
),
127+
DefaultConnectionCliTestCase(
128+
description="freshness without selected sources works without any connection",
129+
project_toml=_NO_CONNECTION_PROJECT,
130+
argv=("freshness",),
131+
expected_exit_code=0,
132+
expected_output_fragments=("OBSERVED=0",),
133+
),
126134
DefaultConnectionCliTestCase(
127135
description="build uses an explicit in-memory connection",
128136
project_toml=_MEMORY_CONNECTION_PROJECT,
@@ -148,6 +156,46 @@ def test_given_target_without_connection_when_resolving_offline_then_connection_
148156
assert not (tmp_path / "shop.duckdb").exists()
149157

150158

159+
@pytest.mark.parametrize(
160+
"test_case",
161+
[
162+
DefaultConnectionCliTestCase(
163+
description="test inspection works without any connection",
164+
project_toml=_NO_CONNECTION_PROJECT,
165+
argv=("test", "--inspect"),
166+
expected_exit_code=0,
167+
expected_output_fragments=(
168+
"boundary: orders is replaced by __ref__orders",
169+
"Test plan inspection complete: 1 selected, 0 errors.",
170+
),
171+
),
172+
DefaultConnectionCliTestCase(
173+
description="test execution fails before connecting without any connection",
174+
project_toml=_NO_CONNECTION_PROJECT,
175+
argv=("test",),
176+
expected_exit_code=1,
177+
expected_output_fragments=("error[D001]", "target dev has no connection"),
178+
),
179+
],
180+
ids=lambda case: case.description,
181+
)
182+
def test_given_no_connection_when_running_sql_tests_then_only_inspection_succeeds(
183+
test_case: DefaultConnectionCliTestCase,
184+
tmp_path: Path,
185+
capsys: pytest.CaptureFixture[str],
186+
) -> None:
187+
write_default_connection_project(project_dir=tmp_path, project_toml=test_case.project_toml)
188+
write_default_connection_sql_test(project_dir=tmp_path)
189+
190+
exit_code: int = main(["--no-color", "--project-dir", str(tmp_path), *test_case.argv])
191+
192+
output: str = "".join(capsys.readouterr())
193+
assert exit_code == test_case.expected_exit_code, output
194+
assert all(fragment in output for fragment in test_case.expected_output_fragments), output
195+
assert "Connecting to" not in output
196+
assert not (tmp_path / "shop.duckdb").exists()
197+
198+
151199
@pytest.mark.parametrize(
152200
"test_case",
153201
[

‎tests/unit/src/sqlbuild/cli/commands/_helpers/runtime/_test_types.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,3 +118,11 @@ class ExplicitMemoryConnectionTestCase:
118118
project_config: ProjectConfig
119119
local_config: LocalConfig
120120
expected_connection: dict[str, object]
121+
122+
123+
@dataclass(frozen=True)
124+
class OfflineConnectionResolutionTestCase:
125+
description: str
126+
project_config: ProjectConfig
127+
local_config: LocalConfig
128+
expected_connection: dict[str, object]

0 commit comments

Comments
 (0)