import os
import random
import shutil
import sqlite3
import tempfile
import pytest
try:
import duckdb
except ImportError:
duckdb = None
try:
from apexbase import ApexClient
except ImportError as exc:
pytest.skip(f"ApexBase not available: {exc}", allow_module_level=True)
CITIES = [
"Beijing", "Shanghai", "Guangzhou", "Shenzhen", "Hangzhou",
"Nanjing", "Chengdu", "Wuhan", "Xian", "Qingdao",
]
CATEGORIES = [
"Electronics", "Clothing", "Food", "Sports", "Books",
"Home", "Auto", "Health", "Travel", "Gaming",
]
NUM_ROWS = 20_000
def generate_data(n=NUM_ROWS):
rng = random.Random(42)
return {
"id": list(range(1, n + 1)),
"name": [f"user_{i}" for i in range(n)],
"age": [rng.randint(18, 80) for _ in range(n)],
"score": [round(rng.uniform(0, 100), 2) for _ in range(n)],
"city": [rng.choice(CITIES) for _ in range(n)],
"category": [rng.choice(CATEGORIES) for _ in range(n)],
}
def _store(client, table, rows):
client.create_table(table)
client.use_table(table)
client.store(rows)
client.flush()
def setup_apex(tmp, data):
client = ApexClient(os.path.join(tmp, "apex"), drop_if_exists=True)
_store(client, "t", data)
_store(client, "meta", {
"city": CITIES,
"pop": [21_540_000, 24_870_000, 18_680_000, 17_680_000,
10_360_000, 9_490_000, 20_940_000, 13_690_000,
13_150_000, 9_480_000],
})
_store(client, "extra", {
"id": list(range(1, 2001)),
"tag": ["T" if i % 3 == 0 else ("U" if i % 3 == 1 else "V")
for i in range(2000)],
"val": [(i * 7) % 500 for i in range(2000)],
})
return client
def setup_sqlite(tmp, data):
con = sqlite3.connect(os.path.join(tmp, "sqlite.db"))
con.execute(
"CREATE TABLE t (id INTEGER, name TEXT, age INTEGER, score REAL, "
"city TEXT, category TEXT)"
)
con.executemany("INSERT INTO t VALUES (?,?,?,?,?,?)", list(zip(
data["id"], data["name"], data["age"], data["score"],
data["city"], data["category"])))
con.execute("CREATE TABLE meta (city TEXT, pop INTEGER)")
con.executemany("INSERT INTO meta VALUES (?,?)", zip(CITIES, [
21_540_000, 24_870_000, 18_680_000, 17_680_000, 10_360_000,
9_490_000, 20_940_000, 13_690_000, 13_150_000, 9_480_000]))
con.execute("CREATE TABLE extra (id INTEGER, tag TEXT, val INTEGER)")
con.executemany(
"INSERT INTO extra VALUES (?,?,?)",
[(i + 1, "T" if i % 3 == 0 else ("U" if i % 3 == 1 else "V"),
(i * 7) % 500) for i in range(2000)])
con.commit()
return con
def setup_duckdb(data):
con = duckdb.connect(":memory:")
con.execute(
"CREATE TABLE t (id INTEGER, name VARCHAR, age INTEGER, score DOUBLE, "
"city VARCHAR, category VARCHAR)"
)
con.executemany("INSERT INTO t VALUES (?,?,?,?,?,?)", list(zip(
data["id"], data["name"], data["age"], data["score"],
data["city"], data["category"])))
con.execute("CREATE TABLE meta (city VARCHAR, pop INTEGER)")
con.executemany("INSERT INTO meta VALUES (?,?)", zip(CITIES, [
21_540_000, 24_870_000, 18_680_000, 17_680_000, 10_360_000,
9_490_000, 20_940_000, 13_690_000, 13_150_000, 9_480_000]))
con.execute("CREATE TABLE extra (id INTEGER, tag VARCHAR, val INTEGER)")
con.executemany(
"INSERT INTO extra VALUES (?,?,?)",
[(i + 1, "T" if i % 3 == 0 else ("U" if i % 3 == 1 else "V"),
(i * 7) % 500) for i in range(2000)])
return con
def normalize(value):
if value is None:
return None
if isinstance(value, bool):
return ("bool", value)
if isinstance(value, int):
return ("num", float(value))
if isinstance(value, float):
return ("num", round(value, 4))
return ("str", str(value))
def run_all(client, con, dcon, sql):
results = {}
apex_rows = client.execute(sql).to_dict()
results["apex"] = {
tuple(normalize(v) for v in row.values()) for row in apex_rows
}
for name, handle in (("sqlite", con), ("duckdb", dcon)):
cur = handle.execute(sql)
columns = [d[0] for d in cur.description]
rows = [dict(zip(columns, row)) for row in cur.fetchall()]
results[name] = {
tuple(normalize(v) for v in row.values()) for row in rows
}
return results
PARITY_QUERIES = [
"SELECT city, MAX(score) AS mx FROM t GROUP BY city ORDER BY city",
"SELECT city, MIN(score) AS mn FROM t GROUP BY city ORDER BY city",
"SELECT city, MAX(score) AS mx, SUM(score) AS s FROM t GROUP BY city ORDER BY city",
"SELECT city, MIN(age) AS mn, MAX(age) AS mx FROM t GROUP BY city ORDER BY city",
"SELECT city, COUNT(CASE WHEN age > 40 THEN 1 END) AS c FROM t GROUP BY city ORDER BY city",
"SELECT city, COUNT(*) AS c FROM t GROUP BY city ORDER BY c DESC LIMIT 5",
"SELECT t.city, COUNT(*) AS c FROM t JOIN meta m ON t.city = m.city GROUP BY t.city ORDER BY c DESC LIMIT 5",
"SELECT COUNT(*) AS c FROM t LEFT JOIN meta m ON t.city = m.city AND m.pop > 5000000",
"SELECT t.id, m.city FROM t LEFT JOIN meta m ON t.city = m.city AND m.pop > 15000000 WHERE t.id <= 20 ORDER BY t.id",
"SELECT COUNT(*) AS c FROM t LEFT JOIN extra e ON t.id = e.id AND e.val > 999999",
"SELECT t.city, COUNT(*) AS c FROM t JOIN meta m ON t.city = m.city WHERE t.age > 30 GROUP BY t.city ORDER BY c DESC LIMIT 5",
"SELECT id, SUM(score) OVER (PARTITION BY city ORDER BY id) AS run FROM t WHERE id <= 2000 ORDER BY id LIMIT 100",
"SELECT id, city, RANK() OVER (PARTITION BY city ORDER BY score DESC) AS rk FROM t WHERE id <= 2000 ORDER BY rk, id LIMIT 100",
"SELECT id, city, LAG(score) OVER (PARTITION BY city ORDER BY id) AS prev FROM t WHERE id <= 2000 ORDER BY id LIMIT 100",
"SELECT city FROM t WHERE age = 25 UNION SELECT city FROM t WHERE age = 26 ORDER BY city",
"SELECT city FROM t WHERE age BETWEEN 20 AND 30 INTERSECT SELECT city FROM t WHERE age BETWEEN 25 AND 35 ORDER BY city",
"SELECT COUNT(*) AS c FROM t WHERE city IN (SELECT city FROM meta WHERE pop > 15000000)",
"SELECT COUNT(*) AS c FROM t b WHERE EXISTS "
"(SELECT 1 FROM meta m WHERE m.city = b.city AND m.pop > 15000000)",
"SELECT city, (SELECT MAX(score) FROM t t2 WHERE t2.city = t.city) AS mx FROM t WHERE id <= 50 ORDER BY id",
"SELECT city, cnt FROM (SELECT city, COUNT(*) AS cnt FROM t GROUP BY city) d WHERE cnt > 1000 ORDER BY cnt DESC LIMIT 5",
"WITH c AS (SELECT city, AVG(score) AS av FROM t GROUP BY city) SELECT city FROM c WHERE av > 50 ORDER BY av DESC LIMIT 5",
"SELECT UPPER(city) AS u, LENGTH(name) AS ln, SUBSTR(name, 1, 5) AS sub, "
"CONCAT(city, '-', category) AS cc, TRIM(category) AS tr FROM t WHERE id <= 100 ORDER BY id",
"SELECT ROUND(score, 0) AS r, ABS(age - 40) AS a, FLOOR(score) AS f, CEIL(score) AS c FROM t WHERE id <= 100 ORDER BY id",
"SELECT COUNT(*) AS c FROM t WHERE COALESCE(NULLIF(category, 'Books'), 'none') = 'Books'",
"SELECT DISTINCT city, category FROM t ORDER BY city, category LIMIT 50",
"SELECT COUNT(*) AS c FROM t WHERE age NOT BETWEEN 20 AND 40 AND name NOT LIKE 'user_5%'",
"SELECT city FROM t GROUP BY city HAVING AVG(score) > 50 AND COUNT(*) > 1000 ORDER BY city",
"SELECT id FROM t WHERE id > 10_000 ORDER BY id LIMIT 5",
"SELECT t.id, m.city FROM t, meta m WHERE t.id <= 10 ORDER BY t.id, m.city LIMIT 50",
"SELECT COUNT(*) AS c FROM t, meta m WHERE t.age < 21",
"SELECT COUNT(*) AS c FROM t, meta m WHERE m.pop < 10000000",
"SELECT COUNT(t.age) AS c FROM t, meta m WHERE t.age < 21",
]
@pytest.fixture()
def engines():
if duckdb is None:
pytest.skip("duckdb is required for parity tests")
tmp = tempfile.mkdtemp(prefix="parity_")
data = generate_data()
client = setup_apex(tmp, data)
con = setup_sqlite(tmp, data)
dcon = setup_duckdb(data)
yield client, con, dcon
client.close()
con.close()
dcon.close()
shutil.rmtree(tmp, ignore_errors=True)
class TestCrossEngineParity:
@pytest.mark.parametrize("sql", PARITY_QUERIES)
def test_parity(self, engines, sql):
client, con, dcon = engines
results = run_all(client, con, dcon, sql)
apex = results["apex"]
assert apex == results["sqlite"], (
f"ApexBase != SQLite for {sql}\n"
f"apex-only={apex - results['sqlite']}\n"
f"sqlite-only={results['sqlite'] - apex}"
)
assert apex == results["duckdb"], (
f"ApexBase != DuckDB for {sql}\n"
f"apex-only={apex - results['duckdb']}\n"
f"duckdb-only={results['duckdb'] - apex}"
)
class TestGroupedMinMaxRegressions:
def test_grouped_max_float_is_max_not_sum(self, engines):
client, _, _ = engines
rows = client.execute(
"SELECT city, MAX(score) AS mx FROM t GROUP BY city ORDER BY city"
).to_dict()
for row in rows:
assert row["mx"] <= 100.0
assert row["mx"] > 90.0
def test_grouped_min_float_is_min_not_sum(self, engines):
client, _, _ = engines
rows = client.execute(
"SELECT city, MIN(score) AS mn FROM t GROUP BY city ORDER BY city"
).to_dict()
for row in rows:
assert row["mn"] < 10.0
def test_grouped_max_int_is_max_not_sum(self, engines):
client, _, _ = engines
rows = client.execute(
"SELECT city, MAX(age) AS mx FROM t GROUP BY city ORDER BY city"
).to_dict()
assert all(row["mx"] <= 80 for row in rows)
def test_mixed_min_max_columns(self, engines):
client, _, _ = engines
rows = client.execute(
"SELECT city, MAX(age) AS ma, MAX(score) AS ms, MIN(score) AS mn "
"FROM t GROUP BY city ORDER BY city"
).to_dict()
for row in rows:
assert row["ma"] <= 80
assert 90 < row["ms"] <= 100
assert row["mn"] < 10
class TestWindowOrderLimitRegression:
def test_window_outer_order_by_limit(self, engines):
client, _, _ = engines
rows = client.execute(
"SELECT id, SUM(score) OVER (PARTITION BY city ORDER BY id) AS run "
"FROM t WHERE id <= 5000 ORDER BY id LIMIT 10"
).to_dict()
assert len(rows) == 10
ids = [row["id"] for row in rows]
assert ids == sorted(ids)
assert ids[0] == 1
def test_window_rank_order_limit(self, engines):
client, _, _ = engines
rows = client.execute(
"SELECT id, city, RANK() OVER (PARTITION BY city ORDER BY score DESC) AS rk "
"FROM t WHERE id <= 5000 ORDER BY rk, id LIMIT 10"
).to_dict()
assert len(rows) == 10
assert rows[0]["rk"] == 1
class TestOuterJoinExtraOnRegression:
def test_left_join_extra_condition_preserves_unmatched(self, engines):
client, _, _ = engines
count = client.execute(
"SELECT COUNT(*) AS c FROM t LEFT JOIN meta m "
"ON t.city = m.city AND m.pop > 5000000"
).scalar()
assert count == NUM_ROWS
def test_left_join_extra_condition_rows(self, engines):
client, _, _ = engines
rows = client.execute(
"SELECT t.id, m.city FROM t LEFT JOIN meta m "
"ON t.city = m.city AND m.pop > 20000000 WHERE t.id <= 20 ORDER BY t.id"
).to_dict()
assert len(rows) == 20
for row in rows:
if row["city"] is not None:
assert row["city"] in {"Beijing", "Shanghai", "Chengdu"}
def test_full_outer_join_extra_condition(self, engines):
client, _, _ = engines
rows = client.execute(
"SELECT m.city, t.id FROM meta m FULL OUTER JOIN t "
"ON t.city = m.city AND t.id = 1 ORDER BY m.city LIMIT 12"
).to_dict()
assert len(rows) == 12
assert any(row["city"] is None and row["id"] is not None for row in rows)
class TestCorrelatedScalarSubqueryRegression:
def test_correlated_scalar_subquery_preserves_float(self, engines):
client, _, _ = engines
rows = client.execute(
"SELECT city, (SELECT MAX(score) FROM t t2 WHERE t2.city = t.city) AS mx "
"FROM t WHERE id <= 50 ORDER BY id"
).to_dict()
for row in rows:
assert isinstance(row["mx"], float)
assert 90.0 < row["mx"] <= 100.0
class TestParserRegressions:
def test_numeric_literal_underscores(self, engines):
client, _, _ = engines
rows = client.execute(
"SELECT id FROM t WHERE id > 10_000 ORDER BY id LIMIT 5"
).to_dict()
assert rows[0]["id"] == 10_001
def test_comma_cross_join(self, engines):
client, _, _ = engines
rows = client.execute(
"SELECT t.id, m.city FROM t, meta m WHERE t.id <= 10 "
"ORDER BY t.id, m.city LIMIT 50"
).to_dict()
assert len(rows) == 50
cities = {row["city"] for row in rows}
assert cities == set(CITIES)