Skip to content

Commit 24a4083

Browse files
authored
Merge branch 'main' into fix/snowflake-iceberg-column-comments
2 parents 3785d4c + 80731de commit 24a4083

15 files changed

Lines changed: 271 additions & 47 deletions

File tree

‎.devcontainer/devcontainer.json‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,7 @@
1111
"postCreateCommand": "bash .devcontainer/post-create-command.sh",
1212
"customizations": {
1313
"vscode": {
14-
"extensions": [
15-
"ms-python.python",
16-
"ms-python.vscode-pylance"
17-
]
14+
"extensions": ["ms-python.python", "ms-python.vscode-pylance"]
1815
}
1916
},
2017
"remoteUser": "vscode"

‎.github/workflows/pr.yaml‎

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ jobs:
1616
outputs:
1717
python: ${{ steps.filter.outputs.python }}
1818
client: ${{ steps.filter.outputs.client }}
19+
vscode: ${{ steps.filter.outputs.vscode }}
1920
ci: ${{ steps.filter.outputs.ci }}
2021
steps:
2122
- uses: actions/checkout@v7
@@ -34,6 +35,8 @@ jobs:
3435
- 'pyproject.toml'
3536
client:
3637
- 'web/client/**'
38+
vscode:
39+
- 'vscode/**'
3740
ci:
3841
- '.github/**'
3942
- 'Makefile'
@@ -188,9 +191,10 @@ jobs:
188191

189192
ui-style:
190193
needs: [changes]
191-
if: false
192-
# needs.changes.outputs.client == 'true' || needs.changes.outputs.ci ==
193-
# 'true' || github.ref == 'refs/heads/main'
194+
if:
195+
needs.changes.outputs.client == 'true' || needs.changes.outputs.vscode ==
196+
'true' || needs.changes.outputs.ci == 'true' || github.ref ==
197+
'refs/heads/main'
194198
runs-on: ubuntu-latest
195199
steps:
196200
- uses: actions/checkout@v7
@@ -252,7 +256,17 @@ jobs:
252256
fail-fast: false
253257
matrix:
254258
engine:
255-
[duckdb, postgres, mysql, mssql, trino, spark, clickhouse, risingwave, starrocks]
259+
[
260+
duckdb,
261+
postgres,
262+
mysql,
263+
mssql,
264+
trino,
265+
spark,
266+
clickhouse,
267+
risingwave,
268+
starrocks,
269+
]
256270
env:
257271
PYTEST_XDIST_AUTO_NUM_WORKERS: 2
258272
SQLMESH__DISABLE_ANONYMIZED_ANALYTICS: '1'
@@ -393,10 +407,13 @@ jobs:
393407
retention-days: 7
394408

395409
test-vscode:
410+
needs: changes
411+
if:
412+
needs.changes.outputs.vscode == 'true' || needs.changes.outputs.ci ==
413+
'true' || github.ref == 'refs/heads/main'
396414
env:
397415
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
398416
runs-on: ubuntu-latest
399-
if: false
400417
steps:
401418
- uses: actions/checkout@v7
402419
- uses: actions/setup-node@v7
@@ -457,7 +474,19 @@ jobs:
457474
strategy:
458475
fail-fast: false
459476
matrix:
460-
dbt-version: ['1.3', '1.4', '1.5', '1.6', '1.7', '1.8', '1.9', '1.10', '1.11', '1.12']
477+
dbt-version:
478+
[
479+
'1.3',
480+
'1.4',
481+
'1.5',
482+
'1.6',
483+
'1.7',
484+
'1.8',
485+
'1.9',
486+
'1.10',
487+
'1.11',
488+
'1.12',
489+
]
461490
steps:
462491
- uses: actions/checkout@v7
463492
- name: Set up Python

‎sqlmesh/core/node.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@
2828
class IntervalUnit(str, Enum):
2929
"""IntervalUnit is the inferred granularity of an incremental node.
3030
31-
IntervalUnit can be one of 5 types, YEAR, MONTH, DAY, HOUR, MINUTE. The unit is inferred
31+
IntervalUnit can be one of 7 types: YEAR, MONTH, DAY, HOUR, HALF_HOUR, QUARTER_HOUR,
32+
FIVE_MINUTE. The unit is inferred
3233
based on the cron schedule of a node. The minimum time delta between a sample set of dates
3334
is used to determine which unit a node's schedule is.
3435

‎sqlmesh/core/state_sync/db/interval.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,9 @@ def remove_intervals(
108108
for snapshot in all_snapshots
109109
]
110110

111+
if not intervals_to_remove:
112+
return
113+
111114
if logger.isEnabledFor(logging.INFO):
112115
snapshot_ids = ", ".join(str(s.snapshot_id) for s, _ in intervals_to_remove)
113116
logger.info("Removing interval for snapshots: %s", snapshot_ids)

‎sqlmesh/core/test/definition.py‎

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -263,12 +263,9 @@ def assert_equal(
263263
for col, value in object_sentinel_values.items():
264264
try:
265265
# can't use `isinstance()` here - https://stackoverflow.com/a/68743663/1707525
266-
if type(value) is datetime.date:
267-
expected[col] = pd.to_datetime(expected[col]).dt.date
268-
elif type(value) is datetime.time:
269-
expected[col] = pd.to_datetime(expected[col]).dt.time
270-
elif type(value) is datetime.datetime:
271-
expected[col] = pd.to_datetime(expected[col]).dt.to_pydatetime()
266+
value_type = type(value)
267+
if value_type in (datetime.date, datetime.time, datetime.datetime):
268+
expected[col] = _parse_expected_datetime_column(expected[col], value_type)
272269
except Exception as e:
273270
from sqlmesh.core.console import get_console
274271

@@ -1014,6 +1011,34 @@ def _raise_error(msg: str, path: Path | None = None) -> None:
10141011
raise TestError(f"Failed to run test:\n{msg}")
10151012

10161013

1014+
def _parse_expected_datetime_column(series: pd.Series, target_type: type) -> pd.Series:
1015+
"""Convert a series of expected values to python ``date``/``time``/``datetime``.
1016+
1017+
Falls back to microsecond resolution when pandas' default nanosecond
1018+
parsing overflows. SQL ``TIMESTAMP`` columns can carry values outside
1019+
pandas' default ``datetime64[ns]`` range (1677-09-21..2262-04-11), so
1020+
unit tests may compare against values like ``0001-01-01`` which are
1021+
valid in the database but overflow the default resolution.
1022+
"""
1023+
import pandas as pd
1024+
from pandas.errors import OutOfBoundsDatetime
1025+
1026+
try:
1027+
parsed = pd.to_datetime(series)
1028+
except OutOfBoundsDatetime:
1029+
parsed = series.astype("datetime64[us]")
1030+
1031+
if target_type is datetime.date:
1032+
return parsed.dt.date
1033+
if target_type is datetime.time:
1034+
return parsed.dt.time
1035+
# `Series.dt.to_pydatetime()` returns an `ndarray` in pandas 2.x. Wrap it in a
1036+
# Series with ``dtype=object`` so pandas does not coerce the values back to
1037+
# ``pd.Timestamp`` (which would reintroduce the nanosecond overflow this
1038+
# function exists to avoid).
1039+
return pd.Series(parsed.dt.to_pydatetime(), index=parsed.index, dtype="object")
1040+
1041+
10171042
def _normalize_df_value(value: t.Any) -> t.Any:
10181043
"""Normalize data in a pandas dataframe so ruamel and sqlglot can deal with it."""
10191044
import numpy as np

‎sqlmesh/core/test/runner.py‎

Lines changed: 24 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -125,25 +125,7 @@ def run_tests(
125125
# Ensure workers are not greater than the number of tests
126126
num_workers = min(len(model_test_metadata) or 1, default_test_connection.concurrent_tasks)
127127

128-
def _run_single_test(
129-
metadata: ModelTestMetadata, engine_adapter: EngineAdapter
130-
) -> t.Optional[ModelTextTestResult]:
131-
test = ModelTest.create_test(
132-
body=metadata.body,
133-
test_name=metadata.test_name,
134-
models=models,
135-
engine_adapter=engine_adapter,
136-
dialect=dialect,
137-
path=metadata.path,
138-
default_catalog=default_catalog,
139-
preserve_fixtures=preserve_fixtures,
140-
concurrency=num_workers > 1,
141-
verbosity=verbosity,
142-
)
143-
144-
if not test:
145-
return None
146-
128+
def _run_single_test(test: ModelTest) -> ModelTextTestResult:
147129
result = t.cast(
148130
ModelTextTestResult,
149131
ModelTextTestRunner().run(t.cast(unittest.TestCase, test)),
@@ -158,11 +140,30 @@ def _run_single_test(
158140

159141
start_time = time.perf_counter()
160142
try:
143+
# Build ModelTest instances on the calling thread before workers start. create_test()
144+
# can call to_datetime() / ttl_cache (time.time()), which races with another worker's
145+
# time_machine freeze when execution_time is set under concurrent_tasks > 1.
146+
# NOTE: We can run create_tests in a separate parallel stage for a future optimization.
147+
# We just can't overlap runs/creations.
148+
tests: list[ModelTest] = []
149+
for metadata, engine_adapter in metadata_to_adapter.items():
150+
test = ModelTest.create_test(
151+
body=metadata.body,
152+
test_name=metadata.test_name,
153+
models=models,
154+
engine_adapter=engine_adapter,
155+
dialect=dialect,
156+
path=metadata.path,
157+
default_catalog=default_catalog,
158+
preserve_fixtures=preserve_fixtures,
159+
concurrency=num_workers > 1,
160+
verbosity=verbosity,
161+
)
162+
if test:
163+
tests.append(test)
164+
161165
with ThreadPoolExecutor(max_workers=num_workers) as pool:
162-
futures = [
163-
pool.submit(_run_single_test, metadata=metadata, engine_adapter=engine_adapter)
164-
for metadata, engine_adapter in metadata_to_adapter.items()
165-
]
166+
futures = [pool.submit(_run_single_test, test) for test in tests]
166167

167168
for future in concurrent.futures.as_completed(futures):
168169
test_results.append(future.result())

‎tests/core/integration/test_restatement.py‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,44 @@ def test_restatement_plan_ignores_changes(init_and_plan_context: t.Callable):
8383
context.apply(plan)
8484

8585

86+
@time_machine.travel("2023-01-08 15:00:00 UTC")
87+
def test_prod_restatement_with_unbackfilled_dev_version(init_and_plan_context: t.Callable):
88+
"""
89+
Scenario:
90+
Prod is built. A breaking change is planned to `dev` with `--skip-backfill`,
91+
so `dev` holds a different snapshot version with no interval rows. Prod is
92+
then restated.
93+
Outcome:
94+
RestatementStage tries to clear `dev` intervals for that other version, finds
95+
none in `_intervals`, and no-ops instead of crashing on an empty insert. Prod
96+
restatement still applies; the un-backfilled `dev` snapshot still has no
97+
intervals.
98+
"""
99+
context, plan = init_and_plan_context("examples/sushi")
100+
context.apply(plan)
101+
102+
prod_snapshot_id = context.get_snapshot("sushi.waiter_revenue_by_day").snapshot_id
103+
104+
context.upsert_model(
105+
add_projection_to_model(t.cast(SqlModel, context.get_model("sushi.waiter_revenue_by_day")))
106+
)
107+
context.plan("dev", skip_backfill=True, auto_apply=True, no_prompts=True)
108+
109+
dev_snapshot_id = context.get_snapshot("sushi.waiter_revenue_by_day").snapshot_id
110+
assert dev_snapshot_id != prod_snapshot_id
111+
assert not context.state_sync.get_snapshots([dev_snapshot_id])[dev_snapshot_id].intervals
112+
113+
context.plan(
114+
restate_models=["sushi.waiter_revenue_by_day"],
115+
start="2023-01-07",
116+
end="2023-01-08",
117+
auto_apply=True,
118+
no_prompts=True,
119+
)
120+
121+
assert not context.state_sync.get_snapshots([dev_snapshot_id])[dev_snapshot_id].intervals
122+
123+
86124
@time_machine.travel("2023-01-08 15:00:00 UTC")
87125
def test_restatement_plan_across_environments_snapshot_with_shared_version(
88126
init_and_plan_context: t.Callable,

‎tests/core/state_sync/test_state_sync.py‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,40 @@ def test_remove_interval_missing_snapshot(
400400
]
401401

402402

403+
def test_remove_interval_no_matching_intervals(
404+
state_sync: EngineAdapterStateSync, make_snapshot: t.Callable
405+
) -> None:
406+
snapshot = make_snapshot(
407+
SqlModel(
408+
name="a",
409+
cron="@daily",
410+
query=parse_one("select 1, ds"),
411+
),
412+
version="a",
413+
)
414+
state_sync.push_snapshots([snapshot])
415+
416+
# The snapshot has never been backfilled, so there are no rows to expand the shared versions from
417+
state_sync.remove_intervals(
418+
[(snapshot, snapshot.inclusive_exclusive("2020-01-15", "2020-01-17"))],
419+
remove_shared_versions=True,
420+
)
421+
422+
remove_records_count = state_sync.engine_adapter.fetchone(
423+
"SELECT COUNT(*) FROM sqlmesh._intervals WHERE name = '\"a\"' AND version = 'a' AND is_removed"
424+
)[0] # type: ignore
425+
assert remove_records_count == 0
426+
427+
assert not state_sync.get_snapshots([snapshot])[snapshot.snapshot_id].intervals
428+
429+
430+
def test_remove_interval_empty_input(state_sync: EngineAdapterStateSync) -> None:
431+
state_sync.remove_intervals([])
432+
state_sync.remove_intervals([], remove_shared_versions=True)
433+
434+
assert state_sync.engine_adapter.fetchone("SELECT COUNT(*) FROM sqlmesh._intervals")[0] == 0 # type: ignore
435+
436+
403437
def test_refresh_snapshot_intervals(
404438
state_sync: EngineAdapterStateSync, make_snapshot: t.Callable
405439
) -> None:

‎tests/core/test_test.py‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2931,6 +2931,48 @@ def test_timestamp_normalization() -> None:
29312931
)
29322932

29332933

2934+
def test_out_of_bounds_nanosecond_timestamp_comparison(mocker: MockerFixture) -> None:
2935+
# https://github.com/TobikoData/sqlmesh/issues/5929
2936+
# Engines like Redshift may return a TIMESTAMP column as an object-dtype
2937+
# series of python `datetime.datetime` instances. Values outside pandas'
2938+
# default `datetime64[ns]` range (1677-09-21..2262-04-11) - which SQL
2939+
# `TIMESTAMP` fully supports - previously raised `OutOfBoundsDatetime`
2940+
# while parsing the expected values, producing a "Failed to convert
2941+
# expected value into `datetime`" warning and a false mismatch on values
2942+
# whose repr survives str-coercion (the values below happen to compare
2943+
# equal via `str()`, so the mismatch was silent).
2944+
test = _create_test(
2945+
body=load_yaml(
2946+
"""
2947+
test_foo:
2948+
model: sushi.foo
2949+
outputs:
2950+
query:
2951+
- ts_col: "0001-01-01 00:00:00"
2952+
- ts_col: "9999-12-31 23:59:59"
2953+
"""
2954+
),
2955+
test_name="test_foo",
2956+
model=_create_model("SELECT ts_col FROM raw"),
2957+
context=Context(config=Config(model_defaults=ModelDefaultsConfig(dialect="duckdb"))),
2958+
)
2959+
actual = pd.DataFrame(
2960+
{
2961+
"ts_col": pd.Series(
2962+
[datetime.datetime(1, 1, 1), datetime.datetime(9999, 12, 31, 23, 59, 59)],
2963+
dtype=object,
2964+
)
2965+
}
2966+
)
2967+
# Use T separator so a string-only comparison (broken path) would mismatch
2968+
# against str(datetime.datetime(1, 1, 1)) == "0001-01-01 00:00:00".
2969+
expected = pd.DataFrame({"ts_col": ["0001-01-01T00:00:00", "9999-12-31T23:59:59"]})
2970+
log_warning = mocker.spy(get_console(), "log_warning")
2971+
test.assert_equal(expected=expected, actual=actual, sort=False)
2972+
for call_args in log_warning.call_args_list:
2973+
assert "Failed to convert expected value" not in call_args.args[0]
2974+
2975+
29342976
@use_terminal_console
29352977
def test_disable_test_logging_if_no_tests_found(mocker: MockerFixture, tmp_path: Path) -> None:
29362978
init_example_project(tmp_path, engine_type="duckdb")

‎tests/web/test_models.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
7+
from sqlmesh.core.context import Context
8+
from web.server.api.endpoints.models import get_models
9+
10+
pytestmark = pytest.mark.web
11+
12+
13+
def test_get_models_multi_repo() -> None:
14+
"""Models of every project are serialized, not just those of the first one.
15+
16+
`context.path` is the first configured project, so it is not an ancestor of the models
17+
defined in any of the others.
18+
"""
19+
context = Context(paths=["examples/multi/repo_1", "examples/multi/repo_2"], gateway="memory")
20+
21+
paths_by_name = {model.name: model.path for model in get_models(context)}
22+
23+
# Each model is reported relative to the project that defines it.
24+
assert paths_by_name["bronze.a"] == "models/a.sql"
25+
assert paths_by_name["silver.c"] == "models/c.sql"

0 commit comments

Comments
 (0)