mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 16:09:05 +00:00
## Summary - **Phase 4 — Error Paths** (`test_4_error_paths.py`): 28 cases across 8 categories that send intentionally invalid SQL and assert 4xx not 5xx. Self-contained, runs in ~24s without data dependency. - **Phase 5 — Concurrency Stress** (`test_5_concurrency.py`): 26 queries (2 per category) run simultaneously at 5/10/15 worker levels via ThreadPoolExecutor. Each thread gets its own client. Catches deadlocks and thread-safety bugs. - **EXPLAIN ANALYZE** (`test_explain_analyze.py`): Verifies EXPLAIN/EXPLAIN ANALYZE return 200 with plan output and no `partial_error`. Also validates EXPLAIN ANALYZE on cross-schema dot-notation queries returns 4xx (not 500 crash). - **Fix**: `load_all_queries()` now skips JSON files without a `"queries"` key. ## Test plan - [x] Error path tests pass on pentest (28/28, all return 400) - [ ] CI run: `rye run pytest tests/query_agent/test_4_error_paths.py -v` - [ ] CI run: `rye run pytest tests/query_agent/test_5_concurrency.py -v` - [ ] CI run: `rye run pytest tests/query_agent/test_explain_analyze.py -v` 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
555 lines
21 KiB
Python
555 lines
21 KiB
Python
"""Compute expected result sets via DuckDB for all replicable queries.
|
|
|
|
Uses a pre-filtered DuckDB VIEW so the time-filter works for ALL queries
|
|
regardless of complexity (CTEs, subqueries, UNIONs, etc.) — no fragile
|
|
SQL WHERE injection needed.
|
|
|
|
sqllogictest-style: generates the exact expected output for each query so
|
|
the test runner can compare OO results cell-by-cell.
|
|
|
|
One-time tool — not a runtime test dependency.
|
|
|
|
Usage:
|
|
cd tests/test-data/query-agent && python3 compute_counts.py
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
import duckdb
|
|
|
|
from data_gen import BASE_TS, build_dataset
|
|
|
|
QUERIES_DIR = Path(__file__).parent / "queries"
|
|
STREAM = "logs"
|
|
|
|
|
|
# ── OO → DuckDB SQL translation ─────────────────────────────────────────────
|
|
|
|
def _replace_re_match(sql: str) -> str:
|
|
"""Replace re_match(..., 'pat') with regexp_matches(..., 'pat')."""
|
|
result = []
|
|
i = 0
|
|
while i < len(sql):
|
|
m = re.match(r're_match\s*\(', sql[i:], re.IGNORECASE)
|
|
if not m:
|
|
result.append(sql[i])
|
|
i += 1
|
|
continue
|
|
|
|
i += m.end()
|
|
depth = 1
|
|
j = i
|
|
while j < len(sql) and depth > 0:
|
|
if sql[j] == '(':
|
|
depth += 1
|
|
elif sql[j] == ')':
|
|
depth -= 1
|
|
j += 1
|
|
paren_close_at = j - 1
|
|
|
|
content = sql[i:paren_close_at]
|
|
comma_pos = -1
|
|
cdepth = 0
|
|
for k, ch in enumerate(content):
|
|
if ch == '(':
|
|
cdepth += 1
|
|
elif ch == ')':
|
|
cdepth -= 1
|
|
elif ch == ',' and cdepth == 0:
|
|
comma_pos = k
|
|
break
|
|
|
|
if comma_pos < 0:
|
|
result.append(sql[i - len(m.group()):j])
|
|
i = j
|
|
continue
|
|
|
|
arg1 = content[:comma_pos].strip()
|
|
arg2 = content[comma_pos + 1:].strip()
|
|
result.append(f"regexp_matches({arg1}, {arg2})")
|
|
|
|
i = j
|
|
if sql[i:i + 7].upper() == ' = TRUE':
|
|
i += 7
|
|
|
|
return ''.join(result)
|
|
|
|
|
|
def _replace_match_all(sql: str) -> str:
|
|
"""Replace match_all('term') with DuckDB LIKE conditions.
|
|
|
|
Tantivy tokenizes the term and matches each token independently
|
|
across all indexed text fields. We emulate this with per-word LIKE
|
|
conditions on the log field (the primary FTS field in test data).
|
|
|
|
Single word: match_all('warehouse') → log LIKE '%warehouse%'
|
|
Multi-word: match_all('ACK batch') → (log LIKE '%ACK%' AND log LIKE '%batch%')
|
|
|
|
Note: This is an approximation — Tantivy's actual tokenization may
|
|
differ from whitespace splitting (stemming, stop words, case folding).
|
|
The per-token content assertion in the test runner provides a
|
|
correctness backstop that is independent of this oracle.
|
|
"""
|
|
def _escape_like(s: str) -> str:
|
|
# Escape LIKE wildcards and single quotes to prevent unintended
|
|
# matches and SQL syntax breaks. Not a parameterized query, but
|
|
# this is test infrastructure against a local in-memory DuckDB.
|
|
return s.replace("'", "''").replace("%", "\\%").replace("_", "\\_")
|
|
|
|
def _build_like(m: re.Match) -> str:
|
|
term = m.group(1)
|
|
words = [_escape_like(w) for w in term.split()]
|
|
if len(words) == 1:
|
|
return f"log LIKE '%{words[0]}%'"
|
|
clauses = " AND ".join(f"log LIKE '%{w}%'" for w in words)
|
|
return f"({clauses})"
|
|
|
|
return re.sub(
|
|
r"match_all\('([^']*)'\)",
|
|
_build_like,
|
|
sql,
|
|
flags=re.IGNORECASE,
|
|
)
|
|
|
|
|
|
def _replace_str_match_ignore_case(sql: str) -> str:
|
|
"""Replace str_match_ignore_case(field, 'pat') with lower(field) LIKE '%pat%'."""
|
|
return re.sub(
|
|
r"str_match_ignore_case\((\w+),\s*'([^']*)'\)",
|
|
lambda m: f"lower({m.group(1)}) LIKE '%{m.group(2).lower()}%'",
|
|
sql,
|
|
flags=re.IGNORECASE,
|
|
)
|
|
|
|
|
|
def _replace_regexp_like(sql: str) -> str:
|
|
"""Replace regexp_like(field, 'pat'[, 'flags']) with regexp_matches(field, 'pat'[, 'flags'])."""
|
|
# Handle optional third argument (flags)
|
|
return re.sub(
|
|
r"regexp_like\s*\(\s*(\w+)\s*,\s*('(?:[^'\\]|\\.)*')\s*(?:,\s*('[^']*')\s*)?\)",
|
|
lambda m: f"regexp_matches({m.group(1)}, {m.group(2)}"
|
|
+ (f", {m.group(3)}" if m.group(3) else "") + ")",
|
|
sql,
|
|
flags=re.IGNORECASE,
|
|
)
|
|
|
|
|
|
def _replace_approx_distinct(sql: str) -> str:
|
|
"""Replace approx_distinct(field) with approx_count_distinct(field)."""
|
|
return re.sub(
|
|
r"approx_distinct\s*\(\s*([^)]+)\s*\)",
|
|
r"approx_count_distinct(\1)",
|
|
sql,
|
|
flags=re.IGNORECASE,
|
|
)
|
|
|
|
|
|
def _replace_json_get_str_chained(sql: str) -> str:
|
|
"""Replace OO json_get_str/json_get_json with DuckDB json_extract_string.
|
|
|
|
json_get_str(json_get_json(field, 'k1'), 'k2') → json_extract_string(field, '$.k1.k2')
|
|
json_get_str(field, 'key') → json_extract_string(field, '$.key')
|
|
json_get_json(field, 'key') → json_extract(field, '$.key')
|
|
"""
|
|
# Chain: json_get_str(json_get_json(field, 'k1'), 'k2') -> json_extract_string(field, '$.k1.k2')
|
|
pattern = r"json_get_str\(json_get_json\((\w+),\s*'([^']+)'\),\s*'([^']+)'\)"
|
|
while re.search(pattern, sql, re.IGNORECASE):
|
|
sql = re.sub(pattern, r"json_extract_string(\1, '$.\2.\3')", sql, flags=re.IGNORECASE)
|
|
|
|
# Single json_get_str(field, 'key') -> json_extract_string(field, '$.key')
|
|
sql = re.sub(
|
|
r"json_get_str\((\w+),\s*'([^']+)'\)",
|
|
r"json_extract_string(\1, '$.\2')",
|
|
sql,
|
|
flags=re.IGNORECASE,
|
|
)
|
|
|
|
# Single json_get_json(field, 'key') -> json_extract(field, '$.key')
|
|
sql = re.sub(
|
|
r"json_get_json\((\w+),\s*'([^']+)'\)",
|
|
r"json_extract(\1, '$.\2')",
|
|
sql,
|
|
flags=re.IGNORECASE,
|
|
)
|
|
|
|
return sql
|
|
|
|
|
|
def _has_oo_specific_functions(sql: str) -> bool:
|
|
"""Check if SQL uses OO-specific array functions that DuckDB cannot replicate."""
|
|
oo_patterns = [
|
|
r'\bcast_to_arr\s*\(', r'\barray_has\s*\(',
|
|
r'\bspath\s*\(', r'\bunnest\s*\(',
|
|
r'\bflatten\s*\(', r'\barray_element\s*\(',
|
|
]
|
|
return any(re.search(p, sql, re.IGNORECASE) for p in oo_patterns)
|
|
|
|
|
|
def _replace_histogram(sql: str) -> tuple[str, bool]:
|
|
"""Translate histogram(_timestamp, 'N unit') → DuckDB integer arithmetic.
|
|
|
|
OO's histogram is internally rewritten to DataFusion's:
|
|
date_bin(interval, to_timestamp_micros(_timestamp),
|
|
to_timestamp('2001-01-01T00:00:00'))
|
|
|
|
Pure integer arithmetic (works in ALL DuckDB versions):
|
|
origin + FLOOR((_timestamp - origin) / bucket_us) * bucket_us
|
|
|
|
where origin = 978307200000000 (2001-01-01 UTC in microseconds).
|
|
|
|
Returns (modified_sql, has_auto_interval). Auto-interval queries
|
|
(no second argument) cannot be replicated and must stay legacy.
|
|
"""
|
|
if re.search(r'histogram\(_timestamp\)', sql, re.IGNORECASE):
|
|
return sql, True
|
|
|
|
_ORIGIN_US = 978307200000000 # 2001-01-01T00:00:00 UTC in micros
|
|
|
|
# Map unit strings to microseconds
|
|
def _bucket_us(m: re.Match) -> str:
|
|
n = int(m.group(1))
|
|
unit = m.group(2)
|
|
if unit.startswith("second"):
|
|
width = n * 1_000_000
|
|
elif unit.startswith("minute"):
|
|
width = n * 60_000_000
|
|
elif unit.startswith("hour"):
|
|
width = n * 3_600_000_000
|
|
elif unit.startswith("day"):
|
|
width = n * 86_400_000_000
|
|
else:
|
|
width = n * 1_000_000
|
|
return f"CAST({_ORIGIN_US} + FLOOR((_timestamp - {_ORIGIN_US}) / {width}) * {width} AS BIGINT)"
|
|
|
|
pattern = (
|
|
r"histogram\(_timestamp,\s*'(\d+)\s*"
|
|
r"(second|seconds|minute|minutes|hour|hours|day|days)'\)"
|
|
)
|
|
result = re.sub(pattern, _bucket_us, sql, flags=re.IGNORECASE)
|
|
return result, False
|
|
|
|
|
|
def translate_oo_to_duckdb(sql: str) -> tuple[str, bool]:
|
|
"""Translate OpenObserve SQL → DuckDB SQL.
|
|
|
|
Time filtering is handled by a pre-filtered view — this only
|
|
replaces the stream placeholder and translates OO functions.
|
|
|
|
Returns (translated_sql, has_oo_specific_functions).
|
|
"""
|
|
s = sql.replace("{stream}", STREAM).replace("{stream2}", "logs2")
|
|
# NOTE: "{stream2}" → "logs2" (DuckDB view name), while conftest.py
|
|
# replaces the same placeholder with STREAM2 (the actual OO stream
|
|
# name). Both are correct — each runs in its own execution context.
|
|
|
|
# Check for OO-specific functions before translation
|
|
has_oo = _has_oo_specific_functions(s)
|
|
|
|
s = re.sub(
|
|
r"approx_percentile_cont\(CAST\((\w+)\s+AS\s+FLOAT\),\s*([0-9.]+)\)",
|
|
r"approx_quantile(\1::FLOAT, \2)", s, flags=re.IGNORECASE,
|
|
)
|
|
|
|
# regexp_match(field, 'pat') in OO returns array of capture groups.
|
|
# array_extract(regexp_match(field, 'pat'), N) → regexp_extract(field, 'pat', N)
|
|
s = re.sub(
|
|
r"array_extract\(\s*regexp_match\(\s*(\w+)\s*,\s*('(?:[^'\\]|\\.)*')\s*\)\s*,\s*(\d+)\s*\)",
|
|
r"regexp_extract(\1, \2, \3)",
|
|
s,
|
|
flags=re.IGNORECASE,
|
|
)
|
|
# Any remaining bare regexp_match → regexp_matches
|
|
s = re.sub(r"\bregexp_match\s*\(", "regexp_matches(", s, flags=re.IGNORECASE)
|
|
|
|
s = _replace_re_match(s)
|
|
s = _replace_match_all(s)
|
|
|
|
# re_not_match: replace with NOT regexp_matches before _replace_re_match runs
|
|
s = re.sub(
|
|
r"re_not_match\s*\(", "NOT regexp_matches(", s, flags=re.IGNORECASE
|
|
)
|
|
s, _ = _replace_histogram(s) # histogram(_timestamp, 'N unit') → date_bin
|
|
s = re.sub(r'\b(pivot)\b', r'pivoted', s, flags=re.IGNORECASE)
|
|
s = _replace_str_match_ignore_case(s)
|
|
s = _replace_regexp_like(s)
|
|
s = _replace_approx_distinct(s)
|
|
s = _replace_json_get_str_chained(s)
|
|
|
|
return s, has_oo
|
|
|
|
|
|
# ── Result-set builder ──────────────────────────────────────────────────────
|
|
|
|
# Columns OO drops due to LAG dedup bug
|
|
_OO_DROPPED_COLS = {"prev_tput", "hours_2_back", "delta_from_prev", "temp_diff"}
|
|
|
|
# Dynamic columns whose values differ between compute time and test time
|
|
_DYNAMIC_COLS = {"_timestamp", "_time"}
|
|
|
|
_EXCLUDE_COLS = _OO_DROPPED_COLS | _DYNAMIC_COLS
|
|
|
|
# Queries with known OO-DuckDB divergence — skip cell-by-cell comparison
|
|
_SKIP_SQLLOGICTEST = {
|
|
"Q016", # ROW_NUMBER tie-breaking with 5 records/query
|
|
"Q037", # ROW_NUMBER tie-breaking differs between engines
|
|
"Q052", # ROW_NUMBER tie-breaking with 5 records/query
|
|
"Q058", # Self-join pairings non-deterministic across engines
|
|
"Q072", # FULL OUTER JOIN histogram: NULL handling differs
|
|
"Q080", # FTS match_all + histogram: DuckDB LIKE != OO Tantivy
|
|
"Q104", # ORDER BY + LIMIT + OFFSET tie with 5 records/query
|
|
"Q111", # ROW_NUMBER ties in pagination
|
|
"Q117", # STRING_AGG ordering differs between engines
|
|
"Q121", # STRING_AGG concatenation order
|
|
"Q219", # STRING_AGG DISTINCT without ORDER BY — ordering non-deterministic
|
|
"Q127", # UNION across 5 groups: boundary records compete for top-10 spots
|
|
"Q141", # NTILE quartile mismatch with 5 records/query
|
|
"Q146", # NTILE quartile mismatch with 5 records/query
|
|
"Q152", # PERCENT_RANK tie-breaking differs
|
|
"Q156", # Multi-CTE with LEFT JOIN self-reference — join pairings non-deterministic
|
|
"Q158", # UNION ALL with regexp_match — DuckDB RE2 doesn't handle OO regexp_match well
|
|
"Q160", # regexp_match with named capture groups — DuckDB RE2 doesn't support (?<name>...) syntax
|
|
"Q161", # spath/unnest/flatten/cast_to_arr — OO-specific array functions
|
|
"Q162", # rtrim/ltrim/spath/array_element/cast_to_arr — OO-specific array functions
|
|
"Q192", # approx_percentile_cont mismatch with 5 records/query
|
|
"Q301", # Hour bucket row count mismatch with 5 records/query
|
|
"Q305", # Hour bucket row count mismatch with 5 records/query
|
|
"Q453", # Hour bucket integer division mismatch (DataFusion vs DuckDB)
|
|
"Q454", # Minute bucket integer division mismatch (DataFusion vs DuckDB)
|
|
"Q308", # SELECT * — OO omits NULL-valued columns from JSON
|
|
"Q320", # SELECT * — OO omits NULL-valued columns from JSON
|
|
"Q145", # ROW_NUMBER tie-breaking with 5 records/query (window)
|
|
"Q395", # LAG window row count mismatch with 5 records/query
|
|
"Q398", # FIRST_VALUE/LAST_VALUE ROWS BETWEEN row count mismatch with 5 records
|
|
}
|
|
|
|
# Queries that use array_has/cast_to_arr — auto-marked as skip_sqllogictest
|
|
# since DuckDB cannot replicate OO's array semantics
|
|
_HAS_ARRAY_HAS = set()
|
|
|
|
# Queries where OO returns fewer columns than DuckDB (e.g. ENT FULL OUTER JOIN
|
|
# column aliasing). Skip the per-column existence check in legacy mode so the
|
|
# test only validates row-count and ORDER BY, not column presence.
|
|
_SKIP_COLUMN_CHECK = {"Q072", "Q308", "Q312", "Q313", "Q318", "Q320", "Q323", "Q395", "Q548"}
|
|
|
|
|
|
def compute_results(con: duckdb.DuckDBPyConnection, q: dict, *, is_histogram: bool = False) -> dict | None:
|
|
"""Compute the expected result set for a single query.
|
|
|
|
Returns the new ``expected`` dict, or None if the query should stay
|
|
in legacy assertion mode.
|
|
"""
|
|
offset = q["time_offset"]
|
|
time_start = BASE_TS + offset["start_offset"]
|
|
time_end = BASE_TS + offset["end_offset"]
|
|
|
|
# Create a pre-filtered view so EVERY query (CTEs, subqueries, etc.)
|
|
# gets the right records without fragile SQL WHERE injection.
|
|
# Views are recreated per query because each query has its own
|
|
# time_offset — this is intentional, not overhead.
|
|
con.execute(
|
|
f"CREATE OR REPLACE VIEW logs AS "
|
|
f"SELECT * FROM _logs WHERE _timestamp >= {time_start} AND _timestamp <= {time_end}"
|
|
)
|
|
# Cross-stream: second stream view uses the same time window because
|
|
# both streams share identical timestamp ranges — only field values
|
|
# differ (via stream_offset=7). A single time_offset covers both.
|
|
con.execute(
|
|
f"CREATE OR REPLACE VIEW logs2 AS "
|
|
f"SELECT * FROM _logs2 WHERE _timestamp >= {time_start} AND _timestamp <= {time_end}"
|
|
)
|
|
|
|
duck_sql, has_oo = translate_oo_to_duckdb(q["sql"])
|
|
|
|
# For histogram queries, _time is deterministic (bucketed via date_bin
|
|
# with fixed origin). Only _timestamp still varies between runs.
|
|
if is_histogram:
|
|
exclude_cols = _OO_DROPPED_COLS | {"_timestamp"}
|
|
else:
|
|
exclude_cols = _EXCLUDE_COLS
|
|
|
|
try:
|
|
rows = con.execute(duck_sql).fetchall()
|
|
cols = [desc[0] for desc in con.description]
|
|
except Exception as e:
|
|
print(f" {q['id']}: DuckDB error — {e}")
|
|
return None
|
|
|
|
# Filter out dynamic/OO-dropped columns
|
|
keep_idx = [i for i, c in enumerate(cols) if c not in exclude_cols]
|
|
filtered_cols = [cols[i] for i in keep_idx]
|
|
filtered_rows = [
|
|
[str(row[i]) for i in keep_idx] for row in rows
|
|
]
|
|
|
|
# Histogram: OO serialises bucket timestamps as ISO strings
|
|
# (e.g. "2026-06-03T04:00:00"), but DuckDB computes epoch
|
|
# microseconds. Convert all columns whose values are epoch-us
|
|
# timestamps (> 1e15). Some queries (Q072 FULL OUTER JOIN)
|
|
# produce multiple histogram bucket columns.
|
|
if is_histogram and filtered_rows:
|
|
from datetime import datetime, timezone
|
|
for row in filtered_rows:
|
|
for j, val in enumerate(row):
|
|
if val and val != 'None':
|
|
try:
|
|
us = int(float(val))
|
|
if us > 1_000_000_000_000_000:
|
|
dt = datetime.fromtimestamp(
|
|
us / 1_000_000, tz=timezone.utc
|
|
)
|
|
row[j] = dt.strftime('%Y-%m-%dT%H:%M:%S')
|
|
except (ValueError, OverflowError):
|
|
pass
|
|
|
|
result = {
|
|
"columns": filtered_cols,
|
|
"results": filtered_rows,
|
|
"results_mode": "rowsort",
|
|
}
|
|
|
|
# Detect empty-aggregate results: DuckDB returns 1 row of NULLs for
|
|
# aggregate queries with zero matching rows, but OO returns 0 rows.
|
|
# OO also drops NULL-valued columns from JSON hits, so column presence
|
|
# and row count are unreliable — skip both.
|
|
if filtered_rows and all(
|
|
all(v == 'None' for v in row) for row in filtered_rows
|
|
):
|
|
result = {
|
|
"skip_sqllogictest": True,
|
|
"skip_row_count": True,
|
|
"skip_column_check": True,
|
|
"columns": filtered_cols,
|
|
}
|
|
print(f" {q['id']}: empty-aggregate → skip_sqllogictest")
|
|
return result
|
|
|
|
if len(filtered_rows) == 0:
|
|
print(f" {q['id']}: 0 rows ({len(cols)} cols, {len(cols) - len(filtered_cols)} excluded)")
|
|
else:
|
|
print(f" {q['id']}: {len(filtered_rows)} rows, {len(filtered_cols)} cols (excluded {len(cols) - len(filtered_cols)})")
|
|
|
|
return result
|
|
|
|
|
|
# ── Main ────────────────────────────────────────────────────────────────────
|
|
|
|
def main():
|
|
print("Generating dataset in DuckDB...")
|
|
records1 = build_dataset()
|
|
records2 = build_dataset(stream_offset=7)
|
|
con = duckdb.connect(":memory:")
|
|
|
|
for label, records in [("_logs", records1), ("_logs2", records2)]:
|
|
jsonl_str = "\n".join(json.dumps(r) for r in records)
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as tf:
|
|
tf.write(jsonl_str)
|
|
tmp_path = tf.name
|
|
|
|
try:
|
|
con.execute(
|
|
f"CREATE TABLE {label} AS SELECT * FROM read_json_auto(?)",
|
|
[tmp_path],
|
|
)
|
|
finally:
|
|
Path(tmp_path).unlink()
|
|
|
|
count = con.execute(f"SELECT COUNT(*) FROM {label}").fetchone()[0]
|
|
print(f"Loaded {label}: {count} records")
|
|
|
|
total = con.execute("SELECT COUNT(*) FROM _logs").fetchone()[0]
|
|
|
|
# Persist the BASE_TS so the test harness uses the same value.
|
|
# Without this, a minute-boundary crossing between compute and test
|
|
# shifts all histogram bucket timestamps.
|
|
override_path = QUERIES_DIR.parent / "base_ts_override.json"
|
|
override_path.write_text(json.dumps({"BASE_TS": BASE_TS}))
|
|
print(f"Saved BASE_TS override: {BASE_TS}")
|
|
|
|
updated = 0
|
|
skipped = 0
|
|
|
|
for fp in sorted(QUERIES_DIR.glob("*.json")):
|
|
with open(fp) as f:
|
|
query_data = json.load(f)
|
|
|
|
queries = query_data.get("queries")
|
|
if queries is None:
|
|
print(f" (skipping {fp.name}: no 'queries' key)")
|
|
continue
|
|
dirty = False
|
|
for q in queries:
|
|
expected = q.get("expected", {})
|
|
|
|
# Histogram queries: skip auto-interval, convert explicit-interval
|
|
is_histogram = "histogram(" in q["sql"]
|
|
if is_histogram:
|
|
_, has_auto = _replace_histogram(q["sql"])
|
|
if has_auto:
|
|
skipped += 1
|
|
continue
|
|
|
|
# Skip Q122 — STRING_AGG DISTINCT ORDER BY: DuckDB requires
|
|
# ORDER BY expressions to appear in the aggregate argument
|
|
# list for DISTINCT aggregates.
|
|
if q["id"] == "Q122":
|
|
skipped += 1
|
|
continue
|
|
|
|
# Preserve manually-set metadata from the old expected block
|
|
# (compute_results only returns columns+results+results_mode)
|
|
old_expected = q.get("expected", {})
|
|
|
|
new_expected = compute_results(con, q, is_histogram=is_histogram)
|
|
if new_expected is None:
|
|
skipped += 1
|
|
continue
|
|
|
|
# Merge preserved keys from old expected into new expected
|
|
_PRESERVE_KEYS = ("skip_sqllogictest", "skip_row_count",
|
|
"skip_column_check",
|
|
"skip_without_single_node_opt",
|
|
"note", "assertions", "row_count")
|
|
for key in _PRESERVE_KEYS:
|
|
if key in old_expected:
|
|
new_expected[key] = old_expected[key]
|
|
|
|
# Auto-mark OO-specific array function queries for skip_sqllogictest
|
|
# (only if not already manually set)
|
|
if "skip_sqllogictest" not in new_expected:
|
|
_, has_oo = translate_oo_to_duckdb(q["sql"])
|
|
if has_oo:
|
|
new_expected["skip_sqllogictest"] = True
|
|
if q["id"] not in _SKIP_SQLLOGICTEST:
|
|
_SKIP_SQLLOGICTEST.add(q["id"])
|
|
|
|
# Mark known-divergent queries for skip-sqllogictest fallback
|
|
if q["id"] in _SKIP_SQLLOGICTEST and "skip_sqllogictest" not in new_expected:
|
|
new_expected["skip_sqllogictest"] = True
|
|
|
|
# Skip per-column existence check for queries where OO returns
|
|
# fewer columns than DuckDB (e.g. FULL OUTER JOIN aliasing in ENT)
|
|
if q["id"] in _SKIP_COLUMN_CHECK:
|
|
new_expected["skip_column_check"] = True
|
|
|
|
# Replace the expected block
|
|
q["expected"] = new_expected
|
|
dirty = True
|
|
|
|
if dirty:
|
|
with open(fp, "w") as f:
|
|
json.dump(query_data, f, indent=2)
|
|
f.write("\n")
|
|
updated += 1
|
|
|
|
print(f"\nUpdated {updated} query files")
|
|
print(f"Skipped {skipped} (auto-interval histogram + Q122 + errors)")
|
|
|
|
con.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|