|
| 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