Skip to content

Commit 6cede38

Browse files
committed
feat: Add include_sql to Search Pipeline Run API
1 parent 8b74a37 commit 6cede38

2 files changed

Lines changed: 129 additions & 11 deletions

File tree

cloud_pipelines_backend/api_server_sql.py

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ class GetPipelineRunResponse(PipelineRunResponse):
6767
class ListPipelineJobsResponse:
6868
pipeline_runs: list[PipelineRunResponse]
6969
next_page_token: str | None = None
70+
sql: str | None = None
7071

7172

7273
class PipelineRunsApiService_Sql:
@@ -169,6 +170,36 @@ def terminate(
169170
execution_node.extra_data["desired_state"] = "TERMINATED"
170171
session.commit()
171172

173+
@staticmethod
174+
def _compile_sql_string(
175+
stmt: sql.Select,
176+
dialect: sql.engine.Dialect,
177+
) -> str:
178+
"""Compile a SQLAlchemy statement to a SQL string for debugging.
179+
180+
Uses ``literal_binds=True`` to inline bound parameters as literal
181+
values, producing a self-contained query string::
182+
183+
SELECT ... WHERE key = 'environment' AND created_at < '2024-01-15' LIMIT 10
184+
185+
If a column type lacks a ``literal_processor`` (raises CompileError or
186+
NotImplementedError), falls back to placeholder syntax with a params
187+
comment::
188+
189+
SELECT ... WHERE key = :key_1 AND created_at < :created_at_1 LIMIT :param_1
190+
-- params: {'key_1': 'environment', 'created_at_1': '2024-01-15', 'param_1': 10}
191+
"""
192+
try:
193+
compiled = stmt.compile(
194+
dialect=dialect,
195+
compile_kwargs={"literal_binds": True},
196+
)
197+
return str(compiled)
198+
except (sql.exc.CompileError, NotImplementedError):
199+
compiled = stmt.compile(dialect=dialect)
200+
params_suffix = f"\n-- params: {compiled.params}" if compiled.params else ""
201+
return str(compiled) + params_suffix
202+
172203
# Note: This method must be last to not shadow the "list" type
173204
def list(
174205
self,
@@ -180,6 +211,7 @@ def list(
180211
current_user: str | None = None,
181212
include_pipeline_names: bool = False,
182213
include_execution_stats: bool = False,
214+
include_sql: bool = False,
183215
) -> ListPipelineJobsResponse:
184216
where_clauses = filter_query_sql.build_list_filters(
185217
filter_value=filter,
@@ -188,18 +220,22 @@ def list(
188220
current_user=current_user,
189221
)
190222

191-
pipeline_runs = list(
192-
session.scalars(
193-
sql.select(bts.PipelineRun)
194-
.where(*where_clauses)
195-
.order_by(
196-
bts.PipelineRun.created_at.desc(),
197-
bts.PipelineRun.id.desc(),
198-
)
199-
.limit(_DEFAULT_PAGE_SIZE)
200-
).all()
223+
stmt = (
224+
sql.select(bts.PipelineRun)
225+
.where(*where_clauses)
226+
.order_by(
227+
bts.PipelineRun.created_at.desc(),
228+
bts.PipelineRun.id.desc(),
229+
)
230+
.limit(_DEFAULT_PAGE_SIZE)
201231
)
202232

233+
sql_string = None
234+
if include_sql:
235+
sql_string = self._compile_sql_string(stmt, session.bind.dialect)
236+
237+
pipeline_runs = list(session.scalars(stmt).all())
238+
203239
next_page_token = filter_query_sql.maybe_next_page_token(
204240
rows=pipeline_runs, page_size=_DEFAULT_PAGE_SIZE
205241
)
@@ -215,6 +251,7 @@ def list(
215251
for pipeline_run in pipeline_runs
216252
],
217253
next_page_token=next_page_token,
254+
sql=sql_string,
218255
)
219256

220257
def _create_pipeline_run_response(

tests/test_api_server_sql.py

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ def test_list_cursor_pagination_order(self, session_factory, service):
202202

203203
with session_factory() as session:
204204
result = service.list(session=session)
205-
205+
206206
dates = [r.created_at for r in result.pipeline_runs]
207207
assert dates == sorted(dates, reverse=True)
208208

@@ -298,6 +298,87 @@ def test_list_filter_created_by_me(self, session_factory, service):
298298
assert len(result.pipeline_runs) == 1
299299
assert result.pipeline_runs[0].created_by == "alice@example.com"
300300

301+
def test_list_include_sql_default_none(self, session_factory, service):
302+
_create_run(session_factory, service, root_task=_make_task_spec())
303+
304+
with session_factory() as session:
305+
result = service.list(session=session)
306+
assert result.sql is None
307+
308+
def test_list_include_sql_true(self, session_factory, service):
309+
_create_run(session_factory, service, root_task=_make_task_spec())
310+
311+
with session_factory() as session:
312+
result = service.list(session=session, include_sql=True)
313+
expected = (
314+
"SELECT pipeline_run.id, pipeline_run.root_execution_id,"
315+
" pipeline_run.annotations, pipeline_run.created_by,"
316+
" pipeline_run.created_at, pipeline_run.updated_at,"
317+
" pipeline_run.parent_pipeline_id, pipeline_run.extra_data \n"
318+
"FROM pipeline_run"
319+
" ORDER BY pipeline_run.created_at DESC, pipeline_run.id DESC\n"
320+
" LIMIT 10 OFFSET 0"
321+
)
322+
assert result.sql == expected
323+
324+
def test_list_include_sql_with_filter_query(self, session_factory, service):
325+
run = _create_run(session_factory, service, root_task=_make_task_spec())
326+
with session_factory() as session:
327+
service.set_annotation(session=session, id=run.id, key="team", value="ml")
328+
329+
fq = json.dumps({"and": [{"key_exists": {"key": "team"}}]})
330+
with session_factory() as session:
331+
result = service.list(session=session, filter_query=fq, include_sql=True)
332+
expected = (
333+
"SELECT pipeline_run.id, pipeline_run.root_execution_id,"
334+
" pipeline_run.annotations, pipeline_run.created_by,"
335+
" pipeline_run.created_at, pipeline_run.updated_at,"
336+
" pipeline_run.parent_pipeline_id, pipeline_run.extra_data \n"
337+
"FROM pipeline_run \n"
338+
"WHERE EXISTS (SELECT pipeline_run_annotation.pipeline_run_id \n"
339+
"FROM pipeline_run_annotation \n"
340+
"WHERE pipeline_run_annotation.pipeline_run_id = pipeline_run.id"
341+
" AND pipeline_run_annotation.\"key\" = 'team')"
342+
" ORDER BY pipeline_run.created_at DESC, pipeline_run.id DESC\n"
343+
" LIMIT 10 OFFSET 0"
344+
)
345+
assert result.sql == expected
346+
347+
def test_list_include_sql_with_cursor(self, session_factory, service):
348+
for i in range(12):
349+
_create_run(
350+
session_factory,
351+
service,
352+
root_task=_make_task_spec(f"pipeline-{i}"),
353+
)
354+
355+
with session_factory() as session:
356+
page1 = service.list(session=session)
357+
assert page1.next_page_token is not None
358+
359+
with session_factory() as session:
360+
page2 = service.list(
361+
session=session,
362+
page_token=page1.next_page_token,
363+
include_sql=True,
364+
)
365+
366+
cursor_dt_iso, cursor_id = page1.next_page_token.split("~")
367+
cursor_dt = datetime.datetime.fromisoformat(cursor_dt_iso)
368+
sql_dt = cursor_dt.strftime("%Y-%m-%d %H:%M:%S.%f")
369+
expected = (
370+
"SELECT pipeline_run.id, pipeline_run.root_execution_id,"
371+
" pipeline_run.annotations, pipeline_run.created_by,"
372+
" pipeline_run.created_at, pipeline_run.updated_at,"
373+
" pipeline_run.parent_pipeline_id, pipeline_run.extra_data \n"
374+
"FROM pipeline_run \n"
375+
f"WHERE (pipeline_run.created_at, pipeline_run.id)"
376+
f" < ('{sql_dt}', '{cursor_id}')"
377+
" ORDER BY pipeline_run.created_at DESC, pipeline_run.id DESC\n"
378+
" LIMIT 10 OFFSET 0"
379+
)
380+
assert page2.sql == expected
381+
301382

302383
class TestCreatePipelineRunResponse:
303384
def test_base_response(self, session_factory, service):

0 commit comments

Comments
 (0)