Skip to content

Commit d9e10c7

Browse files
committed
Added an admin-only EXPLAIN button
1 parent 972ef0f commit d9e10c7

File tree

3 files changed

+167
-12
lines changed

3 files changed

+167
-12
lines changed

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/route.tsx

Lines changed: 89 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -150,15 +150,20 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
150150
organizationId: project.organizationId,
151151
});
152152

153+
// Admins and impersonating users can use EXPLAIN
154+
const isAdmin = user.admin || user.isImpersonating;
155+
153156
return typedjson({
154157
defaultQuery,
155158
history,
159+
isAdmin,
156160
});
157161
};
158162

159163
const ActionSchema = z.object({
160164
query: z.string().min(1, "Query is required"),
161165
scope: z.enum(["environment", "project", "organization"]),
166+
explain: z.enum(["true", "false"]).nullable().optional(),
162167
});
163168

164169
export const action = async ({ request, params }: ActionFunctionArgs) => {
@@ -173,15 +178,31 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
173178
);
174179
if (!canAccess) {
175180
return typedjson(
176-
{ error: "Unauthorized", rows: null, columns: null, stats: null, hiddenColumns: null },
181+
{
182+
error: "Unauthorized",
183+
rows: null,
184+
columns: null,
185+
stats: null,
186+
hiddenColumns: null,
187+
explainOutput: null,
188+
generatedSql: null,
189+
},
177190
{ status: 403 }
178191
);
179192
}
180193

181194
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
182195
if (!project) {
183196
return typedjson(
184-
{ error: "Project not found", rows: null, columns: null, stats: null, hiddenColumns: null },
197+
{
198+
error: "Project not found",
199+
rows: null,
200+
columns: null,
201+
stats: null,
202+
hiddenColumns: null,
203+
explainOutput: null,
204+
generatedSql: null,
205+
},
185206
{ status: 404 }
186207
);
187208
}
@@ -195,6 +216,8 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
195216
columns: null,
196217
stats: null,
197218
hiddenColumns: null,
219+
explainOutput: null,
220+
generatedSql: null,
198221
},
199222
{ status: 404 }
200223
);
@@ -204,6 +227,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
204227
const parsed = ActionSchema.safeParse({
205228
query: formData.get("query"),
206229
scope: formData.get("scope"),
230+
explain: formData.get("explain"),
207231
});
208232

209233
if (!parsed.success) {
@@ -214,12 +238,17 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
214238
columns: null,
215239
stats: null,
216240
hiddenColumns: null,
241+
explainOutput: null,
242+
generatedSql: null,
217243
},
218244
{ status: 400 }
219245
);
220246
}
221247

222-
const { query, scope } = parsed.data;
248+
const { query, scope, explain: explainParam } = parsed.data;
249+
// Only allow explain for admins/impersonating users
250+
const isAdmin = user.admin || user.isImpersonating;
251+
const explain = explainParam === "true" && isAdmin;
223252

224253
try {
225254
const [error, result] = await executeQuery({
@@ -232,6 +261,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
232261
organizationId: project.organizationId,
233262
projectId: project.id,
234263
environmentId: environment.id,
264+
explain,
235265
history: {
236266
source: "DASHBOARD",
237267
userId: user.id,
@@ -240,7 +270,15 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
240270

241271
if (error) {
242272
return typedjson(
243-
{ error: error.message, rows: null, columns: null, stats: null, hiddenColumns: null },
273+
{
274+
error: error.message,
275+
rows: null,
276+
columns: null,
277+
stats: null,
278+
hiddenColumns: null,
279+
explainOutput: null,
280+
generatedSql: null,
281+
},
244282
{ status: 400 }
245283
);
246284
}
@@ -251,11 +289,21 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
251289
columns: result.columns,
252290
stats: result.stats,
253291
hiddenColumns: result.hiddenColumns ?? null,
292+
explainOutput: result.explainOutput ?? null,
293+
generatedSql: result.generatedSql ?? null,
254294
});
255295
} catch (err) {
256296
const errorMessage = err instanceof Error ? err.message : "Unknown error executing query";
257297
return typedjson(
258-
{ error: errorMessage, rows: null, columns: null, stats: null, hiddenColumns: null },
298+
{
299+
error: errorMessage,
300+
rows: null,
301+
columns: null,
302+
stats: null,
303+
hiddenColumns: null,
304+
explainOutput: null,
305+
generatedSql: null,
306+
},
259307
{ status: 500 }
260308
);
261309
}
@@ -276,8 +324,9 @@ const QueryEditorForm = forwardRef<
276324
defaultScope: QueryScope;
277325
history: QueryHistoryItem[];
278326
isLoading: boolean;
327+
isAdmin: boolean;
279328
}
280-
>(function QueryEditorForm({ defaultQuery, defaultScope, history, isLoading }, ref) {
329+
>(function QueryEditorForm({ defaultQuery, defaultScope, history, isLoading, isAdmin }, ref) {
281330
const [query, setQuery] = useState(defaultQuery);
282331
const [scope, setScope] = useState<QueryScope>(defaultScope);
283332

@@ -332,6 +381,17 @@ const QueryEditorForm = forwardRef<
332381
))
333382
}
334383
</Select>
384+
{isAdmin && (
385+
<Button
386+
type="submit"
387+
name="explain"
388+
value="true"
389+
variant="tertiary/small"
390+
disabled={isLoading || !query.trim()}
391+
>
392+
Explain
393+
</Button>
394+
)}
335395
<Button
336396
type="submit"
337397
variant="primary/small"
@@ -348,7 +408,7 @@ const QueryEditorForm = forwardRef<
348408
});
349409

350410
export default function Page() {
351-
const { defaultQuery, history } = useTypedLoaderData<typeof loader>();
411+
const { defaultQuery, history, isAdmin } = useTypedLoaderData<typeof loader>();
352412
const results = useTypedActionData<typeof action>();
353413
const navigation = useNavigation();
354414

@@ -406,6 +466,7 @@ export default function Page() {
406466
defaultScope={initialScope}
407467
history={history}
408468
isLoading={isLoading}
469+
isAdmin={isAdmin}
409470
/>
410471
{/* Results */}
411472
<div className="grid max-h-full grid-rows-[2rem_1fr] overflow-hidden border-t border-grid-dimmed bg-charcoal-800">
@@ -472,6 +533,27 @@ export default function Page() {
472533
Try fix error
473534
</Button>
474535
</div>
536+
) : results?.explainOutput ? (
537+
<div className="flex h-full flex-col gap-4 overflow-auto p-3">
538+
{results.generatedSql && (
539+
<div>
540+
<Header3 className="mb-2">Generated ClickHouse SQL</Header3>
541+
<div className="overflow-auto rounded border border-grid-dimmed bg-charcoal-900 p-3">
542+
<pre className="whitespace-pre font-mono text-xs text-text-bright">
543+
{results.generatedSql}
544+
</pre>
545+
</div>
546+
</div>
547+
)}
548+
<div className="flex min-h-0 flex-1 flex-col">
549+
<Header3 className="mb-2">Query Execution Plan</Header3>
550+
<div className="min-h-0 flex-1 overflow-auto rounded border border-grid-dimmed bg-charcoal-900 p-3">
551+
<pre className="whitespace-pre font-mono text-xs text-text-bright">
552+
{results.explainOutput}
553+
</pre>
554+
</div>
555+
</div>
556+
</div>
475557
) : results?.rows && results?.columns ? (
476558
<div className="flex h-full flex-col overflow-hidden">
477559
{results.hiddenColumns && results.hiddenColumns.length > 0 && (

apps/webapp/app/services/queryService.server.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,8 @@ export async function executeQuery<TOut extends z.ZodSchema>(
161161
});
162162

163163
// If query succeeded and history options provided, save to history
164-
if (result[0] === null && history) {
164+
// Skip history for EXPLAIN queries (admin debugging, not billable)
165+
if (result[0] === null && history && !baseOptions.explain) {
165166
const stats = result[1].stats;
166167
const byteSeconds = parseFloat(stats.byte_seconds) || 0;
167168
const costInCents = byteSeconds * env.CENTS_PER_QUERY_BYTE_SECOND;

internal-packages/clickhouse/src/client/tsql.ts

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,13 @@ export interface ExecuteTSQLOptions<TOut extends z.ZodSchema> {
6767
* ```
6868
*/
6969
fieldMappings?: FieldMappings;
70+
/**
71+
* Run EXPLAIN instead of executing the query.
72+
* Returns the ClickHouse execution plan with index information.
73+
* Should only be used by admins for debugging query performance.
74+
* @default false
75+
*/
76+
explain?: boolean;
7077
}
7178

7279
/**
@@ -81,6 +88,16 @@ export interface TSQLQuerySuccess<T> {
8188
* Only populated when SELECT * is transformed to core columns only.
8289
*/
8390
hiddenColumns?: string[];
91+
/**
92+
* The raw EXPLAIN output from ClickHouse.
93+
* Only populated when `explain: true` is passed.
94+
*/
95+
explainOutput?: string;
96+
/**
97+
* The generated ClickHouse SQL query.
98+
* Only populated when `explain: true` is passed.
99+
*/
100+
generatedSql?: string;
84101
}
85102

86103
/**
@@ -113,6 +130,7 @@ export async function executeTSQL<TOut extends z.ZodSchema>(
113130
options: ExecuteTSQLOptions<TOut>
114131
): Promise<TSQLQueryResult<z.output<TOut>>> {
115132
const shouldTransformValues = options.transformValues ?? true;
133+
const isExplain = options.explain ?? false;
116134

117135
let generatedSql: string | undefined;
118136
let generatedParams: Record<string, unknown> | undefined;
@@ -131,12 +149,15 @@ export async function executeTSQL<TOut extends z.ZodSchema>(
131149
generatedSql = sql;
132150
generatedParams = params;
133151

134-
// 2. Execute the query with stats
152+
// 2. Execute the query (or EXPLAIN) with stats
153+
const queryToExecute = isExplain ? `EXPLAIN indexes = 1 ${sql}` : sql;
154+
135155
const queryFn = reader.queryWithStats({
136-
name: options.name,
137-
query: sql,
156+
name: isExplain ? `${options.name}-explain` : options.name,
157+
query: queryToExecute,
138158
params: z.record(z.any()),
139-
schema: options.schema,
159+
// EXPLAIN returns rows with an 'explain' column
160+
schema: isExplain ? z.object({ explain: z.string() }) : options.schema,
140161
settings: options.clickhouseSettings,
141162
});
142163

@@ -150,6 +171,57 @@ export async function executeTSQL<TOut extends z.ZodSchema>(
150171

151172
const { rows, stats } = result;
152173

174+
// Handle EXPLAIN mode - run multiple explain types and combine outputs
175+
if (isExplain) {
176+
const explainRows = rows as Array<{ explain: string }>;
177+
const indexesOutput = explainRows.map((r) => r.explain).join("\n");
178+
179+
// Run additional explain queries for more comprehensive output
180+
const explainTypes = [
181+
{ name: "ESTIMATE", query: `EXPLAIN ESTIMATE ${sql}` },
182+
{ name: "PIPELINE", query: `EXPLAIN PIPELINE ${sql}` },
183+
];
184+
185+
const additionalOutputs: string[] = [];
186+
187+
for (const explainType of explainTypes) {
188+
try {
189+
const additionalQueryFn = reader.queryWithStats({
190+
name: `${options.name}-explain-${explainType.name.toLowerCase()}`,
191+
query: explainType.query,
192+
params: z.record(z.any()),
193+
schema: z.object({ explain: z.string() }),
194+
settings: options.clickhouseSettings,
195+
});
196+
197+
const [additionalError, additionalResult] = await additionalQueryFn(params);
198+
199+
if (!additionalError && additionalResult) {
200+
const additionalRows = additionalResult.rows as Array<{ explain: string }>;
201+
const output = additionalRows.map((r) => r.explain).join("\n");
202+
additionalOutputs.push(`── ${explainType.name} ──\n${output}`);
203+
}
204+
} catch {
205+
// Ignore errors from additional explain queries
206+
}
207+
}
208+
209+
// Combine all explain outputs
210+
const combinedOutput = ["── INDEXES ──", indexesOutput, "", ...additionalOutputs].join("\n");
211+
212+
return [
213+
null,
214+
{
215+
rows: [] as z.output<TOut>[],
216+
columns: [],
217+
stats,
218+
hiddenColumns,
219+
explainOutput: combinedOutput,
220+
generatedSql,
221+
},
222+
];
223+
}
224+
153225
// Build the result, including hiddenColumns if present
154226
const baseResult = { columns, stats, hiddenColumns };
155227

0 commit comments

Comments
 (0)