Skip to content

Commit f0c52fa

Browse files
nextchamp-saqibmergify[bot]
authored andcommitted
refactor: centralize circular query dependency detection (#1032)
(cherry picked from commit b03eb3b)
1 parent afadcf1 commit f0c52fa

File tree

6 files changed

+162
-76
lines changed

6 files changed

+162
-76
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,3 +185,4 @@ pids
185185
*.pid.lock
186186
todo
187187
frontend/package-lock.json
188+
.opencode/package-lock.json

frontend/src2/query/query.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -125,15 +125,18 @@ export function makeQuery(name: string) {
125125

126126
const dataSource = computed(() => getDataSource(currentOperations.value))
127127

128-
function getDataSource(operations: Operation[]): string {
128+
function getDataSource(operations: Operation[], _visited: Set<string> = new Set()): string {
129129
const sourceOp = operations.find((op) => op.type === 'source')
130130
if (!sourceOp) return ''
131131
if (sourceOp.table.type === 'table') {
132132
return sourceOp.table.data_source
133133
}
134134
if (sourceOp.table.type === 'query' && 'query_name' in sourceOp.table) {
135-
const sourceQuery = useQuery(sourceOp.table.query_name)
136-
return getDataSource(sourceQuery.currentOperations)
135+
const queryName = sourceOp.table.query_name
136+
if (_visited.has(queryName)) return ''
137+
_visited.add(queryName)
138+
const sourceQuery = useQuery(queryName)
139+
return getDataSource(sourceQuery.currentOperations, _visited)
137140
}
138141
return ''
139142
}
@@ -813,7 +816,7 @@ export function makeQuery(name: string) {
813816
operations: Operation[],
814817
currRow: QueryResultRow,
815818
col: QueryResultColumn,
816-
_visitedQueryNames: string[] = [],
819+
_visitedQueries: Set<string> = new Set(),
817820
): Promise<{ ops: Operation[]; filters: FilterArgs[] } | null> {
818821
// If there's a local summarize/pivot, no inlining needed
819822
const hasSummarizeOrPivot = operations.find(
@@ -857,7 +860,7 @@ export function makeQuery(name: string) {
857860
const sourceQueryName = sourceOp.table.query_name
858861

859862
// Guard against circular references
860-
if (_visitedQueryNames.includes(sourceQueryName)) {
863+
if (_visitedQueries.has(sourceQueryName)) {
861864
createToast({
862865
title: __('Failed to drill down'),
863866
message: __('Drill down is only supported on summarized data'),
@@ -884,7 +887,7 @@ export function makeQuery(name: string) {
884887
mergedOps,
885888
currRow,
886889
col,
887-
[..._visitedQueryNames, sourceQueryName],
890+
_visitedQueries.add(sourceQueryName),
888891
)
889892
}
890893

frontend/src2/workbook/workbook.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -448,7 +448,10 @@ export function newWorkbookName() {
448448
return `new-workbook-${unique_id}`
449449
}
450450

451-
export function getLinkedQueries(query_name: string): string[] {
451+
export function getLinkedQueries(query_name: string, _visited: Set<string> = new Set()): string[] {
452+
if (_visited.has(query_name)) return []
453+
_visited.add(query_name)
454+
452455
const query = useQuery(query_name)
453456
const linkedQueries = new Set<string>()
454457

@@ -467,7 +470,7 @@ export function getLinkedQueries(query_name: string): string[] {
467470
}
468471
})
469472

470-
linkedQueries.forEach((q) => getLinkedQueries(q).forEach((q) => linkedQueries.add(q)))
473+
linkedQueries.forEach((q) => getLinkedQueries(q, _visited).forEach((q) => linkedQueries.add(q)))
471474

472475
return Array.from(linkedQueries)
473476
}

insights/insights/doctype/insights_data_source_v3/ibis_utils.py

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@
3232
from .ibis.utils import get_functions
3333

3434

35+
class CircularQueryReferenceError(frappe.ValidationError):
36+
"""Raised when a circular query reference is detected during query building."""
37+
38+
pass
39+
40+
3541
class IbisQueryBuilder:
3642
def __init__(self, doc, active_operation_idx=None):
3743
self.doc = doc
@@ -72,20 +78,34 @@ def set_operations(self):
7278
self.operations = operations
7379

7480
def build(self) -> IbisQuery:
75-
self.query = None
76-
for idx, operation in enumerate(self.operations):
77-
try:
78-
operation = _dict(operation)
79-
self.query = self.perform_operation(operation)
80-
except BaseException as e:
81-
operation_type_title = frappe.bold(operation.type.title())
82-
create_toast(
83-
title=f"Failed to Build {self.title} Query",
84-
message=f"Please check the {operation_type_title} operation at position {idx + 1}",
85-
type="error",
86-
)
87-
raise e
88-
return self.query
81+
if not hasattr(frappe.local, "_insights_building_queries"):
82+
frappe.local._insights_building_queries = set()
83+
84+
if self.doc.name in frappe.local._insights_building_queries:
85+
raise CircularQueryReferenceError(
86+
frappe._('Circular query reference detected while building "{0}"').format(self.title)
87+
)
88+
89+
frappe.local._insights_building_queries.add(self.doc.name)
90+
try:
91+
self.query = None
92+
for idx, operation in enumerate(self.operations):
93+
try:
94+
operation = _dict(operation)
95+
self.query = self.perform_operation(operation)
96+
except CircularQueryReferenceError:
97+
raise
98+
except BaseException as e:
99+
operation_type_title = frappe.bold(operation.type.title())
100+
create_toast(
101+
title=f"Failed to Build {self.title} Query",
102+
message=f"Please check the {operation_type_title} operation at position {idx + 1}",
103+
type="error",
104+
)
105+
raise e
106+
return self.query
107+
finally:
108+
frappe.local._insights_building_queries.discard(self.doc.name)
89109

90110
def perform_operation(self, operation):
91111
if operation.type == "source":

insights/insights/doctype/insights_query_v3/insights_query_v3.py

Lines changed: 52 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,16 @@
1313

1414
from insights.decorators import insights_whitelist
1515
from insights.insights.doctype.insights_data_source_v3.ibis_utils import (
16+
CircularQueryReferenceError,
1617
IbisQueryBuilder,
1718
execute_ibis_query,
1819
get_columns_from_schema,
1920
)
21+
from insights.insights.query_utils import (
22+
extract_query_deps_from_operations,
23+
find_cycle,
24+
transitive_closure,
25+
)
2026
from insights.utils import deep_convert_dict_to_dict
2127

2228

@@ -67,6 +73,27 @@ def on_trash(self):
6773
if self.folder:
6874
self.cleanup_empty_folder(self.folder)
6975

76+
def validate(self):
77+
self._validate_no_circular_dependency()
78+
79+
def _validate_no_circular_dependency(self):
80+
"""Raise an error if the current operations would create a circular query reference."""
81+
operations = frappe.parse_json(self.operations) or []
82+
new_direct_deps = extract_query_deps_from_operations(operations)
83+
84+
if not new_direct_deps:
85+
return
86+
87+
cycle = find_cycle(self.name, new_direct_deps)
88+
if cycle:
89+
path_str = " → ".join(
90+
f'"{frappe.db.get_value("Insights Query v3", q, "title") or q}"' for q in cycle
91+
)
92+
frappe.throw(
93+
f"Circular query reference detected: {path_str}",
94+
exc=CircularQueryReferenceError,
95+
)
96+
7097
def before_save(self):
7198
self.set_linked_queries()
7299

@@ -88,62 +115,33 @@ def cleanup_empty_folder(self, folder_name):
88115
frappe.delete_doc("Insights Folder", folder_name, force=True, ignore_permissions=True)
89116

90117
def set_linked_queries(self):
91-
operations = frappe.parse_json(self.operations)
92-
if not operations:
93-
return
94-
95-
linked_queries = []
96-
for operation in operations:
97-
if (
98-
operation.get("table")
99-
and operation.get("table").get("type") == "query"
100-
and operation.get("table").get("query_name")
101-
):
102-
linked_queries.append(operation.get("table").get("query_name"))
103-
self.linked_queries = linked_queries
104-
105-
def get_source_tables(self, visited=None):
106-
"""Recursively collect all leaf table references from this query and its dependencies."""
107-
if visited is None:
108-
visited = set()
109-
110-
# Prevent infinite recursion from circular dependencies
111-
if self.name in visited:
112-
return []
113-
visited.add(self.name)
118+
operations = frappe.parse_json(self.operations) or []
119+
self.linked_queries = extract_query_deps_from_operations(operations)
114120

121+
def get_source_tables(self):
122+
"""Collect all leaf table references from this query and its dependencies."""
123+
all_query_names = {self.name} | transitive_closure(self.name)
115124
source_tables = []
116-
operations = frappe.parse_json(self.operations)
117-
118-
if not operations:
119-
return source_tables
120-
121-
# Collect direct table references from this query's operations
122-
for operation in operations:
123-
if operation.get("type") in ["source", "join", "union"]:
124-
table = operation.get("table")
125-
if table and table.get("type") == "table":
126-
table_info = {
127-
"data_source": table.get("data_source"),
128-
"table_name": table.get("table_name"),
129-
}
130-
if table_info not in source_tables:
131-
source_tables.append(table_info)
132-
133-
# Recursively collect tables from linked queries
134-
linked_queries = frappe.parse_json(self.linked_queries)
135-
if linked_queries:
136-
for query_name in linked_queries:
137-
if query_name not in visited:
138-
try:
139-
linked_query = frappe.get_doc("Insights Query v3", query_name)
140-
linked_tables = linked_query.get_source_tables(visited)
141-
for table_info in linked_tables:
142-
if table_info not in source_tables:
143-
source_tables.append(table_info)
144-
except Exception:
145-
# Skip if linked query doesn't exist or can't be loaded
146-
continue
125+
126+
for query_name in all_query_names:
127+
if query_name == self.name:
128+
operations = frappe.parse_json(self.operations) or []
129+
else:
130+
operations = (
131+
frappe.parse_json(frappe.db.get_value("Insights Query v3", query_name, "operations"))
132+
or []
133+
)
134+
135+
for operation in operations:
136+
if operation.get("type") in ["source", "join", "union"]:
137+
table = operation.get("table")
138+
if table and table.get("type") == "table":
139+
table_info = {
140+
"data_source": table.get("data_source"),
141+
"table_name": table.get("table_name"),
142+
}
143+
if table_info not in source_tables:
144+
source_tables.append(table_info)
147145

148146
return source_tables
149147

insights/insights/query_utils.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
2+
# For license information, please see license.txt
3+
4+
import frappe
5+
6+
7+
def extract_query_deps_from_operations(operations: list) -> list[str]:
8+
"""Extract all referenced query names from a list of operations."""
9+
return [
10+
op["table"]["query_name"]
11+
for op in operations
12+
if op.get("table")
13+
and op.get("table", {}).get("type") == "query"
14+
and op.get("table", {}).get("query_name")
15+
]
16+
17+
18+
def get_direct_dependencies(query_name: str) -> list[str]:
19+
"""Return the list of query names this query directly depends on, from the DB."""
20+
linked_queries = frappe.db.get_value("Insights Query v3", query_name, "linked_queries")
21+
return frappe.parse_json(linked_queries) or []
22+
23+
24+
def transitive_closure(start: str) -> set[str]:
25+
"""Return all query names reachable from start (not including start itself)."""
26+
reachable: set[str] = set()
27+
stack = list(get_direct_dependencies(start))
28+
while stack:
29+
node = stack.pop()
30+
if node in reachable:
31+
continue
32+
reachable.add(node)
33+
stack.extend(get_direct_dependencies(node))
34+
return reachable
35+
36+
37+
def find_cycle(start: str, new_direct_deps: list[str]) -> list[str] | None:
38+
"""
39+
Check whether declaring `new_direct_deps` as the direct dependencies of `start`
40+
would form a cycle. Returns the cycle path (e.g. ["A", "B", "A"]) or None.
41+
42+
Used during validate() to catch cycles before they are persisted.
43+
"""
44+
for dep in new_direct_deps:
45+
path = _find_path(dep, target=start, path=[start, dep], visited=set())
46+
if path is not None:
47+
return path
48+
return None
49+
50+
51+
def _find_path(current: str, target: str, path: list[str], visited: set[str]) -> list[str] | None:
52+
if current == target:
53+
return path
54+
if current in visited:
55+
return None
56+
visited.add(current)
57+
for dep in get_direct_dependencies(current):
58+
result = _find_path(dep, target, [*path, dep], visited)
59+
if result is not None:
60+
return result
61+
return None

0 commit comments

Comments
 (0)