Skip to content

Commit 904872a

Browse files
committed
add tests for transpile / local linting
1 parent c0adc1d commit 904872a

2 files changed

Lines changed: 213 additions & 0 deletions

File tree

tests/test_sqlfluff.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""SQLFluff linting of the mimic-iv/concepts folder."""
2+
import json
3+
import subprocess
4+
import sys
5+
from pathlib import Path
6+
7+
import pytest
8+
9+
REPO_ROOT = Path(__file__).resolve().parent.parent
10+
CONCEPTS_DIR = REPO_ROOT / "mimic-iv" / "concepts"
11+
CONCEPT_FILES = sorted(CONCEPTS_DIR.rglob("*.sql"))
12+
13+
sqlfluff = pytest.importorskip("sqlfluff")
14+
15+
16+
@pytest.fixture(scope="session")
17+
def lint_results():
18+
"""Run ``sqlfluff lint`` once over the whole concepts folder.
19+
20+
Returns a mapping of absolute file path -> list of violation dicts. Using
21+
a single invocation (across all cores) is far faster than spawning
22+
sqlfluff per file, while the per-file parametrization below still gives a
23+
granular failure for whichever file is at fault.
24+
"""
25+
proc = subprocess.run(
26+
[
27+
sys.executable, "-m", "sqlfluff", "lint",
28+
"--format", "json",
29+
"--processes", "0",
30+
str(CONCEPTS_DIR),
31+
],
32+
cwd=REPO_ROOT,
33+
capture_output=True,
34+
text=True,
35+
)
36+
# sqlfluff exits non-zero when violations are found; that is expected and
37+
# is asserted per file below. A missing/empty payload means it crashed.
38+
if not proc.stdout.strip():
39+
raise RuntimeError(
40+
f"sqlfluff produced no output (exit {proc.returncode}):\n{proc.stderr}"
41+
)
42+
results = json.loads(proc.stdout)
43+
return {
44+
str((REPO_ROOT / entry["filepath"]).resolve()): entry.get("violations", [])
45+
for entry in results
46+
}
47+
48+
49+
@pytest.mark.skipif(not CONCEPT_FILES, reason="concept SQL files not found")
50+
@pytest.mark.parametrize(
51+
"sql_file", CONCEPT_FILES, ids=lambda p: str(p.relative_to(CONCEPTS_DIR))
52+
)
53+
def test_concept_passes_sqlfluff(sql_file, lint_results):
54+
violations = lint_results.get(str(sql_file.resolve()), [])
55+
if violations:
56+
lines = "\n".join(
57+
f" L:{v['start_line_no']} P:{v['start_line_pos']} "
58+
f"{v['code']} {v['description']}"
59+
for v in violations
60+
)
61+
pytest.fail(
62+
f"{sql_file.relative_to(REPO_ROOT)} has SQLFluff violations:\n{lines}"
63+
)

tests/test_transpile.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
"""Unit tests for the BigQuery -> {PostgreSQL, DuckDB} transpiler.
2+
3+
Includes:
4+
5+
1. Unit tests assert the exact SQL produced for each transform.
6+
2. A validity sweep transpiles every real concept and parses the output back,
7+
catching anything that produces unparseable SQL.
8+
3. Custom functions implemented in the dialects.
9+
"""
10+
import re
11+
from pathlib import Path
12+
13+
import pytest
14+
15+
from mimic_utils.transpile import transpile_query
16+
17+
CONCEPTS_DIR = Path(__file__).resolve().parent.parent / "mimic-iv" / "concepts"
18+
19+
20+
def t(bq: str, dialect: str) -> str:
21+
"""Transpile and collapse whitespace, for stable comparison against goldens."""
22+
return re.sub(r"\s+", " ", transpile_query(bq, "bigquery", dialect)).strip()
23+
24+
25+
# (name, bigquery input, dialect, expected normalized output)
26+
TEST_CASES = [
27+
# catalog stripping (dialect-agnostic AST walk)
28+
("catalog_pg", "SELECT * FROM `physionet-data.mimiciv_derived.icustay_times` it",
29+
"postgres", "SELECT * FROM mimiciv_derived.icustay_times AS it"),
30+
("catalog_duckdb", "SELECT * FROM `physionet-data.mimiciv_icu.chartevents` ce",
31+
"duckdb", "SELECT * FROM mimiciv_icu.chartevents AS ce"),
32+
33+
# DATETIME_DIFF -> integer boundary count (postgres)
34+
("diff_hour_pg", "SELECT DATETIME_DIFF(a.outtime, a.intime, HOUR) FROM t a", "postgres",
35+
"SELECT CAST(EXTRACT(EPOCH FROM DATE_TRUNC('hour', a.outtime) "
36+
"- DATE_TRUNC('hour', a.intime)) / 3600 AS BIGINT) FROM t AS a"),
37+
("diff_minute_pg", "SELECT DATETIME_DIFF(a.b, a.c, MINUTE) FROM t a", "postgres",
38+
"SELECT CAST(EXTRACT(EPOCH FROM DATE_TRUNC('minute', a.b) "
39+
"- DATE_TRUNC('minute', a.c)) / 60 AS BIGINT) FROM t AS a"),
40+
("diff_second_pg", "SELECT DATETIME_DIFF(a.b, a.c, SECOND) FROM t a", "postgres",
41+
"SELECT CAST(EXTRACT(EPOCH FROM DATE_TRUNC('second', a.b) "
42+
"- DATE_TRUNC('second', a.c)) / 1 AS BIGINT) FROM t AS a"),
43+
("diff_day_pg", "SELECT DATETIME_DIFF(a.dischtime, a.admittime, DAY) FROM t a", "postgres",
44+
"SELECT (CAST(a.dischtime AS DATE) - CAST(a.admittime AS DATE)) FROM t AS a"),
45+
("diff_year_pg", "SELECT DATETIME_DIFF(a.b, a.c, YEAR) FROM t a", "postgres",
46+
"SELECT CAST(EXTRACT(YEAR FROM a.b) - EXTRACT(YEAR FROM a.c) AS BIGINT) FROM t AS a"),
47+
# DuckDB handles DATETIME_DIFF natively (boundary count), operands swapped
48+
("diff_hour_duckdb", "SELECT DATETIME_DIFF(a.outtime, a.intime, HOUR) FROM t a", "duckdb",
49+
"SELECT DATE_DIFF('HOUR', a.intime, a.outtime) FROM t AS a"),
50+
51+
# DATETIME_ADD / DATETIME_SUB -> interval arithmetic (postgres)
52+
("sub_literal_pg", "SELECT DATETIME_SUB(ie.intime, INTERVAL '6' HOUR) FROM t ie", "postgres",
53+
"SELECT ie.intime - INTERVAL '6' HOUR FROM t AS ie"),
54+
("add_literal_pg", "SELECT DATETIME_ADD(x, INTERVAL 1 HOUR) FROM t", "postgres",
55+
"SELECT x + INTERVAL '1' HOUR FROM t"),
56+
("add_expr_pg", "SELECT DATETIME_ADD(endtime, INTERVAL CAST(h AS INT64) HOUR) FROM t", "postgres",
57+
"SELECT endtime + CAST(h AS BIGINT) * INTERVAL '1' HOUR FROM t"),
58+
59+
# DATETIME_TRUNC -> DATE_TRUNC with quoted unit (postgres)
60+
("trunc_pg", "SELECT DATETIME_TRUNC(it.intime_hr, HOUR) FROM t it", "postgres",
61+
"SELECT DATE_TRUNC('hour', it.intime_hr) FROM t AS it"),
62+
63+
# GENERATE_ARRAY -> ARRAY(SELECT ... GENERATE_SERIES) (postgres)
64+
("generate_array_pg", "SELECT GENERATE_ARRAY(-24, 5) AS hrs FROM t", "postgres",
65+
"SELECT ARRAY(SELECT * FROM GENERATE_SERIES(-24, 5)) AS hrs FROM t"),
66+
67+
# handled natively by sqlglot 30.x (regression guards)
68+
("datetime_date_pg", "SELECT DATETIME(me.chartdate) FROM t me", "postgres",
69+
"SELECT CAST(me.chartdate AS TIMESTAMP) FROM t AS me"),
70+
("datetime_parts_pg", "SELECT DATETIME(pat.anchor_year, 1, 1, 0, 0, 0) FROM t pat", "postgres",
71+
"SELECT MAKE_TIMESTAMP(pat.anchor_year, 1, 1, 0, 0, 0) FROM t AS pat"),
72+
("int64_cast_pg", "SELECT CAST(hr AS INT64) AS hr FROM t", "postgres",
73+
"SELECT CAST(hr AS BIGINT) AS hr FROM t"),
74+
75+
# BigQuery NUMERIC == DECIMAL(38, 9); postgres NUMERIC is arbitrary precision
76+
# (keeps the value), but DuckDB's bare DECIMAL defaults to scale 3 and rounds,
77+
# so we must pin the precision there.
78+
# postgres DECIMAL == NUMERIC, both arbitrary precision, so the value is kept
79+
("numeric_cast_pg", "SELECT CAST(x AS NUMERIC) FROM t", "postgres",
80+
"SELECT CAST(x AS DECIMAL) FROM t"),
81+
("numeric_cast_duckdb", "SELECT CAST(x AS NUMERIC) FROM t", "duckdb",
82+
"SELECT CAST(x AS DECIMAL(38, 9)) FROM t"),
83+
]
84+
85+
86+
@pytest.mark.parametrize("name,bq,dialect,expected", TEST_CASES, ids=[g[0] for g in TEST_CASES])
87+
def test_cases_with_expected_output(name, bq, dialect, expected):
88+
assert t(bq, dialect) == expected
89+
90+
91+
def test_unnest_alias_preserved():
92+
# GENERATE_ARRAY column unnested downstream must stay an array + valid alias
93+
bq = "SELECT h FROM a CROSS JOIN UNNEST(a.hrs) AS h"
94+
for dialect in ("postgres", "duckdb"):
95+
assert "UNNEST(a.hrs)" in t(bq, dialect)
96+
97+
98+
def test_duckdb_comments_stripped():
99+
# DuckDB does not accept /* */ block comments
100+
bq = "/* a comment */ SELECT 1 AS x"
101+
assert "/*" not in transpile_query(bq, "bigquery", "duckdb")
102+
assert "/*" in transpile_query(bq, "bigquery", "postgres")
103+
104+
105+
def test_unsupported_dialect_raises():
106+
with pytest.raises(ValueError):
107+
transpile_query("SELECT 1", "bigquery", "mysql")
108+
109+
110+
# ---------------------------------------------------------------------------
111+
# 2. Every concept transpiles and the output parses back
112+
# ---------------------------------------------------------------------------
113+
114+
CONCEPT_FILES = sorted(CONCEPTS_DIR.rglob("*.sql"))
115+
116+
117+
@pytest.mark.skipif(not CONCEPT_FILES, reason="concept SQL files not found")
118+
@pytest.mark.parametrize("dialect", ["postgres", "duckdb"])
119+
@pytest.mark.parametrize("sql_file", CONCEPT_FILES, ids=lambda p: str(p.relative_to(CONCEPTS_DIR)))
120+
def test_concept_transpiles_and_reparses(sql_file, dialect):
121+
import sqlglot
122+
out = transpile_query(sql_file.read_text(), "bigquery", dialect)
123+
# parse-back: the generated SQL must be syntactically valid in the target dialect
124+
sqlglot.parse_one(out, read=dialect)
125+
126+
127+
# ---------------------------------------------------------------------------
128+
# 3. Custom function checks.
129+
# ---------------------------------------------------------------------------
130+
131+
# date-diff comparison
132+
# (end, start, unit, expected BigQuery boundary-count result)
133+
DIFF_CASES = [
134+
("2150-01-01 02:10:00", "2150-01-01 01:50:00", "HOUR", 1), # crosses 02:00
135+
("2150-01-01 01:59:00", "2150-01-01 01:01:00", "HOUR", 0), # no boundary
136+
("2150-01-02 01:00:00", "2150-01-01 23:00:00", "DAY", 1), # crosses midnight
137+
("2150-01-01 23:00:00", "2150-01-01 01:00:00", "DAY", 0), # same day
138+
("2155-06-15 00:00:00", "2150-01-01 00:00:00", "YEAR", 5),
139+
("2150-01-01 00:01:00", "2150-01-01 00:00:59", "MINUTE", 1),
140+
("2150-01-01 00:00:30", "2150-01-01 00:00:10", "SECOND", 20),
141+
("2150-01-01 01:00:00", "2150-01-01 03:00:00", "HOUR", -2), # negative direction
142+
]
143+
144+
145+
@pytest.mark.parametrize("end,start,unit,expected", DIFF_CASES)
146+
def test_datetime_diff_semantics_duckdb(end, start, unit, expected):
147+
duckdb = pytest.importorskip("duckdb")
148+
bq = f"SELECT DATETIME_DIFF(DATETIME '{end}', DATETIME '{start}', {unit}) AS d"
149+
sql = transpile_query(bq, "bigquery", "duckdb")
150+
assert duckdb.sql(sql).fetchone()[0] == expected

0 commit comments

Comments
 (0)