Skip to content

Commit 472e0f6

Browse files
jpetey75claude
andauthored
feat: compile a query to warehouse SQL without executing it (#30)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4e9516e commit 472e0f6

4 files changed

Lines changed: 210 additions & 0 deletions

File tree

docs/SDK_GUIDE.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,40 @@ lazy_df = result.to_df_lazy()
373373

374374
---
375375

376+
## Compiling to SQL
377+
378+
Use `.compile()` to get the warehouse SQL for a query **without executing it**.
379+
Nothing is run and no rows are fetched — it returns the exact SQL Lightdash
380+
would issue:
381+
382+
```python
383+
sql = (
384+
model.query()
385+
.metrics(model.metrics.revenue)
386+
.dimensions(model.dimensions.country)
387+
.filter(model.dimensions.status == "active")
388+
.compile()
389+
)
390+
print(sql)
391+
# SELECT ... FROM ... WHERE ... GROUP BY ... LIMIT 500
392+
```
393+
394+
This is handy for inspecting or debugging a query, or for running it **directly
395+
against your warehouse** when you need more rows than the query API returns —
396+
for example feeding it to BigQuery/bigframes, dbt, or a data pipeline:
397+
398+
```python
399+
import bigframes.pandas as bpd
400+
401+
sql = model.query().metrics(model.metrics.revenue).limit(10_000_000).compile()
402+
df = bpd.read_gbq(sql) # run it yourself, no Lightdash row cap
403+
```
404+
405+
The query's `limit` is included in the SQL as `LIMIT n`; set a large limit (or
406+
strip the trailing clause) if you're running it yourself.
407+
408+
---
409+
376410
## SQL Runner
377411

378412
Execute raw SQL queries directly against your data warehouse:

lightdash/query.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Query functionality for Lightdash models."""
22
import time
3+
import uuid
34
import warnings
45
from typing import Any, Dict, List, Optional, Union, Sequence, Iterator
56

@@ -12,6 +13,28 @@
1213
from .results import BaseResult
1314

1415

16+
def _inject_filter_ids(filters: Dict[str, Any]) -> Dict[str, Any]:
17+
"""Add the ``id`` fields the v1 ``compileQuery`` endpoint requires on every
18+
filter group and rule.
19+
20+
The v2 query endpoint injects these server-side, but v1 validates them
21+
strictly. Returns a new dict; the input is not mutated.
22+
"""
23+
def visit_group(group: Dict[str, Any]) -> Dict[str, Any]:
24+
out: Dict[str, Any] = {"id": str(uuid.uuid4())}
25+
for key in ("and", "or"):
26+
if key in group:
27+
out[key] = [visit_item(item) for item in group[key]]
28+
return out
29+
30+
def visit_item(item: Dict[str, Any]) -> Dict[str, Any]:
31+
if "and" in item or "or" in item: # nested group
32+
return visit_group(item)
33+
return {**item, "id": str(uuid.uuid4())} # leaf rule
34+
35+
return {ftype: visit_group(group) for ftype, group in filters.items()}
36+
37+
1538
class _QueryExecutor:
1639
"""Internal class that handles V2 async query submission and polling."""
1740

@@ -693,6 +716,54 @@ def execute(
693716
)
694717
return self._result
695718

719+
def compile(self) -> str:
720+
"""
721+
Compile the query to warehouse SQL without executing it.
722+
723+
Returns the SQL Lightdash would run for this query. Nothing is executed
724+
and no rows are fetched — useful for inspecting or debugging a query, or
725+
for running it directly against your warehouse (e.g. BigQuery/bigframes,
726+
dbt, or a data pipeline) when you need more rows than the query API
727+
returns.
728+
729+
The query's ``limit`` is included in the SQL (as ``LIMIT n``); set a
730+
large limit, or strip the trailing clause, if you intend to run it
731+
yourself without Lightdash's row cap.
732+
733+
Returns:
734+
The compiled SQL as a string.
735+
736+
Example:
737+
sql = (
738+
model.query()
739+
.metrics(model.metrics.revenue)
740+
.dimensions(model.dimensions.country)
741+
.filter(model.dimensions.status == "active")
742+
.compile()
743+
)
744+
"""
745+
if self._model._client is None:
746+
raise RuntimeError("Model not properly initialized with client reference")
747+
748+
client = self._model._client
749+
payload = self._build_payload()
750+
751+
# The v1 compileQuery endpoint is stricter than the v2 query endpoint:
752+
# it requires additionalMetrics and an `id` on every filter group/rule.
753+
payload.setdefault("additionalMetrics", [])
754+
filters = payload.get("filters", {})
755+
has_rules = any(
756+
group.get("and") or group.get("or") for group in filters.values()
757+
)
758+
payload["filters"] = _inject_filter_ids(filters) if has_rules else {}
759+
760+
result = client._make_request(
761+
"POST",
762+
f"/api/v1/projects/{client.project_uuid}/explores/{self._model.name}/compileQuery",
763+
json=payload,
764+
)
765+
return result["query"]
766+
696767
def to_records(self) -> List[Dict[str, Any]]:
697768
"""Get all query results as a list of dictionaries."""
698769
return self.execute().to_records()

tests/test_acceptance.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -643,3 +643,30 @@ def test_query_with_filters_class(first_model):
643643
row[dim1_label] == filter1_value and row[dim2_label] == filter2_value
644644
for row in filtered_results
645645
), "Filters did not correctly filter results"
646+
647+
648+
def test_compile_query(first_model):
649+
"""Compiling a query returns warehouse SQL without executing it."""
650+
dimensions = first_model.list_dimensions()
651+
metrics = first_model.list_metrics()
652+
if not dimensions or not metrics:
653+
pytest.skip("No dimensions or metrics available for testing")
654+
655+
# Plain compile -> SELECT ... FROM ...
656+
sql = first_model.query(
657+
dimensions=[dimensions[0].field_id],
658+
metrics=[metrics[0].field_id],
659+
limit=10,
660+
).compile()
661+
assert isinstance(sql, str)
662+
assert "select" in sql.lower()
663+
assert "from" in sql.lower()
664+
665+
# With a filter -> exercises filter-id injection on the v1 endpoint
666+
sql_filtered = first_model.query(
667+
dimensions=[dimensions[0].field_id],
668+
metrics=[metrics[0].field_id],
669+
filters=dimensions[0].is_not_null(),
670+
limit=10,
671+
).compile()
672+
assert "where" in sql_filtered.lower()

tests/test_compile.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""
2+
Unit tests for Query.compile() — compile a query to warehouse SQL (issue #5).
3+
4+
Uses a fake client (no network); the live path is covered by the env-gated
5+
acceptance test ``test_compile_query``.
6+
"""
7+
8+
import pytest
9+
from lightdash.models import Model
10+
from lightdash.dimensions import Dimension
11+
12+
13+
class FakeClient:
14+
project_uuid = "proj-uuid"
15+
16+
def __init__(self):
17+
self.requests = []
18+
19+
def get_query_limits(self):
20+
return {}
21+
22+
def _make_request(self, method, path, params=None, json=None):
23+
self.requests.append({"method": method, "path": path, "json": json})
24+
return {"query": "SELECT 1 AS x FROM `t`", "parameterReferences": []}
25+
26+
27+
@pytest.fixture
28+
def model():
29+
m = Model(name="orders", type="default", database_name="db", schema_name="s")
30+
m._client = FakeClient()
31+
return m
32+
33+
34+
def dim(name):
35+
return Dimension(name=name, model_name="orders")
36+
37+
38+
class TestCompile:
39+
def test_returns_sql_string(self, model):
40+
sql = model.query(metrics=["orders_revenue"], dimensions=["orders_country"]).compile()
41+
assert sql == "SELECT 1 AS x FROM `t`"
42+
43+
def test_posts_to_compile_endpoint(self, model):
44+
model.query(metrics=["orders_revenue"]).compile()
45+
req = model._client.requests[-1]
46+
assert req["method"] == "POST"
47+
assert req["path"] == "/api/v1/projects/proj-uuid/explores/orders/compileQuery"
48+
49+
def test_payload_includes_additional_metrics(self, model):
50+
model.query(metrics=["orders_revenue"]).compile()
51+
assert model._client.requests[-1]["json"]["additionalMetrics"] == []
52+
53+
def test_no_filters_sends_empty_object(self, model):
54+
"""With no filters, send {} — the shape the v1 endpoint accepts."""
55+
model.query(metrics=["orders_revenue"]).compile()
56+
assert model._client.requests[-1]["json"]["filters"] == {}
57+
58+
def test_filters_get_ids_on_group_and_rules(self, model):
59+
"""The v1 endpoint requires an id on every filter group and rule."""
60+
model.query(metrics=["orders_revenue"]).filter(dim("country") == "USA").compile()
61+
filters = model._client.requests[-1]["json"]["filters"]
62+
group = filters["dimensions"]
63+
assert "id" in group
64+
rule = group["and"][0]
65+
assert "id" in rule
66+
assert rule["target"]["fieldId"] == "orders_country"
67+
assert rule["operator"] == "equals"
68+
assert rule["values"] == ["USA"]
69+
70+
def test_limit_included(self, model):
71+
"""The limit flows into the payload (and thus the SQL) unbounded."""
72+
model.query(metrics=["orders_revenue"]).limit(10_000_000).compile()
73+
assert model._client.requests[-1]["json"]["limit"] == 10_000_000
74+
75+
def test_requires_client(self):
76+
m = Model(name="orders", type="default", database_name="db", schema_name="s")
77+
with pytest.raises(RuntimeError, match="not properly initialized"):
78+
m.query(metrics=["orders_revenue"]).compile()

0 commit comments

Comments
 (0)