TRQL and the Query page (#2843)

TRQL (pronounced Treacle like the delicious British dark sweet syrup) is
the TRiggerQueryLanguage. It allows users to safely write queries on
their data. The queries are safely turned into ClickHouse queries which
are tenant-safe and not SQL injectable.


https://github.com/user-attachments/assets/bbfca473-b3fc-4150-8fe6-79e8840a2d29

This started out as a translation of HogQL by PostHog from Python to
TypeScript.

Features
- Tenant safe queries.
- Many underlying ClickHouse features including functions and
aggregations.
- Virtual columns, which are exposed to users as real columns but are
actually expressions.
- Transformations of data types and where clauses.
- Simple JSON path querying.
- Limits on execution time.
- Reporting of query statistics.

## Query page

There’s a new Query page (currently behind a feature flag) where you can
write TRQL queries and execute them against your environment, project or
organization.

Features
- Executing TRQL queries
- Syntax highlighting and errors
- Autocomplete
- AI generation/editing of queries
- Help and examples
- Table with auto-inferred data types from the table schema
- Table cell renderers for our special types like Run ids, environments,
machines, tasks, queues, etc.
- Copy/export as CSV/JSON
- Line and bar graphs with grouping and stacking
- History of queries
This commit is contained in:
Matt Aitken
2026-01-09 11:39:36 +00:00
committed by GitHub
parent cf0aa9b3ca
commit 49df40cb11
100 changed files with 43032 additions and 585 deletions
+32
View File
@@ -0,0 +1,32 @@
import { cn } from "~/utils/cn";
import { Badge } from "./primitives/Badge";
import { SimpleTooltip } from "./primitives/Tooltip";
export function AlphaBadge({
inline = false,
className,
}: {
inline?: boolean;
className?: string;
}) {
return (
<SimpleTooltip
button={
<Badge variant="extra-small" className={cn(inline ? "inline-grid" : "", className)}>
Alpha
</Badge>
}
content="This feature is in Alpha."
disableHoverableContent
/>
);
}
export function AlphaTitle({ children }: { children: React.ReactNode }) {
return (
<>
<span>{children}</span>
<AlphaBadge />
</>
);
}
@@ -0,0 +1,401 @@
import { PencilSquareIcon, PlusIcon, SparklesIcon } from "@heroicons/react/20/solid";
import { AnimatePresence, motion } from "framer-motion";
import { Suspense, lazy, useCallback, useEffect, useRef, useState } from "react";
import { AISparkleIcon } from "~/assets/icons/AISparkleIcon";
// Lazy load streamdown components to avoid SSR issues
const StreamdownRenderer = lazy(() =>
import("streamdown").then((mod) => ({
default: ({ children, isAnimating }: { children: string; isAnimating: boolean }) => (
<mod.ShikiThemeContext.Provider value={["one-dark-pro", "one-dark-pro"]}>
<mod.Streamdown isAnimating={isAnimating}>{children}</mod.Streamdown>
</mod.ShikiThemeContext.Provider>
),
}))
);
import { Button } from "~/components/primitives/Buttons";
import { Spinner } from "~/components/primitives/Spinner";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { cn } from "~/utils/cn";
type StreamEventType =
| { type: "thinking"; content: string }
| { type: "tool_call"; tool: string; args: unknown }
| { type: "result"; success: true; query: string }
| { type: "result"; success: false; error: string };
export type AIQueryMode = "new" | "edit";
interface AIQueryInputProps {
onQueryGenerated: (query: string) => void;
/** Set this to a prompt to auto-populate and immediately submit */
autoSubmitPrompt?: string;
/** Get the current query in the editor (used for edit mode) */
getCurrentQuery?: () => string;
}
export function AIQueryInput({
onQueryGenerated,
autoSubmitPrompt,
getCurrentQuery,
}: AIQueryInputProps) {
const [prompt, setPrompt] = useState("");
const [mode, setMode] = useState<AIQueryMode>("new");
const [isLoading, setIsLoading] = useState(false);
const [thinking, setThinking] = useState("");
const [error, setError] = useState<string | null>(null);
const [showThinking, setShowThinking] = useState(false);
const [lastResult, setLastResult] = useState<"success" | "error" | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const lastAutoSubmitRef = useRef<string | null>(null);
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const resourcePath = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/query/ai-generate`;
// Can only use edit mode if there's a current query
const canEdit = Boolean(getCurrentQuery?.()?.trim());
// If mode is edit but there's no current query, switch to new
useEffect(() => {
if (mode === "edit" && !canEdit) {
setMode("new");
}
}, [mode, canEdit]);
const submitQuery = useCallback(
async (queryPrompt: string, submitMode: AIQueryMode = mode) => {
if (!queryPrompt.trim() || isLoading) return;
const currentQuery = getCurrentQuery?.();
if (submitMode === "edit" && !currentQuery?.trim()) return;
setIsLoading(true);
setThinking("");
setError(null);
setShowThinking(true);
setLastResult(null);
// Abort any existing request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
try {
const formData = new FormData();
formData.append("prompt", queryPrompt);
formData.append("mode", submitMode);
if (submitMode === "edit" && currentQuery) {
formData.append("currentQuery", currentQuery);
}
const response = await fetch(resourcePath, {
method: "POST",
body: formData,
signal: abortControllerRef.current.signal,
});
if (!response.ok) {
const errorData = (await response.json()) as { error?: string };
setError(errorData.error || "Failed to generate query");
setIsLoading(false);
setLastResult("error");
return;
}
const reader = response.body?.getReader();
if (!reader) {
setError("No response stream");
setIsLoading(false);
setLastResult("error");
return;
}
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Process complete events from buffer
const lines = buffer.split("\n\n");
buffer = lines.pop() || ""; // Keep incomplete line in buffer
for (const line of lines) {
if (line.startsWith("data: ")) {
try {
const event = JSON.parse(line.slice(6)) as StreamEventType;
processStreamEvent(event);
} catch {
// Ignore parse errors
}
}
}
}
// Process any remaining data
if (buffer.startsWith("data: ")) {
try {
const event = JSON.parse(buffer.slice(6)) as StreamEventType;
processStreamEvent(event);
} catch {
// Ignore parse errors
}
}
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
// Request was aborted, ignore
return;
}
setError(err instanceof Error ? err.message : "An error occurred");
setLastResult("error");
} finally {
setIsLoading(false);
}
},
[isLoading, resourcePath, mode, getCurrentQuery]
);
const processStreamEvent = useCallback(
(event: StreamEventType) => {
switch (event.type) {
case "thinking":
setThinking((prev) => prev + event.content);
break;
case "tool_call":
setThinking((prev) => prev + `\nValidating query...\n`);
break;
case "result":
if (event.success) {
onQueryGenerated(event.query);
setPrompt("");
setLastResult("success");
// Keep thinking visible to show what happened
} else {
setError(event.error);
setLastResult("error");
}
break;
}
},
[onQueryGenerated]
);
const handleSubmit = useCallback(
(e?: React.FormEvent) => {
e?.preventDefault();
submitQuery(prompt);
},
[prompt, submitQuery]
);
// Auto-submit when autoSubmitPrompt changes
useEffect(() => {
if (
autoSubmitPrompt &&
autoSubmitPrompt.trim() &&
autoSubmitPrompt !== lastAutoSubmitRef.current &&
!isLoading
) {
lastAutoSubmitRef.current = autoSubmitPrompt;
setPrompt(autoSubmitPrompt);
submitQuery(autoSubmitPrompt);
}
}, [autoSubmitPrompt, isLoading, submitQuery]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, []);
// Auto-hide error after delay
useEffect(() => {
if (error) {
const timer = setTimeout(() => setError(null), 15000);
return () => clearTimeout(timer);
}
}, [error]);
return (
<div className="flex flex-col gap-3">
{/* Gradient border wrapper like the schedules AI input */}
<div
className="rounded-md p-px"
style={{ background: "linear-gradient(to bottom right, #E543FF, #286399)" }}
>
<div className="overflow-hidden rounded-[5px] bg-background-bright">
<form onSubmit={handleSubmit}>
<textarea
ref={textareaRef}
name="prompt"
placeholder={
mode === "edit"
? "e.g. add a filter for failed runs, change the limit to 50"
: "e.g. show me failed runs from the last 7 days"
}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
disabled={isLoading}
rows={8}
className="m-0 min-h-10 w-full resize-none border-0 bg-background-bright px-3 py-2.5 text-sm text-text-bright scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600 file:border-0 file:bg-transparent file:text-base file:font-medium placeholder:text-text-dimmed focus:border-0 focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50"
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey && prompt.trim() && !isLoading) {
e.preventDefault();
handleSubmit();
}
}}
/>
<div className="flex justify-end gap-2 px-2 pb-2">
{isLoading ? (
<Button
type="button"
variant="tertiary/small"
disabled={true}
LeadingIcon={Spinner}
className="pl-1.5"
iconSpacing="gap-1.5"
>
{mode === "edit" ? "Editing..." : "Generating..."}
</Button>
) : (
<>
<Button
type="button"
variant="tertiary/small"
disabled={!prompt.trim()}
LeadingIcon={PlusIcon}
iconSpacing="gap-1.5"
onClick={() => {
setMode("new");
submitQuery(prompt, "new");
}}
>
New query
</Button>
<Button
type="button"
variant="tertiary/small"
disabled={!prompt.trim() || !canEdit}
LeadingIcon={PencilSquareIcon}
className={cn(!canEdit && "opacity-50")}
iconSpacing="gap-2"
tooltip={!canEdit ? "Write a query first to enable editing" : undefined}
onClick={() => {
setMode("edit");
submitQuery(prompt, "edit");
}}
>
Edit query
</Button>
</>
)}
</div>
</form>
</div>
</div>
{/* Error message */}
<AnimatePresence>
{error && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="rounded-md border border-error/30 bg-error/10 px-3 py-2 text-sm text-error">
{error}
</div>
</motion.div>
)}
</AnimatePresence>
{/* Thinking panel - stays visible after completion */}
<AnimatePresence>
{showThinking && thinking && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="rounded-md border border-grid-dimmed bg-charcoal-850 p-3">
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
{isLoading ? (
<Spinner
color={{
background: "rgba(99, 102, 241, 0.3)",
foreground: "rgba(99, 102, 241, 1)",
}}
className="size-3"
/>
) : lastResult === "success" ? (
<div className="size-3 rounded-full bg-success" />
) : lastResult === "error" ? (
<div className="size-3 rounded-full bg-error" />
) : null}
<span className="text-xs font-medium text-text-dimmed">
{isLoading
? "AI is thinking..."
: lastResult === "success"
? "Query generated"
: lastResult === "error"
? "Generation failed"
: "AI response"}
</span>
</div>
{isLoading ? (
<Button
variant="minimal/small"
onClick={() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
setIsLoading(false);
setShowThinking(false);
setThinking("");
}}
className="text-xs"
>
Cancel
</Button>
) : (
<Button
variant="minimal/small"
onClick={() => {
setShowThinking(false);
setThinking("");
}}
className="text-xs"
>
Dismiss
</Button>
)}
</div>
<div className="streamdown-container max-h-96 overflow-y-auto text-xs text-text-dimmed scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
<Suspense fallback={<p className="whitespace-pre-wrap">{thinking}</p>}>
<StreamdownRenderer isAnimating={isLoading}>{thinking}</StreamdownRenderer>
</Suspense>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
@@ -0,0 +1,521 @@
import type { OutputColumnMetadata } from "@internal/clickhouse";
import { BarChart, LineChart } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { cn } from "~/utils/cn";
import { Header3 } from "../primitives/Headers";
import { Paragraph } from "../primitives/Paragraph";
import { Select, SelectItem } from "../primitives/Select";
import { Switch } from "../primitives/Switch";
import { Button } from "../primitives/Buttons";
export type ChartType = "bar" | "line";
export type SortDirection = "asc" | "desc";
export type AggregationType = "sum" | "avg" | "count" | "min" | "max";
export interface ChartConfiguration {
chartType: ChartType;
xAxisColumn: string | null;
yAxisColumns: string[];
groupByColumn: string | null;
stacked: boolean;
sortByColumn: string | null;
sortDirection: SortDirection;
aggregation: AggregationType;
}
export const defaultChartConfig: ChartConfiguration = {
chartType: "bar",
xAxisColumn: null,
yAxisColumns: [],
groupByColumn: null,
stacked: false,
sortByColumn: null,
sortDirection: "asc",
aggregation: "sum",
};
interface ChartConfigPanelProps {
columns: OutputColumnMetadata[];
config: ChartConfiguration;
onChange: (config: ChartConfiguration) => void;
className?: string;
}
// Type detection helpers
function isNumericType(type: string): boolean {
return (
type.startsWith("Int") ||
type.startsWith("UInt") ||
type.startsWith("Float") ||
type.startsWith("Decimal") ||
type.startsWith("Nullable(Int") ||
type.startsWith("Nullable(UInt") ||
type.startsWith("Nullable(Float") ||
type.startsWith("Nullable(Decimal")
);
}
function isDateTimeType(type: string): boolean {
return (
type === "DateTime" ||
type === "DateTime64" ||
type === "Date" ||
type === "Date32" ||
type.startsWith("DateTime64(") ||
type.startsWith("Nullable(DateTime") ||
type.startsWith("Nullable(Date")
);
}
function isStringType(type: string): boolean {
return (
type === "String" ||
type === "LowCardinality(String)" ||
type === "Nullable(String)" ||
type.startsWith("Enum") ||
type.startsWith("FixedString")
);
}
export function ChartConfigPanel({ columns, config, onChange, className }: ChartConfigPanelProps) {
// Categorize columns by type
const { numericColumns, dateTimeColumns, categoricalColumns, allColumns } = useMemo(() => {
const numeric: OutputColumnMetadata[] = [];
const dateTime: OutputColumnMetadata[] = [];
const categorical: OutputColumnMetadata[] = [];
for (const col of columns) {
if (isNumericType(col.type)) {
numeric.push(col);
}
if (isDateTimeType(col.type)) {
dateTime.push(col);
}
if (isStringType(col.type) || isDateTimeType(col.type)) {
categorical.push(col);
}
}
return {
numericColumns: numeric,
dateTimeColumns: dateTime,
categoricalColumns: categorical,
allColumns: columns,
};
}, [columns]);
// Create a stable key from column names and types to detect actual changes
const columnsKey = useMemo(() => columns.map((c) => `${c.name}:${c.type}`).join(","), [columns]);
// Use refs to access current config/onChange without adding them as dependencies
const configRef = useRef(config);
const onChangeRef = useRef(onChange);
useEffect(() => {
configRef.current = config;
onChangeRef.current = onChange;
});
// Auto-select defaults when columns change
useEffect(() => {
if (columns.length === 0) return;
const currentConfig = configRef.current;
let needsUpdate = false;
const updates: Partial<ChartConfiguration> = {};
// Auto-select X-axis (prefer datetime, then first categorical)
if (!currentConfig.xAxisColumn) {
const defaultX = dateTimeColumns[0] ?? categoricalColumns[0] ?? columns[0];
if (defaultX) {
updates.xAxisColumn = defaultX.name;
needsUpdate = true;
}
}
// Auto-select Y-axis (first numeric column)
if (currentConfig.yAxisColumns.length === 0 && numericColumns.length > 0) {
updates.yAxisColumns = [numericColumns[0].name];
needsUpdate = true;
}
// Determine the effective x-axis column (either existing or newly selected)
const effectiveXAxis = updates.xAxisColumn ?? currentConfig.xAxisColumn;
// Auto-set sort to x-axis ASC if it's a datetime column and no sort is configured
if (
effectiveXAxis &&
!currentConfig.sortByColumn &&
dateTimeColumns.some((col) => col.name === effectiveXAxis)
) {
updates.sortByColumn = effectiveXAxis;
updates.sortDirection = "asc";
needsUpdate = true;
}
if (needsUpdate) {
onChangeRef.current({ ...currentConfig, ...updates });
}
// Only re-run when the actual column structure changes, not on every config change
}, [columnsKey, columns, dateTimeColumns, categoricalColumns, numericColumns]);
const updateConfig = useCallback(
(updates: Partial<ChartConfiguration>) => {
onChange({ ...config, ...updates });
},
[config, onChange]
);
// X-axis options: prefer datetime and string columns at the top
const xAxisOptions = useMemo(() => {
const preferred = [
...dateTimeColumns,
...categoricalColumns.filter((c) => !isDateTimeType(c.type)),
];
const preferredNames = new Set(preferred.map((c) => c.name));
const other = allColumns.filter((c) => !preferredNames.has(c.name));
const options: Array<{ value: string; label: string; type: string }> = [];
for (const col of preferred) {
options.push({ value: col.name, label: col.name, type: col.type });
}
for (const col of other) {
options.push({ value: col.name, label: col.name, type: col.type });
}
return options;
}, [allColumns, dateTimeColumns, categoricalColumns]);
// Y-axis options: numeric columns only
const yAxisOptions = useMemo(() => {
return numericColumns.map((col) => ({
value: col.name,
label: col.name,
type: col.type,
}));
}, [numericColumns]);
// Aggregation options
const aggregationOptions = [
{ value: "sum", label: "Sum" },
{ value: "avg", label: "Average" },
{ value: "count", label: "Count" },
{ value: "min", label: "Min" },
{ value: "max", label: "Max" },
];
// Group by options: categorical columns (excluding selected X axis)
const groupByOptions = useMemo(() => {
const options = categoricalColumns
.filter((col) => col.name !== config.xAxisColumn)
.map((col) => ({
value: col.name,
label: col.name,
type: col.type,
}));
return [{ value: "__none__", label: "None", type: "" }, ...options];
}, [categoricalColumns, config.xAxisColumn]);
// Sort by options: all columns
const sortByOptions = useMemo(() => {
const options = allColumns.map((col) => ({
value: col.name,
label: col.name,
type: col.type,
}));
return [{ value: "__none__", label: "None", type: "" }, ...options];
}, [allColumns]);
if (columns.length === 0) {
return (
<div className={cn("flex items-center justify-center p-4", className)}>
<Paragraph variant="small" className="text-text-dimmed">
Run a query to configure the chart
</Paragraph>
</div>
);
}
return (
<div className={cn("flex flex-col gap-2 px-3 py-2", className)}>
{/* Chart Type */}
<div className="flex items-center gap-1">
<ConfigField label="Type">
<div className="flex items-center">
<Button
type="button"
variant="tertiary/small"
className={cn(
"rounded-r-none border-b pl-1 pr-2",
config.chartType === "bar" ? "border-indigo-500" : "border-transparent"
)}
iconSpacing="gap-x-1"
onClick={() => updateConfig({ chartType: "bar" })}
LeadingIcon={BarChart}
leadingIconClassName={
config.chartType === "bar" ? "text-indigo-500" : "text-text-dimmed"
}
>
<span className={config.chartType === "bar" ? "text-indigo-500" : "text-text-dimmed"}>
Bar
</span>
</Button>
<Button
type="button"
variant="tertiary/small"
className={cn(
"rounded-l-none border-b pl-1 pr-2",
config.chartType === "line" ? "border-indigo-500" : "border-transparent"
)}
iconSpacing="gap-x-1"
onClick={() => updateConfig({ chartType: "line" })}
LeadingIcon={LineChart}
leadingIconClassName={
config.chartType === "line" ? "text-indigo-500" : "text-text-dimmed"
}
>
<span
className={config.chartType === "line" ? "text-indigo-500" : "text-text-dimmed"}
>
Line
</span>
</Button>
</div>
</ConfigField>
</div>
<div className="flex flex-wrap items-center gap-3">
{/* X-Axis */}
<ConfigField label="X-Axis">
<Select
value={config.xAxisColumn ?? ""}
setValue={(value) => {
const updates: Partial<ChartConfiguration> = { xAxisColumn: value || null };
// Auto-set sort to x-axis ASC if selecting a datetime column
if (value) {
const selectedCol = columns.find((c) => c.name === value);
if (selectedCol && isDateTimeType(selectedCol.type)) {
updates.sortByColumn = value;
updates.sortDirection = "asc";
}
}
updateConfig(updates);
}}
variant="tertiary/small"
placeholder="Select column"
items={xAxisOptions}
dropdownIcon
className="min-w-[140px]"
>
{(items) =>
items.map((item) => (
<SelectItem key={item.value} value={item.value}>
<span className="flex items-center gap-2">
<span>{item.label}</span>
<TypeBadge type={item.type} />
</span>
</SelectItem>
))
}
</Select>
</ConfigField>
{/* Y-Axis */}
<ConfigField label="Y-Axis">
{yAxisOptions.length === 0 ? (
<span className="text-xs text-text-dimmed">No numeric columns</span>
) : (
<Select
value={config.yAxisColumns[0] ?? ""}
setValue={(value) => updateConfig({ yAxisColumns: value ? [value] : [] })}
variant="tertiary/small"
placeholder="Select column"
items={yAxisOptions}
dropdownIcon
className="min-w-[140px]"
>
{(items) =>
items.map((item) => (
<SelectItem key={item.value} value={item.value}>
<span className="flex items-center gap-2">
<span>{item.label}</span>
<TypeBadge type={item.type} />
</span>
</SelectItem>
))
}
</Select>
)}
</ConfigField>
{/* Aggregation */}
<ConfigField label="Aggregation">
<Select
value={config.aggregation}
setValue={(value) => updateConfig({ aggregation: value as AggregationType })}
variant="tertiary/small"
items={aggregationOptions}
dropdownIcon
className="min-w-[100px]"
>
{(items) =>
items.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))
}
</Select>
</ConfigField>
{/* Group By */}
<ConfigField label="Group by">
<Select
value={config.groupByColumn ?? "__none__"}
setValue={(value) =>
updateConfig({ groupByColumn: value === "__none__" ? null : value })
}
variant="tertiary/small"
placeholder="None"
items={groupByOptions}
dropdownIcon
className="min-w-[140px]"
text={(t) => (t === "__none__" ? "None" : t)}
>
{(items) =>
items.map((item) => (
<SelectItem key={item.value} value={item.value}>
<span className="flex items-center gap-2">
<span>{item.label}</span>
{item.type && <TypeBadge type={item.type} />}
</span>
</SelectItem>
))
}
</Select>
</ConfigField>
{/* Stacked toggle (only when grouped) */}
{config.groupByColumn && (
<ConfigField label="">
<Switch
variant="small"
label="Stacked"
checked={config.stacked}
onCheckedChange={(checked) => updateConfig({ stacked: checked })}
/>
</ConfigField>
)}
{/* Order By */}
<ConfigField label="Order by">
<Select
value={config.sortByColumn ?? "__none__"}
setValue={(value) =>
updateConfig({ sortByColumn: value === "__none__" ? null : value })
}
variant="tertiary/small"
placeholder="None"
items={sortByOptions}
dropdownIcon
className="min-w-[140px]"
text={(t) => (t === "__none__" ? "None" : t)}
>
{(items) =>
items.map((item) => (
<SelectItem key={item.value} value={item.value}>
<span className="flex items-center gap-2">
<span>{item.label}</span>
{item.type && <TypeBadge type={item.type} />}
</span>
</SelectItem>
))
}
</Select>
</ConfigField>
{/* Sort Direction (only when sorting) */}
{config.sortByColumn && (
<ConfigField label="">
<SortDirectionToggle
direction={config.sortDirection}
onChange={(direction) => updateConfig({ sortDirection: direction })}
/>
</ConfigField>
)}
</div>
</div>
);
}
function ConfigField({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-center gap-2">
{label && <span className="text-xs text-text-dimmed">{label}</span>}
{children}
</div>
);
}
function SortDirectionToggle({
direction,
onChange,
}: {
direction: SortDirection;
onChange: (direction: SortDirection) => void;
}) {
return (
<div className="flex gap-1">
<button
type="button"
onClick={() => onChange("asc")}
className={cn(
"rounded px-2 py-1 text-xs transition-colors",
direction === "asc"
? "bg-charcoal-700 text-text-bright"
: "text-text-dimmed hover:bg-charcoal-800 hover:text-text-bright"
)}
title="Ascending"
>
Asc
</button>
<button
type="button"
onClick={() => onChange("desc")}
className={cn(
"rounded px-2 py-1 text-xs transition-colors",
direction === "desc"
? "bg-charcoal-700 text-text-bright"
: "text-text-dimmed hover:bg-charcoal-800 hover:text-text-bright"
)}
title="Descending"
>
Desc
</button>
</div>
);
}
function TypeBadge({ type }: { type: string }) {
// Simplify type for display
let displayType = type;
if (type.startsWith("Nullable(")) {
displayType = type.slice(9, -1) + "?";
}
if (type.startsWith("LowCardinality(")) {
displayType = type.slice(15, -1);
}
// Shorten long type names
if (displayType.length > 12) {
displayType = displayType.slice(0, 10) + "…";
}
return (
<span className="rounded bg-charcoal-750 px-1 py-0.5 font-mono text-xxs text-text-dimmed">
{displayType}
</span>
);
}
@@ -20,6 +20,8 @@ async function setup() {
await import("prismjs/components/prism-json");
//@ts-ignore
await import("prismjs/components/prism-typescript");
//@ts-ignore
await import("prismjs/components/prism-sql.js");
}
setup();
@@ -470,6 +472,8 @@ function HighlightCode({
import("prismjs/components/prism-json"),
//@ts-ignore
import("prismjs/components/prism-typescript"),
//@ts-ignore
import("prismjs/components/prism-sql.js"),
]).then(() => setIsLoaded(true));
}, []);
@@ -0,0 +1,989 @@
import type { OutputColumnMetadata } from "@internal/clickhouse";
import { memo, useMemo } from "react";
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Line,
LineChart,
XAxis,
YAxis,
} from "recharts";
import {
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "~/components/primitives/Chart";
import { Paragraph } from "../primitives/Paragraph";
import type { AggregationType, ChartConfiguration } from "./ChartConfigPanel";
// Color palette for chart series
const CHART_COLORS = [
"#7655fd", // Primary purple
"#22c55e", // Green
"#f59e0b", // Amber
"#ef4444", // Red
"#06b6d4", // Cyan
"#ec4899", // Pink
"#8b5cf6", // Violet
"#14b8a6", // Teal
"#f97316", // Orange
"#6366f1", // Indigo
];
function getSeriesColor(index: number): string {
return CHART_COLORS[index % CHART_COLORS.length];
}
interface QueryResultsChartProps {
rows: Record<string, unknown>[];
columns: OutputColumnMetadata[];
config: ChartConfiguration;
}
interface TransformedData {
data: Record<string, unknown>[];
series: string[];
/** Raw date values for determining formatting granularity */
dateValues: Date[];
/** Whether the x-axis is date-based (continuous time scale) */
isDateBased: boolean;
/** The data key to use for x-axis (column name or '__timestamp' for dates) */
xDataKey: string;
/** Min/max timestamps for domain when date-based */
timeDomain: [number, number] | null;
/** Pre-calculated tick values for the time axis */
timeTicks: number[] | null;
}
/**
* Time granularity levels for date formatting
*/
type TimeGranularity = "seconds" | "minutes" | "hours" | "days" | "weeks" | "months" | "years";
/**
* Determines the appropriate time granularity based on the date range
*/
function detectTimeGranularity(dates: Date[]): TimeGranularity {
if (dates.length < 2) return "days";
const sorted = [...dates].sort((a, b) => a.getTime() - b.getTime());
const minDate = sorted[0];
const maxDate = sorted[sorted.length - 1];
const rangeMs = maxDate.getTime() - minDate.getTime();
const SECOND = 1000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
const WEEK = 7 * DAY;
const MONTH = 30 * DAY;
const YEAR = 365 * DAY;
// Choose granularity based on range
if (rangeMs <= 5 * MINUTE) return "seconds"; // < 5 minutes → show seconds
if (rangeMs <= 2 * HOUR) return "minutes"; // < 2 hours → show minutes
if (rangeMs <= 2 * DAY) return "hours"; // < 2 days → show hours
if (rangeMs <= 2 * WEEK) return "days"; // < 2 weeks → show days
if (rangeMs <= 3 * MONTH) return "weeks"; // < 3 months → show weeks
if (rangeMs <= 2 * YEAR) return "months"; // < 2 years → show months
return "years"; // >= 2 years → show years
}
/**
* Formats a date for the X-axis based on the detected granularity
*/
function formatDateByGranularity(date: Date, granularity: TimeGranularity): string {
switch (granularity) {
case "seconds":
// "10:30:45"
return date.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
case "minutes":
// "10:30"
return date.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
case "hours":
// "Jan 15 10:00"
return `${date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})} ${date.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
})}`;
case "days":
// "Jan 15"
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
case "weeks":
// "Jan 15"
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
case "months":
// "Jan 2024"
return date.toLocaleDateString("en-US", { month: "short", year: "numeric" });
case "years":
// "2024"
return date.toLocaleDateString("en-US", { year: "numeric" });
default:
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
}
/**
* Detect the most common interval between consecutive data points
* This helps us understand the natural granularity of the data
*/
function detectDataInterval(timestamps: number[]): number {
if (timestamps.length < 2) return 60 * 1000; // Default to 1 minute
const sorted = [...timestamps].sort((a, b) => a - b);
const gaps: number[] = [];
for (let i = 1; i < sorted.length; i++) {
const gap = sorted[i] - sorted[i - 1];
if (gap > 0) {
gaps.push(gap);
}
}
if (gaps.length === 0) return 60 * 1000;
// Find the most common small gap (this is likely the data's natural interval)
// We use the minimum gap as a heuristic for the data interval
const minGap = Math.min(...gaps);
// Round to a nice interval
const MINUTE = 60 * 1000;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
// Snap to common intervals
if (minGap <= MINUTE) return MINUTE;
if (minGap <= 5 * MINUTE) return 5 * MINUTE;
if (minGap <= 10 * MINUTE) return 10 * MINUTE;
if (minGap <= 15 * MINUTE) return 15 * MINUTE;
if (minGap <= 30 * MINUTE) return 30 * MINUTE;
if (minGap <= HOUR) return HOUR;
if (minGap <= 2 * HOUR) return 2 * HOUR;
if (minGap <= 4 * HOUR) return 4 * HOUR;
if (minGap <= 6 * HOUR) return 6 * HOUR;
if (minGap <= 12 * HOUR) return 12 * HOUR;
if (minGap <= DAY) return DAY;
return minGap;
}
/**
* Fill in missing time slots with zero values
* This ensures the chart shows gaps as zeros rather than connecting distant points
*/
function fillTimeGaps(
data: Record<string, unknown>[],
xDataKey: string,
series: string[],
minTime: number,
maxTime: number,
interval: number,
granularity: TimeGranularity,
aggregation: AggregationType,
maxPoints = 1000
): Record<string, unknown>[] {
const range = maxTime - minTime;
const estimatedPoints = Math.ceil(range / interval);
// If filling would create too many points, increase the interval to stay within limits
let effectiveInterval = interval;
if (estimatedPoints > maxPoints) {
effectiveInterval = Math.ceil(range / maxPoints);
// Round up to a nice interval
const MINUTE = 60 * 1000;
const HOUR = 60 * MINUTE;
if (effectiveInterval < 5 * MINUTE) effectiveInterval = 5 * MINUTE;
else if (effectiveInterval < 10 * MINUTE) effectiveInterval = 10 * MINUTE;
else if (effectiveInterval < 15 * MINUTE) effectiveInterval = 15 * MINUTE;
else if (effectiveInterval < 30 * MINUTE) effectiveInterval = 30 * MINUTE;
else if (effectiveInterval < HOUR) effectiveInterval = HOUR;
else if (effectiveInterval < 2 * HOUR) effectiveInterval = 2 * HOUR;
else if (effectiveInterval < 4 * HOUR) effectiveInterval = 4 * HOUR;
else if (effectiveInterval < 6 * HOUR) effectiveInterval = 6 * HOUR;
else if (effectiveInterval < 12 * HOUR) effectiveInterval = 12 * HOUR;
else effectiveInterval = 24 * HOUR;
}
// Create a map to collect values for each bucket (for aggregation)
const bucketData = new Map<
number,
{ values: Record<string, number[]>; rawDate: Date; originalX: string }
>();
for (const point of data) {
const timestamp = point[xDataKey] as number;
// Bucket to the nearest interval
const bucketedTime = Math.floor(timestamp / effectiveInterval) * effectiveInterval;
if (!bucketData.has(bucketedTime)) {
bucketData.set(bucketedTime, {
values: Object.fromEntries(series.map((s) => [s, []])),
rawDate: new Date(bucketedTime),
originalX: new Date(bucketedTime).toISOString(),
});
}
const bucket = bucketData.get(bucketedTime)!;
for (const s of series) {
const val = point[s] as number;
if (typeof val === "number") {
bucket.values[s].push(val);
}
}
}
// Generate all time slots and fill with zeros where missing
const filledData: Record<string, unknown>[] = [];
const startTime = Math.floor(minTime / effectiveInterval) * effectiveInterval;
for (let t = startTime; t <= maxTime; t += effectiveInterval) {
const bucket = bucketData.get(t);
if (bucket) {
// Apply aggregation to collected values
const point: Record<string, unknown> = {
[xDataKey]: t,
__rawDate: bucket.rawDate,
__granularity: granularity,
__originalX: bucket.originalX,
};
for (const s of series) {
point[s] = aggregateValues(bucket.values[s], aggregation);
}
filledData.push(point);
} else {
// Create a zero-filled data point
const zeroPoint: Record<string, unknown> = {
[xDataKey]: t,
__rawDate: new Date(t),
__granularity: granularity,
__originalX: new Date(t).toISOString(),
};
for (const s of series) {
zeroPoint[s] = 0;
}
filledData.push(zeroPoint);
}
}
return filledData;
}
/**
* "Nice" intervals for time axes - these create human-friendly tick marks
*/
const NICE_TIME_INTERVALS = [
{ value: 1000, label: "1s" }, // 1 second
{ value: 5 * 1000, label: "5s" }, // 5 seconds
{ value: 10 * 1000, label: "10s" }, // 10 seconds
{ value: 30 * 1000, label: "30s" }, // 30 seconds
{ value: 60 * 1000, label: "1m" }, // 1 minute
{ value: 5 * 60 * 1000, label: "5m" }, // 5 minutes
{ value: 10 * 60 * 1000, label: "10m" }, // 10 minutes
{ value: 15 * 60 * 1000, label: "15m" }, // 15 minutes
{ value: 30 * 60 * 1000, label: "30m" }, // 30 minutes
{ value: 60 * 60 * 1000, label: "1h" }, // 1 hour
{ value: 2 * 60 * 60 * 1000, label: "2h" }, // 2 hours
{ value: 3 * 60 * 60 * 1000, label: "3h" }, // 3 hours
{ value: 4 * 60 * 60 * 1000, label: "4h" }, // 4 hours
{ value: 6 * 60 * 60 * 1000, label: "6h" }, // 6 hours
{ value: 12 * 60 * 60 * 1000, label: "12h" }, // 12 hours
{ value: 24 * 60 * 60 * 1000, label: "1d" }, // 1 day
{ value: 2 * 24 * 60 * 60 * 1000, label: "2d" }, // 2 days
{ value: 7 * 24 * 60 * 60 * 1000, label: "1w" }, // 1 week
{ value: 14 * 24 * 60 * 60 * 1000, label: "2w" }, // 2 weeks
{ value: 30 * 24 * 60 * 60 * 1000, label: "1mo" }, // ~1 month
{ value: 90 * 24 * 60 * 60 * 1000, label: "3mo" }, // ~3 months
{ value: 180 * 24 * 60 * 60 * 1000, label: "6mo" }, // ~6 months
{ value: 365 * 24 * 60 * 60 * 1000, label: "1y" }, // 1 year
];
/**
* Generate evenly-spaced tick values for a time axis using "nice" intervals
* that align to natural time boundaries (midnight, noon, hour marks, etc.)
*/
function generateTimeTicks(minTime: number, maxTime: number, maxTicks = 8): number[] {
const range = maxTime - minTime;
if (range <= 0) {
return [minTime];
}
// Find the best "nice" interval that gives us a reasonable number of ticks
// Target: between 4 and maxTicks ticks
let chosenInterval = NICE_TIME_INTERVALS[NICE_TIME_INTERVALS.length - 1].value;
for (const { value: interval } of NICE_TIME_INTERVALS) {
const tickCount = Math.ceil(range / interval);
if (tickCount <= maxTicks && tickCount >= 2) {
chosenInterval = interval;
break;
}
}
// Align the start tick to a nice boundary
// For intervals >= 1 day, align to midnight
// For intervals >= 1 hour, align to hour boundary
// For intervals >= 1 minute, align to minute boundary
const DAY = 24 * 60 * 60 * 1000;
const HOUR = 60 * 60 * 1000;
const MINUTE = 60 * 1000;
let alignTo: number;
if (chosenInterval >= DAY) {
// Align to midnight UTC (or we could use local midnight)
alignTo = DAY;
} else if (chosenInterval >= HOUR) {
alignTo = chosenInterval; // Align to the interval itself for hours
} else if (chosenInterval >= MINUTE) {
alignTo = chosenInterval;
} else {
alignTo = chosenInterval;
}
// Round down to the alignment boundary, then find first tick at or before minTime
const startTick = Math.floor(minTime / alignTo) * alignTo;
// Generate ticks
const ticks: number[] = [];
for (let t = startTick; t <= maxTime + chosenInterval; t += chosenInterval) {
if (t >= minTime - chosenInterval * 0.1 && t <= maxTime + chosenInterval * 0.1) {
ticks.push(t);
}
}
// Ensure we have at least 2 ticks
if (ticks.length < 2) {
return [minTime, maxTime];
}
return ticks;
}
/**
* Formats a date for tooltips (always shows full precision)
*/
function formatDateForTooltip(date: Date, granularity: TimeGranularity): string {
// For shorter time ranges, include time
if (granularity === "seconds" || granularity === "minutes" || granularity === "hours") {
return date.toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
second: granularity === "seconds" ? "2-digit" : undefined,
hour12: false,
});
}
// For longer ranges, just show date
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
/**
* Try to parse a value as a Date
*/
function tryParseDate(value: unknown): Date | null {
if (value instanceof Date) {
return isNaN(value.getTime()) ? null : value;
}
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}/.test(value)) {
const date = new Date(value);
return isNaN(date.getTime()) ? null : date;
}
if (typeof value === "number") {
// First, try treating the number as milliseconds
const dateAsMs = new Date(value);
if (
!isNaN(dateAsMs.getTime()) &&
dateAsMs.getFullYear() >= 1970 &&
dateAsMs.getFullYear() <= 2100
) {
return dateAsMs;
}
// If that fails, try treating the number as seconds (Unix timestamp)
const dateAsSec = new Date(value * 1000);
if (
!isNaN(dateAsSec.getTime()) &&
dateAsSec.getFullYear() >= 1970 &&
dateAsSec.getFullYear() <= 2100
) {
return dateAsSec;
}
}
return null;
}
/**
* Transform raw query results into chart-ready data
*
* When grouped:
* - Pivots data so each unique group value becomes a separate series
* - Each row in output has xAxis value + one key per group value
*
* When not grouped:
* - Uses Y-axis columns directly as series
*
* For date-based x-axes:
* - Uses numeric timestamps so the chart renders with a continuous time scale
* - This ensures gaps in data are visually apparent
*/
function transformDataForChart(
rows: Record<string, unknown>[],
config: ChartConfiguration
): TransformedData {
const { xAxisColumn, yAxisColumns, groupByColumn, aggregation } = config;
if (!xAxisColumn || yAxisColumns.length === 0) {
return {
data: [],
series: [],
dateValues: [],
isDateBased: false,
xDataKey: xAxisColumn || "",
timeDomain: null,
timeTicks: null,
};
}
// Collect date values for granularity detection
const dateValues: Date[] = [];
for (const row of rows) {
const date = tryParseDate(row[xAxisColumn]);
if (date) {
dateValues.push(date);
}
}
// Determine if X-axis is date-based (most values should be parseable as dates)
const isDateBased = dateValues.length >= rows.length * 0.8; // At least 80% are dates
const granularity = isDateBased ? detectTimeGranularity(dateValues) : "days";
// For date-based axes, use a special key for the timestamp
const xDataKey = isDateBased ? "__timestamp" : xAxisColumn;
// Calculate time domain and ticks for date-based axes
let timeDomain: [number, number] | null = null;
let timeTicks: number[] | null = null;
if (isDateBased && dateValues.length > 0) {
const timestamps = dateValues.map((d) => d.getTime());
const minTime = Math.min(...timestamps);
const maxTime = Math.max(...timestamps);
// Add a small padding (2% on each side) so points aren't at the very edge
const padding = (maxTime - minTime) * 0.02;
timeDomain = [minTime - padding, maxTime + padding];
// Generate evenly-spaced ticks across the entire range using nice intervals
timeTicks = generateTimeTicks(minTime, maxTime);
}
// Helper to format X value for categorical axes (non-date)
const formatX = (value: unknown): string => {
if (value === null || value === undefined) return "N/A";
return String(value);
};
// No grouping: use Y columns directly as series
// Group rows by X value first, then aggregate
if (!groupByColumn) {
// Group rows by X-axis value to handle duplicates
const groupedByX = new Map<
string | number,
{ yValues: Record<string, number[]>; rawDate: Date | null; originalX: unknown }
>();
for (const row of rows) {
const rawDate = tryParseDate(row[xAxisColumn]);
// Skip rows with invalid dates for date-based axes
if (isDateBased && !rawDate) continue;
const xKey = isDateBased && rawDate ? rawDate.getTime() : formatX(row[xAxisColumn]);
if (!groupedByX.has(xKey)) {
groupedByX.set(xKey, {
yValues: Object.fromEntries(yAxisColumns.map((col) => [col, []])),
rawDate,
originalX: row[xAxisColumn],
});
}
const existing = groupedByX.get(xKey)!;
for (const yCol of yAxisColumns) {
existing.yValues[yCol].push(toNumber(row[yCol]));
}
}
// Convert to array format with aggregation applied
let data = Array.from(groupedByX.entries()).map(([xKey, { yValues, rawDate, originalX }]) => {
const point: Record<string, unknown> = {
[xDataKey]: xKey,
__rawDate: rawDate,
__granularity: granularity,
__originalX: originalX,
};
for (const yCol of yAxisColumns) {
point[yCol] = aggregateValues(yValues[yCol], aggregation);
}
return point;
});
// Fill in gaps with zeros for date-based data
if (isDateBased && timeDomain) {
const timestamps = dateValues.map((d) => d.getTime());
const dataInterval = detectDataInterval(timestamps);
data = fillTimeGaps(
data,
xDataKey,
yAxisColumns,
timeDomain[0],
timeDomain[1],
dataInterval,
granularity,
aggregation
);
}
return { data, series: yAxisColumns, dateValues, isDateBased, xDataKey, timeDomain, timeTicks };
}
// With grouping: pivot data so each group value becomes a series
const yCol = yAxisColumns[0]; // Use first Y column when grouping
const groupValues = new Set<string>();
// For date-based, key by timestamp; otherwise by formatted string
// Collect all values for aggregation
const groupedByX = new Map<
string | number,
{ values: Record<string, number[]>; rawDate: Date | null; originalX: unknown }
>();
for (const row of rows) {
const rawDate = tryParseDate(row[xAxisColumn]);
// Skip rows with invalid dates for date-based axes
if (isDateBased && !rawDate) continue;
const xKey = isDateBased && rawDate ? rawDate.getTime() : formatX(row[xAxisColumn]);
const groupValue = String(row[groupByColumn] ?? "Unknown");
const yValue = toNumber(row[yCol]);
groupValues.add(groupValue);
if (!groupedByX.has(xKey)) {
groupedByX.set(xKey, { values: {}, rawDate, originalX: row[xAxisColumn] });
}
const existing = groupedByX.get(xKey)!;
// Collect values for aggregation
if (!existing.values[groupValue]) {
existing.values[groupValue] = [];
}
existing.values[groupValue].push(yValue);
}
// Convert to array format with aggregation applied
const series = Array.from(groupValues).sort();
let data = Array.from(groupedByX.entries()).map(([xKey, { values, rawDate, originalX }]) => {
const point: Record<string, unknown> = {
[xDataKey]: xKey,
__rawDate: rawDate,
__granularity: granularity,
__originalX: originalX,
};
for (const group of series) {
point[group] = values[group] ? aggregateValues(values[group], aggregation) : 0;
}
return point;
});
// Fill in gaps with zeros for date-based data
if (isDateBased && timeDomain) {
const timestamps = dateValues.map((d) => d.getTime());
const dataInterval = detectDataInterval(timestamps);
data = fillTimeGaps(
data,
xDataKey,
series,
timeDomain[0],
timeDomain[1],
dataInterval,
granularity,
aggregation
);
}
return { data, series, dateValues, isDateBased, xDataKey, timeDomain, timeTicks };
}
function toNumber(value: unknown): number {
if (typeof value === "number") return value;
if (typeof value === "string") {
const parsed = parseFloat(value);
return isNaN(parsed) ? 0 : parsed;
}
return 0;
}
/**
* Aggregate an array of numbers using the specified aggregation function
*/
function aggregateValues(values: number[], aggregation: AggregationType): number {
if (values.length === 0) return 0;
switch (aggregation) {
case "sum":
return values.reduce((a, b) => a + b, 0);
case "avg":
return values.reduce((a, b) => a + b, 0) / values.length;
case "count":
return values.length;
case "min":
return Math.min(...values);
case "max":
return Math.max(...values);
}
}
/**
* Sort data array by a specified column
*/
function sortData(
data: Record<string, unknown>[],
sortByColumn: string | null,
sortDirection: "asc" | "desc",
xAxisColumn?: string | null
): Record<string, unknown>[] {
if (!sortByColumn) return data;
return [...data].sort((a, b) => {
const aVal = a[sortByColumn];
const bVal = b[sortByColumn];
// Handle null/undefined
if (aVal == null && bVal == null) return 0;
if (aVal == null) return sortDirection === "asc" ? -1 : 1;
if (bVal == null) return sortDirection === "asc" ? 1 : -1;
// Only use date comparison when sorting by the X-axis column
if (sortByColumn === xAxisColumn) {
const aDate = a.__rawDate as Date | null;
const bDate = b.__rawDate as Date | null;
if (aDate && bDate) {
const diff = aDate.getTime() - bDate.getTime();
return sortDirection === "asc" ? diff : -diff;
}
}
// Compare as numbers if possible
const aNum = typeof aVal === "number" ? aVal : parseFloat(String(aVal));
const bNum = typeof bVal === "number" ? bVal : parseFloat(String(bVal));
if (!isNaN(aNum) && !isNaN(bNum)) {
return sortDirection === "asc" ? aNum - bNum : bNum - aNum;
}
// Fall back to string comparison
const aStr = String(aVal);
const bStr = String(bVal);
const cmp = aStr.localeCompare(bStr);
return sortDirection === "asc" ? cmp : -cmp;
});
}
export const QueryResultsChart = memo(function QueryResultsChart({
rows,
columns,
config,
}: QueryResultsChartProps) {
const {
xAxisColumn,
yAxisColumns,
chartType,
groupByColumn,
stacked,
sortByColumn,
sortDirection,
} = config;
// Transform data for charting
const {
data: unsortedData,
series,
dateValues,
isDateBased,
xDataKey,
timeDomain,
timeTicks,
} = useMemo(() => transformDataForChart(rows, config), [rows, config]);
// Apply sorting (for date-based, sort by timestamp to ensure correct order)
const data = useMemo(() => {
if (isDateBased) {
// Always sort by timestamp for date-based axes
return sortData(unsortedData, xDataKey, "asc", xDataKey);
}
return sortData(unsortedData, sortByColumn, sortDirection, xDataKey);
}, [unsortedData, sortByColumn, sortDirection, isDateBased, xDataKey]);
// Detect time granularity for the data
const timeGranularity = useMemo(
() => (dateValues.length > 0 ? detectTimeGranularity(dateValues) : null),
[dateValues]
);
// X-axis tick formatter for date-based axes
const xAxisTickFormatter = useMemo(() => {
if (!isDateBased || !timeGranularity) return undefined;
return (value: number) => {
const date = new Date(value);
return formatDateByGranularity(date, timeGranularity);
};
}, [isDateBased, timeGranularity]);
// Create dynamic Y-axis formatter based on data range
const yAxisFormatter = useMemo(() => createYAxisFormatter(data, series), [data, series]);
// Build chart config for colors/labels
const chartConfig = useMemo(() => {
const cfg: ChartConfig = {};
series.forEach((s, i) => {
cfg[s] = {
label: s,
color: getSeriesColor(i),
};
});
return cfg;
}, [series]);
// Custom tooltip label formatter for better date display
const tooltipLabelFormatter = useMemo(() => {
return (label: string, payload: Array<{ payload?: Record<string, unknown> }>) => {
// Try to get the raw date from the payload for better formatting
const rawDate = payload[0]?.payload?.__rawDate as Date | null | undefined;
const granularity = payload[0]?.payload?.__granularity as TimeGranularity | undefined;
if (rawDate && granularity) {
return formatDateForTooltip(rawDate, granularity);
}
return label;
};
}, []);
// Validation
if (!xAxisColumn) {
return <EmptyState message="Select an X-axis column to display the chart" />;
}
if (yAxisColumns.length === 0) {
return <EmptyState message="Select a Y-axis column to display the chart" />;
}
if (rows.length === 0) {
return <EmptyState message="No data to display" />;
}
if (data.length === 0) {
return <EmptyState message="Unable to transform data for chart" />;
}
const commonProps = {
data,
margin: { top: 10, right: 10, left: 10, bottom: 10 },
};
// Determine appropriate angle for X-axis labels based on granularity
const xAxisAngle = timeGranularity === "hours" || timeGranularity === "seconds" ? -45 : 0;
const xAxisHeight = xAxisAngle !== 0 ? 60 : undefined;
// Build xAxisProps - different config for date-based (continuous) vs categorical axes
const xAxisProps = isDateBased
? {
dataKey: xDataKey,
type: "number" as const,
domain: timeDomain ?? ["auto", "auto"],
scale: "time" as const,
// Explicitly specify tick positions so labels appear across the entire range
ticks: timeTicks ?? undefined,
fontSize: 12,
tickLine: false,
tickMargin: 8,
axisLine: false,
tick: { fill: "var(--color-text-dimmed)" },
tickFormatter: xAxisTickFormatter,
angle: xAxisAngle,
textAnchor: xAxisAngle !== 0 ? ("end" as const) : ("middle" as const),
height: xAxisHeight,
}
: {
dataKey: xDataKey,
fontSize: 12,
tickLine: false,
tickMargin: 8,
axisLine: false,
tick: { fill: "var(--color-text-dimmed)" },
angle: xAxisAngle,
textAnchor: xAxisAngle !== 0 ? ("end" as const) : ("middle" as const),
height: xAxisHeight,
};
const yAxisProps = {
fontSize: 12,
tickLine: false,
tickMargin: 8,
axisLine: false,
tick: { fill: "var(--color-text-dimmed)" },
tickFormatter: yAxisFormatter,
};
return (
<ChartContainer config={chartConfig} className="h-full min-h-[300px] w-full">
{chartType === "bar" ? (
<BarChart {...commonProps}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis {...xAxisProps} />
<YAxis {...yAxisProps} />
<ChartTooltip
content={<ChartTooltipContent />}
labelFormatter={tooltipLabelFormatter}
cursor={{ fill: "var(--color-charcoal-800)", opacity: 0.5 }}
/>
{series.length > 1 && <ChartLegend content={<ChartLegendContent />} />}
{series.map((s, i) => (
<Bar
key={s}
dataKey={s}
fill={getSeriesColor(i)}
stackId={stacked ? "stack" : undefined}
radius={stacked ? [0, 0, 0, 0] : [4, 4, 0, 0]}
/>
))}
</BarChart>
) : stacked && series.length > 1 ? (
<AreaChart {...commonProps} stackOffset="none">
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis {...xAxisProps} />
<YAxis {...yAxisProps} />
<ChartTooltip
content={<ChartTooltipContent indicator="line" />}
labelFormatter={tooltipLabelFormatter}
/>
<ChartLegend content={<ChartLegendContent />} />
{series.map((s, i) => (
<Area
key={s}
type="linear"
dataKey={s}
stroke={getSeriesColor(i)}
fill={getSeriesColor(i)}
fillOpacity={0.6}
strokeWidth={2}
stackId="stack"
/>
))}
</AreaChart>
) : (
<LineChart {...commonProps}>
<CartesianGrid vertical={false} strokeDasharray="3 3" />
<XAxis {...xAxisProps} />
<YAxis {...yAxisProps} />
<ChartTooltip content={<ChartTooltipContent />} labelFormatter={tooltipLabelFormatter} />
{series.length > 1 && <ChartLegend content={<ChartLegendContent />} />}
{series.map((s, i) => (
<Line
key={s}
type="linear"
dataKey={s}
stroke={getSeriesColor(i)}
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
/>
))}
</LineChart>
)}
</ChartContainer>
);
});
/**
* Creates a Y-axis value formatter based on the data range
*/
function createYAxisFormatter(data: Record<string, unknown>[], series: string[]) {
// Find min and max values across all series
let minVal = Infinity;
let maxVal = -Infinity;
for (const point of data) {
for (const s of series) {
const val = point[s];
if (typeof val === "number" && isFinite(val)) {
minVal = Math.min(minVal, val);
maxVal = Math.max(maxVal, val);
}
}
}
const range = maxVal - minVal;
return (value: number): string => {
// Use abbreviations for large numbers
if (Math.abs(value) >= 1_000_000) {
return `${(value / 1_000_000).toFixed(1)}M`;
}
if (Math.abs(value) >= 1_000) {
return `${(value / 1_000).toFixed(1)}K`;
}
// Determine decimal places based on range
if (range === 0 || !isFinite(range)) {
return Number.isInteger(value) ? value.toString() : value.toFixed(2);
}
// For small ranges, show more precision
if (range < 0.01) {
return value.toFixed(4);
}
if (range < 0.1) {
return value.toFixed(3);
}
if (range < 10) {
return value.toFixed(2);
}
if (range < 100) {
return value.toFixed(1);
}
// For large ranges, no decimals
return Math.round(value).toString();
};
}
function EmptyState({ message }: { message: string }) {
return (
<div className="flex h-full min-h-[300px] items-center justify-center">
<Paragraph variant="small" className="text-text-dimmed">
{message}
</Paragraph>
</div>
);
}
@@ -0,0 +1,271 @@
import { sql, StandardSQL } from "@codemirror/lang-sql";
import { autocompletion } from "@codemirror/autocomplete";
import { linter, lintGutter } from "@codemirror/lint";
import type { ViewUpdate } from "@codemirror/view";
import { CheckIcon, ClipboardIcon, SparklesIcon, TrashIcon } from "@heroicons/react/20/solid";
import {
type ReactCodeMirrorProps,
type UseCodeMirror,
useCodeMirror,
} from "@uiw/react-codemirror";
import { useCallback, useEffect, useRef, useState, useMemo } from "react";
import { cn } from "~/utils/cn";
import { Button } from "../primitives/Buttons";
import { getEditorSetup } from "./codeMirrorSetup";
import { darkTheme } from "./codeMirrorTheme";
import { createTSQLCompletion } from "./tsql/tsqlCompletion";
import { createTSQLLinter } from "./tsql/tsqlLinter";
import type { TableSchema } from "@internal/tsql";
import { format as formatSQL } from "sql-formatter";
export interface TSQLEditorProps extends Omit<ReactCodeMirrorProps, "onBlur"> {
/** Initial value for the editor */
defaultValue?: string;
/** Whether the editor is read-only */
readOnly?: boolean;
/** Called when the editor content changes */
onChange?: (value: string) => void;
/** Called when the editor state updates */
onUpdate?: (update: ViewUpdate) => void;
/** Called when the editor loses focus */
onBlur?: (code: string) => void;
/** Schema for table/column autocompletion */
schema?: TableSchema[];
/** Show copy button */
showCopyButton?: boolean;
/** Show clear button */
showClearButton?: boolean;
/** Show format button */
showFormatButton?: boolean;
/** Enable linting (syntax checking) */
linterEnabled?: boolean;
/** Placeholder text when empty */
placeholder?: string;
/** Additional actions to show in the toolbar */
additionalActions?: React.ReactNode;
/** Minimum height of the editor */
minHeight?: string;
}
type TSQLEditorDefaultProps = Partial<TSQLEditorProps>;
const defaultProps: TSQLEditorDefaultProps = {
readOnly: false,
basicSetup: false,
linterEnabled: true,
showCopyButton: true,
showClearButton: false,
showFormatButton: true,
schema: [],
};
export function TSQLEditor(opts: TSQLEditorProps) {
const {
defaultValue = "",
readOnly = false,
onChange,
onUpdate,
onBlur,
basicSetup = false,
autoFocus,
showCopyButton = true,
showClearButton = false,
showFormatButton = true,
linterEnabled = true,
schema = [],
placeholder = "",
additionalActions,
minHeight = undefined,
} = {
...defaultProps,
...opts,
};
// Create extensions - memoize to avoid recreating on every render
const extensions = useMemo(() => {
const exts = getEditorSetup();
// Add SQL language support with StandardSQL dialect
// This provides syntax highlighting
exts.push(
sql({
dialect: StandardSQL,
upperCaseKeywords: true,
})
);
// Add custom TSQL completion
if (schema && schema.length > 0) {
exts.push(
autocompletion({
override: [createTSQLCompletion(schema)],
activateOnTyping: true,
maxRenderedOptions: 50,
})
);
}
// Add TSQL linter
if (linterEnabled) {
exts.push(lintGutter());
exts.push(
linter(createTSQLLinter({ schema }), {
delay: 300, // Debounce linting for better performance
})
);
}
return exts;
}, [schema, linterEnabled]);
const editor = useRef<HTMLDivElement>(null);
const settings: Omit<UseCodeMirror, "onBlur"> = {
...opts,
container: editor.current,
extensions,
editable: !readOnly,
contentEditable: !readOnly,
value: defaultValue,
autoFocus,
theme: darkTheme(),
indentWithTab: false,
basicSetup,
onChange,
onUpdate,
placeholder,
};
const { setContainer, view } = useCodeMirror(settings);
const [copied, setCopied] = useState(false);
useEffect(() => {
if (editor.current) {
setContainer(editor.current);
}
}, [setContainer]);
// Update editor when defaultValue changes
useEffect(() => {
if (view !== undefined) {
if (view.state.doc.toString() === defaultValue) return;
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: defaultValue },
});
}
}, [defaultValue, view]);
const clear = () => {
if (view === undefined) return;
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: undefined },
});
onChange?.("");
};
const copy = useCallback(() => {
if (view === undefined) return;
navigator.clipboard.writeText(view.state.doc.toString());
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 1500);
}, [view]);
const format = useCallback(() => {
if (view === undefined) return;
const currentContent = view.state.doc.toString();
if (!currentContent.trim()) return;
try {
const formatted = autoFormatSQL(currentContent);
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: formatted },
});
onChange?.(formatted);
} catch {
// If formatting fails (e.g., invalid SQL), silently ignore
}
}, [view, onChange]);
const showButtons = showClearButton || showCopyButton || showFormatButton || additionalActions;
return (
<div
className={cn("relative flex h-full flex-col", opts.className)}
style={minHeight ? { minHeight } : undefined}
>
<div
className={cn(
"min-h-0 flex-1 overflow-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
)}
ref={editor}
onBlur={() => {
if (!onBlur) return;
if (!view) return;
onBlur(view.state.doc.toString());
}}
/>
{showButtons && (
<div className="absolute right-0 top-0 z-10 flex items-center justify-end bg-charcoal-900/80 p-0.5">
{additionalActions && additionalActions}
{showFormatButton && (
<Button
type="button"
variant="minimal/small"
className="flex-none"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
format();
}}
shortcut={{ key: "f", modifiers: ["shift", "alt"], enabledOnInputElements: true }}
>
Format
</Button>
)}
{showClearButton && (
<Button
type="button"
variant="minimal/small"
TrailingIcon={TrashIcon}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
clear();
}}
>
Clear
</Button>
)}
{showCopyButton && (
<Button
type="button"
variant="minimal/small"
TrailingIcon={copied ? CheckIcon : ClipboardIcon}
trailingIconClassName={
copied ? "text-green-500 group-hover:text-green-500" : undefined
}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
copy();
}}
>
Copy
</Button>
)}
</div>
)}
</div>
);
}
export function autoFormatSQL(sql: string) {
return formatSQL(sql, {
language: "sql",
keywordCase: "upper",
indentStyle: "standard",
linesBetweenQueries: 2,
});
}
@@ -0,0 +1,459 @@
import type { OutputColumnMetadata } from "@internal/clickhouse";
import { formatDurationMilliseconds, MachinePresetName } from "@trigger.dev/core/v3";
import { memo, useState } from "react";
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
import { MachineLabelCombo } from "~/components/MachineLabelCombo";
import { DateTimeAccurate } from "~/components/primitives/DateTime";
import {
CopyableTableCell,
Table,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import {
descriptionForTaskRunStatus,
isRunFriendlyStatus,
isTaskRunStatus,
runStatusFromFriendlyTitle,
TaskRunStatusCombo,
} from "~/components/runs/v3/TaskRunStatus";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { formatCurrencyAccurate, formatNumber } from "~/utils/numberFormatter";
import { v3ProjectPath, v3RunPathFromFriendlyId } from "~/utils/pathBuilder";
import { Paragraph } from "../primitives/Paragraph";
import { TextLink } from "../primitives/TextLink";
import { SimpleTooltip } from "../primitives/Tooltip";
import { QueueName } from "../runs/v3/QueueName";
const MAX_STRING_DISPLAY_LENGTH = 64;
/**
* Truncate a string for display, adding ellipsis if it exceeds max length
*/
function truncateString(value: string, maxLength: number = MAX_STRING_DISPLAY_LENGTH): string {
if (value.length <= maxLength) {
return value;
}
return value.slice(0, maxLength) + "…";
}
/**
* Convert any value to a string suitable for copying
* Objects and arrays are JSON stringified, primitives use String()
*/
function valueToString(value: unknown): string {
if (value === null) return "NULL";
if (value === undefined) return "UNDEFINED";
if (typeof value === "object") return JSON.stringify(value);
return String(value);
}
/**
* Check if a ClickHouse type is a DateTime type
*/
function isDateTimeType(type: string): boolean {
return (
type === "DateTime" ||
type === "DateTime64" ||
type === "Date" ||
type === "Date32" ||
type.startsWith("Nullable(DateTime") ||
type.startsWith("Nullable(Date")
);
}
/**
* Check if a ClickHouse type is a numeric type
*/
function isNumericType(type: string): boolean {
return (
type.startsWith("Int") ||
type.startsWith("UInt") ||
type.startsWith("Float") ||
type.startsWith("Nullable(Int") ||
type.startsWith("Nullable(UInt") ||
type.startsWith("Nullable(Float")
);
}
/**
* Check if a ClickHouse type is a boolean type
*/
function isBooleanType(type: string): boolean {
return type === "Bool" || type === "Nullable(Bool)";
}
/**
* Wrapper component that tracks hover state and passes it to CellValue
* This optimizes rendering by only enabling tooltips when the cell is hovered
*/
function CellValueWrapper({
value,
column,
prettyFormatting,
}: {
value: unknown;
column: OutputColumnMetadata;
prettyFormatting: boolean;
}) {
const [hovered, setHovered] = useState(false);
return (
<span
className="flex-1"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<CellValue
value={value}
column={column}
prettyFormatting={prettyFormatting}
hovered={hovered}
/>
</span>
);
}
/**
* Render a cell value based on its type and optional customRenderType
*/
function CellValue({
value,
column,
prettyFormatting = true,
hovered = false,
}: {
value: unknown;
column: OutputColumnMetadata;
prettyFormatting?: boolean;
hovered?: boolean;
}) {
// Plain text mode - render everything as monospace text with truncation
if (!prettyFormatting) {
const plainValue = value === null ? "NULL" : String(value);
const isTruncated = plainValue.length > MAX_STRING_DISPLAY_LENGTH;
if (isTruncated) {
return (
<SimpleTooltip
content={
<pre className="max-w-sm whitespace-pre-wrap break-all font-mono text-xs">
{plainValue}
</pre>
}
button={<pre className="font-mono text-xs">{truncateString(plainValue)}</pre>}
/>
);
}
return <pre className="font-mono text-xs">{plainValue}</pre>;
}
if (value === null) {
return <pre className="text-text-dimmed">NULL</pre>;
}
if (value === undefined) {
return <pre className="text-text-dimmed">UNDEFINED</pre>;
}
// First check customRenderType for special rendering
if (column.customRenderType) {
switch (column.customRenderType) {
case "runId": {
if (typeof value === "string") {
return <TextLink to={v3RunPathFromFriendlyId(value)}>{value}</TextLink>;
}
break;
}
case "runStatus": {
// We have mapped the status to a friendly status so we need to map back to render the normal component
const status = isTaskRunStatus(value)
? value
: isRunFriendlyStatus(value)
? runStatusFromFriendlyTitle(value)
: undefined;
if (status) {
if (hovered) {
return (
<SimpleTooltip
content={descriptionForTaskRunStatus(status)}
disableHoverableContent
button={<TaskRunStatusCombo status={status} />}
/>
);
}
return <TaskRunStatusCombo status={status} />;
}
break;
}
case "duration":
if (typeof value === "number") {
return (
<span className="tabular-nums">
{formatDurationMilliseconds(value, { style: "short" })}
</span>
);
}
return <span>{String(value)}</span>;
case "durationSeconds":
if (typeof value === "number") {
return (
<span className="tabular-nums">
{formatDurationMilliseconds(value * 1000, { style: "short" })}
</span>
);
}
return <span>{String(value)}</span>;
case "cost":
if (typeof value === "number") {
// Assume cost values are in cents
return <span className="tabular-nums">{formatCurrencyAccurate(value / 100)}</span>;
}
return <span>{String(value)}</span>;
case "costInDollars":
if (typeof value === "number") {
// Value is already in dollars, no conversion needed
return <span className="tabular-nums">{formatCurrencyAccurate(value)}</span>;
}
return <span>{String(value)}</span>;
case "machine": {
const preset = MachinePresetName.safeParse(value);
if (preset.success) {
return <MachineLabelCombo preset={preset.data} />;
}
return <span>{String(value)}</span>;
}
case "environmentType": {
if (
typeof value === "string" &&
["PRODUCTION", "STAGING", "DEVELOPMENT", "PREVIEW"].includes(value)
) {
return (
<EnvironmentLabel
environment={{ type: value as "PRODUCTION" | "STAGING" | "DEVELOPMENT" | "PREVIEW" }}
/>
);
}
return <span>{String(value)}</span>;
}
case "project": {
if (typeof value === "string") {
return <ProjectCellValue value={value} />;
}
return <span>{String(value)}</span>;
}
case "environment": {
if (typeof value === "string") {
return <EnvironmentCellValue value={value} />;
}
return <span>{String(value)}</span>;
}
case "queue": {
if (typeof value === "string") {
const type = value.startsWith("task/") ? "task" : "custom";
return <QueueName type={type} name={value.replace("task/", "")} />;
}
return <span>{String(value)}</span>;
}
}
}
// Fall back to rendering based on ClickHouse type
const { type } = column;
// DateTime types
if (isDateTimeType(type)) {
if (typeof value === "string") {
return <DateTimeAccurate date={value} showTooltip={hovered} />;
}
return <span>{String(value)}</span>;
}
// JSON type
if (type === "JSON") {
const jsonString = JSON.stringify(value);
const isTruncated = jsonString.length > MAX_STRING_DISPLAY_LENGTH;
if (isTruncated) {
return (
<SimpleTooltip
content={
<pre className="max-w-sm whitespace-pre-wrap break-all font-mono text-xs">
{jsonString}
</pre>
}
button={
<span className="font-mono text-xs text-text-dimmed">{truncateString(jsonString)}</span>
}
/>
);
}
return <span className="font-mono text-xs text-text-dimmed">{jsonString}</span>;
}
// Array types
if (type.startsWith("Array")) {
const arrayString = JSON.stringify(value);
const isTruncated = arrayString.length > MAX_STRING_DISPLAY_LENGTH;
if (isTruncated) {
return (
<SimpleTooltip
content={
<pre className="max-w-sm whitespace-pre-wrap break-all font-mono text-xs">
{arrayString}
</pre>
}
button={
<span className="font-mono text-xs text-text-dimmed">
{truncateString(arrayString)}
</span>
}
/>
);
}
return <span className="font-mono text-xs text-text-dimmed">{arrayString}</span>;
}
// Boolean types
if (isBooleanType(type)) {
if (typeof value === "boolean") {
return <span className="text-text-dimmed">{value ? "true" : "false"}</span>;
}
if (typeof value === "number") {
return <span className="text-text-dimmed">{value === 1 ? "true" : "false"}</span>;
}
return <span>{String(value)}</span>;
}
// Numeric types
if (isNumericType(type)) {
if (typeof value === "number") {
return <span className="tabular-nums">{formatNumber(value)}</span>;
}
return <span>{String(value)}</span>;
}
// Default to string rendering with truncation for long values
const stringValue = String(value);
const isTruncated = stringValue.length > MAX_STRING_DISPLAY_LENGTH;
if (isTruncated) {
return (
<SimpleTooltip
content={
<pre className="max-w-sm whitespace-pre-wrap break-all font-mono text-xs">
{stringValue}
</pre>
}
button={<span>{truncateString(stringValue)}</span>}
/>
);
}
return <span>{stringValue}</span>;
}
function ProjectCellValue({ value }: { value: string }) {
const organization = useOrganization();
const project = organization.projects.find((p) => p.externalRef === value);
if (!project) {
return <span>{value}</span>;
}
return <TextLink to={v3ProjectPath(organization, project)}>{project.name}</TextLink>;
}
function EnvironmentCellValue({ value }: { value: string }) {
const project = useProject();
const environment = project.environments.find((e) => e.slug === value);
if (!environment) {
return <span>{value}</span>;
}
return <EnvironmentLabel environment={environment} />;
}
/**
* Check if a column should be right-aligned (numeric columns, duration, cost)
*/
function isRightAlignedColumn(column: OutputColumnMetadata): boolean {
// Check for custom render types that display numeric values
if (
column.customRenderType === "duration" ||
column.customRenderType === "durationSeconds" ||
column.customRenderType === "cost" ||
column.customRenderType === "costInDollars"
) {
return true;
}
return isNumericType(column.type);
}
export const TSQLResultsTable = memo(function TSQLResultsTable({
rows,
columns,
prettyFormatting = true,
}: {
rows: Record<string, unknown>[];
columns: OutputColumnMetadata[];
prettyFormatting?: boolean;
}) {
if (!columns.length) return null;
return (
<Table fullWidth containerClassName="h-full overflow-y-auto border-t-0">
<TableHeader>
<TableRow>
{columns.map((col) => (
<TableHeaderCell
key={col.name}
alignment={isRightAlignedColumn(col) ? "right" : "left"}
tooltip={col.description}
>
{col.name}
</TableHeaderCell>
))}
</TableRow>
</TableHeader>
<TableBody>
{rows.length === 0 ? (
<TableRow>
<TableCell colSpan={columns.length}>
<Paragraph variant="extra-small" className="p-2 text-text-dimmed">
No results
</Paragraph>
</TableCell>
</TableRow>
) : (
rows.map((row, i) => (
<TableRow key={i}>
{columns.map((col) => (
<CopyableTableCell
key={col.name}
alignment={isRightAlignedColumn(col) ? "right" : "left"}
value={valueToString(row[col.name])}
>
<CellValueWrapper
value={row[col.name]}
column={col}
prettyFormatting={prettyFormatting}
/>
</CopyableTableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
);
});
@@ -0,0 +1,6 @@
// TSQL CodeMirror support
// Provides syntax highlighting, autocompletion, and linting for TSQL queries
export { createTSQLCompletion } from "./tsqlCompletion";
export { createTSQLLinter, isValidTSQLQuery, getTSQLError, type TSQLLinterConfig } from "./tsqlLinter";
@@ -0,0 +1,172 @@
import { describe, it, expect } from "vitest";
import { createTSQLCompletion } from "./tsqlCompletion";
import type { TableSchema, ColumnSchema } from "@internal/tsql";
// Helper to create a mock completion context
function createMockContext(doc: string, pos: number, explicit = false) {
return {
state: {
doc: {
toString: () => doc,
},
},
pos,
explicit,
matchBefore: (regex: RegExp) => {
const beforePos = doc.slice(0, pos);
const match = beforePos.match(new RegExp(regex.source + "$"));
if (match) {
return {
from: pos - match[0].length,
to: pos,
text: match[0],
};
}
return null;
},
} as any;
}
// Test schema
const testSchema: TableSchema[] = [
{
name: "runs",
clickhouseName: "trigger_dev.task_runs_v2",
tenantColumns: {
organizationId: "organization_id",
projectId: "project_id",
environmentId: "environment_id",
},
description: "Task runs table",
columns: {
id: { name: "id", type: "String", description: "Run ID" },
status: { name: "status", type: "String", description: "Run status" },
created_at: { name: "created_at", type: "DateTime64", description: "Creation time" },
organization_id: { name: "organization_id", type: "String" },
project_id: { name: "project_id", type: "String" },
environment_id: { name: "environment_id", type: "String" },
},
},
{
name: "logs",
clickhouseName: "trigger_dev.task_events_v2",
tenantColumns: {
organizationId: "organization_id",
projectId: "project_id",
environmentId: "environment_id",
},
description: "Task logs table",
columns: {
id: { name: "id", type: "String" },
run_id: { name: "run_id", type: "String" },
message: { name: "message", type: "String" },
level: { name: "level", type: "String" },
timestamp: { name: "timestamp", type: "DateTime64" },
organization_id: { name: "organization_id", type: "String" },
project_id: { name: "project_id", type: "String" },
environment_id: { name: "environment_id", type: "String" },
},
},
];
describe("createTSQLCompletion", () => {
const completionSource = createTSQLCompletion(testSchema);
it("should return null for empty input without explicit trigger", () => {
const context = createMockContext("", 0, false);
const result = completionSource(context);
expect(result).toBeNull();
});
it("should return completions when explicitly triggered", () => {
const context = createMockContext("", 0, true);
const result = completionSource(context);
expect(result).not.toBeNull();
expect(result?.options.length).toBeGreaterThan(0);
});
it("should suggest tables after FROM keyword", () => {
const doc = "SELECT * FROM ";
const context = createMockContext(doc, doc.length, true);
const result = completionSource(context);
expect(result).not.toBeNull();
const tableLabels = result?.options.map((o) => o.label);
expect(tableLabels).toContain("runs");
expect(tableLabels).toContain("logs");
});
it("should suggest columns after SELECT keyword", () => {
const doc = "SELECT FROM runs";
// Position cursor right after SELECT
const pos = 7;
const context = createMockContext(doc, pos, true);
const result = completionSource(context);
expect(result).not.toBeNull();
// Should include functions
const labels = result?.options.map((o) => o.label) || [];
expect(labels.some((l) => l === "count")).toBe(true);
expect(labels.some((l) => l === "sum")).toBe(true);
});
it("should suggest columns with table prefix for qualified references", () => {
const doc = "SELECT runs. FROM runs";
// Position cursor right after "runs."
const pos = 12;
const context = createMockContext(doc, pos, true);
const result = completionSource(context);
expect(result).not.toBeNull();
const columnLabels = result?.options.map((o) => o.label);
expect(columnLabels).toContain("id");
expect(columnLabels).toContain("status");
expect(columnLabels).toContain("created_at");
});
it("should include SQL keywords in general context", () => {
const doc = "S";
const context = createMockContext(doc, doc.length, true);
const result = completionSource(context);
expect(result).not.toBeNull();
const labels = result?.options.map((o) => o.label);
expect(labels).toContain("SELECT");
});
it("should include aggregate functions", () => {
const doc = "SELECT ";
const context = createMockContext(doc, doc.length, true);
const result = completionSource(context);
expect(result).not.toBeNull();
const labels = result?.options.map((o) => o.label);
expect(labels).toContain("count");
expect(labels).toContain("sum");
expect(labels).toContain("avg");
expect(labels).toContain("min");
expect(labels).toContain("max");
});
it("should handle WHERE clause context", () => {
const doc = "SELECT * FROM runs WHERE ";
const context = createMockContext(doc, doc.length, true);
const result = completionSource(context);
expect(result).not.toBeNull();
// Should suggest columns
const labels = result?.options.map((o) => o.label) || [];
expect(labels).toContain("status");
// Should include conditional keywords
expect(labels).toContain("AND");
expect(labels).toContain("OR");
});
});
@@ -0,0 +1,464 @@
import type { CompletionContext, CompletionResult, Completion } from "@codemirror/autocomplete";
import type { TableSchema, ColumnSchema } from "@internal/tsql";
import {
TSQL_CLICKHOUSE_FUNCTIONS,
TSQL_AGGREGATIONS,
} from "@internal/tsql";
/**
* SQL keywords for autocomplete
*/
const SQL_KEYWORDS = [
"SELECT",
"FROM",
"WHERE",
"AND",
"OR",
"NOT",
"IN",
"LIKE",
"ILIKE",
"BETWEEN",
"IS",
"NULL",
"TRUE",
"FALSE",
"AS",
"ORDER",
"BY",
"ASC",
"DESC",
"LIMIT",
"OFFSET",
"GROUP",
"HAVING",
"DISTINCT",
"JOIN",
"LEFT",
"RIGHT",
"INNER",
"OUTER",
"FULL",
"CROSS",
"ON",
"UNION",
"INTERSECT",
"EXCEPT",
"ALL",
"WITH",
"CASE",
"WHEN",
"THEN",
"ELSE",
"END",
"OVER",
"PARTITION",
"ROWS",
"RANGE",
"UNBOUNDED",
"PRECEDING",
"FOLLOWING",
"CURRENT",
"ROW",
"NULLS",
"FIRST",
"LAST",
];
/**
* Create keyword completions from the SQL keywords list
*/
function createKeywordCompletions(): Completion[] {
return SQL_KEYWORDS.map((keyword) => ({
label: keyword,
type: "keyword",
boost: -1, // Keywords should have lower priority than schema items
}));
}
/**
* Create function completions from TSQL function definitions
*/
function createFunctionCompletions(): Completion[] {
const functions: Completion[] = [];
// Add regular functions
for (const [name, meta] of Object.entries(TSQL_CLICKHOUSE_FUNCTIONS)) {
// Skip internal functions starting with _
if (name.startsWith("_")) continue;
const argsHint =
meta.maxArgs === 0 ? "()" : meta.minArgs === meta.maxArgs ? `(${meta.minArgs} args)` : `(${meta.minArgs}${meta.maxArgs ? `-${meta.maxArgs}` : "+"} args)`;
functions.push({
label: name,
type: "function",
detail: argsHint,
apply: `${name}()`,
});
}
// Add aggregate functions with slightly higher boost
for (const [name, meta] of Object.entries(TSQL_AGGREGATIONS)) {
if (name.startsWith("_")) continue;
const argsHint =
meta.maxArgs === 0 ? "()" : meta.minArgs === meta.maxArgs ? `(${meta.minArgs} args)` : `(${meta.minArgs}${meta.maxArgs ? `-${meta.maxArgs}` : "+"} args)`;
functions.push({
label: name,
type: "function",
detail: `aggregate ${argsHint}`,
apply: `${name}()`,
boost: 0.5,
});
}
return functions;
}
/**
* Create table completions from schema
*/
function createTableCompletions(schema: TableSchema[]): Completion[] {
return schema.map((table) => ({
label: table.name,
type: "class", // Using "class" type for tables gives them a nice icon
detail: table.description || "table",
boost: 1, // Tables should have higher priority
}));
}
/**
* Create column completions for a specific table
*/
function createColumnCompletions(table: TableSchema, prefix?: string): Completion[] {
const columns: Completion[] = [];
for (const [name, column] of Object.entries(table.columns)) {
columns.push({
label: prefix ? `${prefix}.${name}` : name,
type: "property", // Using "property" type for columns
detail: `${column.type}${column.description ? ` - ${column.description}` : ""}`,
boost: 2, // Columns should have highest priority
});
}
return columns;
}
/**
* Extract table names/aliases from the current query context
* This is a simplified parser that looks for FROM and JOIN clauses
*/
function extractTablesFromQuery(doc: string, schema: TableSchema[]): Map<string, TableSchema> {
const tableMap = new Map<string, TableSchema>();
const tableNames = schema.map((t) => t.name);
// Simple regex to find table references in FROM and JOIN clauses
// Handles: FROM table_name, FROM table_name AS alias, FROM table_name alias
const tablePattern =
/(?:FROM|JOIN)\s+(\w+)(?:\s+(?:AS\s+)?(\w+))?/gi;
let match;
while ((match = tablePattern.exec(doc)) !== null) {
const tableName = match[1];
const alias = match[2] || tableName;
// Find the table schema if it exists
const tableSchema = schema.find(
(t) => t.name.toLowerCase() === tableName.toLowerCase()
);
if (tableSchema) {
tableMap.set(alias.toLowerCase(), tableSchema);
}
}
return tableMap;
}
/**
* Determine what context we're in based on cursor position
*/
type CompletionContextType =
| "table" // After FROM or JOIN
| "column" // After SELECT, WHERE, ORDER BY, GROUP BY, etc.
| "alias" // After table_name.
| "value" // After comparison operator (=, !=, IN, etc.)
| "general"; // Anywhere else
/**
* Result of context detection
*/
interface ContextResult {
type: CompletionContextType;
tablePrefix?: string;
/** Column being compared (for value context) */
columnName?: string;
/** Table alias for the column (for value context) */
columnTableAlias?: string;
}
/**
* Extract column name from text before a comparison operator
* Handles: "column =", "table.column =", "column IN", etc.
*/
function extractColumnBeforeOperator(textBefore: string): { columnName: string; tableAlias?: string } | null {
// Match patterns like: column =, column !=, column IN, table.column =, etc.
// We need to capture the column (and optional table prefix) before the operator
const patterns = [
// column = or column != or column <> (with optional whitespace)
/(\w+)\.(\w+)\s*(?:=|!=|<>)\s*$/i,
/(\w+)\s*(?:=|!=|<>)\s*$/i,
// column IN ( or column NOT IN (
/(\w+)\.(\w+)\s+(?:NOT\s+)?IN\s*\(\s*$/i,
/(\w+)\s+(?:NOT\s+)?IN\s*\(\s*$/i,
// After a comma in IN clause - need to find the column before IN
/(\w+)\.(\w+)\s+(?:NOT\s+)?IN\s*\([^)]*,\s*$/i,
/(\w+)\s+(?:NOT\s+)?IN\s*\([^)]*,\s*$/i,
];
for (const pattern of patterns) {
const match = textBefore.match(pattern);
if (match) {
if (match.length === 3) {
// table.column pattern
return { tableAlias: match[1], columnName: match[2] };
} else {
// just column pattern
return { columnName: match[1] };
}
}
}
return null;
}
function determineContext(
doc: string,
pos: number
): ContextResult {
// Get text before cursor
const textBefore = doc.slice(0, pos);
// Check if we're in a value context (after comparison operator)
// This should be checked before other contexts
const columnInfo = extractColumnBeforeOperator(textBefore);
if (columnInfo) {
return {
type: "value",
columnName: columnInfo.columnName,
columnTableAlias: columnInfo.tableAlias,
};
}
// Check if we're completing after a dot (table.column)
const dotMatch = textBefore.match(/(\w+)\.\s*$/);
if (dotMatch) {
return { type: "alias", tablePrefix: dotMatch[1] };
}
// Find the LAST significant keyword before cursor
// We match all keywords and take the last one
const keywordPattern = /\b(SELECT|FROM|JOIN|WHERE|AND|OR|ORDER\s+BY|GROUP\s+BY|HAVING|ON)\b/gi;
let lastMatch: RegExpExecArray | null = null;
let match: RegExpExecArray | null;
while ((match = keywordPattern.exec(textBefore)) !== null) {
lastMatch = match;
}
if (lastMatch) {
const keyword = lastMatch[1].toUpperCase().replace(/\s+/g, " ");
if (keyword === "FROM" || keyword === "JOIN") {
return { type: "table" };
}
if (
keyword === "SELECT" ||
keyword === "WHERE" ||
keyword === "AND" ||
keyword === "OR" ||
keyword === "ORDER BY" ||
keyword === "GROUP BY" ||
keyword === "HAVING" ||
keyword === "ON"
) {
return { type: "column" };
}
}
return { type: "general" };
}
/**
* Find a column schema by name in the tables map
*/
function findColumnSchema(
columnName: string,
tableAlias: string | undefined,
tables: Map<string, TableSchema>
): ColumnSchema | null {
if (tableAlias) {
// Look in specific table
const tableSchema = tables.get(tableAlias.toLowerCase());
if (tableSchema) {
return tableSchema.columns[columnName] || null;
}
} else {
// Look in all tables
for (const tableSchema of tables.values()) {
const col = tableSchema.columns[columnName];
if (col) {
return col;
}
}
}
return null;
}
/**
* Create completions for enum values
* Uses user-friendly values from valueMap when available, showing internal value as detail
*/
function createEnumValueCompletions(columnSchema: ColumnSchema): Completion[] {
// Prefer valueMap over allowedValues if available
if (columnSchema.valueMap && Object.keys(columnSchema.valueMap).length > 0) {
return Object.entries(columnSchema.valueMap).map(([internalValue, userFriendlyValue]) => ({
label: `'${userFriendlyValue}'`,
type: "enum",
detail: `${internalValue}`,
boost: 3, // Highest priority for enum values in value context
}));
}
// Fall back to allowedValues
if (!columnSchema.allowedValues || columnSchema.allowedValues.length === 0) {
return [];
}
return columnSchema.allowedValues.map((value) => ({
label: `'${value}'`,
type: "enum",
detail: columnSchema.description || "allowed value",
boost: 3, // Highest priority for enum values in value context
}));
}
/**
* Create a TSQL-aware autocompletion source
*
* @param schema - Array of table schemas to use for completions
* @returns A CodeMirror completion source function
*/
export function createTSQLCompletion(
schema: TableSchema[]
): (context: CompletionContext) => CompletionResult | null {
// Pre-compute static completions
const keywordCompletions = createKeywordCompletions();
const functionCompletions = createFunctionCompletions();
const tableCompletions = createTableCompletions(schema);
return (context: CompletionContext): CompletionResult | null => {
// Get the word being typed - include single quotes for value completion
const word = context.matchBefore(/[\w.']+/);
// Don't show completions if no word is being typed and not explicitly triggered
if (!word && !context.explicit) {
return null;
}
const from = word ? word.from : context.pos;
const doc = context.state.doc.toString();
const queryContext = determineContext(doc, context.pos);
let options: Completion[] = [];
switch (queryContext.type) {
case "table":
// After FROM or JOIN, show only tables
options = tableCompletions;
break;
case "alias":
// After table., show columns for that table
if (queryContext.tablePrefix) {
const tables = extractTablesFromQuery(doc, schema);
const tableSchema = tables.get(queryContext.tablePrefix.toLowerCase());
if (tableSchema) {
options = createColumnCompletions(tableSchema);
}
}
break;
case "value":
// After comparison operator, show enum values if available
if (queryContext.columnName) {
const tables = extractTablesFromQuery(doc, schema);
const columnSchema = findColumnSchema(
queryContext.columnName,
queryContext.columnTableAlias,
tables
);
if (columnSchema) {
options = createEnumValueCompletions(columnSchema);
}
}
break;
case "column":
// After SELECT, WHERE, etc., show columns, functions, and some keywords
{
const tables = extractTablesFromQuery(doc, schema);
// Add columns from all tables in the query
tables.forEach((tableSchema, alias) => {
// If multiple tables, prefix with alias
const prefix = tables.size > 1 ? alias : undefined;
options.push(...createColumnCompletions(tableSchema, prefix));
});
// Also add functions and relevant keywords
options.push(...functionCompletions);
options.push(
...keywordCompletions.filter((k) =>
["AND", "OR", "NOT", "IN", "LIKE", "ILIKE", "BETWEEN", "IS", "NULL", "AS", "CASE", "WHEN", "THEN", "ELSE", "END"].includes(
k.label as string
)
)
);
}
break;
case "general":
default:
// Show everything
options = [
...tableCompletions,
...functionCompletions,
...keywordCompletions,
];
// Also add columns from tables in query
{
const tables = extractTablesFromQuery(doc, schema);
tables.forEach((tableSchema, alias) => {
const prefix = tables.size > 1 ? alias : undefined;
options.push(...createColumnCompletions(tableSchema, prefix));
});
}
break;
}
return {
from,
options,
validFor: /^[\w.']*$/,
};
};
}
@@ -0,0 +1,79 @@
import { describe, it, expect } from "vitest";
import { isValidTSQLQuery, getTSQLError } from "./tsqlLinter";
describe("tsqlLinter", () => {
describe("isValidTSQLQuery", () => {
it("should return true for empty queries", () => {
expect(isValidTSQLQuery("")).toBe(true);
expect(isValidTSQLQuery(" ")).toBe(true);
});
it("should return true for valid SELECT queries", () => {
expect(isValidTSQLQuery("SELECT * FROM users")).toBe(true);
expect(isValidTSQLQuery("SELECT id, name FROM users WHERE status = 'active'")).toBe(true);
expect(isValidTSQLQuery("SELECT count(*) FROM users GROUP BY status")).toBe(true);
});
it("should return true for queries with ORDER BY", () => {
expect(isValidTSQLQuery("SELECT * FROM users ORDER BY created_at DESC")).toBe(true);
});
it("should return true for queries with LIMIT", () => {
expect(isValidTSQLQuery("SELECT * FROM users LIMIT 10")).toBe(true);
expect(isValidTSQLQuery("SELECT * FROM users LIMIT 10 OFFSET 20")).toBe(true);
});
it("should return true for queries with JOINs", () => {
expect(isValidTSQLQuery("SELECT * FROM users JOIN orders ON users.id = orders.user_id")).toBe(
true
);
expect(
isValidTSQLQuery(
"SELECT * FROM users LEFT JOIN orders ON users.id = orders.user_id"
)
).toBe(true);
});
it("should return false for invalid syntax", () => {
expect(isValidTSQLQuery("SELEC * FROM users")).toBe(false);
expect(isValidTSQLQuery("SELECT * FORM users")).toBe(false);
expect(isValidTSQLQuery("SELECT FROM users")).toBe(false);
});
it("should return false for incomplete queries", () => {
expect(isValidTSQLQuery("SELECT * FROM")).toBe(false);
expect(isValidTSQLQuery("SELECT")).toBe(false);
});
});
describe("getTSQLError", () => {
it("should return null for empty queries", () => {
expect(getTSQLError("")).toBeNull();
expect(getTSQLError(" ")).toBeNull();
});
it("should return null for valid queries", () => {
expect(getTSQLError("SELECT * FROM users")).toBeNull();
expect(getTSQLError("SELECT id, name FROM users WHERE id = 1")).toBeNull();
});
it("should return error message for invalid queries", () => {
const error = getTSQLError("SELEC * FROM users");
expect(error).not.toBeNull();
expect(typeof error).toBe("string");
});
it("should include position information in error", () => {
const error = getTSQLError("SELECT * FORM users");
expect(error).not.toBeNull();
// Error message should contain line/column info
expect(error).toContain("line");
});
it("should handle missing FROM clause", () => {
const error = getTSQLError("SELECT * WHERE id = 1");
expect(error).not.toBeNull();
});
});
});
@@ -0,0 +1,213 @@
import type { EditorView } from "@codemirror/view";
import type { Diagnostic } from "@codemirror/lint";
import type { TableSchema } from "@internal/tsql";
import { parseTSQLSelect, SyntaxError, QueryError, validateQuery } from "@internal/tsql";
/**
* Configuration for the TSQL linter
*/
export interface TSQLLinterConfig {
/** Optional schema for validating table/column names */
schema?: TableSchema[];
/** Delay in milliseconds before running the linter (debouncing) */
delay?: number;
}
/**
* Extract line and column from a TSQL error message
* Error format: "Syntax error at line X:Y: message"
*/
function parseErrorPosition(message: string): { line: number; column: number } | null {
const match = message.match(/at line (\d+):(\d+)/);
if (match) {
return {
line: parseInt(match[1], 10),
column: parseInt(match[2], 10),
};
}
return null;
}
/**
* Convert line/column to a document position
*/
function positionToOffset(
doc: string,
line: number,
column: number
): number {
const lines = doc.split("\n");
// line is 1-indexed
let offset = 0;
for (let i = 0; i < line - 1 && i < lines.length; i++) {
offset += lines[i].length + 1; // +1 for newline
}
return offset + column;
}
/**
* Find the end of a word/token at the given position
*/
function findTokenEnd(doc: string, start: number): number {
let end = start;
// Scan forward until we hit whitespace or end of string
while (end < doc.length && /\S/.test(doc[end])) {
end++;
}
// If we didn't move, include at least one character
if (end === start) {
end = Math.min(start + 1, doc.length);
}
return end;
}
/**
* Create a TSQL linter function for CodeMirror
*
* This linter uses the TSQL ANTLR parser to detect syntax errors
* and optionally validates against a schema.
*
* @param config - Linter configuration
* @returns A linter function for use with CodeMirror's linter extension
*/
export function createTSQLLinter(
config: TSQLLinterConfig = {}
): (view: EditorView) => Diagnostic[] {
const { schema = [] } = config;
return (view: EditorView): Diagnostic[] => {
const content = view.state.doc.toString().trim();
// Return no errors for empty content
if (!content) {
return [];
}
const diagnostics: Diagnostic[] = [];
try {
// Try to parse the query
const ast = parseTSQLSelect(content);
// If parsing succeeds and we have a schema, run schema validation
if (schema.length > 0) {
const validationResult = validateQuery(ast, schema);
for (const issue of validationResult.issues) {
// Map validation severity to CodeMirror diagnostic severity
const severity: "error" | "warning" | "info" =
issue.severity === "error"
? "error"
: issue.severity === "warning"
? "warning"
: "info";
diagnostics.push({
from: 0,
to: content.length,
severity,
message: issue.message,
source: "tsql",
});
}
}
} catch (error) {
if (error instanceof SyntaxError) {
const position = parseErrorPosition(error.message);
let from: number;
let to: number;
if (position) {
from = positionToOffset(content, position.line, position.column);
to = findTokenEnd(content, from);
} else {
// If we can't parse the position, highlight the whole query
from = 0;
to = content.length;
}
// Clean up the error message
let message = error.message;
// Remove the "Syntax error at line X:Y: " prefix if present
message = message.replace(/^Syntax error at line \d+:\d+:\s*/, "");
diagnostics.push({
from,
to,
severity: "error",
message: message,
source: "tsql",
});
} else if (error instanceof QueryError) {
// Schema validation errors don't have position info,
// so highlight the whole query
diagnostics.push({
from: 0,
to: content.length,
severity: "warning",
message: error.message,
source: "tsql",
});
} else if (error instanceof Error) {
// Unknown error
diagnostics.push({
from: 0,
to: content.length,
severity: "error",
message: error.message,
source: "tsql",
});
}
}
return diagnostics;
};
}
/**
* Check if a TSQL query is valid
*
* @param query - The query to validate
* @returns true if the query is valid, false otherwise
*/
export function isValidTSQLQuery(query: string): boolean {
if (!query.trim()) {
return true; // Empty queries are considered valid
}
try {
parseTSQLSelect(query);
return true;
} catch {
return false;
}
}
/**
* Get error message for a TSQL query, if any
*
* @param query - The query to validate
* @returns Error message if invalid, null if valid
*/
export function getTSQLError(query: string): string | null {
if (!query.trim()) {
return null;
}
try {
parseTSQLSelect(query);
return null;
} catch (error) {
if (error instanceof Error) {
return error.message;
}
return "Unknown error";
}
}
@@ -5,6 +5,7 @@ import {
BellAlertIcon,
ChartBarIcon,
ChevronRightIcon,
CircleStackIcon,
ClockIcon,
Cog8ToothIcon,
CogIcon,
@@ -13,11 +14,13 @@ import {
GlobeAmericasIcon,
IdentificationIcon,
KeyIcon,
MagnifyingGlassCircleIcon,
PencilSquareIcon,
PlusIcon,
RectangleStackIcon,
ServerStackIcon,
Squares2X2Icon,
TableCellsIcon,
UsersIcon,
} from "@heroicons/react/20/solid";
import { Link, useNavigation } from "@remix-run/react";
@@ -31,6 +34,7 @@ import { TaskIconSmall } from "~/assets/icons/TaskIcon";
import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon";
import { Avatar } from "~/components/primitives/Avatar";
import { type MatchedEnvironment } from "~/hooks/useEnvironment";
import { useFeatureFlags } from "~/hooks/useFeatureFlags";
import { useFeatures } from "~/hooks/useFeatures";
import { type MatchedOrganization } from "~/hooks/useOrganizations";
import { type MatchedProject } from "~/hooks/useProject";
@@ -51,6 +55,7 @@ import {
organizationPath,
organizationSettingsPath,
organizationTeamPath,
queryPath,
regionsPath,
v3ApiKeysPath,
v3BatchesPath,
@@ -93,6 +98,7 @@ import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
import { SideMenuHeader } from "./SideMenuHeader";
import { SideMenuItem } from "./SideMenuItem";
import { SideMenuSection } from "./SideMenuSection";
import { AlphaBadge } from "../AlphaBadge";
type SideMenuUser = Pick<User, "email" | "admin"> & { isImpersonating: boolean };
export type SideMenuProject = Pick<
@@ -125,6 +131,7 @@ export function SideMenu({
const isFreeUser = currentPlan?.v3Subscription?.isPaying === false;
const isAdmin = useHasAdminAccess();
const { isManagedCloud } = useFeatures();
const featureFlags = useFeatureFlags();
useEffect(() => {
const handleScroll = () => {
@@ -267,6 +274,16 @@ export function SideMenu({
to={v3TestPath(organization, project, environment)}
data-action="test"
/>
{(user.admin || featureFlags.hasQueryAccess) && (
<SideMenuItem
name="Query"
icon={TableCellsIcon}
activeIconColor="text-purple-500"
to={queryPath(organization, project, environment)}
data-action="query"
badge={<AlphaBadge />}
/>
)}
</div>
<SideMenuSection title="Waitpoints">
@@ -1,11 +1,44 @@
import { GlobeAltIcon, GlobeAmericasIcon } from "@heroicons/react/20/solid";
import { Laptop } from "lucide-react";
import { Fragment, type ReactNode, useEffect, useState } from "react";
import { Fragment, memo, type ReactNode, useMemo, useSyncExternalStore } from "react";
import { CopyButton } from "./CopyButton";
import { useLocales } from "./LocaleProvider";
import { Paragraph } from "./Paragraph";
import { SimpleTooltip } from "./Tooltip";
// Cache the browser's local timezone - resolved once and reused
let cachedLocalTimeZone: string | null = null;
function getLocalTimeZone(): string {
if (cachedLocalTimeZone === null) {
cachedLocalTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
}
return cachedLocalTimeZone;
}
// For SSR compatibility: returns "UTC" on server, actual timezone on client
function subscribeToTimeZone() {
// No-op - timezone doesn't change
return () => {};
}
function getTimeZoneSnapshot(): string {
return getLocalTimeZone();
}
function getServerTimeZoneSnapshot(): string {
return "UTC";
}
/**
* Hook to get the browser's local timezone.
* Uses useSyncExternalStore for SSR compatibility - returns "UTC" on server,
* actual timezone on client. The timezone is cached and only resolved once.
*/
export function useLocalTimeZone(): string {
return useSyncExternalStore(subscribeToTimeZone, getTimeZoneSnapshot, getServerTimeZoneSnapshot);
}
type DateTimeProps = {
date: Date | string;
timeZone?: string;
@@ -28,23 +61,9 @@ export const DateTime = ({
hour12 = true,
}: DateTimeProps) => {
const locales = useLocales();
const [localTimeZone, setLocalTimeZone] = useState<string>("UTC");
const localTimeZone = useLocalTimeZone();
const realDate = typeof date === "string" ? new Date(date) : date;
useEffect(() => {
const resolvedOptions = Intl.DateTimeFormat().resolvedOptions();
setLocalTimeZone(resolvedOptions.timeZone);
}, []);
const tooltipContent = (
<TooltipContent
realDate={realDate}
timeZone={timeZone}
localTimeZone={localTimeZone}
locales={locales}
/>
);
const realDate = useMemo(() => (typeof date === "string" ? new Date(date) : date), [date]);
const formattedDateTime = (
<Fragment>
@@ -62,7 +81,20 @@ export const DateTime = ({
if (!showTooltip) return formattedDateTime;
return <SimpleTooltip button={formattedDateTime} content={tooltipContent} side="right" />;
return (
<SimpleTooltip
button={formattedDateTime}
content={
<TooltipContent
realDate={realDate}
timeZone={timeZone}
localTimeZone={localTimeZone}
locales={locales}
/>
}
side="right"
/>
);
};
export function formatDateTime(
@@ -128,8 +160,9 @@ export function formatDateTimeISO(date: Date, timeZone: string): string {
}
// New component that only shows date when it changes
export const SmartDateTime = ({ date, previousDate = null, timeZone = "UTC", hour12 = true }: DateTimeProps) => {
export const SmartDateTime = ({ date, previousDate = null, hour12 = true }: DateTimeProps) => {
const locales = useLocales();
const localTimeZone = useLocalTimeZone();
const realDate = typeof date === "string" ? new Date(date) : date;
const realPrevDate = previousDate
? typeof previousDate === "string"
@@ -137,29 +170,13 @@ export const SmartDateTime = ({ date, previousDate = null, timeZone = "UTC", hou
: previousDate
: null;
// Initial formatted values
const initialTimeOnly = formatTimeOnly(realDate, timeZone, locales, hour12);
const initialWithDate = formatSmartDateTime(realDate, timeZone, locales, hour12);
// Check if we should show the date
const showDatePart = !realPrevDate || !isSameDay(realDate, realPrevDate);
// State for the formatted time
const [formattedDateTime, setFormattedDateTime] = useState<string>(
realPrevDate && isSameDay(realDate, realPrevDate) ? initialTimeOnly : initialWithDate
);
useEffect(() => {
const resolvedOptions = Intl.DateTimeFormat().resolvedOptions();
const userTimeZone = resolvedOptions.timeZone;
// Check if we should show the date
const showDatePart = !realPrevDate || !isSameDay(realDate, realPrevDate);
// Format with appropriate function
setFormattedDateTime(
showDatePart
? formatSmartDateTime(realDate, userTimeZone, locales, hour12)
: formatTimeOnly(realDate, userTimeZone, locales, hour12)
);
}, [locales, realDate, realPrevDate, hour12]);
// Format with appropriate function
const formattedDateTime = showDatePart
? formatSmartDateTime(realDate, localTimeZone, locales, hour12)
: formatTimeOnly(realDate, localTimeZone, locales, hour12);
return <Fragment>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</Fragment>;
};
@@ -174,7 +191,12 @@ function isSameDay(date1: Date, date2: Date): boolean {
}
// Format with date and time
function formatSmartDateTime(date: Date, timeZone: string, locales: string[], hour12: boolean = true): string {
function formatSmartDateTime(
date: Date,
timeZone: string,
locales: string[],
hour12: boolean = true
): string {
return new Intl.DateTimeFormat(locales, {
month: "short",
day: "numeric",
@@ -189,7 +211,12 @@ function formatSmartDateTime(date: Date, timeZone: string, locales: string[], ho
}
// Format time only
function formatTimeOnly(date: Date, timeZone: string, locales: string[], hour12: boolean = true): string {
function formatTimeOnly(
date: Date,
timeZone: string,
locales: string[],
hour12: boolean = true
): string {
return new Intl.DateTimeFormat(locales, {
hour: "2-digit",
minute: "numeric",
@@ -201,7 +228,7 @@ function formatTimeOnly(date: Date, timeZone: string, locales: string[], hour12:
}).format(date);
}
export const DateTimeAccurate = ({
const DateTimeAccurateInner = ({
date,
timeZone = "UTC",
previousDate = null,
@@ -210,7 +237,7 @@ export const DateTimeAccurate = ({
hour12 = true,
}: DateTimeProps) => {
const locales = useLocales();
const [localTimeZone, setLocalTimeZone] = useState<string>("UTC");
const localTimeZone = useLocalTimeZone();
const realDate = typeof date === "string" ? new Date(date) : date;
const realPrevDate = previousDate
? typeof previousDate === "string"
@@ -218,19 +245,16 @@ export const DateTimeAccurate = ({
: previousDate
: null;
useEffect(() => {
const resolvedOptions = Intl.DateTimeFormat().resolvedOptions();
setLocalTimeZone(resolvedOptions.timeZone);
}, []);
// Smart formatting based on whether date changed
const formattedDateTime = hideDate
? formatTimeOnly(realDate, localTimeZone, locales, hour12)
: realPrevDate
? isSameDay(realDate, realPrevDate)
const formattedDateTime = useMemo(() => {
return hideDate
? formatTimeOnly(realDate, localTimeZone, locales, hour12)
: formatDateTimeAccurate(realDate, localTimeZone, locales, hour12)
: formatDateTimeAccurate(realDate, localTimeZone, locales, hour12);
: realPrevDate
? isSameDay(realDate, realPrevDate)
? formatTimeOnly(realDate, localTimeZone, locales, hour12)
: formatDateTimeAccurate(realDate, localTimeZone, locales, hour12)
: formatDateTimeAccurate(realDate, localTimeZone, locales, hour12);
}, [realDate, localTimeZone, locales, hour12, hideDate, previousDate]);
if (!showTooltip)
return <Fragment>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</Fragment>;
@@ -253,7 +277,34 @@ export const DateTimeAccurate = ({
);
};
function formatDateTimeAccurate(date: Date, timeZone: string, locales: string[], hour12: boolean = true): string {
function areDateTimePropsEqual(prev: DateTimeProps, next: DateTimeProps): boolean {
// Compare Date objects by timestamp value, not reference
const prevTime = prev.date instanceof Date ? prev.date.getTime() : prev.date;
const nextTime = next.date instanceof Date ? next.date.getTime() : next.date;
if (prevTime !== nextTime) return false;
const prevPrevTime =
prev.previousDate instanceof Date ? prev.previousDate.getTime() : prev.previousDate;
const nextPrevTime =
next.previousDate instanceof Date ? next.previousDate.getTime() : next.previousDate;
if (prevPrevTime !== nextPrevTime) return false;
return (
prev.timeZone === next.timeZone &&
prev.showTooltip === next.showTooltip &&
prev.hideDate === next.hideDate &&
prev.hour12 === next.hour12
);
}
export const DateTimeAccurate = memo(DateTimeAccurateInner, areDateTimePropsEqual);
function formatDateTimeAccurate(
date: Date,
timeZone: string,
locales: string[],
hour12: boolean = true
): string {
const formattedDateTime = new Intl.DateTimeFormat(locales, {
month: "short",
day: "numeric",
@@ -269,21 +320,21 @@ function formatDateTimeAccurate(date: Date, timeZone: string, locales: string[],
return formattedDateTime;
}
export const DateTimeShort = ({ date, timeZone = "UTC", hour12 = true }: DateTimeProps) => {
export const DateTimeShort = ({ date, hour12 = true }: DateTimeProps) => {
const locales = useLocales();
const localTimeZone = useLocalTimeZone();
const realDate = typeof date === "string" ? new Date(date) : date;
const initialFormattedDateTime = formatDateTimeShort(realDate, timeZone, locales, hour12);
const [formattedDateTime, setFormattedDateTime] = useState<string>(initialFormattedDateTime);
useEffect(() => {
const resolvedOptions = Intl.DateTimeFormat().resolvedOptions();
setFormattedDateTime(formatDateTimeShort(realDate, resolvedOptions.timeZone, locales, hour12));
}, [locales, realDate, hour12]);
const formattedDateTime = formatDateTimeShort(realDate, localTimeZone, locales, hour12);
return <Fragment>{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}</Fragment>;
};
function formatDateTimeShort(date: Date, timeZone: string, locales: string[], hour12: boolean = true): string {
function formatDateTimeShort(
date: Date,
timeZone: string,
locales: string[],
hour12: boolean = true
): string {
const formattedDateTime = new Intl.DateTimeFormat(locales, {
hour: "numeric",
minute: "numeric",
@@ -310,14 +361,17 @@ function DateTimeTooltipContent({
isoDateTime,
icon,
}: DateTimeTooltipContentProps) {
const getUtcOffset = () => {
if (title !== "Local") return "";
const offset = -new Date().getTimezoneOffset();
const sign = offset >= 0 ? "+" : "-";
const hours = Math.abs(Math.floor(offset / 60));
const minutes = Math.abs(offset % 60);
return `(UTC ${sign}${hours}${minutes ? `:${minutes.toString().padStart(2, "0")}` : ""})`;
};
const getUtcOffset = useMemo(
() => () => {
if (title !== "Local") return "";
const offset = -new Date().getTimezoneOffset();
const sign = offset >= 0 ? "+" : "-";
const hours = Math.abs(Math.floor(offset / 60));
const minutes = Math.abs(offset % 60);
return `(UTC ${sign}${hours}${minutes ? `:${minutes.toString().padStart(2, "0")}` : ""})`;
},
[title]
);
return (
<div className="flex flex-col gap-1">
@@ -1,9 +1,11 @@
import { ChevronRightIcon } from "@heroicons/react/24/solid";
import { Link } from "@remix-run/react";
import React, { type ReactNode, forwardRef, useState, useContext, createContext } from "react";
import { ClipboardCheckIcon, ClipboardIcon } from "lucide-react";
import React, { type ReactNode, createContext, forwardRef, useContext, useState } from "react";
import { useCopy } from "~/hooks/useCopy";
import { cn } from "~/utils/cn";
import { Popover, PopoverContent, PopoverVerticalEllipseTrigger } from "./Popover";
import { InfoIconTooltip } from "./Tooltip";
import { InfoIconTooltip, SimpleTooltip } from "./Tooltip";
const variants = {
bright: {
@@ -104,7 +106,7 @@ export const TableBody = forwardRef<HTMLTableSectionElement, TableBodyProps>(
}
);
type TableRowProps = {
type TableRowProps = JSX.IntrinsicElements["tr"] & {
className?: string;
children: ReactNode;
disabled?: boolean;
@@ -112,11 +114,12 @@ type TableRowProps = {
};
export const TableRow = forwardRef<HTMLTableRowElement, TableRowProps>(
({ className, disabled, isSelected, children }, ref) => {
({ className, disabled, isSelected, children, ...props }, ref) => {
const { variant } = useContext(TableContext);
return (
<tr
ref={ref}
{...props}
className={cn(
"group/table-row relative w-full outline-none after:absolute after:bottom-0 after:left-3 after:right-0 after:h-px after:bg-grid-dimmed",
isSelected && variants[variant].rowSelected,
@@ -154,6 +157,8 @@ export const TableHeaderCell = forwardRef<HTMLTableCellElement, TableHeaderCellP
break;
}
const [isHovered, setIsHovered] = useState(false);
return (
<th
ref={ref}
@@ -165,6 +170,8 @@ export const TableHeaderCell = forwardRef<HTMLTableCellElement, TableHeaderCellP
)}
colSpan={colSpan}
tabIndex={-1}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{hiddenLabel ? (
<span className="sr-only">{children}</span>
@@ -176,7 +183,11 @@ export const TableHeaderCell = forwardRef<HTMLTableCellElement, TableHeaderCellP
})}
>
{children}
<InfoIconTooltip content={tooltip} contentClassName="normal-case tracking-normal" />
<InfoIconTooltip
content={tooltip}
contentClassName="normal-case tracking-normal"
enabled={isHovered}
/>
</div>
) : (
children
@@ -277,6 +288,60 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
}
);
type CopyableTableCellProps = TableCellProps & {
value: string;
};
export const CopyableTableCell = forwardRef<HTMLTableCellElement, CopyableTableCellProps>(
({ value, children, className, ...props }, ref) => {
const [isHovered, setIsHovered] = useState(false);
const { copy, copied } = useCopy(value);
return (
<TableCell ref={ref} className={className} {...props}>
<div
className="relative flex items-center"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{children}
{isHovered && (
<span
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
copy();
}}
className="absolute -right-2 top-1/2 z-10 flex -translate-y-1/2 cursor-pointer"
>
<SimpleTooltip
button={
<span
className={cn(
"flex size-6 items-center justify-center rounded border border-charcoal-650 bg-charcoal-750",
copied
? "text-green-500"
: "text-text-dimmed hover:border-charcoal-600 hover:bg-charcoal-700 hover:text-text-bright"
)}
>
{copied ? (
<ClipboardCheckIcon className="size-3.5" />
) : (
<ClipboardIcon className="size-3.5" />
)}
</span>
}
content={copied ? "Copied!" : "Copy"}
disableHoverableContent
/>
</span>
)}
</div>
</TableCell>
);
}
);
export const TableCellChevron = forwardRef<
HTMLTableCellElement,
{
@@ -113,18 +113,23 @@ export function InfoIconTooltip({
contentClassName,
variant = "basic",
disableHoverableContent = false,
enabled = true,
}: {
content: React.ReactNode;
buttonClassName?: string;
contentClassName?: string;
variant?: Variant;
disableHoverableContent?: boolean;
enabled?: boolean;
}) {
const icon = (
<InformationCircleIcon className={cn("size-3.5 text-text-dimmed", buttonClassName)} />
);
if (!enabled) return icon;
return (
<SimpleTooltip
button={
<InformationCircleIcon className={cn("size-3.5 text-text-dimmed", buttonClassName)} />
}
button={icon}
content={content}
variant={variant}
className={contentClassName}
@@ -0,0 +1,41 @@
import { TaskIconSmall } from "~/assets/icons/TaskIcon";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { cn } from "~/utils/cn";
import { RectangleStackIcon } from "@heroicons/react/20/solid";
export function QueueName({
name,
type,
paused,
className,
}: {
name: string;
type: "task" | "custom";
paused?: boolean;
className?: string;
}) {
return (
<span className={cn("flex items-center gap-1", className)}>
{type === "task" ? (
<SimpleTooltip
button={
<TaskIconSmall
className={cn("size-[1.125rem] text-blue-500", paused && "opacity-50")}
/>
}
content={`This queue was automatically created from your "${name}" task`}
/>
) : (
<SimpleTooltip
button={
<RectangleStackIcon
className={cn("size-[1.125rem] text-purple-500", paused && "opacity-50")}
/>
}
content={`This is a custom queue you added in your code.`}
/>
)}
<span className={paused ? "opacity-50" : undefined}>{name}</span>
</span>
);
}
@@ -236,43 +236,71 @@ export function runStatusClassNameColor(status: TaskRunStatus): string {
}
}
export function runStatusTitle(status: TaskRunStatus): string {
switch (status) {
case "DELAYED":
return "Delayed";
case "PENDING":
return "Queued";
case "PENDING_VERSION":
case "WAITING_FOR_DEPLOY":
return "Pending version";
case "DEQUEUED":
return "Dequeued";
case "EXECUTING":
return "Executing";
case "WAITING_TO_RESUME":
return "Waiting";
case "RETRYING_AFTER_FAILURE":
return "Reattempting";
case "PAUSED":
return "Paused";
case "CANCELED":
return "Canceled";
case "INTERRUPTED":
return "Interrupted";
case "COMPLETED_SUCCESSFULLY":
return "Completed";
case "COMPLETED_WITH_ERRORS":
return "Failed";
case "SYSTEM_FAILURE":
return "System failure";
case "CRASHED":
return "Crashed";
case "EXPIRED":
return "Expired";
case "TIMED_OUT":
return "Timed out";
default: {
assertNever(status);
}
}
export function runStatusTitle(status: TaskRunStatus): RunFriendlyStatus {
return runStatusTitleFromStatus[status];
}
export function runStatusFromFriendlyTitle(friendly: RunFriendlyStatus): TaskRunStatus {
const result = titlesStatusesArray.find(([_, f]) => f === friendly);
if (!result) {
throw new Error(`Unknown friendly status: ${friendly}`);
}
return result[0] as TaskRunStatus;
}
export const runFriendlyStatus = [
"Delayed",
"Queued",
"Pending version",
"Dequeued",
"Executing",
"Waiting",
"Reattempting",
"Paused",
"Canceled",
"Interrupted",
"Completed",
"Failed",
"System failure",
"Crashed",
"Expired",
"Timed out",
] as const;
export type RunFriendlyStatus = (typeof runFriendlyStatus)[number];
/**
* Check if a value is a valid TaskRunStatus
*/
export function isTaskRunStatus(value: unknown): value is TaskRunStatus {
return typeof value === "string" && allTaskRunStatuses.includes(value as TaskRunStatus);
}
/**
* Check if a value is a valid RunFriendlyStatus
*/
export function isRunFriendlyStatus(value: unknown): value is RunFriendlyStatus {
return typeof value === "string" && runFriendlyStatus.includes(value as RunFriendlyStatus);
}
export const runStatusTitleFromStatus: Record<TaskRunStatus, RunFriendlyStatus> = {
DELAYED: "Delayed",
PENDING: "Queued",
PENDING_VERSION: "Pending version",
WAITING_FOR_DEPLOY: "Pending version",
DEQUEUED: "Dequeued",
EXECUTING: "Executing",
WAITING_TO_RESUME: "Waiting",
RETRYING_AFTER_FAILURE: "Reattempting",
PAUSED: "Paused",
CANCELED: "Canceled",
INTERRUPTED: "Interrupted",
COMPLETED_SUCCESSFULLY: "Completed",
COMPLETED_WITH_ERRORS: "Failed",
SYSTEM_FAILURE: "System failure",
CRASHED: "Crashed",
EXPIRED: "Expired",
TIMED_OUT: "Timed out",
};
const titlesStatusesArray = Object.entries(runStatusTitleFromStatus);
+1
View File
@@ -521,6 +521,7 @@ const EnvironmentSchema = z
PROD_USAGE_HEARTBEAT_INTERVAL_MS: z.coerce.number().int().optional(),
CENTS_PER_RUN: z.coerce.number().default(0),
CENTS_PER_QUERY_BYTE_SECOND: z.coerce.number().default(0),
EVENT_LOOP_MONITOR_ENABLED: z.string().default("1"),
RESOURCE_MONITOR_ENABLED: z.string().default("0"),
+11
View File
@@ -0,0 +1,11 @@
import { type UIMatch } from "@remix-run/react";
import { useOptionalOrganization } from "./useOrganizations";
/**
* Hook to access organization-level feature flags.
* Returns the feature flags from the current organization, or an empty object if no organization is found.
*/
export function useFeatureFlags(matches?: UIMatch[]) {
const org = useOptionalOrganization(matches);
return org?.featureFlags ?? {};
}
@@ -10,6 +10,7 @@ import {
} from "./SelectBestEnvironmentPresenter.server";
import { sortEnvironments } from "~/utils/environmentSort";
import { defaultAvatar, parseAvatar } from "~/components/primitives/Avatar";
import { validatePartialFeatureFlags } from "~/v3/featureFlags.server";
export class OrganizationsPresenter {
#prismaClient: PrismaClient;
@@ -132,6 +133,7 @@ export class OrganizationsPresenter {
slug: true,
title: true,
avatar: true,
featureFlags: true,
projects: {
where: { deletedAt: null, version: "V3" },
select: {
@@ -139,6 +141,7 @@ export class OrganizationsPresenter {
slug: true,
name: true,
updatedAt: true,
externalRef: true,
},
orderBy: { name: "asc" },
},
@@ -151,16 +154,23 @@ export class OrganizationsPresenter {
});
return orgs.map((org) => {
const flagsResult = org.featureFlags
? validatePartialFeatureFlags(org.featureFlags as Record<string, unknown>)
: ({ success: false } as const);
const flags = flagsResult.success ? flagsResult.data : {};
return {
id: org.id,
slug: org.slug,
title: org.title,
avatar: parseAvatar(org.avatar, defaultAvatar),
featureFlags: flags,
projects: org.projects.map((project) => ({
id: project.id,
slug: project.slug,
name: project.name,
updatedAt: project.updatedAt,
externalRef: project.externalRef,
})),
membersCount: org._count.members,
};
@@ -0,0 +1,44 @@
import { defaultQuery } from "~/v3/querySchemas";
import { BasePresenter } from "./basePresenter.server";
import type { QueryScope } from "~/services/queryService.server";
export type QueryHistoryItem = {
id: string;
query: string;
scope: QueryScope;
createdAt: Date;
userName: string | null;
};
export class QueryPresenter extends BasePresenter {
public async call({ organizationId }: { organizationId: string }) {
const history = await this._replica.customerQuery.findMany({
where: { organizationId },
orderBy: { createdAt: "desc" },
take: 20,
select: {
id: true,
query: true,
scope: true,
createdAt: true,
user: {
select: { name: true, displayName: true },
},
},
});
return {
defaultQuery,
history: history.map(
(q): QueryHistoryItem => ({
id: q.id,
query: q.query,
scope: q.scope.toLowerCase() as QueryScope,
createdAt: q.createdAt,
userName: q.user?.displayName ?? q.user?.name ?? null,
})
),
};
}
}
@@ -80,6 +80,7 @@ import { PauseEnvironmentService } from "~/v3/services/pauseEnvironment.server";
import { PauseQueueService } from "~/v3/services/pauseQueue.server";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon";
import { QueueName } from "~/components/runs/v3/QueueName";
const SearchParamsSchema = z.object({
query: z.string().optional(),
@@ -516,34 +517,7 @@ export default function Page() {
<TableRow key={queue.name}>
<TableCell>
<span className="flex items-center gap-2">
{queue.type === "task" ? (
<SimpleTooltip
button={
<TaskIconSmall
className={cn(
"size-[1.125rem] text-blue-500",
queue.paused && "opacity-50"
)}
/>
}
content={`This queue was automatically created from your "${queue.name}" task`}
/>
) : (
<SimpleTooltip
button={
<RectangleStackIcon
className={cn(
"size-[1.125rem] text-purple-500",
queue.paused && "opacity-50"
)}
/>
}
content={`This is a custom queue you added in your code.`}
/>
)}
<span className={queue.paused ? "opacity-50" : undefined}>
{queue.name}
</span>
<QueueName {...queue} />
{queue.concurrency?.overriddenAt ? (
<SimpleTooltip
button={
@@ -0,0 +1,204 @@
import { openai } from "@ai-sdk/openai";
import { type ActionFunctionArgs } from "@remix-run/server-runtime";
import { z } from "zod";
import { env } from "~/env.server";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { requireUserId } from "~/services/session.server";
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
import { AIQueryService } from "~/v3/services/aiQueryService.server";
import { querySchemas } from "~/v3/querySchemas";
const RequestSchema = z.object({
prompt: z.string().min(1, "Prompt is required"),
mode: z.enum(["new", "edit"]).default("new"),
currentQuery: z.string().optional(),
});
export async function action({ request, params }: ActionFunctionArgs) {
const userId = await requireUserId(request);
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
// Parse the request body
const formData = await request.formData();
const submission = RequestSchema.safeParse(Object.fromEntries(formData));
if (!submission.success) {
return new Response(
JSON.stringify({
type: "result",
success: false,
error: "Invalid request data",
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
if (!project) {
return new Response(
JSON.stringify({
type: "result",
success: false,
error: "Project not found",
}),
{
status: 404,
headers: { "Content-Type": "application/json" },
}
);
}
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
if (!environment) {
return new Response(
JSON.stringify({
type: "result",
success: false,
error: "Environment not found",
}),
{
status: 404,
headers: { "Content-Type": "application/json" },
}
);
}
if (!env.OPENAI_API_KEY) {
return new Response(
JSON.stringify({
type: "result",
success: false,
error: "OpenAI API key is not configured",
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
const { prompt, mode, currentQuery } = submission.data;
const service = new AIQueryService(
querySchemas,
openai(env.AI_RUN_FILTER_MODEL ?? "gpt-4o-mini")
);
// Create a streaming response
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
const sendEvent = (event: {
type: string;
content?: string;
tool?: string;
args?: unknown;
result?: unknown;
success?: boolean;
query?: string;
error?: string;
}) => {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
};
try {
const result = service.streamQuery(prompt, { mode, currentQuery });
// Process the stream
for await (const part of result.fullStream) {
switch (part.type) {
case "text-delta": {
sendEvent({ type: "thinking", content: part.textDelta });
break;
}
case "tool-call": {
sendEvent({
type: "tool_call",
tool: part.toolName,
args: part.args,
});
break;
}
case "error": {
sendEvent({
type: "result",
success: false,
error: part.error instanceof Error ? part.error.message : String(part.error),
});
break;
}
case "finish": {
// Extract query from the final text
const finalText = await result.text;
const query = extractQueryFromText(finalText);
if (query) {
sendEvent({
type: "result",
success: true,
query,
});
} else if (
finalText.toLowerCase().includes("cannot") ||
finalText.toLowerCase().includes("unable")
) {
sendEvent({
type: "result",
success: false,
error: finalText.slice(0, 300),
});
} else {
sendEvent({
type: "result",
success: false,
error: "Could not generate a valid query",
});
}
break;
}
}
}
} catch (error) {
sendEvent({
type: "result",
success: false,
error: error instanceof Error ? error.message : "An error occurred",
});
} finally {
controller.close();
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
/**
* Extract a SQL query from the AI response text
*/
function extractQueryFromText(text: string): string | null {
// Try to extract from code block first
const codeBlockMatch = text.match(/```(?:sql)?\s*([\s\S]*?)```/i);
if (codeBlockMatch) {
return codeBlockMatch[1].trim();
}
// Try to find a SELECT statement
const selectMatch = text.match(/SELECT[\s\S]+?(?:LIMIT\s+\d+|;|$)/i);
if (selectMatch) {
return selectMatch[0].trim().replace(/;$/, "");
}
return null;
}
+68
View File
@@ -0,0 +1,68 @@
import { redirect, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { z } from "zod";
import { prisma } from "~/db.server";
import { redirectWithErrorMessage } from "~/models/message.server";
import { requireUser } from "~/services/session.server";
import { rootPath, v3RunPath } from "~/utils/pathBuilder";
const ParamsSchema = z.object({
runParam: z.string(),
});
export async function loader({ params, request }: LoaderFunctionArgs) {
const user = await requireUser(request);
const { runParam } = ParamsSchema.parse(params);
const run = await prisma.taskRun.findFirst({
where: {
friendlyId: runParam,
project: {
organization: {
members: {
some: {
userId: user.id,
},
},
},
},
},
select: {
runtimeEnvironment: {
select: {
slug: true,
},
},
project: {
select: {
slug: true,
organization: {
select: {
slug: true,
},
},
},
},
},
});
if (!run) {
return redirectWithErrorMessage(
rootPath(),
request,
"Run either doesn't exist or you don't have permission to view it",
{
ephemeral: false,
}
);
}
return redirect(
v3RunPath(
{ slug: run.project.organization.slug },
{ slug: run.project.slug },
{ slug: run.runtimeEnvironment.slug },
{ friendlyId: runParam }
)
);
}
@@ -2,6 +2,7 @@ import React from "react";
import { Header1, Header2 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
CopyableTableCell,
Table,
TableBody,
TableCell,
@@ -60,6 +61,33 @@ export default function Story() {
</TableBody>
</Table>
</div>
<div className="flex flex-col gap-2">
<Header1>Copyable cells</Header1>
<Paragraph>
Hover over the first column to see the copy button. Click to copy the cell value.
</Paragraph>
<Table>
<TableHeader className="bg-background-bright">
<TableRow>
<TableHeaderCell>ID (copyable)</TableHeaderCell>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: 5 }, (_, index) => {
const id = `run_${crypto.randomUUID().slice(0, 8)}`;
return (
<TableRow key={index}>
<CopyableTableCell value={id}>{id}</CopyableTableCell>
<TableCell>Task {index + 1}</TableCell>
<TableCell>Completed</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</div>
);
}
@@ -0,0 +1,331 @@
import { useState } from "react";
import { TSQLEditor } from "~/components/code/TSQLEditor";
import { column, type TableSchema } from "@internal/tsql";
const RUN_STATUSES = ["PENDING", "QUEUED", "EXECUTING", "COMPLETED", "FAILED", "CANCELED"] as const;
const LOG_LEVELS = ["DEBUG", "INFO", "WARN", "ERROR"] as const;
const runsSchema: TableSchema = {
name: "runs",
clickhouseName: "trigger_dev.task_runs_v2",
description: "Task runs table - stores all task execution records",
tenantColumns: {
organizationId: "organization_id",
projectId: "project_id",
environmentId: "environment_id",
},
columns: {
id: { name: "id", ...column("String", { description: "Unique run identifier" }) },
task_id: { name: "task_id", ...column("String", { description: "Task identifier" }) },
status: {
name: "status",
...column("String", {
description: "Run status",
allowedValues: [...RUN_STATUSES],
}),
},
created_at: {
name: "created_at",
...column("DateTime64", { description: "When the run was created" }),
},
started_at: {
name: "started_at",
...column("Nullable(DateTime64)", { description: "When the run started executing" }),
},
completed_at: {
name: "completed_at",
...column("Nullable(DateTime64)", { description: "When the run completed" }),
},
duration_ms: {
name: "duration_ms",
...column("Nullable(UInt64)", { description: "Run duration in milliseconds" }),
},
// Virtual column: computed from started_at and completed_at
execution_duration: {
name: "execution_duration",
...column("Nullable(Int64)", {
description: "Computed execution time in milliseconds (virtual column)",
}),
expression: "dateDiff('millisecond', started_at, completed_at)",
},
// Virtual column: duration in seconds for convenience
duration_seconds: {
name: "duration_seconds",
...column("Float64", {
description: "Duration in seconds (virtual column)",
}),
expression: "duration_ms / 1000.0",
},
organization_id: { name: "organization_id", ...column("String") },
project_id: { name: "project_id", ...column("String") },
environment_id: { name: "environment_id", ...column("String") },
},
};
const logsSchema: TableSchema = {
name: "logs",
clickhouseName: "trigger_dev.task_events_v2",
description: "Task logs and events",
tenantColumns: {
organizationId: "organization_id",
projectId: "project_id",
environmentId: "environment_id",
},
columns: {
id: { name: "id", ...column("String", { description: "Event identifier" }) },
run_id: { name: "run_id", ...column("String", { description: "Associated run ID" }) },
level: {
name: "level",
...column("String", {
description: "Log level",
allowedValues: [...LOG_LEVELS],
}),
},
message: { name: "message", ...column("String", { description: "Log message content" }) },
timestamp: { name: "timestamp", ...column("DateTime64", { description: "Event timestamp" }) },
organization_id: { name: "organization_id", ...column("String") },
project_id: { name: "project_id", ...column("String") },
environment_id: { name: "environment_id", ...column("String") },
},
};
const exampleSchema = [runsSchema, logsSchema];
const exampleQueries = [
{
name: "Simple SELECT",
query: "SELECT * FROM runs LIMIT 10",
},
{
name: "With WHERE clause",
query: "SELECT id, task_id, status, created_at FROM runs WHERE status = 'COMPLETED' LIMIT 100",
},
{
name: "Enum IN clause",
query: "SELECT * FROM runs WHERE status IN ('PENDING', 'QUEUED', 'EXECUTING') LIMIT 50",
},
{
name: "Virtual columns",
query: `SELECT
id,
status,
execution_duration,
duration_seconds
FROM runs
WHERE execution_duration > 5000
ORDER BY execution_duration DESC
LIMIT 20`,
},
{
name: "Aggregation",
query: "SELECT status, count(*) as count FROM runs GROUP BY status ORDER BY count DESC",
},
{
name: "Join query",
query: `SELECT
runs.id,
runs.status,
logs.message,
logs.level
FROM runs
JOIN logs ON runs.id = logs.run_id
WHERE logs.level = 'ERROR'
LIMIT 50`,
},
{
name: "Date filtering",
query: `SELECT
toStartOfDay(created_at) as day,
count(*) as runs_count,
avg(duration_ms) as avg_duration
FROM runs
WHERE created_at > now() - INTERVAL 7 DAY
GROUP BY day
ORDER BY day DESC`,
},
];
export default function Story() {
const [query, setQuery] = useState(exampleQueries[0].query);
return (
<div className="flex flex-col gap-y-8 p-8">
<div>
<h1 className="mb-2 text-2xl font-bold text-text-bright">TSQL Editor</h1>
<p className="text-text-dimmed">
A CodeMirror-based SQL editor with syntax highlighting, schema-aware autocomplete, and
real-time error detection.
</p>
</div>
{/* Example queries */}
<div className="flex flex-col gap-2">
<h2 className="text-lg font-semibold text-text-bright">Example Queries</h2>
<div className="flex flex-wrap gap-2">
{exampleQueries.map((example) => (
<button
key={example.name}
onClick={() => setQuery(example.query)}
className="rounded bg-charcoal-700 px-3 py-1.5 text-sm text-text-dimmed transition hover:bg-charcoal-600 hover:text-text-bright"
>
{example.name}
</button>
))}
</div>
</div>
{/* Main editor */}
<div className="flex flex-col gap-2">
<h2 className="text-lg font-semibold text-text-bright">Editor with Schema</h2>
<p className="text-sm text-text-dimmed">
Try typing to see autocomplete suggestions. Type <code>status = </code> to see enum value
suggestions. Available tables: <code>runs</code>, <code>logs</code>
</p>
<div className="overflow-hidden rounded-lg border border-grid-dimmed">
<TSQLEditor
defaultValue={query}
onChange={setQuery}
schema={exampleSchema}
linterEnabled={true}
showCopyButton={true}
showClearButton={true}
minHeight="200px"
className="min-h-[200px]"
/>
</div>
</div>
{/* Read-only example */}
<div className="flex flex-col gap-2">
<h2 className="text-lg font-semibold text-text-bright">Read-only Mode</h2>
<div className="overflow-hidden rounded-lg border border-grid-dimmed">
<TSQLEditor
defaultValue="SELECT id, status, created_at FROM runs WHERE status = 'FAILED' ORDER BY created_at DESC LIMIT 10"
readOnly={true}
schema={exampleSchema}
linterEnabled={false}
showCopyButton={true}
showClearButton={false}
className="min-h-[100px]"
/>
</div>
</div>
{/* Editor without schema (no autocomplete) */}
<div className="flex flex-col gap-2">
<h2 className="text-lg font-semibold text-text-bright">Without Schema (Basic Mode)</h2>
<p className="text-sm text-text-dimmed">
Editor without schema - still has SQL syntax highlighting and keyword completion.
</p>
<div className="overflow-hidden rounded-lg border border-grid-dimmed">
<TSQLEditor
defaultValue="SELECT * FROM my_table WHERE id = 1"
linterEnabled={true}
showCopyButton={true}
className="min-h-[100px]"
/>
</div>
</div>
{/* Error example */}
<div className="flex flex-col gap-2">
<h2 className="text-lg font-semibold text-text-bright">With Syntax Error</h2>
<p className="text-sm text-text-dimmed">
The linter detects syntax errors and underlines them in red.
</p>
<div className="overflow-hidden rounded-lg border border-grid-dimmed">
<TSQLEditor
defaultValue="SELEC * FORM runs"
linterEnabled={true}
showCopyButton={true}
className="min-h-[100px]"
/>
</div>
</div>
{/* Invalid enum value example */}
<div className="flex flex-col gap-2">
<h2 className="text-lg font-semibold text-text-bright">With Invalid Enum Value</h2>
<p className="text-sm text-text-dimmed">
The linter validates enum values against the schema. Try changing{" "}
<code>'INVALID_STATUS'</code> to a valid status like <code>'COMPLETED'</code>.
</p>
<div className="overflow-hidden rounded-lg border border-grid-dimmed">
<TSQLEditor
defaultValue="SELECT * FROM runs WHERE status = 'INVALID_STATUS' LIMIT 10"
schema={exampleSchema}
linterEnabled={true}
showCopyButton={true}
className="min-h-[100px]"
/>
</div>
</div>
{/* Unknown column example */}
<div className="flex flex-col gap-2">
<h2 className="text-lg font-semibold text-text-bright">With Unknown Column</h2>
<p className="text-sm text-text-dimmed">
The linter warns about unknown column names. Try changing <code>unknown_col</code> to a
valid column like <code>status</code>.
</p>
<div className="overflow-hidden rounded-lg border border-grid-dimmed">
<TSQLEditor
defaultValue="SELECT id, unknown_col FROM runs LIMIT 10"
schema={exampleSchema}
linterEnabled={true}
showCopyButton={true}
className="min-h-[100px]"
/>
</div>
</div>
{/* Available tables reference */}
<div className="flex flex-col gap-2">
<h2 className="text-lg font-semibold text-text-bright">Available Schema</h2>
<div className="grid gap-4 md:grid-cols-2">
{exampleSchema.map((table) => (
<div
key={table.name}
className="rounded-lg border border-grid-dimmed bg-charcoal-800 p-4"
>
<h3 className="mb-1 font-mono text-sm font-semibold text-text-bright">
{table.name}
</h3>
<p className="mb-3 text-xs text-text-dimmed">{table.description}</p>
<div className="space-y-1">
{Object.entries(table.columns).map(([name, col]) => (
<div key={name} className="flex flex-col gap-0.5 text-xs">
<div className="flex items-baseline gap-2">
<code className={col.expression ? "text-purple-400" : "text-blue-400"}>
{name}
</code>
<span className="text-charcoal-400">{col.type}</span>
{col.expression && (
<span className="rounded bg-purple-500/20 px-1 text-[10px] text-purple-300">
virtual
</span>
)}
{col.description && (
<span className="text-text-dimmed">- {col.description}</span>
)}
</div>
{col.allowedValues && col.allowedValues.length > 0 && (
<div className="ml-4 text-green-400/70">
Allowed: {col.allowedValues.join(", ")}
</div>
)}
{col.expression && (
<div className="ml-4 font-mono text-purple-400/70">
Expression: {col.expression}
</div>
)}
</div>
))}
</div>
</div>
))}
</div>
</div>
</div>
);
}
+7 -5
View File
@@ -4,7 +4,7 @@ import { Fragment } from "react";
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
import { AppContainer } from "~/components/layout/AppLayout";
import { env } from "~/env.server";
import { requireUserId } from "~/services/session.server";
import { requireUser } from "~/services/session.server";
import { cn } from "~/utils/cn";
const stories: Story[] = [
@@ -120,6 +120,10 @@ const stories: Story[] = [
name: "Tree view",
slug: "tree-view",
},
{
name: "TSQL Editor",
slug: "tsql-editor",
},
{
name: "Timeline",
slug: "timeline",
@@ -177,11 +181,9 @@ const stories: Story[] = [
];
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
await requireUserId(request);
const user = await requireUser(request);
console.log("ENV", env.NODE_ENV);
if (env.NODE_ENV !== "development") {
if (!user.admin) {
throw redirect("/");
}
@@ -0,0 +1,116 @@
import {
executeTSQL,
type ExecuteTSQLOptions,
type FieldMappings,
type TSQLQueryResult,
} from "@internal/clickhouse";
import type { CustomerQuerySource } from "@trigger.dev/database";
import type { TableSchema } from "@internal/tsql";
import { type z } from "zod";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { clickhouseClient } from "./clickhouseInstance.server";
export type { TableSchema, TSQLQueryResult };
export type QueryScope = "organization" | "project" | "environment";
const scopeToEnum = {
organization: "ORGANIZATION",
project: "PROJECT",
environment: "ENVIRONMENT",
} as const;
export type ExecuteQueryOptions<TOut extends z.ZodSchema> = Omit<
ExecuteTSQLOptions<TOut>,
"tableSchema" | "organizationId" | "projectId" | "environmentId" | "fieldMappings"
> & {
tableSchema: TableSchema[];
/** The scope of the query - determines tenant isolation */
scope: QueryScope;
/** Organization ID (required) */
organizationId: string;
/** Project ID (required for project/environment scope) */
projectId: string;
/** Environment ID (required for environment scope) */
environmentId: string;
/** History options for saving query to billing/audit */
history?: {
/** Where the query originated from */
source: CustomerQuerySource;
/** User ID (optional, null for API calls) */
userId?: string | null;
};
};
/**
* Execute a TSQL query against ClickHouse with tenant isolation
* Handles building tenant options, field mappings, and optionally saves to history
*/
export async function executeQuery<TOut extends z.ZodSchema>(
options: ExecuteQueryOptions<TOut>
): Promise<TSQLQueryResult<z.output<TOut>>> {
const { scope, organizationId, projectId, environmentId, history, ...baseOptions } = options;
// Build tenant IDs based on scope
const tenantOptions: {
organizationId: string;
projectId?: string;
environmentId?: string;
} = {
organizationId,
};
if (scope === "project" || scope === "environment") {
tenantOptions.projectId = projectId;
}
if (scope === "environment") {
tenantOptions.environmentId = environmentId;
}
// Build field mappings for project_ref → project_id and environment_id → slug translation
const projects = await prisma.project.findMany({
where: { organizationId },
select: { id: true, externalRef: true },
});
const environments = await prisma.runtimeEnvironment.findMany({
where: { project: { organizationId } },
select: { id: true, slug: true },
});
const fieldMappings: FieldMappings = {
project: Object.fromEntries(projects.map((p) => [p.id, p.externalRef])),
environment: Object.fromEntries(environments.map((e) => [e.id, e.slug])),
};
const result = await executeTSQL(clickhouseClient.reader, {
...baseOptions,
...tenantOptions,
fieldMappings,
});
// If query succeeded and history options provided, save to history
if (result[0] === null && history) {
const stats = result[1].stats;
const byteSeconds = parseFloat(stats.byte_seconds) || 0;
const costInCents = byteSeconds * env.CENTS_PER_QUERY_BYTE_SECOND;
await prisma.customerQuery.create({
data: {
query: options.query,
scope: scopeToEnum[scope],
stats: { ...stats },
costInCents,
source: history.source,
organizationId,
projectId: scope === "project" || scope === "environment" ? projectId : null,
environmentId: scope === "environment" ? environmentId : null,
userId: history.userId ?? null,
},
});
}
return result;
}
@@ -921,6 +921,7 @@ export class RunsReplicationService {
concurrency_key: run.concurrencyKey ?? "",
bulk_action_group_ids: run.bulkActionGroupIds ?? [],
worker_queue: run.masterQueue,
max_duration_in_seconds: run.maxDurationInSeconds ?? undefined,
_version: _version.toString(),
_is_deleted: event === "delete" ? 1 : 0,
};
+91
View File
@@ -106,3 +106,94 @@
--gradient-angle: 360deg;
}
}
/* Streamdown markdown styling */
.streamdown-container {
/* Streamdown uses shadcn/ui CSS variables - define them for our theme */
--muted: 220 13% 20%;
--muted-foreground: 215 14% 60%;
--foreground: 210 20% 90%;
--border: 217 19% 27%;
& p {
@apply my-1;
}
& h1, & h2, & h3, & h4, & h5, & h6 {
@apply font-semibold text-text-bright mt-2 mb-1;
}
& h1 {
@apply text-base;
}
& h2 {
@apply text-sm;
}
& h3, & h4, & h5, & h6 {
@apply text-xs;
}
& ul, & ol {
@apply ml-4 my-1;
}
& ul {
@apply list-disc;
}
& ol {
@apply list-decimal;
}
& li {
@apply my-0.5;
}
/* Inline code (not in pre blocks) */
& code:not(pre code) {
@apply bg-charcoal-700 px-1 py-0.5 rounded text-text-bright font-mono;
}
& blockquote {
@apply border-l-2 border-charcoal-600 pl-3 my-2 italic;
}
& a {
@apply text-blue-400 hover:underline;
}
& strong {
@apply font-semibold text-text-bright;
}
& em {
@apply italic;
}
& hr {
@apply my-2 border-charcoal-600;
}
& table {
@apply w-full my-2 border-collapse;
}
& th, & td {
@apply border border-charcoal-600 px-2 py-1 text-left;
}
& th {
@apply bg-charcoal-700 font-semibold;
}
/* Streamdown code block container */
& [data-code-block-container] {
@apply my-2 border-charcoal-700;
}
& [data-code-block-header] {
@apply bg-charcoal-800 text-text-dimmed border-b border-charcoal-700;
}
/* Hide light mode code block, show dark mode */
& [data-code-block].dark\:hidden {
display: none !important;
}
& [data-code-block].hidden.dark\:block {
display: block !important;
}
/* Override the bg-muted/40 class to let inline styles work */
& [data-code-block] pre {
background-color: inherit !important;
@apply scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600;
}
& [data-code-block] pre code {
@apply bg-transparent;
}
& [data-code-block] .line {
@apply leading-relaxed;
}
}
+79
View File
@@ -0,0 +1,79 @@
import type { OutputColumnMetadata } from "@internal/clickhouse";
/**
* Escape a value for CSV format.
* - Wraps in quotes if the value contains commas, quotes, or newlines
* - Escapes quotes by doubling them
*/
function escapeCSVValue(value: unknown): string {
if (value === null || value === undefined) {
return "";
}
const stringValue = typeof value === "object" ? JSON.stringify(value) : String(value);
// Check if we need to quote the value
if (
stringValue.includes(",") ||
stringValue.includes('"') ||
stringValue.includes("\n") ||
stringValue.includes("\r")
) {
// Escape quotes by doubling them and wrap in quotes
return `"${stringValue.replace(/"/g, '""')}"`;
}
return stringValue;
}
/**
* Convert query result rows to CSV format.
*
* @param rows - Array of row objects from query results
* @param columns - Column metadata describing the result columns
* @returns CSV string with header row and data rows
*/
export function rowsToCSV(rows: Record<string, unknown>[], columns: OutputColumnMetadata[]): string {
if (columns.length === 0) {
return "";
}
const columnNames = columns.map((col) => col.name);
// Header row
const headerRow = columnNames.map(escapeCSVValue).join(",");
// Data rows
const dataRows = rows.map((row) => columnNames.map((name) => escapeCSVValue(row[name])).join(","));
return [headerRow, ...dataRows].join("\n");
}
/**
* Convert query result rows to JSON format.
*
* @param rows - Array of row objects from query results
* @returns Formatted JSON string
*/
export function rowsToJSON(rows: Record<string, unknown>[]): string {
return JSON.stringify(rows, null, 2);
}
/**
* Trigger a file download in the browser.
*
* @param content - The file content as a string
* @param filename - The name for the downloaded file
* @param mimeType - The MIME type of the file
*/
export function downloadFile(content: string, filename: string, mimeType: string): void {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
+11
View File
@@ -6,7 +6,18 @@ export const formatNumberCompact = (num: number): string => {
const formatter = Intl.NumberFormat("en");
// Formatter for small decimal values that need more precision
const preciseFormatter = Intl.NumberFormat("en", {
minimumSignificantDigits: 1,
maximumSignificantDigits: 6,
});
export const formatNumber = (num: number): string => {
// For very small numbers (between -1 and 1, exclusive), use precise formatting
// to avoid rounding 0.000025 to 0
if (num !== 0 && Math.abs(num) < 1) {
return preciseFormatter.format(num);
}
return formatter.format(num);
};
+13 -1
View File
@@ -242,6 +242,14 @@ export function v3TestPath(
return `${v3EnvironmentPath(organization, project, environment)}/test`;
}
export function queryPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath
) {
return `${v3EnvironmentPath(organization, project, environment)}/query`;
}
export function v3TestTaskPath(
organization: OrgForPath,
project: ProjectForPath,
@@ -298,11 +306,15 @@ export function v3RunPath(
export function v3RunRedirectPath(
organization: OrgForPath,
project: ProjectForPath,
run: v3RunForPath,
run: v3RunForPath
) {
return `${v3ProjectPath(organization, project)}/runs/${run.friendlyId}`;
}
export function v3RunPathFromFriendlyId(runId: string) {
return `/runs/${runId}`;
}
export function v3RunDownloadLogsPath(run: v3RunForPath) {
return `/resources/runs/${run.friendlyId}/logs/download`;
}
@@ -5,12 +5,14 @@ export const FEATURE_FLAG = {
defaultWorkerInstanceGroupId: "defaultWorkerInstanceGroupId",
runsListRepository: "runsListRepository",
taskEventRepository: "taskEventRepository",
hasQueryAccess: "hasQueryAccess",
} as const;
const FeatureFlagCatalog = {
[FEATURE_FLAG.defaultWorkerInstanceGroupId]: z.string(),
[FEATURE_FLAG.runsListRepository]: z.enum(["clickhouse", "postgres"]),
[FEATURE_FLAG.taskEventRepository]: z.enum(["clickhouse", "clickhouse_v2", "postgres"]),
[FEATURE_FLAG.hasQueryAccess]: z.coerce.boolean(),
};
type FeatureFlagKey = keyof typeof FeatureFlagCatalog;
@@ -83,6 +85,7 @@ export const setFlags = makeSetFlags();
// Create a Zod schema from the existing catalog
export const FeatureFlagCatalogSchema = z.object(FeatureFlagCatalog);
export type FeatureFlagCatalog = z.infer<typeof FeatureFlagCatalogSchema>;
// Utility function to validate a feature flag value
export function validateFeatureFlagValue<T extends FeatureFlagKey>(
+425
View File
@@ -0,0 +1,425 @@
import { column, type TableSchema } from "@internal/tsql";
import { runFriendlyStatus, runStatusTitleFromStatus } from "~/components/runs/v3/TaskRunStatus";
import { logger } from "~/services/logger.server";
/**
* Environment type values
*/
const ENVIRONMENT_TYPES = ["PRODUCTION", "STAGING", "DEVELOPMENT", "PREVIEW"] as const;
/**
* Machine preset values
*/
const MACHINE_PRESETS = [
"micro",
"small-1x",
"small-2x",
"medium-1x",
"medium-2x",
"large-1x",
"large-2x",
] as const;
/**
* Schema definition for the runs table (trigger_dev.task_runs_v2)
*/
export const runsSchema: TableSchema = {
name: "runs",
clickhouseName: "trigger_dev.task_runs_v2",
description: "Task runs - stores all task execution records",
tenantColumns: {
organizationId: "organization_id",
projectId: "project_id",
environmentId: "environment_id",
},
requiredFilters: [{ column: "engine", value: "V2" }],
columns: {
run_id: {
name: "run_id",
clickhouseName: "friendly_id",
...column("String", {
description:
"A unique ID for a run. They always start with `run_`, e.g., run_cm1a2b3c4d5e6f7g8h9i",
customRenderType: "runId",
example: "run_cm1a2b3c4d5e6f7g8h9i",
}),
},
environment: {
name: "environment",
clickhouseName: "environment_id",
...column("String", { description: "The environment slug", example: "prod" }),
fieldMapping: "environment",
customRenderType: "environment",
},
project: {
name: "project",
clickhouseName: "project_id",
...column("String", {
description: "The project reference, they always start with `proj_`.",
example: "proj_howcnaxbfxdmwmxazktx",
}),
fieldMapping: "project",
customRenderType: "project",
},
environment_type: {
name: "environment_type",
...column("LowCardinality(String)", {
description: "Environment type",
allowedValues: [...ENVIRONMENT_TYPES],
customRenderType: "environmentType",
example: "PRODUCTION",
}),
},
attempt_count: {
name: "attempt_count",
clickhouseName: "attempt",
...column("UInt8", {
description: "Number of attempts (starts at 1)",
example: "1",
customRenderType: "number",
}),
},
status: {
name: "status",
...column("LowCardinality(String)", {
description: "Run status",
allowedValues: [...runFriendlyStatus],
valueMap: runStatusTitleFromStatus,
customRenderType: "runStatus",
example: "Completed",
}),
},
is_finished: {
name: "is_finished",
...column("UInt8", {
description:
"Whether the run is finished. This includes failed and successful runs. (0 or 1)",
example: "0",
}),
expression:
"if(status IN ('COMPLETED_SUCCESSFULLY', 'COMPLETED_WITH_ERRORS', 'CANCELED', 'TIMED_OUT', 'CRASHED', 'SYSTEM_FAILURE', 'EXPIRED', 'PAUSED'), true, false)",
},
// Task & queue
task_identifier: {
name: "task_identifier",
...column("String", { description: "Task identifier/slug", example: "my-background-task" }),
},
queue: {
name: "queue",
...column("String", {
description: "Queue name",
example: "task/my-background-task",
customRenderType: "queue",
}),
},
batch_id: {
name: "batch_id",
...column("String", {
description: "Batch ID (if part of a batch)",
example: "batch_5678efgh",
expression: "if(batch_id = '', NULL, 'batch_' || batch_id)",
}),
whereTransform: (value: string) => value.replace(/^batch_/, ""),
},
// Related runs
root_run_id: {
name: "root_run_id",
...column("String", {
description: "Root run ID (for child runs)",
example: "run_cm1a2b3c4d5e6f7g8h9i",
customRenderType: "runId",
expression: "if(root_run_id = '', NULL, 'run_' || root_run_id)",
}),
whereTransform: (value: string) => value.replace(/^run_/, ""),
},
parent_run_id: {
name: "parent_run_id",
...column("String", {
description: "Parent run ID (for child runs)",
example: "run_cm1a2b3c4d5e6f7g8h9i",
customRenderType: "runId",
expression: "if(parent_run_id = '', NULL, 'run_' || parent_run_id)",
}),
whereTransform: (value: string) => value.replace(/^run_/, ""),
},
depth: {
name: "depth",
...column("UInt8", { description: "Nesting depth (0 for root runs)", example: "0" }),
},
is_root_run: {
name: "is_root_run",
...column("UInt8", { description: "Whether this is a root run (0 or 1)", example: "0" }),
expression: "if(depth = 0, true, false)",
},
is_child_run: {
name: "is_child_run",
...column("UInt8", { description: "Whether this is a child run (0 or 1)", example: "0" }),
expression: "if(depth > 0, true, false)",
},
// Useless until we show the user-provided key
idempotency_key: {
name: "idempotency_key",
...column("String", { description: "Idempotency key", example: "user-123-action-456" }),
},
region: {
name: "region",
clickhouseName: "worker_queue",
...column("String", {
description: "Region",
example: "us-east-1",
}),
expression: "if(startsWith(worker_queue, 'cm'), NULL, worker_queue)",
},
// Timing
triggered_at: {
name: "triggered_at",
clickhouseName: "created_at",
...column("DateTime64", {
description: "When the run was triggered.",
example: "2024-01-15 09:30:00.000",
}),
},
queued_at: {
name: "queued_at",
...column("Nullable(DateTime64)", {
description:
"When the run was added to the queue. This is normally the same time as the triggered_at time, unless a delay is passed in or it's a scheduled run.",
example: "2024-01-15 09:30:01.000",
}),
},
dequeued_at: {
name: "dequeued_at",
clickhouseName: "started_at",
...column("Nullable(DateTime64)", {
description:
"When the run was dequeued for execution. This happens when there is available concurrency to execute your run.",
example: "2024-01-15 09:30:01.000",
}),
},
executed_at: {
name: "executed_at",
...column("Nullable(DateTime64)", {
description: "When execution of the run began.",
example: "2024-01-15 09:30:01.500",
}),
},
completed_at: {
name: "completed_at",
...column("Nullable(DateTime64)", {
description: "When the run completed",
example: "2024-01-15 09:30:05.000",
}),
},
delay_until: {
name: "delay_until",
...column("Nullable(DateTime64)", {
description: "Delayed execution until this time",
example: "2024-01-15 10:00:00.000",
}),
},
has_delay: {
name: "has_delay",
...column("UInt8", { description: "Whether the run had a delay passed in", example: "1" }),
expression: "if(isNotNull(delay_until), true, false)",
},
expired_at: {
name: "expired_at",
...column("Nullable(DateTime64)", {
description:
'If there was a TTL on the run, this is when the run "expired". By default dev runs have a TTL of 10 minutes.',
example: "2024-01-15 09:35:00.000",
}),
},
ttl: {
name: "ttl",
clickhouseName: "expiration_ttl",
...column("String", {
description: "The TTL string for expiration by default dev runs have a TTL of '10m'.",
example: "10m",
}),
},
// Useful time periods
execution_duration: {
name: "execution_duration",
...column("Nullable(Int64)", {
description:
"The time between starting to execute and completing. This includes any time spent waiting (it is not compute time, use `usage_duration` for that).",
customRenderType: "duration",
example: "4000",
}),
expression: "dateDiff('millisecond', executed_at, completed_at)",
},
total_duration: {
name: "total_duration",
...column("Nullable(Int64)", {
description:
"The time between being triggered and completing (if it has). This includes any time spent waiting (it is not compute time, use `usage_duration` for that).",
customRenderType: "duration",
example: "4000",
}),
expression: "dateDiff('millisecond', created_at, completed_at)",
},
queued_duration: {
name: "queued_duration",
...column("Nullable(Int64)", {
description:
"The time between being queued and dequeued. Remember you need enough available concurrency for runs to be dequeued and start executing.",
customRenderType: "duration",
example: "4000",
}),
expression: "dateDiff('millisecond', queued_at, started_at)",
},
// Cost & usage
usage_duration: {
name: "usage_duration",
clickhouseName: "usage_duration_ms",
...column("UInt32", {
description: "Compute usage duration in milliseconds.",
customRenderType: "duration",
example: "3500",
}),
},
compute_cost: {
name: "compute_cost",
...column("Float64", {
description: "Compute cost in dollars",
customRenderType: "costInDollars",
example: "0.000676",
}),
expression: "cost_in_cents / 100.0",
},
invocation_cost: {
name: "invocation_cost",
...column("Float64", {
description: "Invocation cost in dollars the cost to start a run.",
customRenderType: "costInDollars",
example: "0.000025",
}),
expression: "base_cost_in_cents / 100.0",
},
total_cost: {
name: "total_cost",
...column("Float64", {
description: "Total cost in dollars (compute_cost + invocation_cost)",
customRenderType: "costInDollars",
example: "0.000701",
}),
expression: "(cost_in_cents + base_cost_in_cents) / 100.0",
},
// Output & error (JSON columns)
// For JSON columns, NULL checks are transformed to check for empty object '{}'
// So `error IS NULL` becomes `error = '{}'` and `error IS NOT NULL` becomes `error != '{}'`
output: {
name: "output",
...column("JSON", {
description: "The data you returned from the task.",
example: '{"result": "success"}',
}),
nullValue: "'{}'", // Transform NULL checks to compare against empty object
},
error: {
name: "error",
...column("JSON", {
description:
"If a run completely failed (after all attempts) then this error will be populated.",
example: '{"message": "Task failed"}',
}),
nullValue: "'{}'", // Transform NULL checks to compare against empty object
},
// Tags & versions
tags: {
name: "tags",
...column("Array(String)", {
description: "Tags you have added to the run.",
customRenderType: "tags",
example: '["user:123", "priority:high"]',
}),
},
task_version: {
name: "task_version",
...column("String", {
description: "The version of your code in reverse date format.",
example: "20240115.1",
}),
},
sdk_version: {
name: "sdk_version",
...column("String", {
description: "The SDK package version for this run.",
example: "3.3.0",
}),
},
cli_version: {
name: "cli_version",
...column("String", {
description: "The CLI package version for this run.",
example: "3.3.0",
}),
},
machine: {
name: "machine",
clickhouseName: "machine_preset",
...column("LowCardinality(String)", {
description: "The machine that the run executed on.",
allowedValues: [...MACHINE_PRESETS],
customRenderType: "machine",
example: "small-1x",
}),
},
is_test: {
name: "is_test",
...column("UInt8", { description: "Whether this is a test run (0 or 1)", example: "0" }),
expression: "if(is_test > 0, true, false)",
},
concurrency_key: {
name: "concurrency_key",
...column("String", {
description: "The concurrency key you passed in when triggering the run.",
example: "user:1234567",
}),
},
max_duration: {
name: "max_duration",
clickhouseName: "max_duration_in_seconds",
...column("Nullable(UInt32)", {
description:
"The maximum allowed compute duration for this run in seconds. If the run exceeds this duration, the run will fail with an error. Can be set on an individual task, in the trigger.config, or per-run when triggering.",
example: "300",
customRenderType: "durationSeconds",
}),
},
bulk_action_group_ids: {
name: "bulk_action_group_ids",
...column("Array(String)", {
description: "Any bulk actions that operated on this run.",
example: '["bulk_12345678", "bulk_34567890"]',
whereTransform: (value: string) => {
logger.log(`WHERE TRANSFORM: ${value}`);
return value.replace(/^bulk_/, "");
},
}),
},
},
};
/**
* All available schemas for the query editor
*/
export const querySchemas: TableSchema[] = [runsSchema];
/**
* Default query for the query editor
*/
export const defaultQuery = `SELECT *
FROM runs
ORDER BY triggered_at DESC
LIMIT 100`;
@@ -0,0 +1,487 @@
import { openai } from "@ai-sdk/openai";
import {
parseTSQLSelect,
validateQuery,
type TableSchema,
type ValidationIssue,
} from "@internal/tsql";
import { streamText, type LanguageModelV1, tool } from "ai";
import { z } from "zod";
/**
* Stream event types for AI query generation
*/
export type AIQueryStreamEvent =
| { type: "thinking"; content: string }
| { type: "tool_call"; tool: string; args: unknown }
| { type: "tool_result"; tool: string; result: unknown }
| { type: "result"; success: true; query: string }
| { type: "result"; success: false; error: string };
/**
* Result type for non-streaming call
*/
export type AIQueryResult = { success: true; query: string } | { success: false; error: string };
/**
* Options for query generation
*/
export interface AIQueryOptions {
mode?: "new" | "edit";
currentQuery?: string;
}
/**
* Validation result from the validateTSQLQuery tool
*/
interface QueryValidationResult {
valid: boolean;
syntaxError?: string;
issues: ValidationIssue[];
}
/**
* Service for generating TSQL queries from natural language using AI
*/
export class AIQueryService {
constructor(
private readonly tableSchema: TableSchema[],
private readonly model: LanguageModelV1 = openai("gpt-4o-mini")
) {}
/**
* Generate a TSQL query from natural language, streaming the result
*/
streamQuery(prompt: string, options: AIQueryOptions = {}) {
const { mode = "new", currentQuery } = options;
const schemaDescription = this.buildSchemaDescription();
const systemPrompt =
mode === "edit" && currentQuery
? this.buildEditSystemPrompt(schemaDescription)
: this.buildSystemPrompt(schemaDescription);
// Build the user prompt based on mode
const userPrompt =
mode === "edit" && currentQuery ? this.buildEditUserPrompt(prompt, currentQuery) : prompt;
return streamText({
model: this.model,
system: systemPrompt,
prompt: userPrompt,
tools: {
validateTSQLQuery: tool({
description:
"Validate a TSQL query for syntax errors and schema compliance. Always use this tool to verify your query before returning it to the user.",
parameters: z.object({
query: z.string().describe("The TSQL query to validate"),
}),
execute: async ({ query }) => {
return this.validateQuery(query);
},
}),
getTableSchema: tool({
description:
"Get detailed schema information about available tables and columns. Use this to understand what data is available and how to query it.",
parameters: z.object({
tableName: z
.string()
.optional()
.describe("Optional: specific table name to get details for"),
}),
execute: async ({ tableName }) => {
return this.getSchemaInfo(tableName);
},
}),
},
maxSteps: 5,
experimental_telemetry: {
isEnabled: true,
metadata: {
feature: "ai-query-generator",
mode,
},
},
});
}
/**
* Generate a TSQL query from natural language (non-streaming)
*/
async call(prompt: string, options: AIQueryOptions = {}): Promise<AIQueryResult> {
const { mode = "new", currentQuery } = options;
const schemaDescription = this.buildSchemaDescription();
const systemPrompt =
mode === "edit" && currentQuery
? this.buildEditSystemPrompt(schemaDescription)
: this.buildSystemPrompt(schemaDescription);
// Build the user prompt based on mode
const userPrompt =
mode === "edit" && currentQuery ? this.buildEditUserPrompt(prompt, currentQuery) : prompt;
const result = await streamText({
model: this.model,
system: systemPrompt,
prompt: userPrompt,
tools: {
validateTSQLQuery: tool({
description:
"Validate a TSQL query for syntax errors and schema compliance. Always use this tool to verify your query before returning it to the user.",
parameters: z.object({
query: z.string().describe("The TSQL query to validate"),
}),
execute: async ({ query }) => {
return this.validateQuery(query);
},
}),
getTableSchema: tool({
description:
"Get detailed schema information about available tables and columns. Use this to understand what data is available and how to query it.",
parameters: z.object({
tableName: z
.string()
.optional()
.describe("Optional: specific table name to get details for"),
}),
execute: async ({ tableName }) => {
return this.getSchemaInfo(tableName);
},
}),
},
maxSteps: 5,
experimental_telemetry: {
isEnabled: true,
metadata: {
feature: "ai-query-generator",
mode,
},
},
});
// Wait for the full response
const text = await result.text;
// Try to extract a valid query from the response
const query = this.extractQueryFromResponse(text);
if (query) {
// Validate the extracted query one more time
const validation = this.validateQuery(query);
if (validation.valid) {
return { success: true, query };
} else {
const errorMessages = validation.issues.map((i) => i.message).join("; ");
return {
success: false,
error: validation.syntaxError || errorMessages || "Query validation failed",
};
}
}
// If no query was found, check if there's an error message
if (text.toLowerCase().includes("cannot") || text.toLowerCase().includes("unable")) {
return { success: false, error: text.slice(0, 200) };
}
return { success: false, error: "Could not generate a valid query" };
}
/**
* Validate a TSQL query using the parser and validator
*/
private validateQuery(query: string): QueryValidationResult {
try {
// First, try to parse the query
const ast = parseTSQLSelect(query);
// Then validate against the schema
const validationResult = validateQuery(ast, this.tableSchema);
return {
valid: validationResult.valid,
issues: validationResult.issues,
};
} catch (error) {
// Syntax error during parsing
return {
valid: false,
syntaxError: error instanceof Error ? error.message : String(error),
issues: [],
};
}
}
/**
* Get schema information for the AI
*/
private getSchemaInfo(tableName?: string): {
tables: Array<{
name: string;
description?: string;
columns: Array<{
name: string;
type: string;
description?: string;
allowedValues?: string[];
example?: string;
}>;
}>;
} {
const tables = tableName
? this.tableSchema.filter((t) => t.name.toLowerCase() === tableName?.toLowerCase())
: this.tableSchema;
return {
tables: tables.map((table) => ({
name: table.name,
description: table.description,
columns: Object.values(table.columns).map((col) => ({
name: col.name,
type: col.type,
description: col.description,
allowedValues: col.valueMap ? Object.values(col.valueMap) : col.allowedValues,
example: col.example,
})),
})),
};
}
/**
* Build a description of the schema for the system prompt
*/
private buildSchemaDescription(): string {
const parts: string[] = [];
for (const table of this.tableSchema) {
parts.push(`## Table: ${table.name}`);
if (table.description) {
parts.push(table.description);
}
parts.push("");
parts.push("Columns:");
for (const col of Object.values(table.columns)) {
let colDesc = `- ${col.name} (${col.type})`;
if (col.description) {
colDesc += `: ${col.description}`;
}
parts.push(colDesc);
// Add allowed values for enum-like columns
const allowedValues = col.valueMap ? Object.values(col.valueMap) : col.allowedValues;
if (allowedValues && allowedValues.length > 0 && allowedValues.length <= 20) {
parts.push(` Allowed values: ${allowedValues.join(", ")}`);
}
// Add example if available
if (col.example) {
parts.push(` Example: ${col.example}`);
}
}
parts.push("");
}
return parts.join("\n");
}
/**
* Build the system prompt for the AI
*/
private buildSystemPrompt(schemaDescription: string): string {
return `You are an expert SQL assistant that generates TSQL queries for a task run analytics system. TSQL is a SQL dialect similar to ClickHouse SQL.
## Your Task
Convert natural language requests into valid TSQL SELECT queries. Always validate your queries using the validateTSQLQuery tool before returning them.
## Available Schema
${schemaDescription}
## TSQL Syntax Guide
TSQL supports standard SQL syntax with some ClickHouse-specific features:
### Basic SELECT
\`\`\`sql
SELECT column1, column2, ...
FROM table_name
WHERE conditions
ORDER BY column [ASC|DESC]
LIMIT n
\`\`\`
### Filtering (WHERE clause)
- Comparison: =, !=, <, >, <=, >=
- Logical: AND, OR, NOT
- Pattern matching: LIKE, ILIKE (case-insensitive), NOT LIKE
- Range: BETWEEN value1 AND value2
- Set membership: IN ('value1', 'value2'), NOT IN (...)
- Null checks: IS NULL, IS NOT NULL
- Array contains: has(array_column, 'value')
### Aggregations
- count() - count rows
- countIf(condition) - count rows matching condition
- sum(column), sumIf(column, condition)
- avg(column), min(column), max(column)
- uniq(column) - approximate unique count
- quantile(p)(column) - percentile (p between 0 and 1)
- groupArray(column) - collect values into array
### Grouping
\`\`\`sql
SELECT column, count() as cnt
FROM table
GROUP BY column
HAVING cnt > 10
\`\`\`
### Date/Time Functions
- now() - current timestamp
- today() - current date
- toDate(datetime) - extract date
- toStartOfDay/Hour/Minute(datetime)
- dateDiff('unit', start, end) - difference in units (second, minute, hour, day, week, month, year)
- INTERVAL n unit - time interval (e.g., INTERVAL 7 DAY)
### Common Patterns
- Recent data: WHERE triggered_at > now() - INTERVAL 7 DAY
- Date range: WHERE triggered_at BETWEEN '2024-01-01' AND '2024-01-31'
- Status filter: WHERE status = 'Failed' or WHERE status IN ('Failed', 'Crashed')
## Important Rules
1. ALWAYS use the validateTSQLQuery tool to check your query before returning it
2. If validation fails, fix the issues and try again (up to 3 attempts)
3. Use column names exactly as defined in the schema (case-sensitive)
4. For enum columns like status, use the allowed values shown in the schema
5. Always include a LIMIT clause (default to 100 if not specified)
6. Use meaningful column aliases with AS for aggregations
7. Format queries with proper indentation for readability
## Response Format
After validating successfully, return ONLY the SQL query wrapped in a code block:
\`\`\`sql
SELECT ...
FROM ...
\`\`\`
If you cannot generate a valid query, explain why briefly.`;
}
/**
* Build the system prompt for edit mode
*/
private buildEditSystemPrompt(schemaDescription: string): string {
return `You are an expert SQL assistant that modifies existing TSQL queries for a task run analytics system. TSQL is a SQL dialect similar to ClickHouse SQL.
## Your Task
Modify the provided TSQL query according to the user's instructions. Make only the changes requested - preserve the existing query structure where possible.
## Available Schema
${schemaDescription}
## TSQL Syntax Guide
TSQL supports standard SQL syntax with some ClickHouse-specific features:
### Basic SELECT
\`\`\`sql
SELECT column1, column2, ...
FROM table_name
WHERE conditions
ORDER BY column [ASC|DESC]
LIMIT n
\`\`\`
### Filtering (WHERE clause)
- Comparison: =, !=, <, >, <=, >=
- Logical: AND, OR, NOT
- Pattern matching: LIKE, ILIKE (case-insensitive), NOT LIKE
- Range: BETWEEN value1 AND value2
- Set membership: IN ('value1', 'value2'), NOT IN (...)
- Null checks: IS NULL, IS NOT NULL
- Array contains: has(array_column, 'value')
### Aggregations
- count() - count rows
- countIf(condition) - count rows matching condition
- sum(column), sumIf(column, condition)
- avg(column), min(column), max(column)
- uniq(column) - approximate unique count
- quantile(p)(column) - percentile (p between 0 and 1)
- groupArray(column) - collect values into array
### Grouping
\`\`\`sql
SELECT column, count() as cnt
FROM table
GROUP BY column
HAVING cnt > 10
\`\`\`
### Date/Time Functions
- now() - current timestamp
- today() - current date
- toDate(datetime) - extract date
- toStartOfDay/Hour/Minute(datetime)
- dateDiff('unit', start, end) - difference in units (second, minute, hour, day, week, month, year)
- INTERVAL n unit - time interval (e.g., INTERVAL 7 DAY)
## Important Rules
1. ALWAYS use the validateTSQLQuery tool to check your modified query before returning it
2. If validation fails, fix the issues and try again (up to 3 attempts)
3. Use column names exactly as defined in the schema (case-sensitive)
4. For enum columns like status, use the allowed values shown in the schema
5. Always include a LIMIT clause (default to 100 if not specified)
6. Preserve the user's existing query structure and style where possible
7. Only make the changes specifically requested by the user
## Response Format
After validating successfully, return ONLY the modified SQL query wrapped in a code block:
\`\`\`sql
SELECT ...
FROM ...
\`\`\`
If you cannot make the requested modification, explain why briefly.`;
}
/**
* Build the user prompt for edit mode
*/
private buildEditUserPrompt(userRequest: string, currentQuery: string): string {
return `Here is the current TSQL query:
\`\`\`sql
${currentQuery}
\`\`\`
Please modify this query according to the following instructions:
${userRequest}`;
}
/**
* Extract a SQL query from the AI response text
*/
private extractQueryFromResponse(text: string): string | null {
// Try to extract from code block first
const codeBlockMatch = text.match(/```(?:sql)?\s*([\s\S]*?)```/i);
if (codeBlockMatch) {
return codeBlockMatch[1].trim();
}
// Try to find a SELECT statement
const selectMatch = text.match(/SELECT[\s\S]+?(?:LIMIT\s+\d+|;|$)/i);
if (selectMatch) {
return selectMatch[0].trim().replace(/;$/, "");
}
return null;
}
}
+374
View File
@@ -0,0 +1,374 @@
import { evalite } from "evalite";
import { Levenshtein } from "autoevals";
import { AIQueryService } from "~/v3/services/aiQueryService.server";
import { runsSchema } from "~/v3/querySchemas";
import dotenv from "dotenv";
import { traceAISDKModel } from "evalite/ai-sdk";
import { openai } from "@ai-sdk/openai";
dotenv.config({ path: "../../.env" });
// Helper to normalize queries for comparison
function normalizeQuery(query: string): string {
return query
.replace(/\s+/g, " ")
.replace(/\(\s+/g, "(")
.replace(/\s+\)/g, ")")
.trim()
.toLowerCase();
}
// Type for parsed query results
interface ParsedQueryResult {
success: boolean;
query?: string;
error?: string;
}
// Custom scorer that checks if the generated query is semantically similar
// and also syntactically valid
const QuerySimilarity = {
name: "QuerySimilarity",
scorer: async ({
input,
output,
expected,
}: {
input: string;
output: string;
expected?: string;
}) => {
if (!expected) {
return 0;
}
// Parse the output to extract the query
const outputParsed = JSON.parse(output) as ParsedQueryResult;
const expectedParsed = JSON.parse(expected) as ParsedQueryResult;
// Check success status first
if (outputParsed.success !== expectedParsed.success) {
return 0;
}
// If both failed, check if error messages are similar
if (!outputParsed.success && !expectedParsed.success) {
// Give partial credit for correctly identifying an error case
return 0.5;
}
// If both succeeded, compare the queries
if (outputParsed.success && expectedParsed.success) {
const normalizedOutput = normalizeQuery(outputParsed.query ?? "");
const normalizedExpected = normalizeQuery(expectedParsed.query ?? "");
// Key patterns to check
const patterns = [
// Table name
/from\s+runs/i,
// Status filter patterns
/status\s*=\s*'[^']+'/i,
/status\s+in\s*\([^)]+\)/i,
// Time patterns
/interval\s+\d+\s+(day|hour|minute|week|month)/i,
/triggered_at\s*>/i,
// Aggregation patterns
/count\(\)/i,
/sum\(/i,
/avg\(/i,
/group\s+by/i,
// Ordering
/order\s+by/i,
// Limit
/limit\s+\d+/i,
];
let matchScore = 0;
let totalPatterns = 0;
for (const pattern of patterns) {
const outputMatch = pattern.test(normalizedOutput);
const expectedMatch = pattern.test(normalizedExpected);
if (expectedMatch) {
totalPatterns++;
if (outputMatch) {
matchScore++;
}
}
}
// Base score from pattern matching
const patternScore = totalPatterns > 0 ? matchScore / totalPatterns : 0.5;
// Use Levenshtein for overall similarity
const levenshteinResult = await Levenshtein({
output: normalizedOutput,
expected: normalizedExpected,
});
const levenshteinScore = levenshteinResult?.score ?? 0;
// Weighted combination
return 0.6 * patternScore + 0.4 * levenshteinScore;
}
return 0;
},
};
evalite("AI Query Generator", {
data: async () => {
return [
// Basic SELECT queries
{
input: "Show me all runs",
expected: JSON.stringify({
success: true,
query: `SELECT *
FROM runs
LIMIT 100`,
}),
},
{
input: "Get the 10 most recent runs",
expected: JSON.stringify({
success: true,
query: `SELECT *
FROM runs
ORDER BY triggered_at DESC
LIMIT 10`,
}),
},
// Status filtering
{
input: "Show failed runs",
expected: JSON.stringify({
success: true,
query: `SELECT *
FROM runs
WHERE status = 'Failed'
LIMIT 100`,
}),
},
{
input: "Get all completed runs",
expected: JSON.stringify({
success: true,
query: `SELECT *
FROM runs
WHERE status = 'Completed'
LIMIT 100`,
}),
},
{
input: "Find runs that crashed or timed out",
expected: JSON.stringify({
success: true,
query: `SELECT *
FROM runs
WHERE status IN ('Crashed', 'Timed out')
LIMIT 100`,
}),
},
// Time-based filtering
{
input: "Runs from the last 7 days",
expected: JSON.stringify({
success: true,
query: `SELECT *
FROM runs
WHERE triggered_at > now() - INTERVAL 7 DAY
LIMIT 100`,
}),
},
{
input: "Show runs from the past hour",
expected: JSON.stringify({
success: true,
query: `SELECT *
FROM runs
WHERE triggered_at > now() - INTERVAL 1 HOUR
LIMIT 100`,
}),
},
{
input: "Failed runs in the last 24 hours",
expected: JSON.stringify({
success: true,
query: `SELECT *
FROM runs
WHERE status = 'Failed'
AND triggered_at > now() - INTERVAL 1 DAY
ORDER BY triggered_at DESC
LIMIT 100`,
}),
},
// Aggregations
{
input: "Count of runs by status",
expected: JSON.stringify({
success: true,
query: `SELECT status, count() AS count
FROM runs
GROUP BY status
ORDER BY count DESC`,
}),
},
{
input: "How many runs per task?",
expected: JSON.stringify({
success: true,
query: `SELECT task_identifier, count() AS run_count
FROM runs
GROUP BY task_identifier
ORDER BY run_count DESC
LIMIT 100`,
}),
},
{
input: "Average execution duration by task",
expected: JSON.stringify({
success: true,
query: `SELECT task_identifier, avg(execution_duration) AS avg_duration
FROM runs
GROUP BY task_identifier
ORDER BY avg_duration DESC
LIMIT 100`,
}),
},
{
input: "Total cost by task in the last 30 days",
expected: JSON.stringify({
success: true,
query: `SELECT task_identifier, sum(total_cost) AS total_cost
FROM runs
WHERE triggered_at > now() - INTERVAL 30 DAY
GROUP BY task_identifier
ORDER BY total_cost DESC
LIMIT 100`,
}),
},
// Complex queries
{
input: "Top 10 most expensive failed runs from last week",
expected: JSON.stringify({
success: true,
query: `SELECT run_id, task_identifier, status, total_cost, triggered_at
FROM runs
WHERE status = 'Failed'
AND triggered_at > now() - INTERVAL 7 DAY
ORDER BY total_cost DESC
LIMIT 10`,
}),
},
{
input: "Runs using large machines that took more than 5 minutes",
expected: JSON.stringify({
success: true,
query: `SELECT *
FROM runs
WHERE machine IN ('large-1x', 'large-2x')
AND usage_duration > 300000
LIMIT 100`,
}),
},
{
input: "Show p95 execution duration by task for completed runs",
expected: JSON.stringify({
success: true,
query: `SELECT task_identifier, quantile(0.95)(execution_duration) AS p95_duration
FROM runs
WHERE status = 'Completed'
AND execution_duration IS NOT NULL
GROUP BY task_identifier
ORDER BY p95_duration DESC
LIMIT 100`,
}),
},
// Specific columns
{
input: "Just show run IDs and their statuses",
expected: JSON.stringify({
success: true,
query: `SELECT run_id, status
FROM runs
LIMIT 100`,
}),
},
{
input: "Get run_id, task, status and cost for recent runs",
expected: JSON.stringify({
success: true,
query: `SELECT run_id, task_identifier, status, total_cost
FROM runs
ORDER BY triggered_at DESC
LIMIT 100`,
}),
},
// Root runs
{
input: "Show only root runs (not child runs)",
expected: JSON.stringify({
success: true,
query: `SELECT *
FROM runs
WHERE is_root_run = 1
LIMIT 100`,
}),
},
// Queue filtering
{
input: "Runs in the shared queue",
expected: JSON.stringify({
success: true,
query: `SELECT *
FROM runs
WHERE queue LIKE '%shared%'
LIMIT 100`,
}),
},
// Tags
{
input: "Find runs with tag 'important'",
expected: JSON.stringify({
success: true,
query: `SELECT *
FROM runs
WHERE has(tags, 'important')
LIMIT 100`,
}),
},
// Error cases
{
input: "Do something",
expected: JSON.stringify({
success: false,
error: "Please be more specific about what data you want to query",
}),
},
{
input: "Show me the weather",
expected: JSON.stringify({
success: false,
error: "I can only generate queries for task run data",
}),
},
];
},
task: async (input) => {
const service = new AIQueryService([runsSchema], traceAISDKModel(openai("gpt-4o-mini")));
const result = await service.call(input);
return JSON.stringify(result);
},
scorers: [QuerySimilarity, Levenshtein],
});
+14 -10
View File
@@ -37,15 +37,16 @@
"@aws-sdk/s3-presigned-post": "^3.936.0",
"@aws-sdk/s3-request-presigner": "^3.936.0",
"@better-auth/utils": "^0.2.6",
"@codemirror/autocomplete": "^6.3.1",
"@codemirror/commands": "^6.1.2",
"@codemirror/lang-javascript": "^6.1.1",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/language": "^6.3.1",
"@codemirror/lint": "^6.4.2",
"@codemirror/search": "^6.2.3",
"@codemirror/state": "^6.1.3",
"@codemirror/view": "^6.5.0",
"@codemirror/autocomplete": "6.4.0",
"@codemirror/commands": "6.1.3",
"@codemirror/lang-javascript": "6.1.2",
"@codemirror/lang-json": "6.0.1",
"@codemirror/lang-sql": "6.5.5",
"@codemirror/language": "6.3.2",
"@codemirror/lint": "6.4.2",
"@codemirror/search": "6.2.3",
"@codemirror/state": "6.2.0",
"@codemirror/view": "6.7.2",
"@conform-to/react": "0.9.2",
"@conform-to/zod": "0.9.2",
"@depot/cli": "0.0.1-cli.2.80.0",
@@ -58,6 +59,7 @@
"@internal/run-engine": "workspace:*",
"@internal/schedule-engine": "workspace:*",
"@internal/tracing": "workspace:*",
"@internal/tsql": "workspace:*",
"@internal/zod-worker": "workspace:*",
"@internationalized/date": "^3.5.1",
"@kapaai/react-sdk": "^0.1.3",
@@ -201,7 +203,9 @@
"socket.io": "4.7.4",
"socket.io-adapter": "^2.5.4",
"sonner": "^1.0.3",
"sql-formatter": "^15.4.10",
"sqs-consumer": "^7.4.0",
"streamdown": "^1.4.0",
"superjson": "^2.2.1",
"tailwind-merge": "^1.12.0",
"tailwind-scrollbar-hide": "^1.1.7",
@@ -287,4 +291,4 @@
"engines": {
"node": ">=18.19.0 || >=20.6.0"
}
}
}
+2
View File
@@ -36,6 +36,8 @@ module.exports = {
os: true,
crypto: true,
http2: true,
assert: true,
util: true,
},
},
};
@@ -0,0 +1,174 @@
import { describe, it, expect } from "vitest";
import { createTSQLCompletion } from "~/components/code/tsql/tsqlCompletion";
import type { TableSchema } from "@internal/tsql";
// Helper to create a mock completion context
function createMockContext(doc: string, pos: number, explicit = false) {
return {
state: {
doc: {
toString: () => doc,
},
},
pos,
explicit,
matchBefore: (regex: RegExp) => {
const beforePos = doc.slice(0, pos);
const match = beforePos.match(new RegExp(regex.source + "$"));
if (match) {
return {
from: pos - match[0].length,
to: pos,
text: match[0],
};
}
return null;
},
} as any;
}
// Test schema
const testSchema: TableSchema[] = [
{
name: "runs",
clickhouseName: "trigger_dev.task_runs_v2",
tenantColumns: {
organizationId: "organization_id",
projectId: "project_id",
environmentId: "environment_id",
},
description: "Task runs table",
columns: {
id: { name: "id", type: "String", description: "Run ID" },
status: { name: "status", type: "String", description: "Run status" },
created_at: { name: "created_at", type: "DateTime64", description: "Creation time" },
organization_id: { name: "organization_id", type: "String" },
project_id: { name: "project_id", type: "String" },
environment_id: { name: "environment_id", type: "String" },
},
},
{
name: "logs",
clickhouseName: "trigger_dev.task_events_v2",
tenantColumns: {
organizationId: "organization_id",
projectId: "project_id",
environmentId: "environment_id",
},
description: "Task logs table",
columns: {
id: { name: "id", type: "String" },
run_id: { name: "run_id", type: "String" },
message: { name: "message", type: "String" },
level: { name: "level", type: "String" },
timestamp: { name: "timestamp", type: "DateTime64" },
organization_id: { name: "organization_id", type: "String" },
project_id: { name: "project_id", type: "String" },
environment_id: { name: "environment_id", type: "String" },
},
},
];
describe("createTSQLCompletion", () => {
const completionSource = createTSQLCompletion(testSchema);
it("should return null for empty input without explicit trigger", () => {
const context = createMockContext("", 0, false);
const result = completionSource(context);
expect(result).toBeNull();
});
it("should return completions when explicitly triggered", () => {
const context = createMockContext("", 0, true);
const result = completionSource(context);
expect(result).not.toBeNull();
expect(result?.options.length).toBeGreaterThan(0);
});
it("should include tables in completions", () => {
// When typing after FROM, tables should be available
const doc = "SELECT * FROM r";
const context = createMockContext(doc, doc.length, true);
const result = completionSource(context);
expect(result).not.toBeNull();
const tableLabels = result?.options.map((o) => o.label);
// Tables should always be available in completions
expect(tableLabels).toContain("runs");
expect(tableLabels).toContain("logs");
});
it("should suggest columns after SELECT keyword", () => {
const doc = "SELECT FROM runs";
// Position cursor right after SELECT
const pos = 7;
const context = createMockContext(doc, pos, true);
const result = completionSource(context);
expect(result).not.toBeNull();
// Should include functions
const labels = result?.options.map((o) => o.label) || [];
expect(labels.some((l) => l === "count")).toBe(true);
expect(labels.some((l) => l === "sum")).toBe(true);
});
it("should suggest columns with table prefix for qualified references", () => {
const doc = "SELECT runs. FROM runs";
// Position cursor right after "runs."
const pos = 12;
const context = createMockContext(doc, pos, true);
const result = completionSource(context);
expect(result).not.toBeNull();
const columnLabels = result?.options.map((o) => o.label);
expect(columnLabels).toContain("id");
expect(columnLabels).toContain("status");
expect(columnLabels).toContain("created_at");
});
it("should include SQL keywords in general context", () => {
const doc = "S";
const context = createMockContext(doc, doc.length, true);
const result = completionSource(context);
expect(result).not.toBeNull();
const labels = result?.options.map((o) => o.label);
expect(labels).toContain("SELECT");
});
it("should include aggregate functions", () => {
const doc = "SELECT ";
const context = createMockContext(doc, doc.length, true);
const result = completionSource(context);
expect(result).not.toBeNull();
const labels = result?.options.map((o) => o.label);
expect(labels).toContain("count");
expect(labels).toContain("sum");
expect(labels).toContain("avg");
expect(labels).toContain("min");
expect(labels).toContain("max");
});
it("should handle WHERE clause context", () => {
const doc = "SELECT * FROM runs WHERE ";
const context = createMockContext(doc, doc.length, true);
const result = completionSource(context);
expect(result).not.toBeNull();
// Should suggest columns
const labels = result?.options.map((o) => o.label) || [];
expect(labels).toContain("status");
// Should include conditional keywords
expect(labels).toContain("AND");
expect(labels).toContain("OR");
});
});
@@ -0,0 +1,79 @@
import { describe, it, expect } from "vitest";
import { isValidTSQLQuery, getTSQLError } from "~/components/code/tsql/tsqlLinter";
describe("tsqlLinter", () => {
describe("isValidTSQLQuery", () => {
it("should return true for empty queries", () => {
expect(isValidTSQLQuery("")).toBe(true);
expect(isValidTSQLQuery(" ")).toBe(true);
});
it("should return true for valid SELECT queries", () => {
expect(isValidTSQLQuery("SELECT * FROM users")).toBe(true);
expect(isValidTSQLQuery("SELECT id, name FROM users WHERE status = 'active'")).toBe(true);
expect(isValidTSQLQuery("SELECT count(*) FROM users GROUP BY status")).toBe(true);
});
it("should return true for queries with ORDER BY", () => {
expect(isValidTSQLQuery("SELECT * FROM users ORDER BY created_at DESC")).toBe(true);
});
it("should return true for queries with LIMIT", () => {
expect(isValidTSQLQuery("SELECT * FROM users LIMIT 10")).toBe(true);
expect(isValidTSQLQuery("SELECT * FROM users LIMIT 10 OFFSET 20")).toBe(true);
});
it("should return true for queries with JOINs", () => {
expect(isValidTSQLQuery("SELECT * FROM users JOIN orders ON users.id = orders.user_id")).toBe(
true
);
expect(
isValidTSQLQuery(
"SELECT * FROM users LEFT JOIN orders ON users.id = orders.user_id"
)
).toBe(true);
});
it("should return false for invalid syntax", () => {
expect(isValidTSQLQuery("SELEC * FROM users")).toBe(false);
expect(isValidTSQLQuery("SELECT * FORM users")).toBe(false);
expect(isValidTSQLQuery("SELECT FROM users")).toBe(false);
});
it("should return false for incomplete queries", () => {
expect(isValidTSQLQuery("SELECT * FROM")).toBe(false);
expect(isValidTSQLQuery("SELECT")).toBe(false);
});
});
describe("getTSQLError", () => {
it("should return null for empty queries", () => {
expect(getTSQLError("")).toBeNull();
expect(getTSQLError(" ")).toBeNull();
});
it("should return null for valid queries", () => {
expect(getTSQLError("SELECT * FROM users")).toBeNull();
expect(getTSQLError("SELECT id, name FROM users WHERE id = 1")).toBeNull();
});
it("should return error message for invalid queries", () => {
const error = getTSQLError("SELEC * FROM users");
expect(error).not.toBeNull();
expect(typeof error).toBe("string");
});
it("should include position information in error", () => {
const error = getTSQLError("SELECT * FORM users");
expect(error).not.toBeNull();
// Error message should contain line/column info
expect(error).toContain("line");
});
it("should handle unclosed string literals", () => {
const error = getTSQLError("SELECT * FROM users WHERE name = 'test");
expect(error).not.toBeNull();
});
});
});
@@ -8,6 +8,7 @@
"dependencies": {
"@clickhouse/client": "^1.12.1",
"@internal/tracing": "workspace:*",
"@internal/tsql": "workspace:*",
"@trigger.dev/core": "workspace:*",
"zod": "3.25.76",
"zod-error": "1.5.0"
@@ -0,0 +1,10 @@
-- +goose Up
/*
Add max_duration_in_seconds column.
*/
ALTER TABLE trigger_dev.task_runs_v2
ADD COLUMN max_duration_in_seconds Nullable (UInt32) DEFAULT NULL;
-- +goose Down
ALTER TABLE trigger_dev.task_runs_v2
DROP COLUMN max_duration_in_seconds;
@@ -16,9 +16,11 @@ import type {
ClickhouseQueryBuilderFastFunction,
ClickhouseQueryBuilderFunction,
ClickhouseQueryFunction,
ClickhouseQueryWithStatsFunction,
ClickhouseReader,
ClickhouseWriter,
ColumnExpression,
QueryStats,
} from "./types.js";
import { generateErrorMessage } from "zod-error";
import { Logger, type LogLevel } from "@trigger.dev/core/logger";
@@ -229,6 +231,184 @@ export class ClickhouseClient implements ClickhouseReader, ClickhouseWriter {
};
}
public queryWithStats<TIn extends z.ZodSchema<any>, TOut extends z.ZodSchema<any>>(req: {
/**
* The name of the operation.
* This will be used to identify the operation in the span.
*/
name: string;
/**
* The SQL query to run.
* Use {paramName: Type} to define parameters
* Example: `SELECT * FROM table WHERE id = {id: String}`
*/
query: string;
/**
* The schema of the parameters
* Example: z.object({ id: z.string() })
*/
params?: TIn;
/**
* The schema of the output of each row
* Example: z.object({ id: z.string() })
*/
schema: TOut;
/**
* The settings to use for the query.
* These will be merged with the default settings.
*/
settings?: ClickHouseSettings;
}): ClickhouseQueryWithStatsFunction<z.input<TIn>, z.output<TOut>> {
return async (params, options) => {
const queryId = randomUUID();
return await startSpan(this.tracer, "queryWithStats", async (span) => {
this.logger.debug("Querying clickhouse with stats", {
name: req.name,
query: req.query.replace(/\s+/g, " "),
params,
settings: req.settings,
attributes: options?.attributes,
queryId,
});
span.setAttributes({
"clickhouse.clientName": this.name,
"clickhouse.operationName": req.name,
"clickhouse.queryId": queryId,
...flattenAttributes(req.settings, "clickhouse.settings"),
...flattenAttributes(options?.attributes),
});
const validParams = req.params?.safeParse(params);
if (validParams?.error) {
recordSpanError(span, validParams.error);
this.logger.error("Error parsing query params", {
name: req.name,
error: validParams.error,
query: req.query,
params,
queryId,
});
return [
new QueryError(`Bad params: ${generateErrorMessage(validParams.error.issues)}`, {
query: req.query,
}),
null,
];
}
let unparsedRows: Array<TOut> = [];
const [clickhouseError, res] = await tryCatch(
this.client.query({
query: req.query,
query_params: validParams?.data,
format: "JSONEachRow",
query_id: queryId,
...options?.params,
clickhouse_settings: {
...req.settings,
...options?.params?.clickhouse_settings,
},
})
);
if (clickhouseError) {
this.logger.error("Error querying clickhouse", {
name: req.name,
error: clickhouseError,
query: req.query,
params,
queryId,
});
recordClickhouseError(span, clickhouseError);
return [
new QueryError(`Unable to query clickhouse: ${clickhouseError.message}`, {
query: req.query,
}),
null,
];
}
unparsedRows = await res.json();
span.setAttributes({
"clickhouse.query_id": res.query_id,
...flattenAttributes(res.response_headers, "clickhouse.response_headers"),
});
// Parse the summary header to get stats
const summaryHeader = res.response_headers["x-clickhouse-summary"];
let stats: QueryStats = {
read_rows: "0",
read_bytes: "0",
written_rows: "0",
written_bytes: "0",
total_rows_to_read: "0",
result_rows: "0",
result_bytes: "0",
elapsed_ns: "0",
byte_seconds: "0",
};
if (typeof summaryHeader === "string") {
const parsedSummary = JSON.parse(summaryHeader);
this.logger.debug("parsedSummary", parsedSummary);
const readBytes = parsedSummary.read_bytes ? parseInt(parsedSummary.read_bytes, 10) : 0;
const elapsedNs = parsedSummary.elapsed_ns ? parseInt(parsedSummary.elapsed_ns, 10) : 0;
const elapsedSeconds = elapsedNs / 1_000_000_000;
const byteSeconds = elapsedSeconds > 0 ? readBytes / elapsedSeconds : 0;
stats = {
read_rows: parsedSummary.read_rows ?? "0",
read_bytes: parsedSummary.read_bytes ?? "0",
written_rows: parsedSummary.written_rows ?? "0",
written_bytes: parsedSummary.written_bytes ?? "0",
total_rows_to_read: parsedSummary.total_rows_to_read ?? "0",
result_rows: parsedSummary.result_rows ?? "0",
result_bytes: parsedSummary.result_bytes ?? "0",
elapsed_ns: parsedSummary.elapsed_ns ?? "0",
byte_seconds: byteSeconds.toString(),
};
span.setAttributes({
...flattenAttributes(parsedSummary, "clickhouse.summary"),
});
}
const parsed = z.array(req.schema).safeParse(unparsedRows);
if (parsed.error) {
this.logger.error("Error parsing clickhouse query result", {
name: req.name,
error: parsed.error,
query: req.query,
params,
queryId,
});
const queryError = new QueryError(generateErrorMessage(parsed.error.issues), {
query: req.query,
});
recordSpanError(span, queryError);
return [queryError, null];
}
span.setAttributes({
"clickhouse.rows": unparsedRows.length,
});
return [null, { rows: parsed.data, stats }];
});
};
}
public queryFast<TOut extends Record<string, any>, TParams extends Record<string, any>>(req: {
name: string;
query: string;
@@ -3,9 +3,10 @@ import { InsertError, QueryError } from "./errors.js";
import {
ClickhouseQueryBuilderFastFunction,
ClickhouseQueryBuilderFunction,
ClickhouseReader,
ClickhouseWriter,
QueryResultWithStats,
} from "./types.js";
import { ClickhouseReader } from "./types.js";
import { z } from "zod";
import { ClickHouseSettings, InsertResult } from "@clickhouse/client";
import { ClickhouseQueryBuilder, ClickhouseQueryFastBuilder } from "./queryBuilder.js";
@@ -51,6 +52,38 @@ export class NoopClient implements ClickhouseReader, ClickhouseWriter {
};
}
public queryWithStats<TIn extends z.ZodSchema<any>, TOut extends z.ZodSchema<any>>(req: {
query: string;
params?: TIn;
schema: TOut;
}): (params: z.input<TIn>) => Promise<Result<QueryResultWithStats<z.output<TOut>>, QueryError>> {
return async (params: z.input<TIn>) => {
const validParams = req.params?.safeParse(params);
if (validParams?.error) {
return [new QueryError(`Bad params: ${validParams.error.message}`, { query: "" }), null];
}
return [
null,
{
rows: [],
stats: {
read_rows: "0",
read_bytes: "0",
written_rows: "0",
written_bytes: "0",
total_rows_to_read: "0",
result_rows: "0",
result_bytes: "0",
elapsed_ns: "0",
byte_seconds: "0",
},
},
];
};
}
public queryFast<TOut extends Record<string, any>, TParams extends Record<string, any>>(req: {
name: string;
query: string;
@@ -0,0 +1,200 @@
/**
* TSQL Query Execution for ClickHouse
*
* This module provides a safe interface for executing TSQL queries against ClickHouse
* with automatic tenant isolation and SQL injection protection.
*/
import type { ClickHouseSettings } from "@clickhouse/client";
import { z } from "zod";
import {
compileTSQL,
transformResults,
type TableSchema,
type QuerySettings,
type FieldMappings,
} from "@internal/tsql";
import type { ClickhouseReader, QueryStats } from "./types.js";
import { QueryError } from "./errors.js";
import type { OutputColumnMetadata } from "@internal/tsql";
import { Logger } from "@trigger.dev/core/logger";
const logger = new Logger("tsql", "info");
export type { QueryStats };
export type { TableSchema, QuerySettings, FieldMappings };
/**
* Options for executing a TSQL query
*/
export interface ExecuteTSQLOptions<TOut extends z.ZodSchema> {
/** The name of the operation (for logging/tracing) */
name: string;
/** The TSQL query string to execute */
query: string;
/** The Zod schema for validating output rows */
schema: TOut;
/** The organization ID for tenant isolation (required) */
organizationId: string;
/** The project ID for tenant isolation (optional - omit to query across all projects) */
projectId?: string;
/** The environment ID for tenant isolation (optional - omit to query across all environments) */
environmentId?: string;
/** Schema registry defining allowed tables and columns */
tableSchema: TableSchema[];
/** Optional ClickHouse query settings */
clickhouseSettings?: ClickHouseSettings;
/** Optional TSQL query settings (maxRows, timezone, etc.) */
querySettings?: Partial<QuerySettings>;
/**
* Whether to transform result values using the schema's valueMap
* When enabled, internal ClickHouse values (e.g., 'COMPLETED_SUCCESSFULLY')
* are converted to user-friendly display names (e.g., 'Completed')
* @default true
*/
transformValues?: boolean;
/**
* Runtime field mappings for dynamic value translation.
* Maps internal ClickHouse values to external user-facing values.
*
* @example
* ```typescript
* {
* project: { "cm12345": "my-project-ref" },
* }
* ```
*/
fieldMappings?: FieldMappings;
}
/**
* Successful result from TSQL query execution
*/
export interface TSQLQuerySuccess<T> {
rows: T[];
columns: OutputColumnMetadata[];
stats: QueryStats;
}
/**
* Result type for TSQL query execution
*/
export type TSQLQueryResult<T> = [QueryError, null] | [null, TSQLQuerySuccess<T>];
/**
* Execute a TSQL query against ClickHouse
*
* This function:
* 1. Compiles the TSQL query to ClickHouse SQL (parse, validate, inject tenant guards)
* 2. Executes the query and returns validated results
*
* @example
* ```typescript
* const [error, rows] = await executeTSQL(reader, {
* name: "get_task_runs",
* query: "SELECT id, status FROM task_runs WHERE status = 'completed' ORDER BY created_at DESC LIMIT 100",
* schema: z.object({ id: z.string(), status: z.string() }),
* organizationId: "org_123",
* projectId: "proj_456",
* environmentId: "env_789",
* tableSchema: [taskRunsSchema],
* });
* ```
*/
export async function executeTSQL<TOut extends z.ZodSchema>(
reader: ClickhouseReader,
options: ExecuteTSQLOptions<TOut>
): Promise<TSQLQueryResult<z.output<TOut>>> {
const shouldTransformValues = options.transformValues ?? true;
let generatedSql: string | undefined;
let generatedParams: Record<string, unknown> | undefined;
try {
// 1. Compile the TSQL query to ClickHouse SQL
const { sql, params, columns } = compileTSQL(options.query, {
organizationId: options.organizationId,
projectId: options.projectId,
environmentId: options.environmentId,
tableSchema: options.tableSchema,
settings: options.querySettings,
fieldMappings: options.fieldMappings,
});
generatedSql = sql;
generatedParams = params;
// 2. Execute the query with stats
const queryFn = reader.queryWithStats({
name: options.name,
query: sql,
params: z.record(z.any()),
schema: options.schema,
settings: options.clickhouseSettings,
});
const [error, result] = await queryFn(params);
if (error) {
return [error, null];
}
const { rows, stats } = result;
// 3. Transform result values if enabled
if (shouldTransformValues && rows) {
const transformedRows = transformResults(
rows as Record<string, unknown>[],
options.tableSchema,
{ fieldMappings: options.fieldMappings }
);
return [null, { rows: transformedRows as z.output<TOut>[], columns, stats }];
}
return [null, { rows: rows ?? [], columns, stats }];
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
// Log TSQL compilation or unexpected errors
logger.error("[TSQL] Query error", {
name: options.name,
error: errorMessage,
tsql: options.query,
generatedSql: generatedSql ?? "(compilation failed)",
generatedParams: generatedParams ?? {},
});
if (error instanceof Error) {
return [new QueryError(error.message, { query: options.query }), null];
}
return [new QueryError("Unknown error executing TSQL query", { query: options.query }), null];
}
}
/**
* Create a reusable TSQL query executor bound to specific table schemas
*
* @example
* ```typescript
* const tsqlExecutor = createTSQLExecutor(reader, [taskRunsSchema, taskEventsSchema]);
*
* const [error, rows] = await tsqlExecutor.execute({
* name: "get_task_runs",
* query: "SELECT * FROM task_runs LIMIT 10",
* schema: taskRunRowSchema,
* organizationId: "org_123",
* projectId: "proj_456",
* environmentId: "env_789",
* });
* ```
*/
export function createTSQLExecutor(reader: ClickhouseReader, tableSchema: TableSchema[]) {
return {
execute: <TOut extends z.ZodSchema>(
options: Omit<ExecuteTSQLOptions<TOut>, "tableSchema">
): Promise<TSQLQueryResult<z.output<TOut>>> => {
return executeTSQL(reader, { ...options, tableSchema });
},
};
}
@@ -13,6 +13,37 @@ export type ClickhouseQueryFunction<TInput, TOutput> = (
}
) => Promise<Result<TOutput[], QueryError>>;
/**
* Query statistics returned by ClickHouse
*/
export interface QueryStats {
read_rows: string;
read_bytes: string;
written_rows: string;
written_bytes: string;
total_rows_to_read: string;
result_rows: string;
result_bytes: string;
elapsed_ns: string;
byte_seconds: string;
}
/**
* Result type for queries that include stats
*/
export interface QueryResultWithStats<TOutput> {
rows: TOutput[];
stats: QueryStats;
}
export type ClickhouseQueryWithStatsFunction<TInput, TOutput> = (
params: TInput,
options?: {
attributes?: Record<string, string | number | boolean>;
params?: BaseQueryParams;
}
) => Promise<Result<QueryResultWithStats<TOutput>, QueryError>>;
export type ClickhouseQueryBuilderFunction<TOutput> = (options?: {
settings?: ClickHouseSettings;
}) => ClickhouseQueryBuilder<TOutput>;
@@ -56,6 +87,39 @@ export interface ClickhouseReader {
settings?: ClickHouseSettings;
}): ClickhouseQueryFunction<z.input<TIn>, z.output<TOut>>;
/**
* Execute a query and return both rows and query statistics.
* Same as `query` but includes ClickHouse query stats in the result.
*/
queryWithStats<TIn extends z.ZodSchema<any>, TOut extends z.ZodSchema<any>>(req: {
/**
* The name of the operation.
* This will be used to identify the operation in the span.
*/
name: string;
/**
* The SQL query to run.
* Use {paramName: Type} to define parameters
* Example: `SELECT * FROM table WHERE id = {id: String}`
*/
query: string;
/**
* The schema of the parameters
* Example: z.object({ id: z.string() })
*/
params?: TIn;
/**
* The schema of the output of each row
* Example: z.object({ id: z.string() })
*/
schema: TOut;
/**
* The settings to use for the query.
* These will be merged with the default settings.
*/
settings?: ClickHouseSettings;
}): ClickhouseQueryWithStatsFunction<z.input<TIn>, z.output<TOut>>;
queryFast<TOut extends Record<string, any>, TParams extends Record<string, any>>(req: {
/**
* The name of the operation.
+13
View File
@@ -31,6 +31,19 @@ export type * from "./taskRuns.js";
export type * from "./taskEvents.js";
export type * from "./client/queryBuilder.js";
// TSQL query execution
export {
executeTSQL,
createTSQLExecutor,
type ExecuteTSQLOptions,
type TableSchema,
type TSQLQueryResult,
type TSQLQuerySuccess,
type QueryStats,
type FieldMappings,
} from "./client/tsql.js";
export type { OutputColumnMetadata } from "@internal/tsql";
export type ClickhouseCommonConfig = {
keepAlive?: {
enabled?: boolean;
@@ -45,6 +45,7 @@ export const TaskRunV2 = z.object({
concurrency_key: z.string().default(""),
bulk_action_group_ids: z.array(z.string()).default([]),
worker_queue: z.string().default(""),
max_duration_in_seconds: z.number().int().nullish(),
_version: z.string(),
_is_deleted: z.number().int().default(0),
});
File diff suppressed because it is too large Load Diff
@@ -6,8 +6,8 @@
"target": "ES2019",
"lib": ["ES2019", "DOM", "DOM.Iterable", "DOM.AsyncIterable"],
"outDir": "dist",
"module": "Node16",
"moduleResolution": "Node16",
"module": "ESNext",
"moduleResolution": "bundler",
"moduleDetection": "force",
"verbatimModuleSyntax": false,
"esModuleInterop": true,
@@ -16,6 +16,7 @@
"preserveWatchOutput": true,
"skipLibCheck": true,
"strict": true,
"noImplicitAny": false,
"declaration": true
}
}
@@ -0,0 +1,40 @@
-- CreateEnum
CREATE TYPE "public"."CustomerQuerySource" AS ENUM ('DASHBOARD', 'API');
-- CreateEnum
CREATE TYPE "public"."CustomerQueryScope" AS ENUM ('ORGANIZATION', 'PROJECT', 'ENVIRONMENT');
-- CreateTable
CREATE TABLE
"public"."CustomerQuery" (
"id" TEXT NOT NULL,
"query" TEXT NOT NULL,
"scope" "public"."CustomerQueryScope" NOT NULL,
"stats" JSONB NOT NULL,
"costInCents" DOUBLE PRECISION NOT NULL DEFAULT 0,
"source" "public"."CustomerQuerySource" NOT NULL DEFAULT 'DASHBOARD',
"organizationId" TEXT NOT NULL,
"projectId" TEXT,
"environmentId" TEXT,
"userId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CustomerQuery_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "CustomerQuery_organizationId_createdAt_idx" ON "public"."CustomerQuery" ("organizationId", "createdAt" DESC);
-- CreateIndex
CREATE INDEX "CustomerQuery_createdAt_idx" ON "public"."CustomerQuery" ("createdAt");
-- AddForeignKey
ALTER TABLE "public"."CustomerQuery" ADD CONSTRAINT "CustomerQuery_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "public"."Organization" ("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."CustomerQuery" ADD CONSTRAINT "CustomerQuery_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."CustomerQuery" ADD CONSTRAINT "CustomerQuery_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "public"."RuntimeEnvironment" ("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."CustomerQuery" ADD CONSTRAINT "CustomerQuery_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User" ("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -59,6 +59,7 @@ model User {
deployments WorkerDeployment[]
backupCodes MfaBackupCode[]
bulkActions BulkActionGroup[]
customerQueries CustomerQuery[]
}
model MfaBackupCode {
@@ -218,6 +219,7 @@ model Organization {
workerGroups WorkerInstanceGroup[]
workerInstances WorkerInstance[]
githubAppInstallations GithubAppInstallation[]
customerQueries CustomerQuery[]
}
model OrgMember {
@@ -335,6 +337,7 @@ model RuntimeEnvironment {
workerInstances WorkerInstance[]
waitpointTags WaitpointTag[]
BulkActionGroup BulkActionGroup[]
customerQueries CustomerQuery[]
@@unique([projectId, slug, orgMemberId])
@@unique([projectId, shortcode])
@@ -399,6 +402,7 @@ model Project {
taskRunCheckpoints TaskRunCheckpoint[]
waitpointTags WaitpointTag[]
connectedGithubRepository ConnectedGithubRepository?
customerQueries CustomerQuery[]
buildSettings Json?
}
@@ -2383,3 +2387,53 @@ model ConnectedGithubRepository {
@@unique([projectId])
@@index([repositoryId])
}
enum CustomerQuerySource {
DASHBOARD
API
}
enum CustomerQueryScope {
ORGANIZATION
PROJECT
ENVIRONMENT
}
model CustomerQuery {
id String @id @default(cuid())
/// The TSQL query text that was executed
query String
/// The scope of the query (determines which tenant IDs were used)
scope CustomerQueryScope
/// Query execution statistics from ClickHouse
stats Json
/// Cost of the query in cents (for Stripe metering)
costInCents Float @default(0)
/// Where the query originated from
source CustomerQuerySource @default(DASHBOARD)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade, onUpdate: Cascade)
organizationId String
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade)
projectId String?
environment RuntimeEnvironment? @relation(fields: [environmentId], references: [id], onDelete: Cascade, onUpdate: Cascade)
environmentId String?
/// Optional user who executed the query (null for API calls)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull, onUpdate: Cascade)
userId String?
createdAt DateTime @default(now())
/// Fast lookup for history menu (most recent 20 per org)
@@index([organizationId, createdAt(sort: Desc)])
/// For Stripe metering job - find unprocessed queries
@@index([createdAt])
}
+3
View File
@@ -0,0 +1,3 @@
# ANTLR intermediate build files
src/grammar/.antlr/
+22
View File
@@ -0,0 +1,22 @@
Portions of this package are derived from PostHog (MIT License).
Copyright (c) 2020-2025 PostHog Inc.
The original license is reproduced below:
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+19
View File
@@ -0,0 +1,19 @@
# TSQL (TriggerSQL)
TriggerSQL is a DSL that is safely converted into ClickHouse SQL queries with protection against SQL injection and it's tenant-safe (users can only query their own data).
## Attribution
This package is derived from [PostHog's HogQL](https://github.com/PostHog/posthog/tree/master/posthog/hogql) (MIT License). See [NOTICE.md](./NOTICE.md) for the full copyright notice.
## ANTLR Grammar
The ANTLR grammar is heavily inspired by PostHog's HogQL.
These are found in [./src/grammar](./src/grammar) and are the `.g4` files.
## Generating the source code
```sh
pnpm run grammar:build
```
+23
View File
@@ -0,0 +1,23 @@
{
"name": "@internal/tsql",
"private": true,
"version": "0.0.1",
"main": "./src/index.ts",
"types": "./src/index.ts",
"type": "module",
"dependencies": {
"@trigger.dev/core": "workspace:*",
"antlr4ts": "0.5.0-alpha.4",
"zod": "3.25.76"
},
"scripts": {
"typecheck": "tsc --noEmit",
"grammar:build": "pnpm grammar:build:typescript",
"grammar:build:typescript": "cat src/grammar/TSQLLexer.typescript.g4 > src/grammar/TSQLLexer.g4 && tail -n +2 src/grammar/TSQLLexer.common.g4 |sed s/isOpeningTag/self.isOpeningTag/ >> src/grammar/TSQLLexer.g4 && antlr4ts src/grammar/TSQLLexer.g4 && rm src/grammar/TSQLLexer.g4 && antlr4ts -visitor -no-listener -Dlanguage=TypeScript src/grammar/TSQLParser.g4",
"test": "vitest --sequence.concurrent=false --no-file-parallelism",
"test:coverage": "vitest --sequence.concurrent=false --no-file-parallelism --coverage.enabled"
},
"devDependencies": {
"antlr4ts-cli": "0.5.0-alpha.4"
}
}
@@ -0,0 +1,288 @@
lexer grammar TSQLLexer;
// NB! We cat TSQLLexter.typescript.g4 when generating the grammar.
// NOTE: don't forget to add new keywords to the parser rule "keyword"!
// Keywords
ALL: A L L;
AND: A N D;
ANTI: A N T I;
ANY: A N Y;
ARRAY: A R R A Y;
AS: A S;
ASCENDING: A S C | A S C E N D I N G;
ASOF: A S O F;
BETWEEN: B E T W E E N;
BOTH: B O T H;
BY: B Y;
CASE: C A S E;
CAST: C A S T;
CATCH: C A T C H;
COHORT: C O H O R T;
COLLATE: C O L L A T E;
CROSS: C R O S S;
CUBE: C U B E;
CURRENT: C U R R E N T;
DATE: D A T E;
DAY: D A Y;
DESC: D E S C;
DESCENDING: D E S C E N D I N G;
DISTINCT: D I S T I N C T;
ELSE: E L S E;
END: E N D;
EXCEPT: E X C E P T;
EXTRACT: E X T R A C T;
FINAL: F I N A L;
FINALLY: F I N A L L Y;
FIRST: F I R S T;
FN: F N;
FOLLOWING: F O L L O W I N G;
FOR: F O R;
FROM: F R O M;
FULL: F U L L;
FUN: F U N;
GROUP: G R O U P;
HAVING: H A V I N G;
HOUR: H O U R;
ID: I D;
IF: I F;
ILIKE: I L I K E;
IN: I N;
INF: I N F | I N F I N I T Y;
INNER: I N N E R;
INTERSECT: I N T E R S E C T;
INTERVAL: I N T E R V A L;
IS: I S;
JOIN: J O I N;
KEY: K E Y;
LAST: L A S T;
LEADING: L E A D I N G;
LEFT: L E F T;
LET: L E T;
LIKE: L I K E;
LIMIT: L I M I T;
MINUTE: M I N U T E;
MONTH: M O N T H;
NAN_SQL: N A N; // conflicts with macro NAN
NOT: N O T;
NULL_SQL: N U L L; // conflicts with macro NULL
NULLS: N U L L S;
OFFSET: O F F S E T;
ON: O N;
OR: O R;
ORDER: O R D E R;
OUTER: O U T E R;
OVER: O V E R;
PARTITION: P A R T I T I O N;
PRECEDING: P R E C E D I N G;
PREWHERE: P R E W H E R E;
QUARTER: Q U A R T E R;
RANGE: R A N G E;
RETURN: R E T U R N;
RIGHT: R I G H T;
ROLLUP: R O L L U P;
ROW: R O W;
ROWS: R O W S;
SAMPLE: S A M P L E;
SECOND: S E C O N D;
SELECT: S E L E C T;
SEMI: S E M I;
SETTINGS: S E T T I N G S;
SUBSTRING: S U B S T R I N G;
THEN: T H E N;
THROW: T H R O W;
TIES: T I E S;
TIMESTAMP: T I M E S T A M P;
TO: T O;
TOP: T O P;
TOTALS: T O T A L S;
TRAILING: T R A I L I N G;
TRIM: T R I M;
TRUNCATE: T R U N C A T E;
TRY: T R Y;
UNBOUNDED: U N B O U N D E D;
UNION: U N I O N;
USING: U S I N G;
WEEK: W E E K;
WHEN: W H E N;
WHERE: W H E R E;
WHILE: W H I L E;
WINDOW: W I N D O W;
WITH: W I T H;
YEAR: Y E A R | Y Y Y Y;
// Tokens
// copied from clickhouse_driver/util/escape.py
ESCAPE_CHAR_COMMON
: BACKSLASH B
| BACKSLASH F
| BACKSLASH R
| BACKSLASH N
| BACKSLASH T
| BACKSLASH '0'
| BACKSLASH A
| BACKSLASH V
| BACKSLASH BACKSLASH;
IDENTIFIER
: (LETTER | UNDERSCORE | DOLLAR) (LETTER | UNDERSCORE | DEC_DIGIT | DOLLAR)*
| BACKQUOTE ( ~([\\`]) | ESCAPE_CHAR_COMMON | BACKSLASH QUOTE_SINGLE | (BACKQUOTE BACKQUOTE) )* BACKQUOTE
| QUOTE_DOUBLE ( ~([\\"]) | ESCAPE_CHAR_COMMON | BACKSLASH QUOTE_DOUBLE | (QUOTE_DOUBLE QUOTE_DOUBLE) )* QUOTE_DOUBLE
;
FLOATING_LITERAL
: HEXADECIMAL_LITERAL DOT HEX_DIGIT* (P | E) (PLUS | DASH)? DEC_DIGIT+
| HEXADECIMAL_LITERAL (P | E) (PLUS | DASH)? DEC_DIGIT+
| DECIMAL_LITERAL DOT DEC_DIGIT* E (PLUS | DASH)? DEC_DIGIT+
| DOT DECIMAL_LITERAL E (PLUS | DASH)? DEC_DIGIT+
| DECIMAL_LITERAL E (PLUS | DASH)? DEC_DIGIT+
;
OCTAL_LITERAL: '0' OCT_DIGIT+;
DECIMAL_LITERAL: DEC_DIGIT+;
HEXADECIMAL_LITERAL: '0' X HEX_DIGIT+;
// It's important that quote-symbol is a single character.
STRING_LITERAL: QUOTE_SINGLE ( ~([\\']) | ESCAPE_CHAR_COMMON | BACKSLASH QUOTE_SINGLE | (QUOTE_SINGLE QUOTE_SINGLE) )* QUOTE_SINGLE;
// Alphabet and allowed symbols
fragment A: [aA];
fragment B: [bB];
fragment C: [cC];
fragment D: [dD];
fragment E: [eE];
fragment F: [fF];
fragment G: [gG];
fragment H: [hH];
fragment I: [iI];
fragment J: [jJ];
fragment K: [kK];
fragment L: [lL];
fragment M: [mM];
fragment N: [nN];
fragment O: [oO];
fragment P: [pP];
fragment Q: [qQ];
fragment R: [rR];
fragment S: [sS];
fragment T: [tT];
fragment U: [uU];
fragment V: [vV];
fragment W: [wW];
fragment X: [xX];
fragment Y: [yY];
fragment Z: [zZ];
fragment LETTER: [a-zA-Z];
fragment OCT_DIGIT: [0-7];
fragment DEC_DIGIT: [0-9];
fragment HEX_DIGIT: [0-9a-fA-F];
ARROW: '->';
ASTERISK: '*';
BACKQUOTE: '`';
BACKSLASH: '\\';
COLON: ':';
COMMA: ',';
CONCAT: '||';
DASH: '-';
DOLLAR: '$';
DOT: '.';
EQ_DOUBLE: '==';
EQ_SINGLE: '=';
GT_EQ: '>=';
GT: '>';
HASH: '#';
IREGEX_SINGLE: '~*';
IREGEX_DOUBLE: '=~*';
LBRACE: '{' -> pushMode(DEFAULT_MODE);
LBRACKET: '[';
LPAREN: '(';
LT_EQ: '<=';
TAG_LT_SLASH: '</' -> type(LT_SLASH), pushMode(TSQLX_TAG_CLOSE);
TAG_LT_OPEN: '<' {isOpeningTag()}? -> type(LT), pushMode(TSQLX_TAG_OPEN);
LT: '<';
LT_SLASH: '</';
NOT_EQ: '!=' | '<>';
NOT_IREGEX: '!~*';
NOT_REGEX: '!~';
NULL_PROPERTY: '?.';
NULLISH: '??';
PERCENT: '%';
PLUS: '+';
QUERY: '?';
QUOTE_DOUBLE: '"';
QUOTE_SINGLE_TEMPLATE: 'f\'' -> pushMode(IN_TEMPLATE_STRING); // start of regular f'' template strings
QUOTE_SINGLE_TEMPLATE_FULL: 'F\'' -> pushMode(IN_FULL_TEMPLATE_STRING); // magic F' symbol used to parse "full text" templates
QUOTE_SINGLE: '\'';
REGEX_SINGLE: '~';
REGEX_DOUBLE: '=~';
RBRACE: '}' -> popMode;
RBRACKET: ']';
RPAREN: ')';
SEMICOLON: ';';
SLASH: '/';
SLASH_GT: '/>';
UNDERSCORE: '_';
// Comments and whitespace
MULTI_LINE_COMMENT: '/*' .*? '*/' -> skip;
SINGLE_LINE_COMMENT: ('--' | '//') ~('\n'|'\r')* ('\n' | '\r' | EOF) -> skip;
// whitespace is hidden and not skipped so that it's preserved in ANTLR errors like "no viable alternative"
WHITESPACE: [ \u000B\u000C\t\r\n] -> channel(HIDDEN);
// ───────── f' TEMPLATE STRING MODE ─────────
mode IN_TEMPLATE_STRING;
STRING_TEXT: ((~([\\'{])) | ESCAPE_CHAR_COMMON | BACKSLASH QUOTE_SINGLE | (BACKSLASH LBRACE) | (QUOTE_SINGLE QUOTE_SINGLE))+;
STRING_ESCAPE_TRIGGER: LBRACE -> pushMode(DEFAULT_MODE);
STRING_QUOTE_SINGLE: QUOTE_SINGLE -> type(QUOTE_SINGLE), popMode;
// ───────── F' FULL TEMPLATE STRING MODE ─────────
// a magic F' takes us to "full template strings" mode, where we don't need to escape single quotes and parse until EOF
// this can't be used within a normal columnExpr, but has to be parsed for separately
mode IN_FULL_TEMPLATE_STRING;
FULL_STRING_TEXT: ((~([{])) | ESCAPE_CHAR_COMMON | (BACKSLASH LBRACE))+;
FULL_STRING_ESCAPE_TRIGGER: LBRACE -> pushMode(DEFAULT_MODE);
// ───────── TSQLX TAG MODE for opening/self-closing tags ─────────
mode TSQLX_TAG_OPEN;
TAG_SELF_CLOSE_GT : '/>' -> type(SLASH_GT), popMode; // <tag …/>
TAG_OPEN_GT : '>' -> type(GT), popMode, pushMode(TSQLX_TEXT); // <tag …>
// minimal token set; map everything back to the default token types
TAG_IDENT : [a-zA-Z_][a-zA-Z0-9_-]* -> type(IDENTIFIER);
TAG_EQ : '=' -> type(EQ_SINGLE);
TAG_STRING : STRING_LITERAL -> type(STRING_LITERAL);
TAG_WS : [ \t\r\n]+ -> channel(HIDDEN);
TAG_LBRACE : '{' -> type(LBRACE), pushMode(DEFAULT_MODE);
// ───────── TSQLX TAG MODE for closing tags ─────────
mode TSQLX_TAG_CLOSE;
TAGC_GT : '>' -> type(GT), popMode; // *** no TEXT push ***
TAGC_IDENT : [a-zA-Z_][a-zA-Z0-9_-]* -> type(IDENTIFIER);
TAGC_WS : [ \t\r\n]+ -> channel(HIDDEN);
// ───────── TSQLX TEXT MODE ─────────
mode TSQLX_TEXT;
TSQLX_TEXT_TEXT
: ~[<{]+ ; // everything except “{” or “<”
TSQLX_TEXT_LBRACE
: '{' -> type(LBRACE), pushMode(DEFAULT_MODE);
TSQLX_TEXT_LT_SLASH
: '</' -> type(LT_SLASH), popMode, pushMode(TSQLX_TAG_CLOSE);
TSQLX_TEXT_LT
: '<' -> type(LT), pushMode(TSQLX_TAG_OPEN);
TSQLX_TEXT_WS
: [ \t\r\n]+ -> channel(HIDDEN);
File diff suppressed because one or more lines are too long
@@ -0,0 +1,208 @@
ALL=1
AND=2
ANTI=3
ANY=4
ARRAY=5
AS=6
ASCENDING=7
ASOF=8
BETWEEN=9
BOTH=10
BY=11
CASE=12
CAST=13
CATCH=14
COHORT=15
COLLATE=16
CROSS=17
CUBE=18
CURRENT=19
DATE=20
DAY=21
DESC=22
DESCENDING=23
DISTINCT=24
ELSE=25
END=26
EXCEPT=27
EXTRACT=28
FINAL=29
FINALLY=30
FIRST=31
FN=32
FOLLOWING=33
FOR=34
FROM=35
FULL=36
FUN=37
GROUP=38
HAVING=39
HOUR=40
ID=41
IF=42
ILIKE=43
IN=44
INF=45
INNER=46
INTERSECT=47
INTERVAL=48
IS=49
JOIN=50
KEY=51
LAST=52
LEADING=53
LEFT=54
LET=55
LIKE=56
LIMIT=57
MINUTE=58
MONTH=59
NAN_SQL=60
NOT=61
NULL_SQL=62
NULLS=63
OFFSET=64
ON=65
OR=66
ORDER=67
OUTER=68
OVER=69
PARTITION=70
PRECEDING=71
PREWHERE=72
QUARTER=73
RANGE=74
RETURN=75
RIGHT=76
ROLLUP=77
ROW=78
ROWS=79
SAMPLE=80
SECOND=81
SELECT=82
SEMI=83
SETTINGS=84
SUBSTRING=85
THEN=86
THROW=87
TIES=88
TIMESTAMP=89
TO=90
TOP=91
TOTALS=92
TRAILING=93
TRIM=94
TRUNCATE=95
TRY=96
UNBOUNDED=97
UNION=98
USING=99
WEEK=100
WHEN=101
WHERE=102
WHILE=103
WINDOW=104
WITH=105
YEAR=106
ESCAPE_CHAR_COMMON=107
IDENTIFIER=108
FLOATING_LITERAL=109
OCTAL_LITERAL=110
DECIMAL_LITERAL=111
HEXADECIMAL_LITERAL=112
STRING_LITERAL=113
ARROW=114
ASTERISK=115
BACKQUOTE=116
BACKSLASH=117
COLON=118
COMMA=119
CONCAT=120
DASH=121
DOLLAR=122
DOT=123
EQ_DOUBLE=124
EQ_SINGLE=125
GT_EQ=126
GT=127
HASH=128
IREGEX_SINGLE=129
IREGEX_DOUBLE=130
LBRACE=131
LBRACKET=132
LPAREN=133
LT_EQ=134
LT=135
LT_SLASH=136
NOT_EQ=137
NOT_IREGEX=138
NOT_REGEX=139
NULL_PROPERTY=140
NULLISH=141
PERCENT=142
PLUS=143
QUERY=144
QUOTE_DOUBLE=145
QUOTE_SINGLE_TEMPLATE=146
QUOTE_SINGLE_TEMPLATE_FULL=147
QUOTE_SINGLE=148
REGEX_SINGLE=149
REGEX_DOUBLE=150
RBRACE=151
RBRACKET=152
RPAREN=153
SEMICOLON=154
SLASH=155
SLASH_GT=156
UNDERSCORE=157
MULTI_LINE_COMMENT=158
SINGLE_LINE_COMMENT=159
WHITESPACE=160
STRING_TEXT=161
STRING_ESCAPE_TRIGGER=162
FULL_STRING_TEXT=163
FULL_STRING_ESCAPE_TRIGGER=164
TAG_WS=165
TAGC_WS=166
TSQLX_TEXT_TEXT=167
TSQLX_TEXT_WS=168
'->'=114
'*'=115
'`'=116
'\\'=117
':'=118
','=119
'||'=120
'-'=121
'$'=122
'.'=123
'=='=124
'>='=126
'#'=128
'~*'=129
'=~*'=130
'{'=131
'['=132
'('=133
'<='=134
'<'=135
'</'=136
'!~*'=138
'!~'=139
'?.'=140
'??'=141
'%'=142
'+'=143
'?'=144
'"'=145
'f\''=146
'F\''=147
'\''=148
'~'=149
'=~'=150
'}'=151
']'=152
')'=153
';'=154
'/'=155
'_'=157
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,81 @@
lexer grammar TSQLLexer;
@header {
// put any global imports you need here
}
@members {
private _peekChar(k: number): string {
// Return the k-th look-ahead as a *single-char string* or '\0' at EOF.
const c = this._input.LA(k); // int code point or IntStream.EOF (-1)
if (c < 0 || c > 0x10FFFF) { // EOF or out-of-range → sentinel
return '\0';
}
return String.fromCharCode(c);
}
private _skipWsAndComments(idx: number): number {
// Return the first index ≥ idx that is *not* whitespace / single-line comment.
while (true) {
const ch = this._peekChar(idx);
if (/\s/.test(ch)) { // spaces, newlines, tabs …
idx++;
continue;
}
// single-line comments
if (ch === '/' && this._peekChar(idx + 1) === '/') { // //
idx += 2;
} else if (ch === '-' && this._peekChar(idx + 1) === '-') { // --
idx += 2;
} else if (ch === '#') { // #
idx++;
} else {
break; // no ws / comment
}
// consume until EOL / EOF
while (!['\0', '\n', '\r'].includes(this._peekChar(idx))) {
idx++;
}
}
return idx;
}
// ───── opening tag test ─────
isOpeningTag(): boolean {
const ch1 = this._peekChar(1);
if (!(/[a-zA-Z]/.test(ch1) || ch1 === '_')) {
return false; // not a tag name start
}
// skip tag name
let i = 2;
while (true) {
const ch = this._peekChar(i);
if (/[a-zA-Z0-9]/.test(ch) || ch === '_' || ch === '-') {
i++;
} else {
break;
}
}
let ch = this._peekChar(i);
// immediate delimiter → tag
if (ch === '>' || ch === '/') {
return true;
}
// need to look beyond whitespace
if (/\s/.test(ch)) {
i = this._skipWsAndComments(i + 1);
ch = this._peekChar(i);
return ch === '>' || ch === '/' || /[a-zA-Z0-9]/.test(ch) || ch === '_';
}
// anything else → not a tag
return false;
}
}
@@ -0,0 +1,315 @@
parser grammar TSQLParser;
options {
tokenVocab = TSQLLexer;
}
program: declaration* EOF;
declaration: varDecl | statement ;
expression: columnExpr;
varDecl: LET identifier ( COLON EQ_SINGLE expression )? ;
identifierList: identifier (COMMA identifier)* COMMA?;
statement : returnStmt
| throwStmt
| tryCatchStmt
| ifStmt
| whileStmt
| forInStmt
| forStmt
| funcStmt
| varAssignment
| block
| exprStmt
| emptyStmt
;
returnStmt : RETURN expression? SEMICOLON?;
throwStmt : THROW expression? SEMICOLON?;
catchBlock : CATCH (LPAREN catchVar=identifier (COLON catchType=identifier)? RPAREN)? catchStmt=block;
tryCatchStmt : TRY tryStmt=block catchBlock* (FINALLY finallyStmt=block)?;
ifStmt : IF LPAREN expression RPAREN statement ( ELSE statement )? ;
whileStmt : WHILE LPAREN expression RPAREN statement SEMICOLON?;
forStmt : FOR LPAREN
(initializerVarDeclr=varDecl | initializerVarAssignment=varAssignment | initializerExpression=expression)? SEMICOLON
condition=expression? SEMICOLON
(incrementVarDeclr=varDecl | incrementVarAssignment=varAssignment | incrementExpression=expression)?
RPAREN statement SEMICOLON?;
forInStmt : FOR LPAREN LET identifier (COMMA identifier)? IN expression RPAREN statement SEMICOLON?;
funcStmt : (FN | FUN) identifier LPAREN identifierList? RPAREN block;
varAssignment : expression COLON EQ_SINGLE expression ;
exprStmt : expression SEMICOLON?;
emptyStmt : SEMICOLON ;
block : LBRACE declaration* RBRACE ;
kvPair: expression ':' expression ;
kvPairList: kvPair (COMMA kvPair)* COMMA?;
// SELECT statement
select: (selectSetStmt | selectStmt | tSQLxTagElement) SEMICOLON? EOF;
selectStmtWithParens: selectStmt | LPAREN selectSetStmt RPAREN | placeholder;
subsequentSelectSetClause: (EXCEPT | UNION ALL | UNION DISTINCT | INTERSECT | INTERSECT DISTINCT) selectStmtWithParens;
selectSetStmt: selectStmtWithParens (subsequentSelectSetClause)*;
selectStmt:
with=withClause?
SELECT DISTINCT? topClause?
columns=columnExprList
from=fromClause?
arrayJoinClause?
prewhereClause?
where=whereClause?
groupByClause? (WITH (CUBE | ROLLUP))? (WITH TOTALS)?
havingClause?
windowClause?
orderByClause?
limitByClause?
(limitAndOffsetClause | offsetOnlyClause)?
settingsClause?
;
withClause: WITH withExprList;
topClause: TOP DECIMAL_LITERAL (WITH TIES)?;
fromClause: FROM joinExpr;
arrayJoinClause: (LEFT | INNER)? ARRAY JOIN columnExprList;
windowClause: WINDOW identifier AS LPAREN windowExpr RPAREN (COMMA identifier AS LPAREN windowExpr RPAREN)*;
prewhereClause: PREWHERE columnExpr;
whereClause: WHERE columnExpr;
groupByClause: GROUP BY ((CUBE | ROLLUP) LPAREN columnExprList RPAREN | columnExprList);
havingClause: HAVING columnExpr;
orderByClause: ORDER BY orderExprList;
projectionOrderByClause: ORDER BY columnExprList;
limitByClause: LIMIT limitExpr BY columnExprList;
limitAndOffsetClause
: LIMIT columnExpr (COMMA columnExpr)? (WITH TIES)? // compact OFFSET-optional form
| LIMIT columnExpr (WITH TIES)? OFFSET columnExpr // verbose OFFSET-included form with WITH TIES
;
offsetOnlyClause: OFFSET columnExpr;
settingsClause: SETTINGS settingExprList;
joinExpr
: joinExpr joinOp? JOIN joinExpr joinConstraintClause # JoinExprOp
| joinExpr joinOpCross joinExpr # JoinExprCrossOp
| tableExpr FINAL? sampleClause? # JoinExprTable
| LPAREN joinExpr RPAREN # JoinExprParens
;
joinOp
: ((ALL | ANY | ASOF)? INNER | INNER (ALL | ANY | ASOF)? | (ALL | ANY | ASOF)) # JoinOpInner
| ( (SEMI | ALL | ANTI | ANY | ASOF)? (LEFT | RIGHT) OUTER?
| (LEFT | RIGHT) OUTER? (SEMI | ALL | ANTI | ANY | ASOF)?
) # JoinOpLeftRight
| ((ALL | ANY)? FULL OUTER? | FULL OUTER? (ALL | ANY)?) # JoinOpFull
;
joinOpCross
: CROSS JOIN
| COMMA
;
joinConstraintClause
: ON columnExprList
| USING LPAREN columnExprList RPAREN
| USING columnExprList
;
sampleClause: SAMPLE ratioExpr (OFFSET ratioExpr)?;
limitExpr: columnExpr ((COMMA | OFFSET) columnExpr)?;
orderExprList: orderExpr (COMMA orderExpr)*;
orderExpr: columnExpr (ASCENDING | DESCENDING | DESC)? (NULLS (FIRST | LAST))? (COLLATE STRING_LITERAL)?;
ratioExpr: placeholder | numberLiteral (SLASH numberLiteral)?;
settingExprList: settingExpr (COMMA settingExpr)*;
settingExpr: identifier EQ_SINGLE literal;
windowExpr: winPartitionByClause? winOrderByClause? winFrameClause?;
winPartitionByClause: PARTITION BY columnExprList;
winOrderByClause: ORDER BY orderExprList;
winFrameClause: (ROWS | RANGE) winFrameExtend;
winFrameExtend
: winFrameBound # frameStart
| BETWEEN winFrameBound AND winFrameBound # frameBetween
;
winFrameBound: (CURRENT ROW | UNBOUNDED PRECEDING | UNBOUNDED FOLLOWING | numberLiteral PRECEDING | numberLiteral FOLLOWING);
//rangeClause: RANGE LPAREN (MIN identifier MAX identifier | MAX identifier MIN identifier) RPAREN;
// Columns
expr: columnExpr EOF;
columnTypeExpr
: identifier # ColumnTypeExprSimple // UInt64
| identifier LPAREN identifier columnTypeExpr (COMMA identifier columnTypeExpr)* COMMA? RPAREN # ColumnTypeExprNested // Nested
| identifier LPAREN enumValue (COMMA enumValue)* COMMA? RPAREN # ColumnTypeExprEnum // Enum
| identifier LPAREN columnTypeExpr (COMMA columnTypeExpr)* COMMA? RPAREN # ColumnTypeExprComplex // Array, Tuple
| identifier LPAREN columnExprList? RPAREN # ColumnTypeExprParam // FixedString(N)
;
columnExprList: columnExpr (COMMA columnExpr)* COMMA?;
columnExpr
: CASE caseExpr=columnExpr? (WHEN whenExpr=columnExpr THEN thenExpr=columnExpr)+ (ELSE elseExpr=columnExpr)? END # ColumnExprCase
| CAST LPAREN columnExpr AS columnTypeExpr RPAREN # ColumnExprCast
| DATE STRING_LITERAL # ColumnExprDate
// | EXTRACT LPAREN interval FROM columnExpr RPAREN # ColumnExprExtract // Interferes with a function call
| INTERVAL STRING_LITERAL # ColumnExprIntervalString
| INTERVAL columnExpr interval # ColumnExprInterval
| SUBSTRING LPAREN columnExpr FROM columnExpr (FOR columnExpr)? RPAREN # ColumnExprSubstring
| TIMESTAMP STRING_LITERAL # ColumnExprTimestamp
| TRIM LPAREN (BOTH | LEADING | TRAILING) string FROM columnExpr RPAREN # ColumnExprTrim
| identifier (LPAREN columnExprs=columnExprList? RPAREN) (LPAREN DISTINCT? columnArgList=columnExprList? RPAREN)? OVER LPAREN windowExpr RPAREN # ColumnExprWinFunction
| identifier (LPAREN columnExprs=columnExprList? RPAREN) (LPAREN DISTINCT? columnArgList=columnExprList? RPAREN)? OVER identifier # ColumnExprWinFunctionTarget
| identifier (LPAREN columnExprs=columnExprList? RPAREN)? LPAREN DISTINCT? columnArgList=columnExprList? RPAREN # ColumnExprFunction
| columnExpr LPAREN selectSetStmt RPAREN # ColumnExprCallSelect
| columnExpr LPAREN columnExprList? RPAREN # ColumnExprCall
| tSQLxTagElement # ColumnExprTagElement
| templateString # ColumnExprTemplateString
| literal # ColumnExprLiteral
// FIXME(ilezhankin): this part looks very ugly, maybe there is another way to express it
| columnExpr LBRACKET columnExpr RBRACKET # ColumnExprArrayAccess
| columnExpr DOT DECIMAL_LITERAL # ColumnExprTupleAccess
| columnExpr DOT identifier # ColumnExprPropertyAccess
| columnExpr NULL_PROPERTY LBRACKET columnExpr RBRACKET # ColumnExprNullArrayAccess
| columnExpr NULL_PROPERTY DECIMAL_LITERAL # ColumnExprNullTupleAccess
| columnExpr NULL_PROPERTY identifier # ColumnExprNullPropertyAccess
| DASH columnExpr # ColumnExprNegate
| left=columnExpr ( operator=ASTERISK // *
| operator=SLASH // /
| operator=PERCENT // %
) right=columnExpr # ColumnExprPrecedence1
| left=columnExpr ( operator=PLUS // +
| operator=DASH // -
| operator=CONCAT // ||
) right=columnExpr # ColumnExprPrecedence2
| left=columnExpr ( operator=EQ_DOUBLE // =
| operator=EQ_SINGLE // ==
| operator=NOT_EQ // !=
| operator=LT_EQ // <=
| operator=LT // <
| operator=GT_EQ // >=
| operator=GT // >
| operator=NOT? IN COHORT? // in, not in; in cohort; not in cohort
| operator=NOT? (LIKE | ILIKE) // like, not like, ilike, not ilike
| operator=REGEX_SINGLE // ~
| operator=REGEX_DOUBLE // =~
| operator=NOT_REGEX // !~
| operator=IREGEX_SINGLE // ~*
| operator=IREGEX_DOUBLE // =~*
| operator=NOT_IREGEX // !~*
) right=columnExpr # ColumnExprPrecedence3
| columnExpr IS NOT? NULL_SQL # ColumnExprIsNull
| columnExpr NULLISH columnExpr # ColumnExprNullish
| NOT columnExpr # ColumnExprNot
| columnExpr AND columnExpr # ColumnExprAnd
| columnExpr OR columnExpr # ColumnExprOr
// TODO(ilezhankin): `BETWEEN a AND b AND c` is parsed in a wrong way: `BETWEEN (a AND b) AND c`
| columnExpr NOT? BETWEEN columnExpr AND columnExpr # ColumnExprBetween
| <assoc=right> columnExpr QUERY columnExpr COLON columnExpr # ColumnExprTernaryOp
| columnExpr (AS identifier | AS STRING_LITERAL) # ColumnExprAlias
| (tableIdentifier DOT)? ASTERISK # ColumnExprAsterisk // single-column only
| LPAREN selectSetStmt RPAREN # ColumnExprSubquery // single-column only
| LPAREN columnExpr RPAREN # ColumnExprParens // single-column only
| LPAREN columnExprList RPAREN # ColumnExprTuple
| LBRACKET columnExprList? RBRACKET # ColumnExprArray
| LBRACE (kvPairList)? RBRACE # ColumnExprDict
| columnLambdaExpr # ColumnExprLambda
| columnIdentifier # ColumnExprIdentifier
;
columnLambdaExpr:
( LPAREN identifier (COMMA identifier)* COMMA? RPAREN
| identifier (COMMA identifier)* COMMA?
| LPAREN RPAREN
)
ARROW (columnExpr | block)
;
tSQLxChildElement
: tSQLxTagElement
| TSQLX_TEXT_TEXT
| LBRACE columnExpr RBRACE;
tSQLxTagElement
: LT identifier tSQLxTagAttribute* SLASH_GT
| LT identifier tSQLxTagAttribute* GT tSQLxChildElement* LT_SLASH identifier GT
;
tSQLxTagAttribute
: identifier EQ_SINGLE string
| identifier EQ_SINGLE LBRACE columnExpr RBRACE
| identifier
;
withExprList: withExpr (COMMA withExpr)* COMMA?;
withExpr
: identifier AS LPAREN selectSetStmt RPAREN # WithExprSubquery
// NOTE: asterisk and subquery goes before |columnExpr| so that we can mark them as multi-column expressions.
| columnExpr AS identifier # WithExprColumn
;
// This is slightly different in TSQL compared to ClickHouse SQL
// TSQL allows unlimited ("*") nestedIdentifier-s "properties.b.a.a.w.a.s".
// We parse and convert "databaseIdentifier.tableIdentifier.columnIdentifier.nestedIdentifier.*"
// to just one ast.Field(chain=['a','b','columnIdentifier','on','and','on']).
columnIdentifier: placeholder | ((tableIdentifier DOT)? nestedIdentifier);
nestedIdentifier: identifier (DOT identifier)*;
tableExpr
: tableIdentifier # TableExprIdentifier
| tableFunctionExpr # TableExprFunction
| LPAREN selectSetStmt RPAREN # TableExprSubquery
| tableExpr (alias | AS identifier) # TableExprAlias
| tSQLxTagElement # TableExprTag
| placeholder # TableExprPlaceholder
;
tableFunctionExpr: identifier LPAREN tableArgList? RPAREN;
tableIdentifier: (databaseIdentifier DOT)? nestedIdentifier;
tableArgList: columnExpr (COMMA columnExpr)* COMMA?;
// Databases
databaseIdentifier: identifier;
// Basics
floatingLiteral
: FLOATING_LITERAL
| DOT (DECIMAL_LITERAL | OCTAL_LITERAL)
| DECIMAL_LITERAL DOT (DECIMAL_LITERAL | OCTAL_LITERAL)? // can't move this to the lexer or it will break nested tuple access: t.1.2
;
numberLiteral: (PLUS | DASH)? (floatingLiteral | OCTAL_LITERAL | DECIMAL_LITERAL | HEXADECIMAL_LITERAL | INF | NAN_SQL);
literal
: numberLiteral
| STRING_LITERAL
| NULL_SQL
;
interval: SECOND | MINUTE | HOUR | DAY | WEEK | MONTH | QUARTER | YEAR;
keyword
// except NULL_SQL, INF, NAN_SQL
: ALL | AND | ANTI | ANY | ARRAY | AS | ASCENDING | ASOF | BETWEEN | BOTH | BY | CASE
| CAST | COHORT | COLLATE | CROSS | CUBE | CURRENT | DATE | DESC | DESCENDING
| DISTINCT | ELSE | END | EXTRACT | FINAL | FIRST
| FOR | FOLLOWING | FROM | FULL | GROUP | HAVING | ID | IS
| IF | ILIKE | IN | INNER | INTERVAL | JOIN | KEY
| LAST | LEADING | LEFT | LIKE | LIMIT
| NOT | NULLS | OFFSET | ON | OR | ORDER | OUTER | OVER | PARTITION
| PRECEDING | PREWHERE | RANGE | RETURN | RIGHT | ROLLUP | ROW
| ROWS | SAMPLE | SELECT | SEMI | SETTINGS | SUBSTRING
| THEN | TIES | TIMESTAMP | TOTALS | TRAILING | TRIM | TRUNCATE | TO | TOP
| UNBOUNDED | UNION | USING | WHEN | WHERE | WINDOW | WITH
;
keywordForAlias
: DATE | FIRST | ID | KEY
;
alias: IDENTIFIER | keywordForAlias; // |interval| can't be an alias, otherwise 'INTERVAL 1 SOMETHING' becomes ambiguous.
identifier: IDENTIFIER | interval | keyword;
enumValue: string EQ_SINGLE numberLiteral;
placeholder: LBRACE columnExpr RBRACE;
string: STRING_LITERAL | templateString;
templateString : QUOTE_SINGLE_TEMPLATE stringContents* QUOTE_SINGLE ;
stringContents : STRING_ESCAPE_TRIGGER columnExpr RBRACE | STRING_TEXT;
// These are magic "full template strings", which are used to parse "full text field" templates without the surrounding SQL.
// We will need to add F' to the start of the string to change the lexer's mode.
fullTemplateString: QUOTE_SINGLE_TEMPLATE_FULL stringContentsFull* EOF ;
stringContentsFull : FULL_STRING_ESCAPE_TRIGGER columnExpr RBRACE | FULL_STRING_TEXT;
File diff suppressed because one or more lines are too long
@@ -0,0 +1,208 @@
ALL=1
AND=2
ANTI=3
ANY=4
ARRAY=5
AS=6
ASCENDING=7
ASOF=8
BETWEEN=9
BOTH=10
BY=11
CASE=12
CAST=13
CATCH=14
COHORT=15
COLLATE=16
CROSS=17
CUBE=18
CURRENT=19
DATE=20
DAY=21
DESC=22
DESCENDING=23
DISTINCT=24
ELSE=25
END=26
EXCEPT=27
EXTRACT=28
FINAL=29
FINALLY=30
FIRST=31
FN=32
FOLLOWING=33
FOR=34
FROM=35
FULL=36
FUN=37
GROUP=38
HAVING=39
HOUR=40
ID=41
IF=42
ILIKE=43
IN=44
INF=45
INNER=46
INTERSECT=47
INTERVAL=48
IS=49
JOIN=50
KEY=51
LAST=52
LEADING=53
LEFT=54
LET=55
LIKE=56
LIMIT=57
MINUTE=58
MONTH=59
NAN_SQL=60
NOT=61
NULL_SQL=62
NULLS=63
OFFSET=64
ON=65
OR=66
ORDER=67
OUTER=68
OVER=69
PARTITION=70
PRECEDING=71
PREWHERE=72
QUARTER=73
RANGE=74
RETURN=75
RIGHT=76
ROLLUP=77
ROW=78
ROWS=79
SAMPLE=80
SECOND=81
SELECT=82
SEMI=83
SETTINGS=84
SUBSTRING=85
THEN=86
THROW=87
TIES=88
TIMESTAMP=89
TO=90
TOP=91
TOTALS=92
TRAILING=93
TRIM=94
TRUNCATE=95
TRY=96
UNBOUNDED=97
UNION=98
USING=99
WEEK=100
WHEN=101
WHERE=102
WHILE=103
WINDOW=104
WITH=105
YEAR=106
ESCAPE_CHAR_COMMON=107
IDENTIFIER=108
FLOATING_LITERAL=109
OCTAL_LITERAL=110
DECIMAL_LITERAL=111
HEXADECIMAL_LITERAL=112
STRING_LITERAL=113
ARROW=114
ASTERISK=115
BACKQUOTE=116
BACKSLASH=117
COLON=118
COMMA=119
CONCAT=120
DASH=121
DOLLAR=122
DOT=123
EQ_DOUBLE=124
EQ_SINGLE=125
GT_EQ=126
GT=127
HASH=128
IREGEX_SINGLE=129
IREGEX_DOUBLE=130
LBRACE=131
LBRACKET=132
LPAREN=133
LT_EQ=134
LT=135
LT_SLASH=136
NOT_EQ=137
NOT_IREGEX=138
NOT_REGEX=139
NULL_PROPERTY=140
NULLISH=141
PERCENT=142
PLUS=143
QUERY=144
QUOTE_DOUBLE=145
QUOTE_SINGLE_TEMPLATE=146
QUOTE_SINGLE_TEMPLATE_FULL=147
QUOTE_SINGLE=148
REGEX_SINGLE=149
REGEX_DOUBLE=150
RBRACE=151
RBRACKET=152
RPAREN=153
SEMICOLON=154
SLASH=155
SLASH_GT=156
UNDERSCORE=157
MULTI_LINE_COMMENT=158
SINGLE_LINE_COMMENT=159
WHITESPACE=160
STRING_TEXT=161
STRING_ESCAPE_TRIGGER=162
FULL_STRING_TEXT=163
FULL_STRING_ESCAPE_TRIGGER=164
TAG_WS=165
TAGC_WS=166
TSQLX_TEXT_TEXT=167
TSQLX_TEXT_WS=168
'->'=114
'*'=115
'`'=116
'\\'=117
':'=118
','=119
'||'=120
'-'=121
'$'=122
'.'=123
'=='=124
'>='=126
'#'=128
'~*'=129
'=~*'=130
'{'=131
'['=132
'('=133
'<='=134
'<'=135
'</'=136
'!~*'=138
'!~'=139
'?.'=140
'??'=141
'%'=142
'+'=143
'?'=144
'"'=145
'f\''=146
'F\''=147
'\''=148
'~'=149
'=~'=150
'}'=151
']'=152
')'=153
';'=154
'/'=155
'_'=157
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,166 @@
import { describe, it, expect } from "vitest";
import { CharStreams, CommonTokenStream } from "antlr4ts";
import { TSQLLexer } from "./TSQLLexer.js";
import { TSQLParser } from "./TSQLParser.js";
describe("TSQLParser", () => {
function parse(input: string) {
const inputStream = CharStreams.fromString(input);
const lexer = new TSQLLexer(inputStream);
const tokenStream = new CommonTokenStream(lexer);
const parser = new TSQLParser(tokenStream);
return parser;
}
describe("select statements", () => {
it("should parse a simple SELECT statement", () => {
const parser = parse("SELECT * FROM users;");
const tree = parser.select();
expect(tree).toBeDefined();
// The select rule can return selectStmt, selectSetStmt, or tSQLxTagElement
// Most SELECT statements are wrapped in selectSetStmt
const selectSetStmt = tree.selectSetStmt();
expect(selectSetStmt).toBeDefined();
// Get the underlying selectStmt from selectSetStmt -> selectStmtWithParens
const selectStmtWithParens = selectSetStmt!.selectStmtWithParens();
const selectStmt = selectStmtWithParens.selectStmt();
expect(selectStmt).toBeDefined();
expect(selectStmt!.SELECT()).toBeDefined();
expect(selectStmt!.columnExprList()).toBeDefined();
expect(selectStmt!.fromClause()).toBeDefined();
});
it("should parse SELECT with WHERE clause", () => {
const parser = parse("SELECT id, name FROM users WHERE id = 1;");
const tree = parser.select();
expect(tree).toBeDefined();
const selectSetStmt = tree.selectSetStmt();
const selectStmt = selectSetStmt!.selectStmtWithParens().selectStmt()!;
expect(selectStmt.whereClause()).toBeDefined();
expect(selectStmt.whereClause()!.WHERE()).toBeDefined();
expect(selectStmt.whereClause()!.columnExpr()).toBeDefined();
});
it("should parse SELECT with multiple columns", () => {
const parser = parse("SELECT id, name, email FROM users;");
const tree = parser.select();
expect(tree).toBeDefined();
const selectSetStmt = tree.selectSetStmt();
const selectStmt = selectSetStmt!.selectStmtWithParens().selectStmt()!;
const columnList = selectStmt.columnExprList();
expect(columnList).toBeDefined();
// columnExprList contains comma-separated expressions
expect(columnList.columnExpr().length).toBeGreaterThanOrEqual(1);
});
it("should parse SELECT with DISTINCT", () => {
const parser = parse("SELECT DISTINCT id FROM users;");
const tree = parser.select();
expect(tree).toBeDefined();
const selectSetStmt = tree.selectSetStmt();
const selectStmt = selectSetStmt!.selectStmtWithParens().selectStmt()!;
expect(selectStmt.DISTINCT()).toBeDefined();
});
});
describe("expressions", () => {
it("should parse a simple addition expression", () => {
const parser = parse("1 + 2");
const tree = parser.expr();
expect(tree).toBeDefined();
const columnExpr = tree.columnExpr();
expect(columnExpr).toBeDefined();
// Check that the expression has children (the operands and operator)
expect(columnExpr.text).toBeDefined();
const text = columnExpr.text;
expect(text).toContain("1");
expect(text).toContain("2");
expect(text).toContain("+");
});
it("should parse arithmetic expressions with parentheses", () => {
const parser = parse("(1 + 2) * 3");
const tree = parser.expr();
expect(tree).toBeDefined();
const columnExpr = tree.columnExpr();
expect(columnExpr).toBeDefined();
// The expression should contain the operators
const text = columnExpr.text;
expect(text).toContain("+");
expect(text).toContain("*");
expect(text).toContain("1");
expect(text).toContain("2");
expect(text).toContain("3");
});
it("should parse string literals", () => {
const parser = parse("'hello world'");
const tree = parser.expr();
expect(tree).toBeDefined();
const columnExpr = tree.columnExpr();
expect(columnExpr).toBeDefined();
const text = columnExpr.text;
expect(text).toContain("hello");
expect(text).toContain("world");
});
it("should parse numeric literals", () => {
const parser = parse("42");
const tree = parser.expr();
expect(tree).toBeDefined();
const columnExpr = tree.columnExpr();
expect(columnExpr).toBeDefined();
expect(columnExpr.text).toContain("42");
});
});
describe("program", () => {
it("should parse an empty program", () => {
const parser = parse("");
const tree = parser.program();
expect(tree).toBeDefined();
expect(tree.EOF()).toBeDefined();
expect(tree.declaration().length).toBe(0);
});
it("should parse variable declarations", () => {
const parser = parse("let x := 1");
const tree = parser.program();
expect(tree).toBeDefined();
expect(tree.declaration().length).toBe(1);
const declaration = tree.declaration(0);
// Check if it's a varDecl
const varDecl = declaration.varDecl();
expect(varDecl).toBeDefined();
expect(varDecl!.LET()).toBeDefined();
expect(varDecl!.identifier()).toBeDefined();
expect(varDecl!.expression()).toBeDefined();
});
it("should parse multiple declarations", () => {
const parser = parse("let x := 1; let y := 2");
const tree = parser.program();
expect(tree).toBeDefined();
// Count only non-empty declarations (varDecl or non-empty statements)
const varDecls = tree.declaration().filter((d) => d.varDecl() !== undefined);
expect(varDecls.length).toBe(2);
});
});
});
+277
View File
@@ -0,0 +1,277 @@
// TSQL - Type-Safe SQL Query Language for ClickHouse
// Originally derived from PostHog's HogQL (see NOTICE.md for attribution)
import type { ANTLRErrorListener, RecognitionException, Recognizer } from "antlr4ts";
import { CharStreams, CommonTokenStream } from "antlr4ts";
import type { Token } from "antlr4ts/Token";
import { TSQLLexer } from "./grammar/TSQLLexer.js";
import { TSQLParser } from "./grammar/TSQLParser.js";
import type { Expression, SelectQuery, SelectSetQuery } from "./query/ast.js";
import { SyntaxError as TSQLSyntaxError } from "./query/errors.js";
import { TSQLParseTreeConverter } from "./query/parser.js";
import { printToClickHouse, type PrintResult } from "./query/printer.js";
import { createPrinterContext, type QuerySettings } from "./query/printer_context.js";
import { createSchemaRegistry, type FieldMappings, type TableSchema } from "./query/schema.js";
/**
* Simple error listener that captures syntax errors
*/
class TSQLErrorListener implements ANTLRErrorListener<Token> {
public error: string | null = null;
syntaxError(
_recognizer: Recognizer<Token, any>,
_offendingSymbol: Token | undefined,
line: number,
charPositionInLine: number,
msg: string,
_e: RecognitionException | undefined
): void {
this.error = `Syntax error at line ${line}:${charPositionInLine}: ${msg}`;
}
}
// Re-export AST types
export * from "./query/ast.js";
// Re-export errors
export * from "./query/errors.js";
// Re-export escape utilities
export {
escapeClickHouseIdentifier,
escapeClickHouseString,
escapeTSQLIdentifier,
escapeTSQLString,
getClickHouseType,
} from "./query/escape.js";
// Re-export function definitions
export {
findTSQLAggregation,
findTSQLFunction,
getAllExposedFunctionNames,
TSQL_AGGREGATIONS,
TSQL_CLICKHOUSE_FUNCTIONS,
TSQL_COMPARISON_MAPPING,
type TSQLFunctionMeta,
} from "./query/functions.js";
// Re-export schema types and functions
export {
column,
createSchemaRegistry,
findColumn,
findTable,
getAllowedUserValues,
getExternalValue,
getInternalValue,
getInternalValueFromMapping,
getInternalValueFromMappingCaseInsensitive,
// Value mapping utilities
getUserFriendlyValue,
getVirtualColumnExpression,
// Field mapping utilities (runtime dynamic mappings)
hasFieldMapping,
isValidUserValue,
// Virtual column utilities
isVirtualColumn,
validateFilterColumn,
validateGroupColumn,
validateSelectColumn,
validateSortColumn,
validateTable,
type ClickHouseType,
type ColumnSchema,
type FieldMappings,
type OutputColumnMetadata,
type RequiredFilter,
type SchemaRegistry,
type TableSchema,
type TenantColumnConfig,
} from "./query/schema.js";
// Re-export printer context
export {
createPrinterContext,
DEFAULT_QUERY_SETTINGS,
PrinterContext,
type PrinterContextOptions,
type QueryNotice,
type QuerySettings,
} from "./query/printer_context.js";
// Re-export printer
export { ClickHousePrinter, printToClickHouse, type PrintResult } from "./query/printer.js";
// Re-export parser converter for advanced usage
export { TSQLParseTreeConverter } from "./query/parser.js";
// Re-export validator
export {
validateQuery,
type ValidationIssue,
type ValidationResult,
type ValidationSeverity,
} from "./query/validator.js";
// Re-export result transformation utilities
export {
createResultTransformer,
transformResults,
type TransformResultsOptions,
} from "./query/results.js";
/**
* Parse a TSQL SELECT query string into an AST
*
* @param query - The TSQL query string to parse
* @returns The parsed AST (SelectQuery or SelectSetQuery)
* @throws TSQLSyntaxError if the query is invalid
*
* @example
* ```typescript
* const ast = parseTSQLSelect("SELECT * FROM users WHERE id = 1");
* ```
*/
export function parseTSQLSelect(query: string): SelectQuery | SelectSetQuery {
const inputStream = CharStreams.fromString(query);
const lexer = new TSQLLexer(inputStream);
const tokenStream = new CommonTokenStream(lexer);
const parser = new TSQLParser(tokenStream);
// Remove default error listeners and add custom one
parser.removeErrorListeners();
const errorListener = new TSQLErrorListener();
parser.addErrorListener(errorListener);
const parseTree = parser.select();
if (errorListener.error) {
throw new TSQLSyntaxError(errorListener.error);
}
const converter = new TSQLParseTreeConverter();
const ast = converter.visit(parseTree);
// Validate the result is a select query
if (typeof ast === "string" || !("expression_type" in ast)) {
throw new TSQLSyntaxError("Failed to parse SELECT query");
}
if (ast.expression_type !== "select_query" && ast.expression_type !== "select_set_query") {
throw new TSQLSyntaxError(`Expected SELECT query, got ${ast.expression_type}`);
}
return ast as SelectQuery | SelectSetQuery;
}
/**
* Parse a TSQL expression string into an AST
*
* @param expr - The TSQL expression string to parse
* @returns The parsed expression AST
* @throws TSQLSyntaxError if the expression is invalid
*
* @example
* ```typescript
* const ast = parseTSQLExpr("id = 1 AND name = 'test'");
* ```
*/
export function parseTSQLExpr(expr: string): Expression {
const inputStream = CharStreams.fromString(expr);
const lexer = new TSQLLexer(inputStream);
const tokenStream = new CommonTokenStream(lexer);
const parser = new TSQLParser(tokenStream);
// Remove default error listeners and add custom one
parser.removeErrorListeners();
const errorListener = new TSQLErrorListener();
parser.addErrorListener(errorListener);
const parseTree = parser.columnExpr(0);
if (errorListener.error) {
throw new TSQLSyntaxError(errorListener.error);
}
const converter = new TSQLParseTreeConverter();
return converter.visit(parseTree) as Expression;
}
/**
* Options for compiling a TSQL query to ClickHouse SQL
*/
export interface CompileTSQLOptions {
/** The organization ID for tenant isolation (required) */
organizationId: string;
/** The project ID for tenant isolation (optional - omit to query across all projects) */
projectId?: string;
/** The environment ID for tenant isolation (optional - omit to query across all environments) */
environmentId?: string;
/** Schema definitions for allowed tables and columns */
tableSchema: TableSchema[];
/** Optional query settings */
settings?: Partial<QuerySettings>;
/**
* Runtime field mappings for dynamic value translation.
* Maps internal ClickHouse values to external user-facing values.
*
* @example
* ```typescript
* {
* project: { "cm12345": "my-project-ref" },
* }
* ```
*/
fieldMappings?: FieldMappings;
}
/**
* Compile a TSQL query string to ClickHouse SQL with parameters
*
* This function:
* 1. Parses the TSQL query into an AST
* 2. Validates tables and columns against the schema
* 3. Injects tenant isolation WHERE clauses
* 4. Generates parameterized ClickHouse SQL
*
* @param query - The TSQL query string to compile
* @param options - Compilation options including tenant IDs and schema
* @returns The compiled SQL and parameters
* @throws TSQLSyntaxError if the query is invalid
* @throws QueryError if tables/columns are not allowed
*
* @example
* ```typescript
* const { sql, params } = compileTSQL(
* "SELECT * FROM task_runs WHERE status = 'completed' LIMIT 100",
* {
* organizationId: "org_123",
* projectId: "proj_456",
* environmentId: "env_789",
* tableSchema: [taskRunsSchema],
* }
* );
* ```
*/
export function compileTSQL(query: string, options: CompileTSQLOptions): PrintResult {
// 1. Parse the TSQL query
const ast = parseTSQLSelect(query);
// 2. Create schema registry from table schemas
const schemaRegistry = createSchemaRegistry(options.tableSchema);
// 3. Create printer context with tenant IDs and field mappings
const context = createPrinterContext({
organizationId: options.organizationId,
projectId: options.projectId,
environmentId: options.environmentId,
schema: schemaRegistry,
settings: options.settings,
fieldMappings: options.fieldMappings,
});
// 4. Print the AST to ClickHouse SQL
return printToClickHouse(ast, context);
}
+672
View File
@@ -0,0 +1,672 @@
// TypeScript translation of posthog/hogql/ast.py
import type { TSQLContext } from "./context";
import type {
DatabaseField,
ExpressionField,
FieldOrTable,
FieldTraverser,
LazyJoin,
LazyTable,
StringArrayDatabaseField,
StringJSONDatabaseField,
Table,
UnknownDatabaseField,
VirtualTable,
} from "./models";
import type { ConstantDataType, TSQLQuerySettings } from "./constants";
// Base types
export interface AST {
start?: number;
end?: number;
accept?(visitor: any): any;
}
export interface Type extends AST {
get_child?(name: string, context: TSQLContext): Type;
has_child?(name: string, context: TSQLContext): boolean;
resolve_constant_type?(context: TSQLContext): ConstantType;
resolve_column_constant_type?(name: string, context: TSQLContext): ConstantType;
}
export interface Expr extends AST {
type?: Type;
}
export interface ConstantType extends Type {
data_type: ConstantDataType;
nullable?: boolean;
print_type?(): string;
}
export interface UnknownType extends ConstantType {
data_type: "unknown";
}
export type Expression =
| CTE
| Alias
| ArithmeticOperation
| And
| Or
| CompareOperation
| Not
| BetweenExpr
| OrderExpr
| ArrayAccess
| Array
| Dict
| TupleAccess
| Tuple
| Lambda
| Constant
| Field
| Placeholder
| Call
| ExprCall
| JoinConstraint
| JoinExpr
| WindowFrameExpr
| WindowExpr
| WindowFunction
| LimitByExpr
| SelectQuery
| SelectSetQuery
| RatioExpr
| SampleExpr
| TSQLXTag;
export interface CTE extends Expr {
expression_type: "cte";
name: string;
expr: Expression;
cte_type: "column" | "subquery";
}
// Type system
export type TableOrSelectType =
| BaseTableType
| SelectSetQueryType
| SelectQueryType
| SelectQueryAliasType;
export interface FieldAliasType extends Type {
alias: string;
type: Type;
}
export interface BaseTableType extends Type {
resolve_database_table?(context: TSQLContext): Table;
}
export interface TableType extends BaseTableType {
table: Table;
}
export interface LazyJoinType extends BaseTableType {
table_type: TableOrSelectType;
field: string;
lazy_join: LazyJoin;
}
export interface LazyTableType extends BaseTableType {
table: LazyTable;
}
export interface TableAliasType extends BaseTableType {
alias: string;
table_type: TableType | LazyTableType;
}
export interface VirtualTableType extends BaseTableType {
table_type: TableOrSelectType;
field: string;
virtual_table: VirtualTable;
}
export interface SelectQueryType extends Type {
aliases: Record<string, FieldAliasType>;
columns: Record<string, Type>;
tables: Record<string, TableOrSelectType>;
ctes: Record<string, CTE>;
anonymous_tables: (SelectQueryType | SelectSetQueryType)[];
parent?: SelectQueryType | SelectSetQueryType;
is_lambda_type?: boolean;
}
export interface SelectSetQueryType extends Type {
types: (SelectQueryType | SelectSetQueryType)[];
}
export interface SelectViewType extends BaseTableType {
view_name: string;
alias: string;
select_query_type: SelectQueryType | SelectSetQueryType;
}
export interface SelectQueryAliasType extends Type {
alias: string;
select_query_type: SelectQueryType | SelectSetQueryType;
}
export interface IntegerType extends ConstantType {
data_type: "int";
}
export interface DecimalType extends ConstantType {
data_type: "unknown";
}
export interface FloatType extends ConstantType {
data_type: "float";
}
export interface StringType extends ConstantType {
data_type: "str";
}
export interface StringJSONType extends StringType {}
export interface StringArrayType extends StringType {}
export interface BooleanType extends ConstantType {
data_type: "bool";
}
export interface DateType extends ConstantType {
data_type: "date";
}
export interface DateTimeType extends ConstantType {
data_type: "datetime";
}
export interface IntervalType extends ConstantType {
data_type: "unknown";
}
export interface UUIDType extends ConstantType {
data_type: "uuid";
}
export interface ArrayType extends ConstantType {
data_type: "array";
item_type: ConstantType;
}
export interface TupleType extends ConstantType {
data_type: "tuple";
item_types: ConstantType[];
repeat?: boolean;
}
export interface CallType extends Type {
name: string;
arg_types: ConstantType[];
param_types?: ConstantType[];
return_type: ConstantType;
}
export interface AsteriskType extends Type {
table_type: TableOrSelectType;
}
export interface FieldTraverserType extends Type {
chain: (string | number)[];
table_type: TableOrSelectType;
}
export interface ExpressionFieldType extends Type {
name: string;
expr: Expression;
table_type: TableOrSelectType;
isolate_scope?: boolean;
}
export interface FieldType extends Type {
name: string;
table_type: TableOrSelectType;
}
export interface UnresolvedFieldType extends Type {
name: string;
}
export interface PropertyType extends Type {
chain: (string | number)[];
field_type: FieldType;
joined_subquery?: SelectQueryAliasType;
joined_subquery_field_name?: string;
}
export interface LambdaArgumentType extends Type {
name: string;
}
// Enums
export enum ArithmeticOperationOp {
Add = "+",
Sub = "-",
Mult = "*",
Div = "/",
Mod = "%",
}
export enum CompareOperationOp {
Eq = "==",
NotEq = "!=",
Gt = ">",
GtEq = ">=",
Lt = "<",
LtEq = "<=",
Like = "like",
ILike = "ilike",
NotLike = "not like",
NotILike = "not ilike",
In = "in",
GlobalIn = "global in",
NotIn = "not in",
GlobalNotIn = "global not in",
InCohort = "in cohort",
NotInCohort = "not in cohort",
Regex = "=~",
IRegex = "=~*",
NotRegex = "!~",
NotIRegex = "!~*",
}
export const NEGATED_COMPARE_OPS: CompareOperationOp[] = [
CompareOperationOp.NotEq,
CompareOperationOp.NotLike,
CompareOperationOp.NotILike,
CompareOperationOp.NotIn,
CompareOperationOp.GlobalNotIn,
CompareOperationOp.NotInCohort,
CompareOperationOp.NotRegex,
CompareOperationOp.NotIRegex,
];
export type SetOperator =
| "UNION ALL"
| "UNION DISTINCT"
| "INTERSECT"
| "INTERSECT DISTINCT"
| "EXCEPT";
export type ParseResult = Expression | Declaration | string;
// Declaration and Statement types
export interface Declaration extends AST {}
export interface VariableAssignment extends Declaration {
left: Expression;
right: Expression;
}
export interface VariableDeclaration extends Declaration {
name: string;
expr?: Expression;
}
export interface Statement extends Declaration {}
export interface ExprStatement extends Statement {
expr?: Expression;
}
export interface ReturnStatement extends Statement {
expr?: Expression;
}
export interface ThrowStatement extends Statement {
expr?: Expression;
}
export interface TryCatchStatement extends Statement {
try_stmt: Statement;
catches: [string | null, string | null, Statement][];
finally_stmt?: Statement;
}
export interface IfStatement extends Statement {
expr: Expression;
then: Statement;
else_?: Statement;
}
export interface WhileStatement extends Statement {
expr: Expression;
body: Statement;
}
export interface ForStatement extends Statement {
initializer?: VariableDeclaration | VariableAssignment | Expression;
condition?: Expression;
increment?: VariableDeclaration;
body: Statement;
}
export interface ForInStatement extends Statement {
keyVar?: string;
valueVar: string;
expr: Expression;
body: Statement;
}
export interface Function extends Statement {
name: string;
params: string[];
body: Statement;
}
export interface Block extends Statement {
declarations: Declaration[];
}
export interface Program extends AST {
declarations: Declaration[];
}
// Expression types
export interface Alias extends Expr {
expression_type: "alias";
alias: string;
expr: Expression;
hidden?: boolean;
from_asterisk?: boolean;
}
export interface ArithmeticOperation extends Expr {
expression_type: "arithmetic_operation";
left: Expression;
right: Expression;
op: ArithmeticOperationOp;
}
export interface And extends Expr {
expression_type: "and";
type?: ConstantType;
exprs: Expression[];
}
export interface Or extends Expr {
expression_type: "or";
exprs: Expression[];
type?: ConstantType;
}
export interface CompareOperation extends Expr {
expression_type: "compare_operation";
left: Expression;
right: Expression;
op: CompareOperationOp;
type?: ConstantType;
}
export interface Not extends Expr {
expression_type: "not";
expr: Expression;
type?: ConstantType;
}
export interface BetweenExpr extends Expr {
expression_type: "between_expr";
expr: Expression;
low: Expression;
high: Expression;
negated?: boolean;
type?: ConstantType;
}
export interface OrderExpr extends Expr {
expression_type: "order_expr";
expr: Expression;
order?: "ASC" | "DESC";
}
export interface ArrayAccess extends Expr {
expression_type: "array_access";
array: Expression;
property: Expression;
nullish?: boolean;
}
export interface Array extends Expr {
expression_type: "array";
exprs: Expression[];
}
export interface Dict extends Expr {
expression_type: "dict";
items: [Expression, Expression][];
}
export interface TupleAccess extends Expr {
expression_type: "tuple_access";
tuple: Expression;
index: number;
nullish?: boolean;
}
export interface Tuple extends Expr {
expression_type: "tuple";
exprs: Expression[];
}
export interface Lambda extends Expr {
expression_type: "lambda";
args: string[];
expr: Expression | Block;
}
export interface Constant extends Expr {
expression_type: "constant";
value: any;
}
export interface Field extends Expr {
expression_type: "field";
chain: (string | number)[];
from_asterisk?: boolean;
}
export interface Placeholder extends Expr {
expression_type: "placeholder";
expr: Expression;
// Computed properties
chain?: (string | number)[] | null;
field?: string | null;
}
export interface Call extends Expr {
expression_type: "call";
name: string;
args: Expression[];
params?: Expression[];
distinct?: boolean;
}
export interface ExprCall extends Expr {
expression_type: "expr_call";
expr: Expression;
args: Expression[];
}
export interface JoinConstraint extends Expr {
expression_type: "join_constraint";
expr: Expression;
constraint_type: "ON" | "USING";
}
export interface JoinExpr extends Expr {
expression_type: "join_expr";
type?: TableOrSelectType;
join_type?: string;
table?: SelectQuery | SelectSetQuery | Placeholder | TSQLXTag | Field;
table_args?: Expression[];
alias?: string;
table_final?: boolean;
constraint?: JoinConstraint;
next_join?: JoinExpr;
sample?: SampleExpr;
}
export interface WindowFrameExpr extends Expr {
expression_type: "window_frame_expr";
frame_type?: "CURRENT ROW" | "PRECEDING" | "FOLLOWING";
frame_value?: number;
}
export interface WindowExpr extends Expr {
expression_type: "window_expr";
partition_by?: Expression[];
order_by?: OrderExpr[];
frame_method?: "ROWS" | "RANGE";
frame_start?: WindowFrameExpr;
frame_end?: WindowFrameExpr;
}
export interface WindowFunction extends Expr {
expression_type: "window_function";
name: string;
args?: Expression[];
exprs?: Expression[];
over_expr?: WindowExpr;
over_identifier?: string;
}
export interface LimitByExpr extends Expr {
expression_type: "limit_by_expr";
n: Expression;
exprs: Expression[];
offset_value?: Expression;
}
export interface SelectQuery extends Expr {
expression_type: "select_query";
type?: SelectQueryType;
ctes?: Record<string, CTE>;
select: Expression[];
distinct?: boolean;
select_from?: JoinExpr;
array_join_op?: string;
array_join_list?: Expression[];
window_exprs?: Record<string, WindowExpr>;
where?: Expression;
prewhere?: Expression;
having?: Expression;
group_by?: Expression[];
order_by?: OrderExpr[];
limit?: Expression;
limit_by?: LimitByExpr;
limit_with_ties?: boolean;
offset?: Expression;
settings?: TSQLQuerySettings;
view_name?: string;
}
export interface SelectSetNode extends AST {
select_query: SelectQuery | SelectSetQuery;
set_operator: SetOperator;
}
export interface SelectSetQuery extends Expr {
expression_type: "select_set_query";
type?: SelectSetQueryType;
initial_select_query: SelectQuery | SelectSetQuery;
subsequent_select_queries: SelectSetNode[];
// Equivalent to select_queries() method
select_queries?(): (SelectQuery | SelectSetQuery)[];
}
// Add static method equivalent for SelectSetQuery.create_from_queries()
export namespace SelectSetQuery {
export function createFromQueries(
queries: (SelectQuery | SelectSetQuery)[],
set_operator: SetOperator
): SelectQuery | SelectSetQuery {
return createSelectSetQueryFromQueries(queries, set_operator);
}
}
export interface RatioExpr extends Expr {
expression_type: "ratio_expr";
left: Constant;
right?: Constant;
}
export interface SampleExpr extends Expr {
expression_type: "sample_expr";
sample_value: RatioExpr;
offset_value?: RatioExpr;
}
export interface TSQLXAttribute extends AST {
name: string;
value: any;
}
export interface TSQLXTag extends Expr {
expression_type: "tsqlx_tag";
kind: string;
attributes: TSQLXAttribute[];
// Equivalent to to_dict() method
to_dict?(): Record<string, any>;
}
// Helper function to create empty SelectQuery (equivalent to SelectQuery.empty())
export function createEmptySelectQuery(columns?: Record<string, FieldOrTable>): SelectQuery {
if (!columns) {
columns = { _: { name: "_" } as UnknownDatabaseField };
}
return {
expression_type: "select_query",
select: Object.entries(columns).map(([column, field]) => ({
expression_type: "alias" as const,
alias: column,
expr: {
expression_type: "constant",
value: (field as DatabaseField).default_value?.() ?? null,
} as Constant,
})),
where: { expression_type: "constant", value: false } as Constant,
};
}
// Add static method equivalent for SelectQuery.empty()
export namespace SelectQuery {
export function empty(columns?: Record<string, FieldOrTable>): SelectQuery {
return createEmptySelectQuery(columns);
}
}
// Helper function for SelectSetQuery.select_queries()
export function selectQueries(query: SelectSetQuery): (SelectQuery | SelectSetQuery)[] {
return [
query.initial_select_query,
...query.subsequent_select_queries.map((node) => node.select_query),
];
}
// Helper function to create SelectSetQuery from multiple queries
export function createSelectSetQueryFromQueries(
queries: (SelectQuery | SelectSetQuery)[],
set_operator: SetOperator
): SelectQuery | SelectSetQuery {
if (queries.length === 0) {
throw new Error("Cannot create a SelectSetQuery from an empty list of queries");
} else if (queries.length === 1) {
return queries[0];
}
return {
expression_type: "select_set_query",
initial_select_query: queries[0],
subsequent_select_queries: queries.slice(1).map((query) => ({
select_query: query,
set_operator,
})) as SelectSetNode[],
} as SelectSetQuery;
}
@@ -0,0 +1,71 @@
// TypeScript translation of posthog/hogql/constants.py
export type ConstantDataType =
| "int"
| "float"
| "str"
| "bool"
| "array"
| "tuple"
| "date"
| "datetime"
| "uuid"
| "unknown";
export type ConstantSupportedPrimitive = number | string | boolean | Date | null;
export type ConstantSupportedData =
| ConstantSupportedPrimitive
| ConstantSupportedPrimitive[]
| [ConstantSupportedPrimitive, ...ConstantSupportedPrimitive[]];
export const KEYWORDS = ["true", "false", "null"] as const;
export const RESERVED_KEYWORDS = [...KEYWORDS, "team_id"] as const;
export const DEFAULT_RETURNED_ROWS = 100;
export const MAX_SELECT_RETURNED_ROWS = 50000;
export const MAX_SELECT_RETENTION_LIMIT = 100000;
export const MAX_SELECT_HEATMAPS_LIMIT = 1000000;
export const MAX_SELECT_COHORT_CALCULATION_LIMIT = 1000000000;
export const MAX_BYTES_BEFORE_EXTERNAL_GROUP_BY = 22 * 1024 * 1024 * 1024;
export const CSV_EXPORT_LIMIT = 300000;
export const CSV_EXPORT_BREAKDOWN_LIMIT_INITIAL = 512;
export const CSV_EXPORT_BREAKDOWN_LIMIT_LOW = 64;
export const BREAKDOWN_VALUES_LIMIT = 25;
export const BREAKDOWN_VALUES_LIMIT_FOR_COUNTRIES = 300;
export enum LimitContext {
QUERY = "query",
QUERY_ASYNC = "query_async",
EXPORT = "export",
COHORT_CALCULATION = "cohort_calculation",
HEATMAPS = "heatmaps",
SAVED_QUERY = "saved_query",
RETENTION = "retention",
}
// Settings applied at the SELECT level
export interface TSQLQuerySettings {
optimize_aggregation_in_order?: boolean;
date_time_output_format?: string;
date_time_input_format?: string;
join_algorithm?: string;
}
// Settings applied on top of all TSQL queries
export interface TSQLGlobalSettings extends TSQLQuerySettings {
readonly?: number;
max_execution_time?: number;
max_memory_usage?: number;
max_threads?: number;
allow_experimental_object_type?: boolean;
format_csv_allow_double_quotes?: boolean;
max_ast_elements?: number;
max_expanded_ast_elements?: number;
max_bytes_before_external_group_by?: number;
allow_experimental_analyzer?: boolean;
transform_null_in?: boolean;
optimize_min_equality_disjunction_chain_length?: number;
allow_experimental_join_condition?: boolean;
preferred_block_size_bytes?: number;
use_hive_partitioning?: number;
}
@@ -0,0 +1,56 @@
// TypeScript translation of posthog/hogql/context.py
import type { LimitContext } from "./constants";
import type { Database } from "./database";
import type { PropertySwapper } from "./property_types";
import type { TSQLTimings } from "./timings";
export interface TSQLNotice {
start?: number;
end?: number;
message: string;
fix?: string;
}
export interface TSQLQueryModifiers {
optimizeJoinedFilters?: boolean;
debug?: boolean;
timings?: boolean;
useMaterializedViews?: boolean;
formatCsvAllowDoubleQuotes?: boolean;
convertToProjectTimezone?: boolean;
usePreaggregatedTableTransforms?: boolean;
optimizeProjections?: boolean;
}
export interface TSQLFieldAccess {
input: string[];
type?: "run";
field?: string;
sql: string;
}
export interface Team {
id: number;
project_id: number;
}
export interface TSQLContext {
team_id?: number;
team?: Team;
database?: Database;
values: Record<string, any>;
within_non_tsql_query?: boolean;
enable_select_queries?: boolean;
limit_top_select?: boolean;
limit_context?: LimitContext;
output_format?: string | null;
globals?: Record<string, any>;
warnings: TSQLNotice[];
notices: TSQLNotice[];
errors: TSQLNotice[];
timings: TSQLTimings;
modifiers: TSQLQueryModifiers;
debug?: boolean;
property_swapper?: PropertySwapper;
}
@@ -0,0 +1,585 @@
// TypeScript translation of posthog/hogql/database/database.py
//
// NOTE: This implementation requires database/ORM access for:
// - serialize() method (needs DataWarehouseTable, DataWarehouseSavedQuery queries)
// - create_for() method (needs Team, DataWarehouseJoin, DataWarehouseSavedQuery queries)
// Adapt these methods to your database/ORM setup
import type { ConstantType } from "./ast";
import type { TSQLContext, TSQLQueryModifiers, Team } from "./context";
import type {
DatabaseField,
ExpressionField,
FieldOrTable,
FieldTraverser,
LazyJoin,
Table,
TableNode,
VirtualTable,
} from "./models";
import type { TSQLTimings } from "./timings";
import { QueryError, ResolutionError } from "./errors";
import { TSQLTimings as TSQLTimingsClass } from "./timings";
// Type definitions for schema serialization (adapt to your schema types)
export interface DatabaseSchemaTable {
fields: Record<string, DatabaseSchemaField>;
id: string;
name: string;
}
export interface DatabaseSchemaSystemTable extends DatabaseSchemaTable {
fields: Record<string, DatabaseSchemaField>;
id: string;
name: string;
}
export interface DatabaseSchemaDataWarehouseTable extends DatabaseSchemaTable {
fields: Record<string, DatabaseSchemaField>;
id: string;
name: string;
format?: string;
url_pattern?: string;
schema?: DatabaseSchemaSchema;
source?: DatabaseSchemaSource;
row_count?: number;
}
export interface DatabaseSchemaViewTable extends DatabaseSchemaTable {
fields: Record<string, DatabaseSchemaField>;
id: string;
name: string;
query: { query: string };
row_count?: number;
}
export interface DatabaseSchemaManagedViewTable extends DatabaseSchemaTable {
fields: Record<string, DatabaseSchemaField>;
id: string;
name: string;
kind: string;
source_id?: string;
query: { query: string };
}
export interface DatabaseSchemaEndpointTable extends DatabaseSchemaTable {
fields: Record<string, DatabaseSchemaField>;
id: string;
name: string;
query: { query: string };
row_count?: number;
status?: string;
}
export interface DatabaseSchemaField {
name: string;
tsql_value: string;
type: DatabaseSerializedFieldType;
schema_valid: boolean;
fields?: string[];
table?: string;
chain?: Array<string | number>;
id?: string;
}
export interface DatabaseSchemaSchema {
id: string;
name: string;
should_sync: boolean;
incremental: boolean;
status: string;
last_synced_at: string;
}
export interface DatabaseSchemaSource {
id: string;
status: string;
source_type: string;
prefix: string;
last_synced_at?: string | null;
}
export enum DatabaseSerializedFieldType {
STRING = "string",
INTEGER = "integer",
FLOAT = "float",
DECIMAL = "decimal",
BOOLEAN = "boolean",
DATE = "date",
DATETIME = "datetime",
UUID = "uuid",
ARRAY = "array",
JSON = "json",
TUPLE = "tuple",
UNKNOWN = "unknown",
EXPRESSION = "expression",
VIEW = "view",
LAZY_TABLE = "lazy_table",
VIRTUAL_TABLE = "virtual_table",
FIELD_TRAVERSER = "field_traverser",
}
export interface SerializedField {
key: string;
name: string;
type: DatabaseSerializedFieldType;
schema_valid: boolean;
fields?: string[];
table?: string;
chain?: Array<string | number>;
}
import { TableNodeImpl } from "./models";
export class Database {
// Users can query from the tables below
tables: TableNode;
private _warehouseTableNames: string[] = [];
private _warehouseSelfManagedTableNames: string[] = [];
private _viewTableNames: string[] = [];
private _coreTableNames: string[] = [];
private _timezone?: string | null;
private _weekStartDay?: string | null; // WeekStartDay enum
private _serializationErrors: Record<string, string> = {};
constructor(timezone?: string | null, weekStartDay?: string | null) {
// Initialize with root TableNode
this.tables = new TableNodeImpl("root");
this._timezone = timezone || null;
this._weekStartDay = weekStartDay || null;
}
getTimezone(): string {
return this._timezone || "UTC";
}
getWeekStartDay(): string {
return this._weekStartDay || "sunday"; // Adapt to your WeekStartDay enum
}
getSerializationErrors(): Record<string, string> {
/** Return any errors encountered during serialization. */
return { ...this._serializationErrors };
}
hasTable(tableName: string | string[]): boolean {
const path = typeof tableName === "string" ? tableName.split(".") : tableName;
return this.tables.has_child ? this.tables.has_child(path) : false;
}
getTableNode(tableName: string | string[]): TableNode {
let path: string[];
if (typeof tableName === "string") {
path = tableName.split(".");
} else {
path = tableName;
}
// Handle edge case where tableName is a list with a single string containing dots
if (path.length === 1 && path[0].includes(".")) {
path = path[0].split(".");
}
if (!this.tables.get_child) {
throw new ResolutionError(`TableNode.get_child not implemented`);
}
return this.tables.get_child(path);
}
getTable(tableName: string | string[]): Table {
try {
const node = this.getTableNode(tableName);
if (!node.get) {
throw new ResolutionError("TableNode.get not implemented");
}
const table = node.get();
if (!table || typeof table !== "object" || !("fields" in table)) {
throw new ResolutionError("Table is not set");
}
return table as Table;
} catch (e) {
const name = Array.isArray(tableName) ? tableName.join(".") : tableName;
if (e instanceof ResolutionError) {
throw new QueryError(`Unknown table \`${name}\`.`);
}
throw e;
}
}
getAllTableNames(): string[] {
const warehouseTableNames = this._warehouseTableNames.filter((x) => x.includes("."));
return [
...this._coreTableNames,
...warehouseTableNames,
...this._warehouseSelfManagedTableNames,
...this._viewTableNames,
];
}
// Core tables exposed via SQL editor autocomplete and data management
getCoreTableNames(): string[] {
return [...this._coreTableNames, ...this.getSystemTableNames()];
}
getSystemTableNames(): string[] {
const systemNode = this.tables.children["system"];
if (systemNode && systemNode.resolve_all_table_names) {
return ["query_log", ...systemNode.resolve_all_table_names()];
}
return ["query_log"];
}
getWarehouseTableNames(): string[] {
return [...this._warehouseTableNames, ...this._warehouseSelfManagedTableNames];
}
getViewNames(): string[] {
return this._viewTableNames;
}
addCoreTable(tableName: string, node: TableNode): void {
if (this.tables.add_child) {
this.tables.add_child(node);
}
this._coreTableNames.push(tableName);
}
private _addWarehouseTables(node: TableNode): void {
if (this.tables.merge_with) {
this.tables.merge_with(node);
}
if (node.resolve_all_table_names) {
const names = node.resolve_all_table_names();
this._warehouseTableNames.push(...names.sort());
}
}
private _addWarehouseSelfManagedTables(node: TableNode): void {
if (this.tables.merge_with) {
this.tables.merge_with(node);
}
if (node.resolve_all_table_names) {
const names = node.resolve_all_table_names();
this._warehouseSelfManagedTableNames.push(...names.sort());
}
}
private _addViews(node: TableNode): void {
if (this.tables.merge_with) {
this.tables.merge_with(node);
}
if (node.resolve_all_table_names) {
const names = node.resolve_all_table_names();
this._viewTableNames.push(...names.sort());
}
}
serialize(context: TSQLContext, includeOnly?: Set<string>): Record<string, DatabaseSchemaTable> {
// NOTE: This method requires database queries to fetch:
// - DataWarehouseTable objects
// - DataWarehouseSavedQuery objects
// - External data sources and schemas
//
// Adapt this to your database/ORM setup
const tables: Record<string, DatabaseSchemaTable> = {};
if (!context.team_id) {
throw new ResolutionError("Must provide team_id to serialize database");
}
// Core tables
const coreTableNames = this.getCoreTableNames();
for (const tableName of coreTableNames) {
if (includeOnly && !includeOnly.has(tableName)) {
continue;
}
let fieldInput: Record<string, FieldOrTable> = {};
const table = this.getTable(tableName);
if ("get_asterisk" in table && typeof table.get_asterisk === "function") {
fieldInput = table.get_asterisk() || {};
} else if ("fields" in table) {
fieldInput = table.fields;
}
const fields = serializeFields(fieldInput, context, tableName.split("."), undefined);
const fieldsDict: Record<string, DatabaseSchemaField> = {};
for (const field of fields) {
fieldsDict[field.name] = field;
}
tables[tableName] = {
fields: fieldsDict,
id: tableName,
name: tableName,
} as DatabaseSchemaTable;
}
// System tables
const systemTables = this.getSystemTableNames();
for (const tableKey of systemTables) {
if (includeOnly && !includeOnly.has(tableKey)) {
continue;
}
let systemFieldInput: Record<string, FieldOrTable> = {};
const table = this.getTable(tableKey);
if ("get_asterisk" in table && typeof table.get_asterisk === "function") {
systemFieldInput = table.get_asterisk() || {};
} else if ("fields" in table) {
systemFieldInput = table.fields;
}
const fields = serializeFields(systemFieldInput, context, tableKey.split("."), undefined);
const fieldsDict: Record<string, DatabaseSchemaField> = {};
for (const field of fields) {
fieldsDict[field.name] = field;
}
tables[tableKey] = {
fields: fieldsDict,
id: tableKey,
name: tableKey,
} as DatabaseSchemaSystemTable;
}
// NOTE: Data Warehouse Tables and Views processing requires database queries
// Implement based on your database/ORM setup:
// - Fetch DataWarehouseTable objects
// - Fetch DataWarehouseSavedQuery objects
// - Process and serialize them
return tables;
}
static createFor(
teamId?: number,
options?: {
team?: Team;
modifiers?: TSQLQueryModifiers;
timings?: TSQLTimings;
}
): Database {
// NOTE: This method requires extensive database/ORM access:
// - Team model queries
// - DataWarehouseTable queries
// - DataWarehouseSavedQuery queries
// - DataWarehouseJoin queries
// - GroupTypeMapping queries
// - Feature flag checks
//
// This is a skeleton structure - adapt to your setup
const timings = options?.timings || new TSQLTimingsClass();
const { team, modifiers } = options || {};
// Validate team/teamId
if (!teamId && !team) {
throw new Error("Either team_id or team must be provided");
}
if (team && teamId && team.id !== teamId) {
throw new Error("team_id and team must be the same");
}
// NOTE: Fetch team from database if not provided
// const fetchedTeam = team || await Team.findById(teamId);
// Create database instance
const database = timings.measure("database", () => {
// NOTE: Get timezone and week_start_day from team
// const timezone = fetchedTeam.timezone;
// const weekStartDay = fetchedTeam.week_start_day;
return new Database(undefined, undefined);
});
// NOTE: Apply modifiers, setup tables, etc.
// This requires extensive database access and table setup logic
return database;
}
}
// Helper functions
const TSQL_CHARACTERS_TO_BE_WRAPPED = ["@", "-", "!", "$", "+"];
function constantTypeToSerializedFieldType(
constantType: ConstantType
): DatabaseSerializedFieldType | null {
// Type checking for ConstantType subtypes
// NOTE: In TypeScript, we need to check properties rather than instanceof
// since these are interfaces, not classes
if ("data_type" in constantType) {
const dataType = constantType.data_type;
if (dataType === "str") {
return DatabaseSerializedFieldType.STRING;
}
if (dataType === "bool") {
return DatabaseSerializedFieldType.BOOLEAN;
}
if (dataType === "date") {
return DatabaseSerializedFieldType.DATE;
}
if (dataType === "datetime") {
return DatabaseSerializedFieldType.DATETIME;
}
if (dataType === "uuid") {
return DatabaseSerializedFieldType.STRING;
}
if (dataType === "array") {
return DatabaseSerializedFieldType.ARRAY;
}
if (dataType === "tuple") {
return DatabaseSerializedFieldType.JSON;
}
if (dataType === "int") {
return DatabaseSerializedFieldType.INTEGER;
}
if (dataType === "float") {
return DatabaseSerializedFieldType.FLOAT;
}
}
// Fallback: check print_type if available
if ("print_type" in constantType && typeof constantType.print_type === "function") {
const printed = constantType.print_type();
if (printed === "String" || printed === "JSON" || printed === "Array") {
return printed === "String"
? DatabaseSerializedFieldType.STRING
: printed === "JSON"
? DatabaseSerializedFieldType.JSON
: DatabaseSerializedFieldType.ARRAY;
}
if (printed === "Boolean") return DatabaseSerializedFieldType.BOOLEAN;
if (printed === "Date") return DatabaseSerializedFieldType.DATE;
if (printed === "DateTime") return DatabaseSerializedFieldType.DATETIME;
if (printed === "UUID") return DatabaseSerializedFieldType.STRING;
if (printed === "Integer") return DatabaseSerializedFieldType.INTEGER;
if (printed === "Float") return DatabaseSerializedFieldType.FLOAT;
if (printed === "Decimal") return DatabaseSerializedFieldType.DECIMAL;
}
return null;
}
export function serializeFields(
fieldInput: Record<string, FieldOrTable>,
context: TSQLContext,
tableChain: string[],
dbColumns?: Record<string, any> // DataWarehouseTableColumns
): DatabaseSchemaField[] {
// NOTE: This requires resolve_types_from_table from resolver
// Import as needed: import { resolveTypesFromTable } from '../resolver';
const fieldOutput: DatabaseSchemaField[] = [];
for (const [fieldKey, field] of Object.entries(fieldInput)) {
let schemaValid = true;
if (dbColumns) {
const column = dbColumns[fieldKey];
if (typeof column === "string") {
schemaValid = true;
} else if (column && typeof column === "object") {
schemaValid = column.valid !== false;
}
}
let tsqlValue: string;
if (TSQL_CHARACTERS_TO_BE_WRAPPED.some((char) => fieldKey.includes(char))) {
tsqlValue = `\`${fieldKey}\``;
} else {
tsqlValue = fieldKey;
}
if ("hidden" in field && field.hidden) {
continue;
}
if ("name" in field && "get_constant_type" in field) {
// DatabaseField
const dbField = field as DatabaseField;
let fieldType: DatabaseSerializedFieldType;
// Determine field type based on DatabaseField subclass
// NOTE: You'll need to check instanceof or use type guards
// For now, using a simplified approach
if (dbField.get_constant_type) {
const constantType = dbField.get_constant_type();
fieldType =
constantTypeToSerializedFieldType(constantType) || DatabaseSerializedFieldType.UNKNOWN;
} else {
fieldType = DatabaseSerializedFieldType.UNKNOWN;
}
fieldOutput.push({
name: fieldKey,
tsql_value: tsqlValue,
type: fieldType,
schema_valid: schemaValid,
});
} else if ("expr" in field) {
// ExpressionField
const exprField = field as ExpressionField;
// NOTE: Requires resolve_types_from_table
// const resolvedExpr = resolveTypesFromTable(exprField.expr, tableChain, context, 'tsql');
// const constantType = resolvedExpr.type?.resolve_constant_type(context);
// const fieldType = constantTypeToSerializedFieldType(constantType) || DatabaseSerializedFieldType.EXPRESSION;
fieldOutput.push({
name: fieldKey,
tsql_value: tsqlValue,
type: DatabaseSerializedFieldType.EXPRESSION,
schema_valid: schemaValid,
});
} else if ("resolve_table" in field) {
// LazyJoin
const lazyJoin = field as LazyJoin;
if (lazyJoin.resolve_table) {
const resolvedTable = lazyJoin.resolve_table(context);
const type =
"id" in resolvedTable && resolvedTable.id
? DatabaseSerializedFieldType.VIEW
: DatabaseSerializedFieldType.LAZY_TABLE;
fieldOutput.push({
name: fieldKey,
tsql_value: tsqlValue,
type,
schema_valid: schemaValid,
table: resolvedTable.to_printed_tsql ? resolvedTable.to_printed_tsql() : fieldKey,
fields: "fields" in resolvedTable ? Object.keys(resolvedTable.fields) : [],
id: "id" in resolvedTable && resolvedTable.id ? String(resolvedTable.id) : fieldKey,
});
}
} else if ("fields" in field && !("resolve_table" in field)) {
// VirtualTable
const virtualTable = field as VirtualTable;
fieldOutput.push({
name: fieldKey,
tsql_value: tsqlValue,
type: DatabaseSerializedFieldType.VIRTUAL_TABLE,
schema_valid: schemaValid,
table: virtualTable.to_printed_tsql ? virtualTable.to_printed_tsql() : fieldKey,
fields: Object.keys(virtualTable.fields),
});
} else if ("chain" in field) {
// FieldTraverser
const traverser = field as FieldTraverser;
fieldOutput.push({
name: fieldKey,
tsql_value: tsqlValue,
type: DatabaseSerializedFieldType.FIELD_TRAVERSER,
schema_valid: schemaValid,
chain: traverser.chain,
});
}
}
return fieldOutput;
}
@@ -0,0 +1,61 @@
// TypeScript translation of posthog/hogql/errors.py
import type { Expr } from "./ast";
export class BaseTSQLError extends Error {
message: string;
start?: number;
end?: number;
constructor(
message: string,
options?: {
start?: number;
end?: number;
node?: Expr;
}
) {
super(message);
this.message = message;
if (options?.node && options.node.start !== undefined && options.node.end !== undefined) {
this.start = options.node.start;
this.end = options.node.end;
} else {
this.start = options?.start;
this.end = options?.end;
}
}
}
export class ExposedTSQLError extends BaseTSQLError {
/** An exception that can be exposed to the user. */
}
export class InternalTSQLError extends BaseTSQLError {
/** An internal exception in the TSQL engine. */
}
export class SyntaxError extends ExposedTSQLError {
/** The input does not conform to TSQL syntax. */
}
export class QueryError extends ExposedTSQLError {
/** The query is invalid, though correct syntactically. */
}
export class NotImplementedError extends InternalTSQLError {
/** This feature isn't implemented in TSQL (yet). */
}
export class ParsingError extends InternalTSQLError {
/** Parsing failed. */
}
export class ImpossibleASTError extends InternalTSQLError {
/** Parsing or resolution resulted in an impossible AST. */
}
export class ResolutionError extends InternalTSQLError {
/** Resolution of a table/field/expression failed. */
}
@@ -0,0 +1,241 @@
import { describe, it, expect } from "vitest";
import {
escapeClickHouseIdentifier,
escapeTSQLIdentifier,
escapeClickHouseString,
escapeTSQLString,
getClickHouseType,
SQLValueEscaper,
safeIdentifier,
} from "./escape.js";
import { QueryError } from "./errors.js";
describe("escapeClickHouseIdentifier", () => {
it("should pass through simple identifiers", () => {
expect(escapeClickHouseIdentifier("id")).toBe("id");
expect(escapeClickHouseIdentifier("user_name")).toBe("user_name");
expect(escapeClickHouseIdentifier("Column1")).toBe("Column1");
expect(escapeClickHouseIdentifier("_private")).toBe("_private");
});
it("should escape identifiers with special characters", () => {
expect(escapeClickHouseIdentifier("my column")).toBe("`my column`");
expect(escapeClickHouseIdentifier("table-name")).toBe("`table-name`");
expect(escapeClickHouseIdentifier("column.with.dots")).toBe("`column.with.dots`");
});
it("should escape identifiers starting with numbers", () => {
expect(escapeClickHouseIdentifier("1column")).toBe("`1column`");
expect(escapeClickHouseIdentifier("123")).toBe("`123`");
});
it("should escape backticks in identifiers", () => {
expect(escapeClickHouseIdentifier("column`name")).toBe("`column\\`name`");
});
it("should escape control characters", () => {
expect(escapeClickHouseIdentifier("col\nname")).toBe("`col\\nname`");
expect(escapeClickHouseIdentifier("col\tname")).toBe("`col\\tname`");
});
it("should throw for identifiers containing %", () => {
expect(() => escapeClickHouseIdentifier("column%name")).toThrow(QueryError);
});
});
describe("escapeTSQLIdentifier", () => {
it("should pass through simple identifiers", () => {
expect(escapeTSQLIdentifier("id")).toBe("id");
expect(escapeTSQLIdentifier("user_name")).toBe("user_name");
});
it("should allow dollar signs in identifiers", () => {
expect(escapeTSQLIdentifier("$property")).toBe("$property");
expect(escapeTSQLIdentifier("property$value")).toBe("property$value");
});
it("should handle numeric identifiers", () => {
expect(escapeTSQLIdentifier(0)).toBe("0");
expect(escapeTSQLIdentifier(123)).toBe("123");
});
it("should throw for identifiers containing %", () => {
expect(() => escapeTSQLIdentifier("column%name")).toThrow(QueryError);
});
});
describe("SQLValueEscaper", () => {
describe("ClickHouse dialect", () => {
const escaper = new SQLValueEscaper({ dialect: "clickhouse" });
it("should escape null", () => {
expect(escaper.visit(null)).toBe("NULL");
expect(escaper.visit(undefined)).toBe("NULL");
});
it("should escape booleans as numbers", () => {
expect(escaper.visit(true)).toBe("1");
expect(escaper.visit(false)).toBe("0");
});
it("should escape integers", () => {
expect(escaper.visit(0)).toBe("0");
expect(escaper.visit(42)).toBe("42");
expect(escaper.visit(-100)).toBe("-100");
});
it("should escape floats", () => {
expect(escaper.visit(3.14)).toBe("3.14");
expect(escaper.visit(-0.5)).toBe("-0.5");
});
it("should escape special floats", () => {
expect(escaper.visit(NaN)).toBe("NaN");
expect(escaper.visit(Infinity)).toBe("Inf");
expect(escaper.visit(-Infinity)).toBe("-Inf");
});
it("should escape strings with quotes", () => {
expect(escaper.visit("hello")).toBe("'hello'");
expect(escaper.visit("hello'world")).toBe("'hello\\'world'");
});
it("should escape strings with control characters", () => {
expect(escaper.visit("line1\nline2")).toBe("'line1\\nline2'");
expect(escaper.visit("col1\tcol2")).toBe("'col1\\tcol2'");
});
it("should escape arrays", () => {
expect(escaper.visit([1, 2, 3])).toBe("[1, 2, 3]");
expect(escaper.visit(["a", "b"])).toBe("['a', 'b']");
expect(escaper.visit(["hello", "world"])).toBe("['hello', 'world']");
});
it("should escape nested arrays", () => {
expect(
escaper.visit([
[1, 2],
[3, 4],
])
).toBe("[[1, 2], [3, 4]]");
});
it("should escape dates with toDateTime64 and default UTC timezone", () => {
const date = new Date("2024-01-15T10:30:00.500Z");
const result = escaper.visit(date);
expect(result).toBe("toDateTime64('2024-01-15 10:30:00.500000', 6, 'UTC')");
});
it("should escape dates with custom timezone", () => {
const escaperWithTz = new SQLValueEscaper({
dialect: "clickhouse",
timezone: "America/New_York",
});
const date = new Date("2024-01-15T10:30:00.500Z");
const result = escaperWithTz.visit(date);
expect(result).toBe("toDateTime64('2024-01-15 10:30:00.500000', 6, 'America/New_York')");
});
});
describe("TSQL dialect", () => {
const escaper = new SQLValueEscaper({ dialect: "tsql" });
it("should escape booleans as keywords", () => {
expect(escaper.visit(true)).toBe("true");
expect(escaper.visit(false)).toBe("false");
});
it("should escape dates with toDateTime and default UTC timezone", () => {
const date = new Date("2024-01-15T10:30:00.500Z");
const result = escaper.visit(date);
expect(result).toBe("toDateTime('2024-01-15 10:30:00.500000', 'UTC')");
});
it("should escape dates with custom timezone", () => {
const escaperWithTz = new SQLValueEscaper({
dialect: "tsql",
timezone: "Europe/London",
});
const date = new Date("2024-01-15T10:30:00.500Z");
const result = escaperWithTz.visit(date);
expect(result).toBe("toDateTime('2024-01-15 10:30:00.500000', 'Europe/London')");
});
});
});
describe("escapeClickHouseString", () => {
it("should escape string values", () => {
expect(escapeClickHouseString("test")).toBe("'test'");
});
it("should handle null", () => {
expect(escapeClickHouseString(null)).toBe("NULL");
});
it("should handle numbers", () => {
expect(escapeClickHouseString(42)).toBe("42");
});
});
describe("escapeTSQLString", () => {
it("should escape string values", () => {
expect(escapeTSQLString("test")).toBe("'test'");
});
it("should handle booleans differently from ClickHouse", () => {
expect(escapeTSQLString(true)).toBe("true");
expect(escapeTSQLString(false)).toBe("false");
});
});
describe("getClickHouseType", () => {
it("should return String for strings", () => {
expect(getClickHouseType("hello")).toBe("String");
});
it("should return UInt8 for booleans", () => {
expect(getClickHouseType(true)).toBe("UInt8");
expect(getClickHouseType(false)).toBe("UInt8");
});
it("should return Int32 for small integers", () => {
expect(getClickHouseType(42)).toBe("Int32");
expect(getClickHouseType(-100)).toBe("Int32");
});
it("should return Int64 for large integers", () => {
expect(getClickHouseType(3000000000)).toBe("Int64");
expect(getClickHouseType(-3000000000)).toBe("Int64");
});
it("should return Float64 for floats", () => {
expect(getClickHouseType(3.14)).toBe("Float64");
});
it("should return DateTime64(6) for dates", () => {
expect(getClickHouseType(new Date())).toBe("DateTime64(6)");
});
it("should return Array type for arrays", () => {
expect(getClickHouseType(["a", "b"])).toBe("Array(String)");
expect(getClickHouseType([1, 2])).toBe("Array(Int32)");
expect(getClickHouseType([])).toBe("Array(String)");
});
it("should return Nullable(String) for null", () => {
expect(getClickHouseType(null)).toBe("Nullable(String)");
expect(getClickHouseType(undefined)).toBe("Nullable(String)");
});
});
describe("safeIdentifier", () => {
it("should return identifier unchanged if no %", () => {
expect(safeIdentifier("column")).toBe("column");
expect(safeIdentifier("table_name")).toBe("table_name");
});
it("should remove % characters", () => {
expect(safeIdentifier("column%name")).toBe("columnname");
expect(safeIdentifier("%%test%%")).toBe("test");
});
});
+265
View File
@@ -0,0 +1,265 @@
// TypeScript port of posthog/hogql/escape_sql.py
// Keep this file in sync with the Python version
import { QueryError } from "./errors";
/**
* Character escape maps for ClickHouse string escaping
* Copied from clickhouse_driver.util.escape
*
* Note: In JavaScript, \a and \v are not recognized escape sequences like in Python.
* We use the actual ASCII codes: \x07 for bell (Python's \a) and \x0B for vertical tab.
*/
const escapeCharsMap: Record<string, string> = {
"\b": "\\b",
"\f": "\\f",
"\r": "\\r",
"\n": "\\n",
"\t": "\\t",
"\0": "\\0",
"\x07": "\\a", // Bell character (ASCII 7) - Python's \a
"\x0B": "\\v", // Vertical tab (ASCII 11) - use explicit code since JS \v may not work in all contexts
"\\": "\\\\",
};
const singlequoteEscapeCharsMap: Record<string, string> = {
...escapeCharsMap,
"'": "\\'",
};
const backquoteEscapeCharsMap: Record<string, string> = {
...escapeCharsMap,
"`": "\\`",
};
/**
* Sanitize an identifier by removing % characters
*/
export function safeIdentifier(identifier: string): string {
if (identifier.includes("%")) {
return identifier.replace(/%/g, "");
}
return identifier;
}
/**
* Escape a string value for use as a parameter in ClickHouse
* Copied from clickhouse_driver.util.escape_param
*/
export function escapeParamClickhouse(value: string): string {
const escaped = value
.split("")
.map((c) => singlequoteEscapeCharsMap[c] || c)
.join("");
return `'${escaped}'`;
}
/**
* Escape an identifier for use in TSQL/HogQL queries
* Adapted from clickhouse_driver.util.escape with support for $ in identifiers
*/
export function escapeTSQLIdentifier(identifier: string | number): string {
if (typeof identifier === "number") {
// In TSQL we allow integers as identifiers to access array elements
return String(identifier);
}
if (identifier.includes("%")) {
throw new QueryError(
`The TSQL identifier "${identifier}" is not permitted as it contains the "%" character`
);
}
// TSQL allows dollars in the identifier (same regex as frontend escapePropertyAsTSQLIdentifier)
if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(identifier)) {
return identifier;
}
const escaped = identifier
.split("")
.map((c) => backquoteEscapeCharsMap[c] || c)
.join("");
return `\`${escaped}\``;
}
/**
* Escape an identifier for use in ClickHouse queries
* Copied from clickhouse_driver.util.escape, adapted from single quotes to backquotes
*/
export function escapeClickHouseIdentifier(identifier: string): string {
if (identifier.includes("%")) {
throw new QueryError(
`The identifier "${identifier}" is not permitted as it contains the "%" character`
);
}
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) {
return identifier;
}
const escaped = identifier
.split("")
.map((c) => backquoteEscapeCharsMap[c] || c)
.join("");
return `\`${escaped}\``;
}
/**
* Type for values that can be escaped as SQL strings
*/
export type EscapableValue =
| null
| undefined
| string
| number
| boolean
| Date
| EscapableValue[]
| [EscapableValue, ...EscapableValue[]];
/**
* SQL Value Escaper class that handles different types of values
* Port of SQLValueEscaper from escape_sql.py
*/
export class SQLValueEscaper {
private timezone: string;
private dialect: "tsql" | "clickhouse";
constructor(options: { timezone?: string; dialect?: "tsql" | "clickhouse" } = {}) {
this.timezone = options.timezone || "UTC";
this.dialect = options.dialect || "clickhouse";
}
visit(value: EscapableValue): string {
if (value === null || value === undefined) {
return this.visitNull();
}
if (typeof value === "string") {
return this.visitString(value);
}
if (typeof value === "boolean") {
return this.visitBoolean(value);
}
if (typeof value === "number") {
if (Number.isInteger(value)) {
return this.visitInt(value);
}
return this.visitFloat(value);
}
if (value instanceof Date) {
return this.visitDateTime(value);
}
if (Array.isArray(value)) {
return this.visitArray(value);
}
throw new QueryError(`SQLValueEscaper cannot handle value of type ${typeof value}`);
}
private visitNull(): string {
return "NULL";
}
private visitString(value: string): string {
return escapeParamClickhouse(value);
}
private visitBoolean(value: boolean): string {
if (this.dialect === "clickhouse") {
return value ? "1" : "0";
}
return value ? "true" : "false";
}
private visitInt(value: number): string {
return String(value);
}
private visitFloat(value: number): string {
if (Number.isNaN(value)) {
return "NaN";
}
if (!Number.isFinite(value)) {
return value < 0 ? "-Inf" : "Inf";
}
return String(value);
}
private visitDateTime(value: Date): string {
// Format: YYYY-MM-DD HH:MM:SS.ffffff
const pad = (n: number, len: number = 2) => String(n).padStart(len, "0");
const year = value.getUTCFullYear();
const month = pad(value.getUTCMonth() + 1);
const day = pad(value.getUTCDate());
const hours = pad(value.getUTCHours());
const minutes = pad(value.getUTCMinutes());
const seconds = pad(value.getUTCSeconds());
const ms = pad(value.getUTCMilliseconds(), 3);
const datetimeString = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${ms}000`;
if (this.dialect === "tsql") {
return `toDateTime(${this.visitString(datetimeString)}, ${this.visitString(this.timezone)})`;
}
return `toDateTime64(${this.visitString(datetimeString)}, 6, ${this.visitString(
this.timezone
)})`;
}
private visitArray(value: EscapableValue[]): string {
return `[${value.map((x) => this.visit(x)).join(", ")}]`;
}
}
/**
* Escape a value for use in a TSQL/HogQL query string
*/
export function escapeTSQLString(value: EscapableValue, timezone?: string): string {
return new SQLValueEscaper({ timezone, dialect: "tsql" }).visit(value);
}
/**
* Escape a value for use in a ClickHouse query string
*/
export function escapeClickHouseString(value: EscapableValue, timezone?: string): string {
return new SQLValueEscaper({ timezone, dialect: "clickhouse" }).visit(value);
}
/**
* Get the ClickHouse type string for a value
* Used when creating parameterized query placeholders like {param: Type}
*/
export function getClickHouseType(value: unknown): string {
if (value === null || value === undefined) {
return "Nullable(String)";
}
if (typeof value === "string") {
return "String";
}
if (typeof value === "boolean") {
return "UInt8";
}
if (typeof value === "number") {
if (Number.isInteger(value)) {
// Use Int64 for large integers, Int32 for smaller ones
if (value > 2147483647 || value < -2147483648) {
return "Int64";
}
return "Int32";
}
return "Float64";
}
if (value instanceof Date) {
return "DateTime64(6)";
}
if (Array.isArray(value)) {
if (value.length === 0) {
return "Array(String)";
}
const itemType = getClickHouseType(value[0]);
return `Array(${itemType})`;
}
// Default to String for unknown types
return "String";
}
@@ -0,0 +1,648 @@
// TypeScript port of posthog/hogql/functions/mapping.py and aggregations.py
// Keep this file in sync with the Python version
import { CompareOperationOp } from "./ast";
/**
* Metadata for a TSQL function
*/
export interface TSQLFunctionMeta {
/** The ClickHouse function name to use */
clickhouseName: string;
/** Minimum number of arguments */
minArgs: number;
/** Maximum number of arguments (undefined means unlimited) */
maxArgs?: number;
/** Minimum number of parameters (for parametric functions) */
minParams?: number;
/** Maximum number of parameters */
maxParams?: number;
/** Whether this is an aggregate function */
aggregate?: boolean;
/** Whether function is case-sensitive */
caseSensitive?: boolean;
/** Whether function is timezone-aware (will append timezone as last arg) */
tzAware?: boolean;
/** Whether the function uses placeholder arguments like {} */
usingPlaceholderArguments?: boolean;
}
/**
* Comparison function mappings from function names to CompareOperationOp
*/
export const TSQL_COMPARISON_MAPPING: Record<string, CompareOperationOp> = {
equals: CompareOperationOp.Eq,
notEquals: CompareOperationOp.NotEq,
less: CompareOperationOp.Lt,
greater: CompareOperationOp.Gt,
lessOrEquals: CompareOperationOp.LtEq,
greaterOrEquals: CompareOperationOp.GtEq,
like: CompareOperationOp.Like,
ilike: CompareOperationOp.ILike,
notLike: CompareOperationOp.NotLike,
notILike: CompareOperationOp.NotILike,
in: CompareOperationOp.In,
notIn: CompareOperationOp.NotIn,
};
/**
* ClickHouse functions available in TSQL
* Port of HOGQL_CLICKHOUSE_FUNCTIONS from mapping.py
*/
export const TSQL_CLICKHOUSE_FUNCTIONS: Record<string, TSQLFunctionMeta> = {
// Comparison
equals: { clickhouseName: "equals", minArgs: 2, maxArgs: 2 },
notEquals: { clickhouseName: "notEquals", minArgs: 2, maxArgs: 2 },
less: { clickhouseName: "less", minArgs: 2, maxArgs: 2 },
greater: { clickhouseName: "greater", minArgs: 2, maxArgs: 2 },
lessOrEquals: { clickhouseName: "lessOrEquals", minArgs: 2, maxArgs: 2 },
greaterOrEquals: { clickhouseName: "greaterOrEquals", minArgs: 2, maxArgs: 2 },
// Logical
and: { clickhouseName: "and", minArgs: 2 },
or: { clickhouseName: "or", minArgs: 2 },
xor: { clickhouseName: "xor", minArgs: 2 },
not: { clickhouseName: "not", minArgs: 1, maxArgs: 1, caseSensitive: false },
// Conditional
if: { clickhouseName: "if", minArgs: 3, maxArgs: 3, caseSensitive: false },
multiIf: { clickhouseName: "multiIf", minArgs: 3 },
// In
in: { clickhouseName: "in", minArgs: 2, maxArgs: 2 },
notIn: { clickhouseName: "notIn", minArgs: 2, maxArgs: 2 },
// Arithmetic
plus: { clickhouseName: "plus", minArgs: 2, maxArgs: 2 },
minus: { clickhouseName: "minus", minArgs: 2, maxArgs: 2 },
multiply: { clickhouseName: "multiply", minArgs: 2, maxArgs: 2 },
divide: { clickhouseName: "divide", minArgs: 2, maxArgs: 2 },
intDiv: { clickhouseName: "intDiv", minArgs: 2, maxArgs: 2 },
intDivOrZero: { clickhouseName: "intDivOrZero", minArgs: 2, maxArgs: 2 },
modulo: { clickhouseName: "modulo", minArgs: 2, maxArgs: 2 },
moduloOrZero: { clickhouseName: "moduloOrZero", minArgs: 2, maxArgs: 2 },
positiveModulo: { clickhouseName: "positiveModulo", minArgs: 2, maxArgs: 2 },
negate: { clickhouseName: "negate", minArgs: 1, maxArgs: 1 },
abs: { clickhouseName: "abs", minArgs: 1, maxArgs: 1 },
gcd: { clickhouseName: "gcd", minArgs: 2, maxArgs: 2 },
lcm: { clickhouseName: "lcm", minArgs: 2, maxArgs: 2 },
// Mathematical
exp: { clickhouseName: "exp", minArgs: 1, maxArgs: 1 },
log: { clickhouseName: "log", minArgs: 1, maxArgs: 1 },
ln: { clickhouseName: "log", minArgs: 1, maxArgs: 1 },
exp2: { clickhouseName: "exp2", minArgs: 1, maxArgs: 1 },
log2: { clickhouseName: "log2", minArgs: 1, maxArgs: 1 },
exp10: { clickhouseName: "exp10", minArgs: 1, maxArgs: 1 },
log10: { clickhouseName: "log10", minArgs: 1, maxArgs: 1 },
sqrt: { clickhouseName: "sqrt", minArgs: 1, maxArgs: 1 },
cbrt: { clickhouseName: "cbrt", minArgs: 1, maxArgs: 1 },
erf: { clickhouseName: "erf", minArgs: 1, maxArgs: 1 },
erfc: { clickhouseName: "erfc", minArgs: 1, maxArgs: 1 },
lgamma: { clickhouseName: "lgamma", minArgs: 1, maxArgs: 1 },
tgamma: { clickhouseName: "tgamma", minArgs: 1, maxArgs: 1 },
sin: { clickhouseName: "sin", minArgs: 1, maxArgs: 1 },
cos: { clickhouseName: "cos", minArgs: 1, maxArgs: 1 },
tan: { clickhouseName: "tan", minArgs: 1, maxArgs: 1 },
asin: { clickhouseName: "asin", minArgs: 1, maxArgs: 1 },
acos: { clickhouseName: "acos", minArgs: 1, maxArgs: 1 },
atan: { clickhouseName: "atan", minArgs: 1, maxArgs: 1 },
pow: { clickhouseName: "pow", minArgs: 2, maxArgs: 2 },
power: { clickhouseName: "power", minArgs: 2, maxArgs: 2 },
round: { clickhouseName: "round", minArgs: 1, maxArgs: 2 },
floor: { clickhouseName: "floor", minArgs: 1, maxArgs: 2 },
ceil: { clickhouseName: "ceil", minArgs: 1, maxArgs: 2 },
ceiling: { clickhouseName: "ceiling", minArgs: 1, maxArgs: 2 },
trunc: { clickhouseName: "trunc", minArgs: 1, maxArgs: 2 },
truncate: { clickhouseName: "truncate", minArgs: 1, maxArgs: 2 },
sign: { clickhouseName: "sign", minArgs: 1, maxArgs: 1 },
// String functions
empty: { clickhouseName: "empty", minArgs: 1, maxArgs: 1 },
notEmpty: { clickhouseName: "notEmpty", minArgs: 1, maxArgs: 1 },
length: { clickhouseName: "length", minArgs: 1, maxArgs: 1 },
lengthUTF8: { clickhouseName: "lengthUTF8", minArgs: 1, maxArgs: 1 },
char_length: { clickhouseName: "char_length", minArgs: 1, maxArgs: 1 },
character_length: { clickhouseName: "character_length", minArgs: 1, maxArgs: 1 },
lower: { clickhouseName: "lower", minArgs: 1, maxArgs: 1 },
upper: { clickhouseName: "upper", minArgs: 1, maxArgs: 1 },
lowerUTF8: { clickhouseName: "lowerUTF8", minArgs: 1, maxArgs: 1 },
upperUTF8: { clickhouseName: "upperUTF8", minArgs: 1, maxArgs: 1 },
reverse: { clickhouseName: "reverse", minArgs: 1, maxArgs: 1 },
reverseUTF8: { clickhouseName: "reverseUTF8", minArgs: 1, maxArgs: 1 },
concat: { clickhouseName: "concat", minArgs: 1 },
concatAssumeInjective: { clickhouseName: "concatAssumeInjective", minArgs: 1 },
substring: { clickhouseName: "substring", minArgs: 2, maxArgs: 3 },
substr: { clickhouseName: "substring", minArgs: 2, maxArgs: 3 },
mid: { clickhouseName: "substring", minArgs: 2, maxArgs: 3 },
substringUTF8: { clickhouseName: "substringUTF8", minArgs: 2, maxArgs: 3 },
appendTrailingCharIfAbsent: { clickhouseName: "appendTrailingCharIfAbsent", minArgs: 2, maxArgs: 2 },
convertCharset: { clickhouseName: "convertCharset", minArgs: 3, maxArgs: 3 },
base58Encode: { clickhouseName: "base58Encode", minArgs: 1, maxArgs: 1 },
base58Decode: { clickhouseName: "base58Decode", minArgs: 1, maxArgs: 1 },
base64Encode: { clickhouseName: "base64Encode", minArgs: 1, maxArgs: 1 },
base64Decode: { clickhouseName: "base64Decode", minArgs: 1, maxArgs: 1 },
tryBase64Decode: { clickhouseName: "tryBase64Decode", minArgs: 1, maxArgs: 1 },
endsWith: { clickhouseName: "endsWith", minArgs: 2, maxArgs: 2 },
startsWith: { clickhouseName: "startsWith", minArgs: 2, maxArgs: 2 },
trim: { clickhouseName: "trim", minArgs: 1, maxArgs: 2 },
trimLeft: { clickhouseName: "trimLeft", minArgs: 1, maxArgs: 2 },
trimRight: { clickhouseName: "trimRight", minArgs: 1, maxArgs: 2 },
ltrim: { clickhouseName: "trimLeft", minArgs: 1, maxArgs: 1 },
rtrim: { clickhouseName: "trimRight", minArgs: 1, maxArgs: 1 },
leftPad: { clickhouseName: "leftPad", minArgs: 2, maxArgs: 3 },
rightPad: { clickhouseName: "rightPad", minArgs: 2, maxArgs: 3 },
leftPadUTF8: { clickhouseName: "leftPadUTF8", minArgs: 2, maxArgs: 3 },
rightPadUTF8: { clickhouseName: "rightPadUTF8", minArgs: 2, maxArgs: 3 },
left: { clickhouseName: "left", minArgs: 2, maxArgs: 2 },
right: { clickhouseName: "right", minArgs: 2, maxArgs: 2 },
repeat: { clickhouseName: "repeat", minArgs: 2, maxArgs: 2 },
space: { clickhouseName: "space", minArgs: 1, maxArgs: 1 },
replace: { clickhouseName: "replace", minArgs: 3, maxArgs: 3 },
replaceOne: { clickhouseName: "replaceOne", minArgs: 3, maxArgs: 3 },
replaceAll: { clickhouseName: "replaceAll", minArgs: 3, maxArgs: 3 },
replaceRegexpOne: { clickhouseName: "replaceRegexpOne", minArgs: 3, maxArgs: 3 },
replaceRegexpAll: { clickhouseName: "replaceRegexpAll", minArgs: 3, maxArgs: 3 },
position: { clickhouseName: "position", minArgs: 2, maxArgs: 2 },
positionCaseInsensitive: { clickhouseName: "positionCaseInsensitive", minArgs: 2, maxArgs: 2 },
positionUTF8: { clickhouseName: "positionUTF8", minArgs: 2, maxArgs: 2 },
positionCaseInsensitiveUTF8: { clickhouseName: "positionCaseInsensitiveUTF8", minArgs: 2, maxArgs: 2 },
locate: { clickhouseName: "locate", minArgs: 2, maxArgs: 2 },
match: { clickhouseName: "match", minArgs: 2, maxArgs: 2 },
multiMatchAny: { clickhouseName: "multiMatchAny", minArgs: 2, maxArgs: 2 },
multiMatchAnyIndex: { clickhouseName: "multiMatchAnyIndex", minArgs: 2, maxArgs: 2 },
multiMatchAllIndices: { clickhouseName: "multiMatchAllIndices", minArgs: 2, maxArgs: 2 },
multiSearchFirstPosition: { clickhouseName: "multiSearchFirstPosition", minArgs: 2, maxArgs: 2 },
multiSearchFirstIndex: { clickhouseName: "multiSearchFirstIndex", minArgs: 2, maxArgs: 2 },
multiSearchAny: { clickhouseName: "multiSearchAny", minArgs: 2, maxArgs: 2 },
extract: { clickhouseName: "extract", minArgs: 2, maxArgs: 2 },
extractAll: { clickhouseName: "extractAll", minArgs: 2, maxArgs: 2 },
extractAllGroupsHorizontal: { clickhouseName: "extractAllGroupsHorizontal", minArgs: 2, maxArgs: 2 },
extractAllGroupsVertical: { clickhouseName: "extractAllGroupsVertical", minArgs: 2, maxArgs: 2 },
like: { clickhouseName: "like", minArgs: 2, maxArgs: 2 },
ilike: { clickhouseName: "ilike", minArgs: 2, maxArgs: 2 },
notLike: { clickhouseName: "notLike", minArgs: 2, maxArgs: 2 },
notILike: { clickhouseName: "notILike", minArgs: 2, maxArgs: 2 },
splitByChar: { clickhouseName: "splitByChar", minArgs: 2, maxArgs: 3 },
splitByString: { clickhouseName: "splitByString", minArgs: 2, maxArgs: 3 },
splitByRegexp: { clickhouseName: "splitByRegexp", minArgs: 2, maxArgs: 3 },
arrayStringConcat: { clickhouseName: "arrayStringConcat", minArgs: 1, maxArgs: 2 },
format: { clickhouseName: "format", minArgs: 1 },
coalesce: { clickhouseName: "coalesce", minArgs: 1 },
ifNull: { clickhouseName: "ifNull", minArgs: 2, maxArgs: 2 },
nullIf: { clickhouseName: "nullIf", minArgs: 2, maxArgs: 2 },
assumeNotNull: { clickhouseName: "assumeNotNull", minArgs: 1, maxArgs: 1 },
toNullable: { clickhouseName: "toNullable", minArgs: 1, maxArgs: 1 },
isNull: { clickhouseName: "isNull", minArgs: 1, maxArgs: 1 },
isNotNull: { clickhouseName: "isNotNull", minArgs: 1, maxArgs: 1 },
// Type conversions
toString: { clickhouseName: "toString", minArgs: 1, maxArgs: 1 },
toFixedString: { clickhouseName: "toFixedString", minArgs: 2, maxArgs: 2 },
toUInt8: { clickhouseName: "toUInt8", minArgs: 1, maxArgs: 1 },
toUInt16: { clickhouseName: "toUInt16", minArgs: 1, maxArgs: 1 },
toUInt32: { clickhouseName: "toUInt32", minArgs: 1, maxArgs: 1 },
toUInt64: { clickhouseName: "toUInt64", minArgs: 1, maxArgs: 1 },
toInt8: { clickhouseName: "toInt8", minArgs: 1, maxArgs: 1 },
toInt16: { clickhouseName: "toInt16", minArgs: 1, maxArgs: 1 },
toInt32: { clickhouseName: "toInt32", minArgs: 1, maxArgs: 1 },
toInt64: { clickhouseName: "toInt64", minArgs: 1, maxArgs: 1 },
toInt128: { clickhouseName: "toInt128", minArgs: 1, maxArgs: 1 },
toInt256: { clickhouseName: "toInt256", minArgs: 1, maxArgs: 1 },
toUInt128: { clickhouseName: "toUInt128", minArgs: 1, maxArgs: 1 },
toUInt256: { clickhouseName: "toUInt256", minArgs: 1, maxArgs: 1 },
toFloat32: { clickhouseName: "toFloat32", minArgs: 1, maxArgs: 1 },
toFloat64: { clickhouseName: "toFloat64", minArgs: 1, maxArgs: 1 },
toDecimal32: { clickhouseName: "toDecimal32", minArgs: 2, maxArgs: 2 },
toDecimal64: { clickhouseName: "toDecimal64", minArgs: 2, maxArgs: 2 },
toDecimal128: { clickhouseName: "toDecimal128", minArgs: 2, maxArgs: 2 },
toDecimal256: { clickhouseName: "toDecimal256", minArgs: 2, maxArgs: 2 },
toDate: { clickhouseName: "toDate", minArgs: 1, maxArgs: 2 },
toDateOrNull: { clickhouseName: "toDateOrNull", minArgs: 1, maxArgs: 2 },
toDateOrZero: { clickhouseName: "toDateOrZero", minArgs: 1, maxArgs: 2 },
toDate32: { clickhouseName: "toDate32", minArgs: 1, maxArgs: 2 },
toDate32OrNull: { clickhouseName: "toDate32OrNull", minArgs: 1, maxArgs: 2 },
toDate32OrZero: { clickhouseName: "toDate32OrZero", minArgs: 1, maxArgs: 2 },
toDateTime: { clickhouseName: "toDateTime", minArgs: 1, maxArgs: 2 },
toDateTimeOrNull: { clickhouseName: "toDateTimeOrNull", minArgs: 1, maxArgs: 2 },
toDateTimeOrZero: { clickhouseName: "toDateTimeOrZero", minArgs: 1, maxArgs: 2 },
toDateTime64: { clickhouseName: "toDateTime64", minArgs: 1, maxArgs: 3 },
toDateTime64OrNull: { clickhouseName: "toDateTime64OrNull", minArgs: 1, maxArgs: 3 },
toDateTime64OrZero: { clickhouseName: "toDateTime64OrZero", minArgs: 1, maxArgs: 3 },
toUUID: { clickhouseName: "toUUID", minArgs: 1, maxArgs: 1 },
toUUIDOrNull: { clickhouseName: "toUUIDOrNull", minArgs: 1, maxArgs: 1 },
toUUIDOrZero: { clickhouseName: "toUUIDOrZero", minArgs: 1, maxArgs: 1 },
toTypeName: { clickhouseName: "toTypeName", minArgs: 1, maxArgs: 1 },
// Date/time functions
now: { clickhouseName: "now", minArgs: 0, maxArgs: 1, tzAware: true },
now64: { clickhouseName: "now64", minArgs: 0, maxArgs: 2, tzAware: true },
today: { clickhouseName: "today", minArgs: 0, maxArgs: 0 },
yesterday: { clickhouseName: "yesterday", minArgs: 0, maxArgs: 0 },
toYear: { clickhouseName: "toYear", minArgs: 1, maxArgs: 1 },
toQuarter: { clickhouseName: "toQuarter", minArgs: 1, maxArgs: 1 },
toMonth: { clickhouseName: "toMonth", minArgs: 1, maxArgs: 1 },
toDayOfYear: { clickhouseName: "toDayOfYear", minArgs: 1, maxArgs: 1 },
toDayOfMonth: { clickhouseName: "toDayOfMonth", minArgs: 1, maxArgs: 1 },
toDayOfWeek: { clickhouseName: "toDayOfWeek", minArgs: 1, maxArgs: 3 },
toHour: { clickhouseName: "toHour", minArgs: 1, maxArgs: 1 },
toMinute: { clickhouseName: "toMinute", minArgs: 1, maxArgs: 1 },
toSecond: { clickhouseName: "toSecond", minArgs: 1, maxArgs: 1 },
toUnixTimestamp: { clickhouseName: "toUnixTimestamp", minArgs: 1, maxArgs: 2 },
toStartOfYear: { clickhouseName: "toStartOfYear", minArgs: 1, maxArgs: 1 },
toStartOfQuarter: { clickhouseName: "toStartOfQuarter", minArgs: 1, maxArgs: 1 },
toStartOfMonth: { clickhouseName: "toStartOfMonth", minArgs: 1, maxArgs: 1 },
toMonday: { clickhouseName: "toMonday", minArgs: 1, maxArgs: 1 },
toStartOfWeek: { clickhouseName: "toStartOfWeek", minArgs: 1, maxArgs: 2 },
toStartOfDay: { clickhouseName: "toStartOfDay", minArgs: 1, maxArgs: 1 },
toStartOfHour: { clickhouseName: "toStartOfHour", minArgs: 1, maxArgs: 1 },
toStartOfMinute: { clickhouseName: "toStartOfMinute", minArgs: 1, maxArgs: 1 },
toStartOfSecond: { clickhouseName: "toStartOfSecond", minArgs: 1, maxArgs: 1 },
toStartOfFiveMinutes: { clickhouseName: "toStartOfFiveMinutes", minArgs: 1, maxArgs: 1 },
toStartOfTenMinutes: { clickhouseName: "toStartOfTenMinutes", minArgs: 1, maxArgs: 1 },
toStartOfFifteenMinutes: { clickhouseName: "toStartOfFifteenMinutes", minArgs: 1, maxArgs: 1 },
toStartOfInterval: { clickhouseName: "toStartOfInterval", minArgs: 2, maxArgs: 4 },
toTime: { clickhouseName: "toTime", minArgs: 1, maxArgs: 2 },
toISOYear: { clickhouseName: "toISOYear", minArgs: 1, maxArgs: 1 },
toISOWeek: { clickhouseName: "toISOWeek", minArgs: 1, maxArgs: 1 },
toWeek: { clickhouseName: "toWeek", minArgs: 1, maxArgs: 3 },
toYearWeek: { clickhouseName: "toYearWeek", minArgs: 1, maxArgs: 3 },
date_add: { clickhouseName: "date_add", minArgs: 3, maxArgs: 3 },
date_diff: { clickhouseName: "date_diff", minArgs: 3, maxArgs: 4 },
date_sub: { clickhouseName: "date_sub", minArgs: 3, maxArgs: 3 },
date_trunc: { clickhouseName: "date_trunc", minArgs: 2, maxArgs: 3 },
dateDiff: { clickhouseName: "dateDiff", minArgs: 3, maxArgs: 4 },
dateAdd: { clickhouseName: "dateAdd", minArgs: 3, maxArgs: 3 },
dateSub: { clickhouseName: "dateSub", minArgs: 3, maxArgs: 3 },
dateTrunc: { clickhouseName: "dateTrunc", minArgs: 2, maxArgs: 3 },
addSeconds: { clickhouseName: "addSeconds", minArgs: 2, maxArgs: 2 },
addMinutes: { clickhouseName: "addMinutes", minArgs: 2, maxArgs: 2 },
addHours: { clickhouseName: "addHours", minArgs: 2, maxArgs: 2 },
addDays: { clickhouseName: "addDays", minArgs: 2, maxArgs: 2 },
addWeeks: { clickhouseName: "addWeeks", minArgs: 2, maxArgs: 2 },
addMonths: { clickhouseName: "addMonths", minArgs: 2, maxArgs: 2 },
addQuarters: { clickhouseName: "addQuarters", minArgs: 2, maxArgs: 2 },
addYears: { clickhouseName: "addYears", minArgs: 2, maxArgs: 2 },
subtractSeconds: { clickhouseName: "subtractSeconds", minArgs: 2, maxArgs: 2 },
subtractMinutes: { clickhouseName: "subtractMinutes", minArgs: 2, maxArgs: 2 },
subtractHours: { clickhouseName: "subtractHours", minArgs: 2, maxArgs: 2 },
subtractDays: { clickhouseName: "subtractDays", minArgs: 2, maxArgs: 2 },
subtractWeeks: { clickhouseName: "subtractWeeks", minArgs: 2, maxArgs: 2 },
subtractMonths: { clickhouseName: "subtractMonths", minArgs: 2, maxArgs: 2 },
subtractQuarters: { clickhouseName: "subtractQuarters", minArgs: 2, maxArgs: 2 },
subtractYears: { clickhouseName: "subtractYears", minArgs: 2, maxArgs: 2 },
toTimeZone: { clickhouseName: "toTimeZone", minArgs: 2, maxArgs: 2 },
formatDateTime: { clickhouseName: "formatDateTime", minArgs: 2, maxArgs: 3 },
parseDateTime: { clickhouseName: "parseDateTime", minArgs: 2, maxArgs: 3 },
parseDateTimeBestEffort: { clickhouseName: "parseDateTimeBestEffort", minArgs: 1, maxArgs: 2, tzAware: true },
parseDateTimeBestEffortOrNull: { clickhouseName: "parseDateTimeBestEffortOrNull", minArgs: 1, maxArgs: 2, tzAware: true },
parseDateTimeBestEffortOrZero: { clickhouseName: "parseDateTimeBestEffortOrZero", minArgs: 1, maxArgs: 2, tzAware: true },
parseDateTime64BestEffort: { clickhouseName: "parseDateTime64BestEffort", minArgs: 1, maxArgs: 3, tzAware: true },
parseDateTime64BestEffortOrNull: { clickhouseName: "parseDateTime64BestEffortOrNull", minArgs: 1, maxArgs: 3, tzAware: true },
parseDateTime64BestEffortOrZero: { clickhouseName: "parseDateTime64BestEffortOrZero", minArgs: 1, maxArgs: 3, tzAware: true },
// Interval functions
toIntervalSecond: { clickhouseName: "toIntervalSecond", minArgs: 1, maxArgs: 1 },
toIntervalMinute: { clickhouseName: "toIntervalMinute", minArgs: 1, maxArgs: 1 },
toIntervalHour: { clickhouseName: "toIntervalHour", minArgs: 1, maxArgs: 1 },
toIntervalDay: { clickhouseName: "toIntervalDay", minArgs: 1, maxArgs: 1 },
toIntervalWeek: { clickhouseName: "toIntervalWeek", minArgs: 1, maxArgs: 1 },
toIntervalMonth: { clickhouseName: "toIntervalMonth", minArgs: 1, maxArgs: 1 },
toIntervalQuarter: { clickhouseName: "toIntervalQuarter", minArgs: 1, maxArgs: 1 },
toIntervalYear: { clickhouseName: "toIntervalYear", minArgs: 1, maxArgs: 1 },
// Array functions
array: { clickhouseName: "array", minArgs: 0 },
range: { clickhouseName: "range", minArgs: 1, maxArgs: 3 },
arrayElement: { clickhouseName: "arrayElement", minArgs: 2, maxArgs: 2 },
has: { clickhouseName: "has", minArgs: 2, maxArgs: 2 },
hasAll: { clickhouseName: "hasAll", minArgs: 2, maxArgs: 2 },
hasAny: { clickhouseName: "hasAny", minArgs: 2, maxArgs: 2 },
hasSubstr: { clickhouseName: "hasSubstr", minArgs: 2, maxArgs: 2 },
indexOf: { clickhouseName: "indexOf", minArgs: 2, maxArgs: 2 },
arrayCount: { clickhouseName: "arrayCount", minArgs: 1, maxArgs: 2 },
countEqual: { clickhouseName: "countEqual", minArgs: 2, maxArgs: 2 },
arrayEnumerate: { clickhouseName: "arrayEnumerate", minArgs: 1, maxArgs: 1 },
arrayEnumerateDense: { clickhouseName: "arrayEnumerateDense", minArgs: 1 },
arrayEnumerateUniq: { clickhouseName: "arrayEnumerateUniq", minArgs: 1 },
arrayEnumerateUniqRanked: { clickhouseName: "arrayEnumerateUniqRanked", minArgs: 1 },
arrayPopBack: { clickhouseName: "arrayPopBack", minArgs: 1, maxArgs: 1 },
arrayPopFront: { clickhouseName: "arrayPopFront", minArgs: 1, maxArgs: 1 },
arrayPushBack: { clickhouseName: "arrayPushBack", minArgs: 2, maxArgs: 2 },
arrayPushFront: { clickhouseName: "arrayPushFront", minArgs: 2, maxArgs: 2 },
arrayResize: { clickhouseName: "arrayResize", minArgs: 2, maxArgs: 3 },
arraySlice: { clickhouseName: "arraySlice", minArgs: 2, maxArgs: 3 },
arraySort: { clickhouseName: "arraySort", minArgs: 1, maxArgs: 2 },
arrayPartialSort: { clickhouseName: "arrayPartialSort", minArgs: 2, maxArgs: 3 },
arrayReverseSort: { clickhouseName: "arrayReverseSort", minArgs: 1, maxArgs: 2 },
arrayPartialReverseSort: { clickhouseName: "arrayPartialReverseSort", minArgs: 2, maxArgs: 3 },
arrayShuffle: { clickhouseName: "arrayShuffle", minArgs: 1, maxArgs: 2 },
arrayUniq: { clickhouseName: "arrayUniq", minArgs: 1 },
arrayJoin: { clickhouseName: "arrayJoin", minArgs: 1, maxArgs: 1 },
arrayDifference: { clickhouseName: "arrayDifference", minArgs: 1, maxArgs: 1 },
arrayDistinct: { clickhouseName: "arrayDistinct", minArgs: 1, maxArgs: 1 },
arrayIntersect: { clickhouseName: "arrayIntersect", minArgs: 1 },
arrayReduce: { clickhouseName: "arrayReduce", minArgs: 2 },
arrayReverse: { clickhouseName: "arrayReverse", minArgs: 1, maxArgs: 1 },
arrayFlatten: { clickhouseName: "arrayFlatten", minArgs: 1, maxArgs: 1 },
arrayCompact: { clickhouseName: "arrayCompact", minArgs: 1, maxArgs: 1 },
arrayZip: { clickhouseName: "arrayZip", minArgs: 1 },
arrayMap: { clickhouseName: "arrayMap", minArgs: 2, maxArgs: 2 },
arrayFilter: { clickhouseName: "arrayFilter", minArgs: 2, maxArgs: 2 },
arrayFill: { clickhouseName: "arrayFill", minArgs: 2, maxArgs: 2 },
arrayReverseFill: { clickhouseName: "arrayReverseFill", minArgs: 2, maxArgs: 2 },
arraySplit: { clickhouseName: "arraySplit", minArgs: 2, maxArgs: 2 },
arrayReverseSplit: { clickhouseName: "arrayReverseSplit", minArgs: 2, maxArgs: 2 },
arrayExists: { clickhouseName: "arrayExists", minArgs: 1, maxArgs: 2 },
arrayAll: { clickhouseName: "arrayAll", minArgs: 1, maxArgs: 2 },
arrayFirst: { clickhouseName: "arrayFirst", minArgs: 1, maxArgs: 2 },
arrayLast: { clickhouseName: "arrayLast", minArgs: 1, maxArgs: 2 },
arrayFirstIndex: { clickhouseName: "arrayFirstIndex", minArgs: 1, maxArgs: 2 },
arrayLastIndex: { clickhouseName: "arrayLastIndex", minArgs: 1, maxArgs: 2 },
arrayMin: { clickhouseName: "arrayMin", minArgs: 1, maxArgs: 2 },
arrayMax: { clickhouseName: "arrayMax", minArgs: 1, maxArgs: 2 },
arraySum: { clickhouseName: "arraySum", minArgs: 1, maxArgs: 2 },
arrayAvg: { clickhouseName: "arrayAvg", minArgs: 1, maxArgs: 2 },
arrayCumSum: { clickhouseName: "arrayCumSum", minArgs: 1, maxArgs: 2 },
arrayCumSumNonNegative: { clickhouseName: "arrayCumSumNonNegative", minArgs: 1, maxArgs: 2 },
arrayProduct: { clickhouseName: "arrayProduct", minArgs: 1, maxArgs: 1 },
// JSON functions
JSONHas: { clickhouseName: "JSONHas", minArgs: 1 },
JSONLength: { clickhouseName: "JSONLength", minArgs: 1 },
JSONType: { clickhouseName: "JSONType", minArgs: 1 },
JSONExtractUInt: { clickhouseName: "JSONExtractUInt", minArgs: 1 },
JSONExtractInt: { clickhouseName: "JSONExtractInt", minArgs: 1 },
JSONExtractFloat: { clickhouseName: "JSONExtractFloat", minArgs: 1 },
JSONExtractBool: { clickhouseName: "JSONExtractBool", minArgs: 1 },
JSONExtractString: { clickhouseName: "JSONExtractString", minArgs: 1 },
JSONExtract: { clickhouseName: "JSONExtract", minArgs: 2 },
JSONExtractRaw: { clickhouseName: "JSONExtractRaw", minArgs: 1 },
JSONExtractArrayRaw: { clickhouseName: "JSONExtractArrayRaw", minArgs: 1 },
JSONExtractKeysAndValues: { clickhouseName: "JSONExtractKeysAndValues", minArgs: 2, maxArgs: 2 },
JSONExtractKeys: { clickhouseName: "JSONExtractKeys", minArgs: 1 },
toJSONString: { clickhouseName: "toJSONString", minArgs: 1, maxArgs: 1 },
// Tuple functions
tuple: { clickhouseName: "tuple", minArgs: 0 },
tupleElement: { clickhouseName: "tupleElement", minArgs: 2, maxArgs: 3 },
untuple: { clickhouseName: "untuple", minArgs: 1, maxArgs: 1 },
// Map functions
map: { clickhouseName: "map", minArgs: 0 },
mapFromArrays: { clickhouseName: "mapFromArrays", minArgs: 2, maxArgs: 2 },
mapContains: { clickhouseName: "mapContains", minArgs: 2, maxArgs: 2 },
mapKeys: { clickhouseName: "mapKeys", minArgs: 1, maxArgs: 1 },
mapValues: { clickhouseName: "mapValues", minArgs: 1, maxArgs: 1 },
// Hash functions
MD5: { clickhouseName: "MD5", minArgs: 1, maxArgs: 1 },
SHA1: { clickhouseName: "SHA1", minArgs: 1, maxArgs: 1 },
SHA224: { clickhouseName: "SHA224", minArgs: 1, maxArgs: 1 },
SHA256: { clickhouseName: "SHA256", minArgs: 1, maxArgs: 1 },
SHA384: { clickhouseName: "SHA384", minArgs: 1, maxArgs: 1 },
SHA512: { clickhouseName: "SHA512", minArgs: 1, maxArgs: 1 },
sipHash64: { clickhouseName: "sipHash64", minArgs: 1 },
sipHash128: { clickhouseName: "sipHash128", minArgs: 1 },
cityHash64: { clickhouseName: "cityHash64", minArgs: 1 },
intHash32: { clickhouseName: "intHash32", minArgs: 1, maxArgs: 1 },
intHash64: { clickhouseName: "intHash64", minArgs: 1, maxArgs: 1 },
farmHash64: { clickhouseName: "farmHash64", minArgs: 1 },
farmFingerprint64: { clickhouseName: "farmFingerprint64", minArgs: 1 },
xxHash32: { clickhouseName: "xxHash32", minArgs: 1 },
xxHash64: { clickhouseName: "xxHash64", minArgs: 1 },
murmurHash2_32: { clickhouseName: "murmurHash2_32", minArgs: 1 },
murmurHash2_64: { clickhouseName: "murmurHash2_64", minArgs: 1 },
murmurHash3_32: { clickhouseName: "murmurHash3_32", minArgs: 1 },
murmurHash3_64: { clickhouseName: "murmurHash3_64", minArgs: 1 },
murmurHash3_128: { clickhouseName: "murmurHash3_128", minArgs: 1 },
hex: { clickhouseName: "hex", minArgs: 1, maxArgs: 1 },
unhex: { clickhouseName: "unhex", minArgs: 1, maxArgs: 1 },
// URL functions
protocol: { clickhouseName: "protocol", minArgs: 1, maxArgs: 1 },
domain: { clickhouseName: "domain", minArgs: 1, maxArgs: 1 },
domainWithoutWWW: { clickhouseName: "domainWithoutWWW", minArgs: 1, maxArgs: 1 },
topLevelDomain: { clickhouseName: "topLevelDomain", minArgs: 1, maxArgs: 1 },
firstSignificantSubdomain: { clickhouseName: "firstSignificantSubdomain", minArgs: 1, maxArgs: 1 },
cutToFirstSignificantSubdomain: { clickhouseName: "cutToFirstSignificantSubdomain", minArgs: 1, maxArgs: 1 },
cutToFirstSignificantSubdomainWithWWW: { clickhouseName: "cutToFirstSignificantSubdomainWithWWW", minArgs: 1, maxArgs: 1 },
port: { clickhouseName: "port", minArgs: 1, maxArgs: 2 },
path: { clickhouseName: "path", minArgs: 1, maxArgs: 1 },
pathFull: { clickhouseName: "pathFull", minArgs: 1, maxArgs: 1 },
queryString: { clickhouseName: "queryString", minArgs: 1, maxArgs: 1 },
fragment: { clickhouseName: "fragment", minArgs: 1, maxArgs: 1 },
extractURLParameter: { clickhouseName: "extractURLParameter", minArgs: 2, maxArgs: 2 },
extractURLParameters: { clickhouseName: "extractURLParameters", minArgs: 1, maxArgs: 1 },
encodeURLComponent: { clickhouseName: "encodeURLComponent", minArgs: 1, maxArgs: 1 },
decodeURLComponent: { clickhouseName: "decodeURLComponent", minArgs: 1, maxArgs: 1 },
// UUID functions
generateUUIDv4: { clickhouseName: "generateUUIDv4", minArgs: 0, maxArgs: 0 },
UUIDStringToNum: { clickhouseName: "UUIDStringToNum", minArgs: 1, maxArgs: 1 },
UUIDNumToString: { clickhouseName: "UUIDNumToString", minArgs: 1, maxArgs: 1 },
// Other functions
isFinite: { clickhouseName: "isFinite", minArgs: 1, maxArgs: 1 },
isInfinite: { clickhouseName: "isInfinite", minArgs: 1, maxArgs: 1 },
ifNotFinite: { clickhouseName: "ifNotFinite", minArgs: 1, maxArgs: 1 },
isNaN: { clickhouseName: "isNaN", minArgs: 1, maxArgs: 1 },
bar: { clickhouseName: "bar", minArgs: 4, maxArgs: 4 },
transform: { clickhouseName: "transform", minArgs: 3, maxArgs: 4 },
formatReadableDecimalSize: { clickhouseName: "formatReadableDecimalSize", minArgs: 1, maxArgs: 1 },
formatReadableSize: { clickhouseName: "formatReadableSize", minArgs: 1, maxArgs: 1 },
formatReadableQuantity: { clickhouseName: "formatReadableQuantity", minArgs: 1, maxArgs: 1 },
formatReadableTimeDelta: { clickhouseName: "formatReadableTimeDelta", minArgs: 1, maxArgs: 2 },
least: { clickhouseName: "least", minArgs: 2, maxArgs: 2, caseSensitive: false },
greatest: { clickhouseName: "greatest", minArgs: 2, maxArgs: 2, caseSensitive: false },
min2: { clickhouseName: "min2", minArgs: 2, maxArgs: 2 },
max2: { clickhouseName: "max2", minArgs: 2, maxArgs: 2 },
runningDifference: { clickhouseName: "runningDifference", minArgs: 1, maxArgs: 1 },
runningDifferenceStartingWithFirstValue: { clickhouseName: "runningDifferenceStartingWithFirstValue", minArgs: 1, maxArgs: 1 },
neighbor: { clickhouseName: "neighbor", minArgs: 2, maxArgs: 3 },
// Window functions
rank: { clickhouseName: "rank", minArgs: 0, maxArgs: 0 },
dense_rank: { clickhouseName: "dense_rank", minArgs: 0, maxArgs: 0 },
row_number: { clickhouseName: "row_number", minArgs: 0, maxArgs: 0 },
first_value: { clickhouseName: "first_value", minArgs: 1, maxArgs: 1 },
last_value: { clickhouseName: "last_value", minArgs: 1, maxArgs: 1 },
nth_value: { clickhouseName: "nth_value", minArgs: 2, maxArgs: 2 },
lagInFrame: { clickhouseName: "lagInFrame", minArgs: 1, maxArgs: 3 },
leadInFrame: { clickhouseName: "leadInFrame", minArgs: 1, maxArgs: 3 },
lag: { clickhouseName: "lagInFrame", minArgs: 1, maxArgs: 3 },
lead: { clickhouseName: "leadInFrame", minArgs: 1, maxArgs: 3 },
};
/**
* Aggregate functions available in TSQL
* Port of HOGQL_AGGREGATIONS from aggregations.py
*/
export const TSQL_AGGREGATIONS: Record<string, TSQLFunctionMeta> = {
// Standard aggregate functions
count: { clickhouseName: "count", minArgs: 0, maxArgs: 1, aggregate: true, caseSensitive: false },
countIf: { clickhouseName: "countIf", minArgs: 1, maxArgs: 2, aggregate: true },
countDistinct: { clickhouseName: "countDistinct", minArgs: 1, maxArgs: 1, aggregate: true },
countDistinctIf: { clickhouseName: "countDistinctIf", minArgs: 1, maxArgs: 2, aggregate: true },
min: { clickhouseName: "min", minArgs: 1, maxArgs: 1, aggregate: true, caseSensitive: false },
minIf: { clickhouseName: "minIf", minArgs: 2, maxArgs: 2, aggregate: true },
max: { clickhouseName: "max", minArgs: 1, maxArgs: 1, aggregate: true, caseSensitive: false },
maxIf: { clickhouseName: "maxIf", minArgs: 2, maxArgs: 2, aggregate: true },
sum: { clickhouseName: "sum", minArgs: 1, maxArgs: 1, aggregate: true, caseSensitive: false },
sumIf: { clickhouseName: "sumIf", minArgs: 2, maxArgs: 2, aggregate: true },
avg: { clickhouseName: "avg", minArgs: 1, maxArgs: 1, aggregate: true, caseSensitive: false },
avgIf: { clickhouseName: "avgIf", minArgs: 2, maxArgs: 2, aggregate: true },
any: { clickhouseName: "any", minArgs: 1, maxArgs: 1, aggregate: true },
anyIf: { clickhouseName: "anyIf", minArgs: 2, maxArgs: 2, aggregate: true },
anyLast: { clickhouseName: "anyLast", minArgs: 1, maxArgs: 1, aggregate: true },
anyLastIf: { clickhouseName: "anyLastIf", minArgs: 2, maxArgs: 2, aggregate: true },
anyHeavy: { clickhouseName: "anyHeavy", minArgs: 1, maxArgs: 1, aggregate: true },
anyHeavyIf: { clickhouseName: "anyHeavyIf", minArgs: 2, maxArgs: 2, aggregate: true },
argMin: { clickhouseName: "argMin", minArgs: 2, maxArgs: 2, aggregate: true },
argMinIf: { clickhouseName: "argMinIf", minArgs: 3, maxArgs: 3, aggregate: true },
argMax: { clickhouseName: "argMax", minArgs: 2, maxArgs: 2, aggregate: true },
argMaxIf: { clickhouseName: "argMaxIf", minArgs: 3, maxArgs: 3, aggregate: true },
stddevPop: { clickhouseName: "stddevPop", minArgs: 1, maxArgs: 1, aggregate: true },
stddevSamp: { clickhouseName: "stddevSamp", minArgs: 1, maxArgs: 1, aggregate: true },
varPop: { clickhouseName: "varPop", minArgs: 1, maxArgs: 1, aggregate: true },
varSamp: { clickhouseName: "varSamp", minArgs: 1, maxArgs: 1, aggregate: true },
covarPop: { clickhouseName: "covarPop", minArgs: 2, maxArgs: 2, aggregate: true },
covarSamp: { clickhouseName: "covarSamp", minArgs: 2, maxArgs: 2, aggregate: true },
corr: { clickhouseName: "corr", minArgs: 2, maxArgs: 2, aggregate: true },
// Array aggregations
groupArray: { clickhouseName: "groupArray", minArgs: 1, maxArgs: 1, aggregate: true },
groupArrayIf: { clickhouseName: "groupArrayIf", minArgs: 2, maxArgs: 2, aggregate: true },
groupUniqArray: { clickhouseName: "groupUniqArray", minArgs: 1, maxArgs: 1, aggregate: true },
groupUniqArrayIf: { clickhouseName: "groupUniqArrayIf", minArgs: 2, maxArgs: 2, aggregate: true },
groupArrayInsertAt: { clickhouseName: "groupArrayInsertAt", minArgs: 2, maxArgs: 2, aggregate: true },
groupArrayMovingAvg: { clickhouseName: "groupArrayMovingAvg", minArgs: 1, maxArgs: 1, aggregate: true },
groupArrayMovingSum: { clickhouseName: "groupArrayMovingSum", minArgs: 1, maxArgs: 1, aggregate: true },
groupArraySample: { clickhouseName: "groupArraySample", minArgs: 1, maxArgs: 1, minParams: 1, maxParams: 2, aggregate: true },
array_agg: { clickhouseName: "groupArray", minArgs: 1, maxArgs: 1, aggregate: true },
// Bitmap aggregations
groupBitmap: { clickhouseName: "groupBitmap", minArgs: 1, maxArgs: 1, aggregate: true },
groupBitmapAnd: { clickhouseName: "groupBitmapAnd", minArgs: 1, maxArgs: 1, aggregate: true },
groupBitmapOr: { clickhouseName: "groupBitmapOr", minArgs: 1, maxArgs: 1, aggregate: true },
groupBitmapXor: { clickhouseName: "groupBitmapXor", minArgs: 1, maxArgs: 1, aggregate: true },
// Uniq functions
uniq: { clickhouseName: "uniq", minArgs: 1, aggregate: true },
uniqIf: { clickhouseName: "uniqIf", minArgs: 2, aggregate: true },
uniqExact: { clickhouseName: "uniqExact", minArgs: 1, aggregate: true },
uniqExactIf: { clickhouseName: "uniqExactIf", minArgs: 2, aggregate: true },
uniqHLL12: { clickhouseName: "uniqHLL12", minArgs: 1, aggregate: true },
uniqTheta: { clickhouseName: "uniqTheta", minArgs: 1, aggregate: true },
// Quantile functions
median: { clickhouseName: "median", minArgs: 1, maxArgs: 1, aggregate: true },
medianIf: { clickhouseName: "medianIf", minArgs: 2, maxArgs: 2, aggregate: true },
medianExact: { clickhouseName: "medianExact", minArgs: 1, maxArgs: 1, aggregate: true },
quantile: { clickhouseName: "quantile", minArgs: 1, maxArgs: 1, minParams: 1, maxParams: 1, aggregate: true },
quantileIf: { clickhouseName: "quantileIf", minArgs: 2, maxArgs: 2, minParams: 1, maxParams: 1, aggregate: true },
quantiles: { clickhouseName: "quantiles", minArgs: 1, aggregate: true },
// Statistical functions
simpleLinearRegression: { clickhouseName: "simpleLinearRegression", minArgs: 2, maxArgs: 2, aggregate: true },
contingency: { clickhouseName: "contingency", minArgs: 2, maxArgs: 2, aggregate: true },
cramersV: { clickhouseName: "cramersV", minArgs: 2, maxArgs: 2, aggregate: true },
theilsU: { clickhouseName: "theilsU", minArgs: 2, maxArgs: 2, aggregate: true },
// Sum/Map variants
sumMap: { clickhouseName: "sumMap", minArgs: 1, maxArgs: 2, aggregate: true },
minMap: { clickhouseName: "minMap", minArgs: 1, maxArgs: 2, aggregate: true },
maxMap: { clickhouseName: "maxMap", minArgs: 1, maxArgs: 2, aggregate: true },
// TopK
topK: { clickhouseName: "topK", minArgs: 1, maxArgs: 1, minParams: 1, maxParams: 1, aggregate: true },
// Funnel
windowFunnel: { clickhouseName: "windowFunnel", minArgs: 1, maxArgs: 99, aggregate: true },
};
/**
* Find a function in the TSQL functions map
* Supports case-insensitive lookup for non-case-sensitive functions
*/
function findFunction(
name: string,
functions: Record<string, TSQLFunctionMeta>
): TSQLFunctionMeta | undefined {
const func = functions[name];
if (func !== undefined) {
return func;
}
const lowerFunc = functions[name.toLowerCase()];
if (lowerFunc === undefined) {
return undefined;
}
// If we haven't found a function with the case preserved, but we have found it in lowercase,
// then the function names are different case-wise only.
if (lowerFunc.caseSensitive) {
return undefined;
}
return lowerFunc;
}
/**
* Find a TSQL aggregation function by name
*/
export function findTSQLAggregation(name: string): TSQLFunctionMeta | undefined {
return findFunction(name, TSQL_AGGREGATIONS);
}
/**
* Find a TSQL function by name
*/
export function findTSQLFunction(name: string): TSQLFunctionMeta | undefined {
return findFunction(name, TSQL_CLICKHOUSE_FUNCTIONS);
}
/**
* Get all exposed function names (for autocomplete, suggestions, etc.)
*/
export function getAllExposedFunctionNames(): string[] {
const functionNames = Object.keys(TSQL_CLICKHOUSE_FUNCTIONS).filter((name) => !name.startsWith("_"));
const aggregationNames = Object.keys(TSQL_AGGREGATIONS).filter((name) => !name.startsWith("_"));
return [...functionNames, ...aggregationNames];
}
/**
* Validate function arguments
*/
export function validateFunctionArgs(
args: unknown[],
minArgs: number,
maxArgs: number | undefined,
functionName: string,
options: {
functionTerm?: string;
argumentTerm?: string;
} = {}
): void {
const { functionTerm = "function", argumentTerm = "argument" } = options;
const tooFew = args.length < minArgs;
const tooMany = maxArgs !== undefined && args.length > maxArgs;
if (minArgs === maxArgs && (tooFew || tooMany)) {
throw new Error(
`${functionTerm.charAt(0).toUpperCase() + functionTerm.slice(1)} '${functionName}' expects ${minArgs} ${argumentTerm}${minArgs !== 1 ? "s" : ""}, found ${args.length}`
);
}
if (tooFew) {
throw new Error(
`${functionTerm.charAt(0).toUpperCase() + functionTerm.slice(1)} '${functionName}' expects at least ${minArgs} ${argumentTerm}${minArgs !== 1 ? "s" : ""}, found ${args.length}`
);
}
if (tooMany) {
throw new Error(
`${functionTerm.charAt(0).toUpperCase() + functionTerm.slice(1)} '${functionName}' expects at most ${maxArgs} ${argumentTerm}${maxArgs !== 1 ? "s" : ""}, found ${args.length}`
);
}
}
+253
View File
@@ -0,0 +1,253 @@
// TypeScript translation of posthog/hogql/database/models.py
import type { Expr, ConstantType } from "./ast";
import type { TSQLContext } from "./context";
export interface FieldOrTable {
hidden?: boolean;
}
export interface DatabaseField extends FieldOrTable {
name: string;
array?: boolean;
nullable?: boolean;
is_nullable?(): boolean;
get_constant_type?(): ConstantType;
default_value?(): any;
}
export interface IntegerDatabaseField extends DatabaseField {}
export interface FloatDatabaseField extends DatabaseField {}
export interface DecimalDatabaseField extends DatabaseField {}
export interface StringDatabaseField extends DatabaseField {}
export interface UnknownDatabaseField extends DatabaseField {}
export interface StringJSONDatabaseField extends DatabaseField {}
export interface StringArrayDatabaseField extends DatabaseField {}
export interface FloatArrayDatabaseField extends DatabaseField {}
export interface DateDatabaseField extends DatabaseField {}
export interface DateTimeDatabaseField extends DatabaseField {}
export interface BooleanDatabaseField extends DatabaseField {}
export interface UUIDDatabaseField extends DatabaseField {}
export interface ExpressionField extends DatabaseField {
expr: Expr;
isolate_scope?: boolean;
}
export interface FieldTraverser extends FieldOrTable {
chain: Array<string | number>;
}
export interface Table extends FieldOrTable {
fields: Record<string, FieldOrTable>;
has_field?(name: string | number): boolean;
get_field?(name: string | number): FieldOrTable;
to_printed_clickhouse?(context: TSQLContext): string;
to_printed_tsql?(): string;
avoid_asterisk_fields?(): string[];
get_asterisk?(): Record<string, FieldOrTable>;
}
export interface LazyJoin extends FieldOrTable {
join_function?(from_table: Table, to_table: Table, requesting_table: Table): Expr;
resolve_table?(context: TSQLContext): Table;
}
export interface LazyTable extends Table {}
export interface VirtualTable extends Table {}
export interface SavedQuery extends Table {
query: Expr;
}
export interface FunctionCallTable extends Table {
call_function?(context: TSQLContext): Expr;
}
export interface TableNode {
name: "root" | string;
table?: FieldOrTable | null;
children: Record<string, TableNode>;
get?(): FieldOrTable;
has_child?(path: string[]): boolean;
get_child?(path: string[]): TableNode;
add_child?(
child: TableNode,
options?: {
table_conflict_mode?: "override" | "ignore";
children_conflict_mode?: "override" | "merge" | "ignore";
}
): void;
merge_with?(
other: TableNode,
options?: {
table_conflict_mode?: "override" | "ignore";
children_conflict_mode?: "override" | "merge" | "ignore";
}
): void;
resolve_all_table_names?(): string[];
}
// Basic TableNode implementation class
export class TableNodeImpl implements TableNode {
name: "root" | string;
table?: FieldOrTable | null;
children: Record<string, TableNode>;
constructor(name: "root" | string = "root", table?: FieldOrTable | null) {
this.name = name;
this.table = table || null;
this.children = {};
}
get(): FieldOrTable {
if (this.table === null || this.table === undefined) {
throw new Error(`Table is not set at \`${this.name}\``);
}
return this.table;
}
has_child(path: string[]): boolean {
if (path.length === 0) {
return this.table !== null && this.table !== undefined;
}
const [first, ...restOfPath] = path;
if (!(first in this.children)) {
return false;
}
return this.children[first].has_child ? this.children[first].has_child!(restOfPath) : false;
}
get_child(path: string[]): TableNode {
if (path.length === 0) {
return this;
}
const [first, ...restOfPath] = path;
if (!(first in this.children)) {
throw new Error(`Unknown child \`${first}\` at \`${this.name}\`.`);
}
return this.children[first].get_child
? this.children[first].get_child!(restOfPath)
: this.children[first];
}
add_child(
child: TableNode,
options?: {
table_conflict_mode?: "override" | "ignore";
children_conflict_mode?: "override" | "merge" | "ignore";
}
): void {
const tableConflictMode = options?.table_conflict_mode || "ignore";
const childrenConflictMode = options?.children_conflict_mode || "merge";
if (child.name in this.children) {
if (childrenConflictMode === "override") {
this.children[child.name] = child;
} else if (childrenConflictMode === "merge") {
const existing = this.children[child.name];
if (existing.merge_with) {
existing.merge_with(child, {
table_conflict_mode: tableConflictMode,
children_conflict_mode: childrenConflictMode,
});
}
}
// ignore mode: do nothing
return;
}
this.children[child.name] = child;
}
merge_with(
other: TableNode,
options?: {
table_conflict_mode?: "override" | "ignore";
children_conflict_mode?: "override" | "merge" | "ignore";
}
): void {
const tableConflictMode = options?.table_conflict_mode || "ignore";
const childrenConflictMode = options?.children_conflict_mode || "merge";
if (other.table !== null && other.table !== undefined) {
if (this.table === null || this.table === undefined) {
this.table = other.table;
} else {
// Conflict - check conflict mode
if (tableConflictMode === "override") {
this.table = other.table;
}
// ignore mode: do nothing
}
}
for (const child of Object.values(other.children)) {
this.add_child(child, {
table_conflict_mode: tableConflictMode,
children_conflict_mode: childrenConflictMode,
});
}
}
resolve_all_table_names(): string[] {
const names: string[] = [];
if (this.table !== null && this.table !== undefined) {
names.push(this.name);
}
for (const child of Object.values(this.children)) {
const childNames = child.resolve_all_table_names ? child.resolve_all_table_names() : [];
// The root node should NOT include itself in the names
if (this.name === "root") {
names.push(...childNames);
} else {
names.push(...childNames.map((x) => `${this.name}.${x}`));
}
}
return names;
}
static createNestedForChain(chain: string[], table: Table): TableNode {
if (chain.length === 0) {
throw new Error("Chain must have at least one element");
}
const start = new TableNodeImpl(chain[0]);
let current: TableNode = start;
for (let i = 1; i < chain.length; i++) {
const child = new TableNodeImpl(chain[i]);
if (current.add_child) {
current.add_child(child);
} else {
current.children[child.name] = child;
}
current = child;
}
current.table = table;
return start;
}
}
export interface LazyTableToAdd {
lazy_table: LazyTable;
fields_accessed: Record<string, Array<string | number>>;
}
export interface LazyJoinToAdd {
from_table: string;
to_table: string;
lazy_join: LazyJoin;
lazy_join_type: any; // LazyJoinType from ast.ts
fields_accessed: Record<string, Array<string | number>>;
}
@@ -0,0 +1,65 @@
// TypeScript translation of posthog/hogql/parse_string.py
// Keep this file in sync with the Python version
import { SyntaxError } from './errors';
function replaceCommonEscapeCharacters(text: string): string {
// copied from clickhouse_driver/util/escape.py
// Note: \a (bell) and \v (vertical tab) are not directly supported in JavaScript strings
// but we handle them as escape sequences that get replaced
text = text.replace(/\\b/g, '\b');
text = text.replace(/\\f/g, '\f');
text = text.replace(/\\r/g, '\r');
text = text.replace(/\\n/g, '\n');
text = text.replace(/\\t/g, '\t');
text = text.replace(/\\0/g, ''); // NUL characters are ignored
text = text.replace(/\\a/g, '\x07'); // Bell character (ASCII 7)
text = text.replace(/\\v/g, '\x0B'); // Vertical tab (ASCII 11)
text = text.replace(/\\\\/g, '\\');
return text;
}
export function parseStringLiteralText(text: string): string {
/** Converts a string received from antlr via ctx.getText() into a JavaScript string */
let result: string;
if (text.startsWith("'") && text.endsWith("'")) {
result = text.slice(1, -1);
result = result.replace(/''/g, "'");
result = result.replace(/\\'/g, "'");
} else if (text.startsWith('"') && text.endsWith('"')) {
result = text.slice(1, -1);
result = result.replace(/""/g, '"');
result = result.replace(/\\"/g, '"');
} else if (text.startsWith('`') && text.endsWith('`')) {
result = text.slice(1, -1);
result = result.replace(/``/g, '`');
result = result.replace(/\\`/g, '`');
} else if (text.startsWith('{') && text.endsWith('}')) {
result = text.slice(1, -1);
result = result.replace(/{{/g, '{');
result = result.replace(/\\{/g, '{');
} else {
throw new SyntaxError(`Invalid string literal, must start and end with the same quote type: ${text}`);
}
return replaceCommonEscapeCharacters(result);
}
export function parseStringLiteralCtx(ctx: { getText(): string }): string {
/** Converts a STRING_LITERAL received from antlr via ctx.getText() into a JavaScript string */
const text = ctx.getText();
return parseStringLiteralText(text);
}
export function parseStringTextCtx(ctx: { getText(): string }, escapeQuotes: boolean = true): string {
/** Converts a STRING_TEXT received from antlr via ctx.getText() into a JavaScript string */
let text = ctx.getText();
if (escapeQuotes) {
text = text.replace(/''/g, "'");
text = text.replace(/\\'/g, "'");
}
text = text.replace(/\\{/g, '{');
return replaceCommonEscapeCharacters(text);
}
@@ -0,0 +1,629 @@
import { describe, it, expect } from "vitest";
import { CharStreams, CommonTokenStream } from "antlr4ts";
import { TSQLLexer } from "../grammar/TSQLLexer.js";
import { TSQLParser } from "../grammar/TSQLParser.js";
import { TSQLParseTreeConverter } from "./parser.js";
import { ArithmeticOperationOp, CompareOperationOp } from "./ast.js";
import { SyntaxError } from "./errors.js";
/**
* Helper function to parse TSQL input and convert to AST
*/
function parseAndConvert(input: string) {
const inputStream = CharStreams.fromString(input);
const lexer = new TSQLLexer(inputStream);
const tokenStream = new CommonTokenStream(lexer);
const parser = new TSQLParser(tokenStream);
const parseTree = parser.select();
const converter = new TSQLParseTreeConverter();
return converter.visit(parseTree);
}
describe("TSQLParseTreeConverter", () => {
describe("SELECT statements", () => {
it("should convert a simple SELECT statement", () => {
const ast = parseAndConvert("SELECT * FROM users");
expect(ast).toMatchObject({
expression_type: "select_query",
select: [{ expression_type: "field", chain: ["*"] }],
select_from: {
expression_type: "join_expr",
table: { expression_type: "field", chain: ["users"] },
},
});
});
it("should convert SELECT with multiple columns", () => {
const ast = parseAndConvert("SELECT id, name, email FROM users");
expect(ast).toMatchObject({
expression_type: "select_query",
select: [
{ expression_type: "field", chain: ["id"] },
{ expression_type: "field", chain: ["name"] },
{ expression_type: "field", chain: ["email"] },
],
select_from: {
expression_type: "join_expr",
table: { expression_type: "field", chain: ["users"] },
},
});
});
it("should convert SELECT with DISTINCT", () => {
const ast = parseAndConvert("SELECT DISTINCT id FROM users");
expect(ast).toMatchObject({
expression_type: "select_query",
distinct: true,
select: [{ expression_type: "field", chain: ["id"] }],
});
});
it("should convert SELECT with WHERE clause", () => {
const ast = parseAndConvert("SELECT * FROM users WHERE id = 1");
expect(ast).toMatchObject({
expression_type: "select_query",
select: [{ expression_type: "field", chain: ["*"] }],
select_from: {
expression_type: "join_expr",
table: { expression_type: "field", chain: ["users"] },
},
where: {
expression_type: "compare_operation",
op: CompareOperationOp.Eq,
left: { expression_type: "field", chain: ["id"] },
right: { expression_type: "constant", value: 1 },
},
});
});
it("should convert SELECT with ORDER BY", () => {
const ast = parseAndConvert("SELECT * FROM users ORDER BY id DESC");
expect(ast).toMatchObject({
expression_type: "select_query",
select: [{ expression_type: "field", chain: ["*"] }],
order_by: [
{
expression_type: "order_expr",
order: "DESC",
expr: { expression_type: "field", chain: ["id"] },
},
],
});
});
it("should convert SELECT with LIMIT", () => {
const ast = parseAndConvert("SELECT * FROM users LIMIT 10");
expect(ast).toMatchObject({
expression_type: "select_query",
select: [{ expression_type: "field", chain: ["*"] }],
limit: { expression_type: "constant", value: 10 },
});
});
it("should convert SELECT with LIMIT and OFFSET", () => {
const ast = parseAndConvert("SELECT * FROM users LIMIT 10 OFFSET 5");
expect(ast).toMatchObject({
expression_type: "select_query",
select: [{ expression_type: "field", chain: ["*"] }],
limit: { expression_type: "constant", value: 10 },
offset: { expression_type: "constant", value: 5 },
});
});
it("should convert SELECT with GROUP BY", () => {
const ast = parseAndConvert("SELECT category, COUNT(*) FROM products GROUP BY category");
expect(ast).toMatchObject({
expression_type: "select_query",
select: [
{ expression_type: "field", chain: ["category"] },
{ expression_type: "call", name: "COUNT" },
],
group_by: [{ expression_type: "field", chain: ["category"] }],
});
});
it("should convert SELECT with HAVING", () => {
const ast = parseAndConvert(
"SELECT category FROM products GROUP BY category HAVING COUNT(*) > 10"
);
expect(ast).toMatchObject({
expression_type: "select_query",
select: [{ expression_type: "field", chain: ["category"] }],
group_by: [{ expression_type: "field", chain: ["category"] }],
having: {
expression_type: "compare_operation",
op: CompareOperationOp.Gt,
left: { expression_type: "call", name: "COUNT" },
right: { expression_type: "constant", value: 10 },
},
});
});
});
describe("expressions", () => {
it("should convert numeric constants", () => {
const ast = parseAndConvert("SELECT 42 FROM users");
expect(ast).toMatchObject({
expression_type: "select_query",
select: [{ expression_type: "constant", value: 42 }],
});
});
it("should convert string constants", () => {
const ast = parseAndConvert("SELECT 'hello' FROM users");
expect(ast).toMatchObject({
expression_type: "select_query",
select: [{ expression_type: "constant", value: "hello" }],
});
});
it("should convert boolean constants", () => {
const ast = parseAndConvert("SELECT true FROM users");
expect(ast).toMatchObject({
expression_type: "select_query",
select: [{ expression_type: "constant", value: true }],
});
});
it("should convert arithmetic addition", () => {
const ast = parseAndConvert("SELECT 1 + 2 FROM users");
expect(ast).toMatchObject({
expression_type: "select_query",
select: [
{
expression_type: "arithmetic_operation",
op: ArithmeticOperationOp.Add,
left: { expression_type: "constant", value: 1 },
right: { expression_type: "constant", value: 2 },
},
],
});
});
it("should convert arithmetic subtraction", () => {
const ast = parseAndConvert("SELECT 5 - 3 FROM users");
expect(ast).toMatchObject({
expression_type: "select_query",
select: [
{
expression_type: "arithmetic_operation",
op: ArithmeticOperationOp.Sub,
left: { expression_type: "constant", value: 5 },
right: { expression_type: "constant", value: 3 },
},
],
});
});
it("should convert arithmetic multiplication", () => {
const ast = parseAndConvert("SELECT 2 * 3 FROM users");
expect(ast).toMatchObject({
expression_type: "select_query",
select: [
{
expression_type: "arithmetic_operation",
op: ArithmeticOperationOp.Mult,
left: { expression_type: "constant", value: 2 },
right: { expression_type: "constant", value: 3 },
},
],
});
});
it("should convert arithmetic division", () => {
const ast = parseAndConvert("SELECT 10 / 2 FROM users");
expect(ast).toMatchObject({
expression_type: "select_query",
select: [
{
expression_type: "arithmetic_operation",
op: ArithmeticOperationOp.Div,
left: { expression_type: "constant", value: 10 },
right: { expression_type: "constant", value: 2 },
},
],
});
});
it("should convert comparison equals", () => {
const ast = parseAndConvert("SELECT * FROM users WHERE id = 1");
expect(ast).toMatchObject({
expression_type: "select_query",
where: {
expression_type: "compare_operation",
op: CompareOperationOp.Eq,
left: { expression_type: "field", chain: ["id"] },
right: { expression_type: "constant", value: 1 },
},
});
});
it("should convert comparison not equals", () => {
const ast = parseAndConvert("SELECT * FROM users WHERE id != 1");
expect(ast).toMatchObject({
expression_type: "select_query",
where: {
expression_type: "compare_operation",
op: CompareOperationOp.NotEq,
left: { expression_type: "field", chain: ["id"] },
right: { expression_type: "constant", value: 1 },
},
});
});
it("should convert comparison less than", () => {
const ast = parseAndConvert("SELECT * FROM users WHERE id < 10");
expect(ast).toMatchObject({
expression_type: "select_query",
where: {
expression_type: "compare_operation",
op: CompareOperationOp.Lt,
left: { expression_type: "field", chain: ["id"] },
right: { expression_type: "constant", value: 10 },
},
});
});
it("should convert comparison greater than", () => {
const ast = parseAndConvert("SELECT * FROM users WHERE id > 5");
expect(ast).toMatchObject({
expression_type: "select_query",
where: {
expression_type: "compare_operation",
op: CompareOperationOp.Gt,
left: { expression_type: "field", chain: ["id"] },
right: { expression_type: "constant", value: 5 },
},
});
});
it("should convert LIKE comparison", () => {
const ast = parseAndConvert("SELECT * FROM users WHERE name LIKE '%john%'");
expect(ast).toMatchObject({
expression_type: "select_query",
where: {
expression_type: "compare_operation",
op: CompareOperationOp.Like,
left: { expression_type: "field", chain: ["name"] },
right: { expression_type: "constant", value: "%john%" },
},
});
});
it("should convert IN comparison", () => {
const ast = parseAndConvert("SELECT * FROM users WHERE id IN (1, 2, 3)");
expect(ast).toMatchObject({
expression_type: "select_query",
where: {
expression_type: "compare_operation",
op: CompareOperationOp.In,
left: { expression_type: "field", chain: ["id"] },
right: {
expression_type: "tuple",
exprs: [
{ expression_type: "constant", value: 1 },
{ expression_type: "constant", value: 2 },
{ expression_type: "constant", value: 3 },
],
},
},
});
});
it("should convert function calls", () => {
const ast = parseAndConvert("SELECT COUNT(*) FROM users");
expect(ast).toMatchObject({
expression_type: "select_query",
select: [
{
expression_type: "call",
name: "COUNT",
args: [{ expression_type: "field", chain: ["*"] }],
},
],
});
});
it("should convert nested field access", () => {
const ast = parseAndConvert("SELECT user.profile.name FROM users");
expect(ast).toMatchObject({
expression_type: "select_query",
select: [{ expression_type: "field", chain: ["user", "profile", "name"] }],
});
});
it("should convert aliased expressions", () => {
const ast = parseAndConvert("SELECT id AS user_id FROM users");
expect(ast).toMatchObject({
expression_type: "select_query",
select: [
{
expression_type: "alias",
alias: "user_id",
expr: { expression_type: "field", chain: ["id"] },
},
],
});
});
});
describe("JOINs", () => {
it("should convert INNER JOIN", () => {
const ast = parseAndConvert(
"SELECT * FROM users INNER JOIN orders ON users.id = orders.user_id"
);
expect(ast).toMatchObject({
expression_type: "select_query",
select: [{ expression_type: "field", chain: ["*"] }],
select_from: {
expression_type: "join_expr",
table: { expression_type: "field", chain: ["users"] },
next_join: {
expression_type: "join_expr",
join_type: "INNER JOIN",
table: { expression_type: "field", chain: ["orders"] },
constraint: {
expression_type: "join_constraint",
constraint_type: "ON",
expr: {
expression_type: "compare_operation",
op: CompareOperationOp.Eq,
},
},
},
},
});
});
it("should convert LEFT JOIN", () => {
const ast = parseAndConvert(
"SELECT * FROM users LEFT JOIN orders ON users.id = orders.user_id"
);
expect(ast).toMatchObject({
expression_type: "select_query",
select_from: {
expression_type: "join_expr",
table: { expression_type: "field", chain: ["users"] },
next_join: {
expression_type: "join_expr",
join_type: "LEFT JOIN",
table: { expression_type: "field", chain: ["orders"] },
constraint: { constraint_type: "ON" },
},
},
});
});
it("should convert CROSS JOIN", () => {
const ast = parseAndConvert("SELECT * FROM users CROSS JOIN orders");
expect(ast).toMatchObject({
expression_type: "select_query",
select_from: {
expression_type: "join_expr",
table: { expression_type: "field", chain: ["users"] },
next_join: {
expression_type: "join_expr",
join_type: "CROSS JOIN",
table: { expression_type: "field", chain: ["orders"] },
},
},
});
});
});
describe("UNION queries", () => {
it("should convert UNION DISTINCT query", () => {
// Grammar supports UNION ALL, UNION DISTINCT, INTERSECT, INTERSECT DISTINCT, EXCEPT
// Bare UNION (without ALL/DISTINCT) is not supported
const ast = parseAndConvert("SELECT id FROM users UNION DISTINCT SELECT id FROM customers");
expect(ast).toMatchObject({
expression_type: "select_set_query",
initial_select_query: {
expression_type: "select_query",
select: [{ expression_type: "field", chain: ["id"] }],
select_from: {
table: { expression_type: "field", chain: ["users"] },
},
},
subsequent_select_queries: [
{
set_operator: "UNION DISTINCT",
select_query: {
expression_type: "select_query",
select: [{ expression_type: "field", chain: ["id"] }],
select_from: {
table: { expression_type: "field", chain: ["customers"] },
},
},
},
],
});
});
it("should convert UNION ALL query", () => {
const ast = parseAndConvert("SELECT id FROM users UNION ALL SELECT id FROM customers");
expect(ast).toMatchObject({
expression_type: "select_set_query",
initial_select_query: {
expression_type: "select_query",
select: [{ expression_type: "field", chain: ["id"] }],
},
subsequent_select_queries: [
{
set_operator: "UNION ALL",
select_query: {
expression_type: "select_query",
select: [{ expression_type: "field", chain: ["id"] }],
},
},
],
});
});
});
describe("WITH clauses (CTEs)", () => {
it("should convert SELECT with WITH clause", () => {
const ast = parseAndConvert(
"WITH recent_users AS (SELECT * FROM users WHERE created_at > '2024-01-01') SELECT * FROM recent_users"
);
expect(ast).toMatchObject({
expression_type: "select_query",
ctes: {
recent_users: {
expression_type: "cte",
name: "recent_users",
cte_type: "subquery",
expr: {
expression_type: "select_query",
select: [{ expression_type: "field", chain: ["*"] }],
select_from: {
table: { expression_type: "field", chain: ["users"] },
},
where: {
expression_type: "compare_operation",
op: CompareOperationOp.Gt,
},
},
},
},
select: [{ expression_type: "field", chain: ["*"] }],
select_from: {
table: { expression_type: "field", chain: ["recent_users"] },
},
});
});
});
describe("error handling", () => {
it("should preserve position information in errors", () => {
const input = "SELECT * FROM users WHERE invalid syntax";
const inputStream = CharStreams.fromString(input);
const lexer = new TSQLLexer(inputStream);
const tokenStream = new CommonTokenStream(lexer);
const parser = new TSQLParser(tokenStream);
// This might not parse correctly, but if it does and we visit an error node,
// it should throw with position info
try {
const parseTree = parser.select();
const converter = new TSQLParseTreeConverter();
converter.visit(parseTree);
} catch (error) {
if (error instanceof SyntaxError) {
// Error should have position information if available
expect(error).toBeInstanceOf(SyntaxError);
}
}
});
});
describe("complex queries", () => {
it("should convert a complex query with multiple clauses", () => {
const ast = parseAndConvert(
"SELECT category, COUNT(*) as count " +
"FROM products " +
"WHERE price > 100 " +
"GROUP BY category " +
"HAVING COUNT(*) > 5 " +
"ORDER BY count DESC " +
"LIMIT 10"
);
expect(ast).toMatchObject({
expression_type: "select_query",
select: [
{ expression_type: "field", chain: ["category"] },
{
expression_type: "alias",
alias: "count",
expr: { expression_type: "call", name: "COUNT" },
},
],
select_from: {
table: { expression_type: "field", chain: ["products"] },
},
where: {
expression_type: "compare_operation",
op: CompareOperationOp.Gt,
left: { expression_type: "field", chain: ["price"] },
right: { expression_type: "constant", value: 100 },
},
group_by: [{ expression_type: "field", chain: ["category"] }],
having: {
expression_type: "compare_operation",
op: CompareOperationOp.Gt,
left: { expression_type: "call", name: "COUNT" },
right: { expression_type: "constant", value: 5 },
},
order_by: [
{
expression_type: "order_expr",
order: "DESC",
expr: { expression_type: "field", chain: ["count"] },
},
],
limit: { expression_type: "constant", value: 10 },
});
});
it("should convert query with multiple JOINs", () => {
const ast = parseAndConvert(
"SELECT * FROM users " +
"INNER JOIN orders ON users.id = orders.user_id " +
"LEFT JOIN products ON orders.product_id = products.id"
);
expect(ast).toMatchObject({
expression_type: "select_query",
select: [{ expression_type: "field", chain: ["*"] }],
select_from: {
expression_type: "join_expr",
table: { expression_type: "field", chain: ["users"] },
next_join: {
expression_type: "join_expr",
join_type: "INNER JOIN",
table: { expression_type: "field", chain: ["orders"] },
constraint: { constraint_type: "ON" },
next_join: {
expression_type: "join_expr",
join_type: "LEFT JOIN",
table: { expression_type: "field", chain: ["products"] },
constraint: { constraint_type: "ON" },
},
},
},
});
});
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,215 @@
// TypeScript port of posthog/hogql/context.py
// Adapted for ClickHouse client's {param: Type} syntax
import { getClickHouseType } from "./escape";
import { SchemaRegistry, FieldMappings } from "./schema";
/**
* Settings that control query execution behavior
*/
export interface QuerySettings {
/** Maximum number of rows to return */
maxRows?: number;
/** Timezone for date/time operations */
timezone?: string;
/** Whether to allow full table scans */
allowFullTableScans?: boolean;
/** Query timeout in seconds */
timeoutSeconds?: number;
}
/**
* Default query settings
*/
export const DEFAULT_QUERY_SETTINGS: Required<QuerySettings> = {
maxRows: 10000,
timezone: "UTC",
allowFullTableScans: false,
timeoutSeconds: 60,
};
/**
* A warning or notice collected during query printing
*/
export interface QueryNotice {
code: string;
message: string;
start?: number;
end?: number;
}
/**
* Context for the TSQL to ClickHouse printer
*
* Holds:
* - Tenant IDs for automatic WHERE clause injection
* - Schema registry for table/column validation
* - Parameter accumulator for SQL injection safety
* - Query settings and execution options
* - Field mappings for runtime value translation
*/
export class PrinterContext {
/** Accumulated parameter values for parameterized query */
private values: Record<string, unknown> = {};
/** Counter for generating unique parameter names */
private paramCounter = 0;
/** Warnings collected during printing */
readonly warnings: QueryNotice[] = [];
/** Errors collected during printing */
readonly errors: QueryNotice[] = [];
/** Runtime field mappings for dynamic value translation */
readonly fieldMappings: FieldMappings;
constructor(
/** The organization ID for tenant isolation (required) */
public readonly organizationId: string,
/** The project ID for tenant isolation (optional - omit to query across all projects) */
public readonly projectId: string | undefined,
/** The environment ID for tenant isolation (optional - omit to query across all environments) */
public readonly environmentId: string | undefined,
/** Schema registry containing allowed tables and columns */
public readonly schema: SchemaRegistry,
/** Query execution settings */
public readonly settings: QuerySettings = {},
/** Runtime field mappings for dynamic value translation */
fieldMappings: FieldMappings = {}
) {
// Initialize with default settings
this.settings = { ...DEFAULT_QUERY_SETTINGS, ...settings };
this.fieldMappings = fieldMappings;
}
/**
* Get the timezone setting
*/
get timezone(): string {
return this.settings.timezone ?? DEFAULT_QUERY_SETTINGS.timezone;
}
/**
* Get the max rows setting
*/
get maxRows(): number {
return this.settings.maxRows ?? DEFAULT_QUERY_SETTINGS.maxRows;
}
/**
* Add a value to the parameter map and return a ClickHouse placeholder
*
* @param value The value to parameterize
* @returns A placeholder string like "{tsql_val_0: String}"
*/
addValue(value: unknown): string {
const key = `tsql_val_${this.paramCounter++}`;
this.values[key] = value;
const chType = getClickHouseType(value);
return `{${key}: ${chType}}`;
}
/**
* Add a value with a specific key (for named parameters)
*
* @param key The parameter name
* @param value The value
* @returns A placeholder string like "{key: Type}"
*/
addNamedValue(key: string, value: unknown): string {
this.values[key] = value;
const chType = getClickHouseType(value);
return `{${key}: ${chType}}`;
}
/**
* Get all accumulated parameter values
*/
getParams(): Record<string, unknown> {
return { ...this.values };
}
/**
* Add a warning notice
*/
addWarning(code: string, message: string, start?: number, end?: number): void {
this.warnings.push({ code, message, start, end });
}
/**
* Add an error notice
*/
addError(code: string, message: string, start?: number, end?: number): void {
this.errors.push({ code, message, start, end });
}
/**
* Check if any errors were collected
*/
hasErrors(): boolean {
return this.errors.length > 0;
}
/**
* Create a child context that shares the same parameter accumulator
* Useful for handling subqueries while keeping parameters unified
*/
createChildContext(): PrinterContext {
const child = new PrinterContext(
this.organizationId,
this.projectId,
this.environmentId,
this.schema,
this.settings,
this.fieldMappings
);
// Share the same values map so parameters are unified
child.values = this.values;
// Share the same counter reference via closure
const parentThis = this;
Object.defineProperty(child, "paramCounter", {
get() {
return parentThis.paramCounter;
},
set(v: number) {
parentThis.paramCounter = v;
},
});
return child;
}
}
/**
* Options for creating a printer context
*/
export interface PrinterContextOptions {
/** The organization ID for tenant isolation (required) */
organizationId: string;
/** The project ID for tenant isolation (optional - omit to query across all projects) */
projectId?: string;
/** The environment ID for tenant isolation (optional - omit to query across all environments) */
environmentId?: string;
schema: SchemaRegistry;
settings?: QuerySettings;
/**
* Runtime field mappings for dynamic value translation.
* Maps internal ClickHouse values to external user-facing values.
*/
fieldMappings?: FieldMappings;
}
/**
* Create a new PrinterContext
*/
export function createPrinterContext(options: PrinterContextOptions): PrinterContext {
return new PrinterContext(
options.organizationId,
options.projectId,
options.environmentId,
options.schema,
options.settings,
options.fieldMappings
);
}
@@ -0,0 +1,720 @@
// TypeScript translation of posthog/hogql/transforms/property_types.py
import type {
AST,
Expr,
Field,
PropertyType,
FieldType,
BaseTableType,
VirtualTableType,
LazyJoinType,
LazyTableType,
Call,
Constant,
CallType,
DateTimeType,
} from "./ast";
import type { TSQLContext } from "./context";
import type { BooleanDatabaseField, DateTimeDatabaseField, Table } from "./models";
// Helper function to escape TSQL identifiers
function escapeTSQLIdentifier(identifier: string | number): string {
if (typeof identifier === "number") {
return String(identifier);
}
if (identifier.includes("%")) {
throw new Error(
`The TSQL identifier "${identifier}" is not permitted as it contains the "%" character`
);
}
// TSQL allows dollars in the identifier
if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(identifier)) {
return identifier;
}
// Escape backticks and other special characters
const backquoteEscapeChars: Record<string, string> = {
"\b": "\\b",
"\f": "\\f",
"\r": "\\r",
"\n": "\\n",
"\t": "\\t",
"\0": "\\0",
a: "\\a",
"\v": "\\v",
"\\": "\\\\",
"`": "\\`",
};
return `\`${identifier
.split("")
.map((c) => backquoteEscapeChars[c] || c)
.join("")}\``;
}
// Visitor dispatcher - converts node type to visitor method name
// Matches Python's camel_case_pattern.sub("_", class_name).lower() logic
function getVisitorMethodName(node: AST): string {
// Get the constructor name or use a type guard to determine the type
const nodeType = (node as any).constructor?.name || detectNodeType(node);
if (!nodeType) {
return "visit_unknown";
}
// Convert CamelCase to snake_case (e.g., "PropertyType" -> "property_type")
const snakeCase = nodeType
.replace(/([A-Z])/g, "_$1")
.toLowerCase()
.replace(/^_/, "");
// Handle special cases (matching Python replacements)
const replacements: Record<string, string> = {
tsqlxtag: "tsqlx_tag",
tsqlxattribute: "tsqlx_attribute",
uuidtype: "uuid_type",
string_jsontype: "string_json_type",
};
return replacements[snakeCase] || snakeCase;
}
// Type detection helper (since we can't use instanceof with interfaces)
function detectNodeType(node: AST): string {
// Use property presence to detect node types
if ("chain" in node && "type" in node && !("name" in node)) {
return "Field";
}
if ("chain" in node && "field_type" in node) {
return "PropertyType";
}
if ("name" in node && "args" in node && !("expr" in node)) {
return "Call";
}
if ("value" in node && !("name" in node) && !("args" in node)) {
return "Constant";
}
// Add more type detection as needed
return "";
}
// Base visitor class - matches Python Visitor pattern
abstract class Visitor<T> {
visit(node: AST | null | undefined): T {
if (node === null || node === undefined) {
return node as T;
}
// Try using accept method if available (double dispatch)
if (node.accept) {
return node.accept(this) as T;
}
// Fallback: use dispatcher
const methodName = getVisitorMethodName(node);
const method = (this as any)[methodName];
if (method && typeof method === "function") {
return method.call(this, node) as T;
}
// Try visit_unknown as fallback
if ((this as any).visit_unknown) {
return (this as any).visit_unknown(node) as T;
}
throw new Error(`${this.constructor.name} has no method ${methodName} or visit_unknown`);
}
}
// TraversingVisitor - matches Python TraversingVisitor
class TraversingVisitor extends Visitor<void> {
visitPropertyType(node: PropertyType): void {
this.visit(node.field_type);
}
visitField(node: Field): void {
if (node.type) {
this.visit(node.type as any);
}
}
visitCall(node: Call): void {
for (const arg of node.args) {
this.visit(arg);
}
if (node.params) {
for (const param of node.params) {
this.visit(param);
}
}
}
visitConstant(node: Constant): void {
if (node.type) {
this.visit(node.type as any);
}
}
// Default handler for unknown types - traverse common properties
visit_unknown(node: AST): void {
// Traverse children based on common AST node properties
if ("expr" in node) {
this.visit((node as any).expr);
}
if ("exprs" in node) {
for (const expr of (node as any).exprs) {
this.visit(expr);
}
}
if ("left" in node && "right" in node) {
this.visit((node as any).left);
this.visit((node as any).right);
}
if ("args" in node) {
for (const arg of (node as any).args) {
this.visit(arg);
}
}
if ("type" in node) {
this.visit((node as any).type);
}
}
}
// CloningVisitor - matches Python CloningVisitor
class CloningVisitor extends Visitor<any> {
protected clearTypes: boolean;
protected clearLocations: boolean;
constructor(clearTypes: boolean = true, clearLocations: boolean = false) {
super();
this.clearTypes = clearTypes;
this.clearLocations = clearLocations;
}
visitField(node: Field): Field {
return {
...node,
type: this.clearTypes ? undefined : node.type ? this.visit(node.type as any) : node.type,
start: this.clearLocations ? undefined : node.start,
end: this.clearLocations ? undefined : node.end,
};
}
visitPropertyType(node: PropertyType): PropertyType {
return {
...node,
field_type: this.visit(node.field_type) as FieldType,
start: this.clearLocations ? undefined : node.start,
end: this.clearLocations ? undefined : node.end,
};
}
visitCall(node: Call): Call {
return {
...node,
args: node.args.map((arg) => this.visit(arg)),
params: node.params ? node.params.map((param) => this.visit(param)) : undefined,
start: this.clearLocations ? undefined : node.start,
end: this.clearLocations ? undefined : node.end,
type: this.clearTypes ? undefined : node.type,
};
}
visitConstant(node: Constant): Constant {
return {
...node,
start: this.clearLocations ? undefined : node.start,
end: this.clearLocations ? undefined : node.end,
type: this.clearTypes ? undefined : node.type,
};
}
// Default handler for unknown types - shallow clone
visit_unknown(node: AST): any {
const cloned: any = { ...node };
// Clone common properties
if ("expr" in node) {
cloned.expr = this.visit((node as any).expr);
}
if ("exprs" in node) {
cloned.exprs = (node as any).exprs.map((e: any) => this.visit(e));
}
if ("left" in node && "right" in node) {
cloned.left = this.visit((node as any).left);
cloned.right = this.visit((node as any).right);
}
if ("args" in node) {
cloned.args = (node as any).args.map((a: any) => this.visit(a));
}
if ("type" in node) {
cloned.type = this.clearTypes ? undefined : this.visit((node as any).type);
}
if (this.clearLocations) {
cloned.start = undefined;
cloned.end = undefined;
}
return cloned;
}
}
// PropertyFinder: Traverses AST to find all property references
class PropertyFinder extends TraversingVisitor {
context: TSQLContext;
personProperties: Set<string> = new Set();
eventProperties: Set<string> = new Set();
groupProperties: Map<number, Set<string>> = new Map();
foundTimestamps: boolean = false;
constructor(context: TSQLContext) {
super();
this.context = context;
}
visitPropertyType(node: PropertyType): void {
if (node.field_type.name === "properties" && node.chain.length === 1) {
const tableType = node.field_type.table_type;
if (this.isBaseTableType(tableType)) {
const table = tableType.resolve_database_table?.(this.context);
if (table) {
const tableName = table.to_printed_tsql?.() || "";
const propertyName = String(node.chain[0]);
if (tableName === "persons" || tableName === "raw_persons") {
this.personProperties.add(propertyName);
} else if (tableName === "groups") {
if (this.isLazyJoinType(tableType)) {
if (tableType.field.startsWith("group_")) {
const groupId = parseInt(tableType.field.split("_")[1], 10);
if (!this.groupProperties.has(groupId)) {
this.groupProperties.set(groupId, new Set());
}
this.groupProperties.get(groupId)!.add(propertyName);
}
} else if (this.isLazyTableType(tableType)) {
const globalGroupId = this.context.globals?.group_id;
if (typeof globalGroupId === "number") {
if (!this.groupProperties.has(globalGroupId)) {
this.groupProperties.set(globalGroupId, new Set());
}
this.groupProperties.get(globalGroupId)!.add(propertyName);
}
}
} else if (tableName === "events") {
if (this.isVirtualTableType(tableType) && tableType.field === "poe") {
this.personProperties.add(propertyName);
} else {
this.eventProperties.add(propertyName);
}
}
}
}
}
super.visitPropertyType(node);
}
visitField(node: Field): void {
super.visitField(node);
if (this.isFieldType(node.type)) {
const dbField = (node.type as any).resolve_database_field?.(this.context);
if (this.isDateTimeDatabaseField(dbField)) {
this.foundTimestamps = true;
}
}
}
private isBaseTableType(type: any): type is BaseTableType {
return type && typeof type.resolve_database_table === "function";
}
private isLazyJoinType(type: any): type is LazyJoinType {
return type && "lazy_join" in type && "field" in type;
}
private isLazyTableType(type: any): type is LazyTableType {
return type && "table" in type && !("lazy_join" in type);
}
private isVirtualTableType(type: any): type is VirtualTableType {
return type && "virtual_table" in type && "field" in type;
}
private isFieldType(type: any): type is FieldType {
return type && typeof type.resolve_database_field === "function";
}
private isDateTimeDatabaseField(field: any): field is DateTimeDatabaseField {
return field && "name" in field; // Simplified check
}
}
// PropertySwapper: Transforms property accesses with type conversions
export class PropertySwapper extends CloningVisitor {
timezone: string;
eventProperties: Map<string, string>;
personProperties: Map<string, string>;
groupProperties: Map<string, string>;
context: TSQLContext;
setTimeZones: boolean;
constructor(
timezone: string,
eventProperties: Map<string, string> | Record<string, string>,
personProperties: Map<string, string> | Record<string, string>,
groupProperties: Map<string, string> | Record<string, string>,
context: TSQLContext,
setTimeZones: boolean
) {
super(false); // Don't clear types
this.timezone = timezone;
this.eventProperties =
eventProperties instanceof Map ? eventProperties : new Map(Object.entries(eventProperties));
this.personProperties =
personProperties instanceof Map
? personProperties
: new Map(Object.entries(personProperties));
this.groupProperties =
groupProperties instanceof Map ? groupProperties : new Map(Object.entries(groupProperties));
this.context = context;
this.setTimeZones = setTimeZones;
}
visitField(node: Field): any {
if (this.isFieldType(node.type)) {
if (this.setTimeZones) {
const dbField = (node.type as any).resolve_database_field?.(this.context);
if (this.isDateTimeDatabaseField(dbField)) {
return this.createToTimeZoneCall(node);
}
}
if (this.isLazyJoinType(node.type.table_type)) {
const lazyJoinType = node.type.table_type;
const resolvedTable = lazyJoinType.lazy_join.resolve_table?.(this.context);
// Check if it's an S3Table-like table (has fields property)
if (resolvedTable && "fields" in resolvedTable) {
const field = node.chain[node.chain.length - 1];
const fieldType = resolvedTable.fields[String(field)];
let propType = "String";
if (this.isDateTimeDatabaseField(fieldType)) {
propType = "DateTime";
} else if (this.isBooleanDatabaseField(fieldType)) {
propType = "Boolean";
}
return this.fieldTypeToPropertyCall(node, propType);
}
}
}
const type = node.type;
if (
this.isPropertyType(type) &&
type.field_type.name === "properties" &&
type.chain.length === 1
) {
const propertyName = String(type.chain[0]);
const tableType = type.field_type.table_type;
if (this.isVirtualTableType(tableType) && tableType.field === "poe") {
if (this.personProperties.has(propertyName)) {
return this.convertStringPropertyToType(node, "person", propertyName);
}
} else if (this.isBaseTableType(tableType)) {
const table = tableType.resolve_database_table?.(this.context);
if (table) {
const tableName = table.to_printed_tsql?.() || "";
if (tableName === "persons" || tableName === "raw_persons") {
if (this.personProperties.has(propertyName)) {
return this.convertStringPropertyToType(node, "person", propertyName);
}
} else if (tableName === "groups") {
if (this.isLazyJoinType(tableType)) {
if (tableType.field.startsWith("group_")) {
const groupId = parseInt(tableType.field.split("_")[1], 10);
const groupKey = `${groupId}_${propertyName}`;
if (this.groupProperties.has(groupKey)) {
return this.convertStringPropertyToType(node, "group", groupKey);
}
}
} else if (this.isLazyTableType(tableType)) {
const globalGroupId = this.context.globals?.group_id;
if (typeof globalGroupId === "number") {
const groupKey = `${globalGroupId}_${propertyName}`;
if (this.groupProperties.has(groupKey)) {
return this.convertStringPropertyToType(node, "group", groupKey);
}
}
}
} else if (tableName === "events") {
if (this.eventProperties.has(propertyName)) {
return this.convertStringPropertyToType(node, "event", propertyName);
}
}
}
}
}
if (
this.isPropertyType(type) &&
type.field_type.name === "person_properties" &&
type.chain.length === 1
) {
const propertyName = String(type.chain[0]);
const tableType = type.field_type.table_type;
if (this.isBaseTableType(tableType)) {
const table = tableType.resolve_database_table?.(this.context);
if (table) {
const tableName = table.to_printed_tsql?.() || "";
if (tableName === "events") {
if (this.personProperties.has(propertyName)) {
return this.convertStringPropertyToType(node, "person", propertyName);
}
}
}
}
}
return super.visitField(node);
}
private convertStringPropertyToType(
node: Field,
propertyType: "event" | "person" | "group",
propertyName: string
): Expr {
let fieldTypeValue: string | undefined;
if (propertyType === "person") {
fieldTypeValue = this.personProperties.get(propertyName);
} else if (propertyType === "group") {
fieldTypeValue = this.groupProperties.get(propertyName);
} else {
fieldTypeValue = this.eventProperties.get(propertyName);
}
const fieldType = fieldTypeValue === "Numeric" ? "Float" : fieldTypeValue || "String";
this.addPropertyNotice(node, propertyType, fieldType);
return this.fieldTypeToPropertyCall(node, fieldType);
}
private fieldTypeToPropertyCall(node: Field, fieldType: string): Expr {
if (fieldType === "DateTime") {
return this.createToDateTimeCall(node);
}
if (fieldType === "Float") {
return this.createToFloatCall(node);
}
if (fieldType === "Boolean") {
return this.createToBoolCall(node);
}
return node;
}
private createToTimeZoneCall(node: Field): Call {
return {
expression_type: "call",
name: "toTimeZone",
args: [node, this.createConstant(this.timezone)],
type: {
name: "toTimeZone",
arg_types: [{ data_type: "datetime" } as DateTimeType],
return_type: { data_type: "datetime" } as DateTimeType,
} as CallType,
start: node.start,
end: node.end,
} as Call;
}
private createToDateTimeCall(node: Field): Call {
return {
expression_type: "call",
name: "toDateTime",
args: [node],
start: node.start,
end: node.end,
};
}
private createToFloatCall(node: Field): Call {
return {
expression_type: "call",
name: "toFloat",
args: [node],
start: node.start,
end: node.end,
};
}
private createToBoolCall(node: Field): Call {
return {
expression_type: "call",
name: "toBool",
args: [
{
name: "transform",
args: [
{
name: "toString",
args: [node],
start: node.start,
end: node.end,
} as Call,
this.createConstant(["true", "false"]),
this.createConstant([1, 0]),
this.createConstant(null),
],
start: node.start,
end: node.end,
} as Call,
],
start: node.start,
end: node.end,
} as Call;
}
private createConstant(value: any): Constant {
return {
expression_type: "constant",
value,
};
}
private addPropertyNotice(
node: Field,
propertyType: "event" | "person" | "group",
fieldType: string
): void {
let propertyName = String(node.chain[node.chain.length - 1]);
let materializedColumn: any = null; // MaterializedColumn type not yet defined
if (propertyType === "person") {
// if (this.context.modifiers.personsOnEventsMode !== "disabled") {
// materializedColumn = getMaterializedColumnForProperty('events', propertyName, 'person_properties');
// } else {
// materializedColumn = getMaterializedColumnForProperty('person', propertyName, 'properties');
// }
} else if (propertyType === "group") {
const nameParts = propertyName.split("_");
nameParts.shift();
propertyName = nameParts.join("_");
// materializedColumn = getMaterializedColumnForProperty('groups', propertyName, 'properties');
} else {
// materializedColumn = getMaterializedColumnForProperty('events', propertyName, 'properties');
}
let message = `${
propertyType.charAt(0).toUpperCase() + propertyType.slice(1)
} property '${propertyName}' is of type '${fieldType}'.`;
if (this.context.debug) {
if (materializedColumn !== null) {
message += " This property is materialized ⚡️.";
} else {
message += " This property is not materialized 🐢.";
}
}
this.addNotice(node, message);
}
private addNotice(node: Field, message: string): void {
if (node.start === undefined || node.end === undefined) {
return; // Don't add notices for nodes without location
}
// Only highlight the last part of the chain
const lastPart = node.chain[node.chain.length - 1];
const identifierLength = escapeTSQLIdentifier(lastPart).length;
this.context.notices.push({
start: Math.max(node.start, node.end - identifierLength),
end: node.end,
message,
});
}
private isFieldType(type: any): type is FieldType {
return type && typeof type.resolve_database_field === "function";
}
private isPropertyType(type: any): type is PropertyType {
return type && "field_type" in type && "chain" in type;
}
private isBaseTableType(type: any): type is BaseTableType {
return type && typeof type.resolve_database_table === "function";
}
private isLazyJoinType(type: any): type is LazyJoinType {
return type && "lazy_join" in type && "field" in type;
}
private isLazyTableType(type: any): type is LazyTableType {
return type && "table" in type && !("lazy_join" in type);
}
private isVirtualTableType(type: any): type is VirtualTableType {
return type && "virtual_table" in type && "field" in type;
}
private isDateTimeDatabaseField(field: any): field is DateTimeDatabaseField {
return field && "name" in field; // Simplified check
}
private isBooleanDatabaseField(field: any): field is BooleanDatabaseField {
return field && "name" in field; // Simplified check
}
}
// Main function to build property swapper
export function buildPropertySwapper(node: AST, context: TSQLContext): void {
if (!context || !context.team_id) {
return;
}
// NOTE: In TypeScript, you'll need to fetch the team from your database/ORM
// This is a placeholder - replace with your actual team fetching logic
// if (!context.team) {
// context.team = await Team.findById(context.team_id);
// }
if (!context.team) {
return;
}
// Find all properties
const propertyFinder = new PropertyFinder(context);
propertyFinder.visit(node);
// NOTE: In TypeScript, you'll need to query PropertyDefinition from your database
// This is a placeholder - replace with your actual property definition fetching logic
// const eventPropertyValues = await PropertyDefinition.find({
// project_id: context.team.project_id,
// name: { $in: Array.from(propertyFinder.eventProperties) },
// type: { $in: [null, 'event'] },
// }).select('name property_type');
// const eventProperties = new Map(
// eventPropertyValues.filter((p: any) => p.property_type).map((p: any) => [p.name, p.property_type])
// );
const eventProperties = new Map<string, string>();
const personProperties = new Map<string, string>();
const groupProperties = new Map<string, string>();
// TODO: Implement actual property definition fetching from database
// For now, these are empty maps
const timezone = (context.database as any)?._timezone || "UTC";
context.property_swapper = new PropertySwapper(
timezone,
eventProperties,
personProperties,
groupProperties,
context,
true
);
}
@@ -0,0 +1,234 @@
import { describe, it, expect } from "vitest";
import { transformResults, createResultTransformer } from "./results.js";
import { column, type TableSchema } from "./schema.js";
/**
* Test schema with valueMap
*/
const taskRunsSchema: TableSchema = {
name: "task_runs",
clickhouseName: "trigger_dev.task_runs_v2",
columns: {
id: { name: "id", ...column("String") },
status: {
name: "status",
...column("String"),
valueMap: {
COMPLETED_SUCCESSFULLY: "Completed",
COMPLETED_WITH_ERRORS: "Completed with errors",
SYSTEM_FAILURE: "System failure",
PENDING: "Pending",
EXECUTING: "Running",
FAILED: "Failed",
CANCELLED: "Cancelled",
},
},
environment_type: {
name: "environment_type",
...column("String"),
valueMap: {
DEVELOPMENT: "Development",
STAGING: "Staging",
PRODUCTION: "Production",
},
},
task_identifier: { name: "task_identifier", ...column("String") },
organization_id: { name: "organization_id", ...column("String") },
project_id: { name: "project_id", ...column("String") },
environment_id: { name: "environment_id", ...column("String") },
},
tenantColumns: {
organizationId: "organization_id",
projectId: "project_id",
environmentId: "environment_id",
},
};
/**
* Schema without valueMap
*/
const simpleSchema: TableSchema = {
name: "simple",
clickhouseName: "trigger_dev.simple",
columns: {
id: { name: "id", ...column("String") },
name: { name: "name", ...column("String") },
organization_id: { name: "organization_id", ...column("String") },
project_id: { name: "project_id", ...column("String") },
environment_id: { name: "environment_id", ...column("String") },
},
tenantColumns: {
organizationId: "organization_id",
projectId: "project_id",
environmentId: "environment_id",
},
};
describe("transformResults", () => {
it("should transform internal values to user-friendly values", () => {
const rows = [
{ id: "run_1", status: "COMPLETED_SUCCESSFULLY", task_identifier: "my-task" },
{ id: "run_2", status: "PENDING", task_identifier: "other-task" },
{ id: "run_3", status: "FAILED", task_identifier: "my-task" },
];
const transformed = transformResults(rows, [taskRunsSchema]);
expect(transformed[0].status).toBe("Completed");
expect(transformed[1].status).toBe("Pending");
expect(transformed[2].status).toBe("Failed");
});
it("should transform multiple columns with valueMaps", () => {
const rows = [
{ id: "run_1", status: "COMPLETED_SUCCESSFULLY", environment_type: "PRODUCTION" },
{ id: "run_2", status: "PENDING", environment_type: "DEVELOPMENT" },
];
const transformed = transformResults(rows, [taskRunsSchema]);
expect(transformed[0].status).toBe("Completed");
expect(transformed[0].environment_type).toBe("Production");
expect(transformed[1].status).toBe("Pending");
expect(transformed[1].environment_type).toBe("Development");
});
it("should not modify columns without valueMap", () => {
const rows = [
{ id: "run_1", status: "COMPLETED_SUCCESSFULLY", task_identifier: "my-task" },
];
const transformed = transformResults(rows, [taskRunsSchema]);
// id and task_identifier should be unchanged
expect(transformed[0].id).toBe("run_1");
expect(transformed[0].task_identifier).toBe("my-task");
});
it("should pass through values not in valueMap unchanged", () => {
const rows = [{ id: "run_1", status: "UNKNOWN_STATUS", task_identifier: "my-task" }];
const transformed = transformResults(rows, [taskRunsSchema]);
// UNKNOWN_STATUS is not in the valueMap, should be passed through
expect(transformed[0].status).toBe("UNKNOWN_STATUS");
});
it("should return original rows if no columns have valueMap", () => {
const rows = [
{ id: "run_1", name: "test" },
{ id: "run_2", name: "other" },
];
const transformed = transformResults(rows, [simpleSchema]);
// Should return the same array (reference equality)
expect(transformed).toBe(rows);
});
it("should handle empty rows array", () => {
const rows: Array<{ id: string; status: string }> = [];
const transformed = transformResults(rows, [taskRunsSchema]);
expect(transformed).toEqual([]);
});
it("should handle case-insensitive internal value matching", () => {
const rows = [
{ id: "run_1", status: "completed_successfully" },
{ id: "run_2", status: "COMPLETED_SUCCESSFULLY" },
{ id: "run_3", status: "Completed_Successfully" },
];
const transformed = transformResults(rows, [taskRunsSchema]);
// All should map to "Completed"
expect(transformed[0].status).toBe("Completed");
expect(transformed[1].status).toBe("Completed");
expect(transformed[2].status).toBe("Completed");
});
it("should preserve non-string column values", () => {
const rows = [{ id: "run_1", status: "COMPLETED_SUCCESSFULLY", count: 42, active: true }];
const transformed = transformResults(rows, [taskRunsSchema]);
expect(transformed[0].count).toBe(42);
expect(transformed[0].active).toBe(true);
expect(transformed[0].status).toBe("Completed");
});
it("should preserve row reference if no changes made", () => {
const rows = [{ id: "run_1", status: "UNKNOWN_STATUS" }];
const transformed = transformResults(rows, [taskRunsSchema]);
// The row has status that doesn't match any valueMap entry
// But the column does have a valueMap, so we still check it
// Since the value doesn't change, the row reference should be preserved
expect(transformed[0]).toBe(rows[0]);
});
it("should handle multiple table schemas", () => {
const anotherSchema: TableSchema = {
name: "events",
clickhouseName: "trigger_dev.events",
columns: {
id: { name: "id", ...column("String") },
event_type: {
name: "event_type",
...column("String"),
valueMap: {
TASK_START: "Started",
TASK_END: "Ended",
},
},
organization_id: { name: "organization_id", ...column("String") },
project_id: { name: "project_id", ...column("String") },
environment_id: { name: "environment_id", ...column("String") },
},
tenantColumns: {
organizationId: "organization_id",
projectId: "project_id",
environmentId: "environment_id",
},
};
const rows = [
{ status: "COMPLETED_SUCCESSFULLY", event_type: "TASK_START" },
{ status: "PENDING", event_type: "TASK_END" },
];
const transformed = transformResults(rows, [taskRunsSchema, anotherSchema]);
expect(transformed[0].status).toBe("Completed");
expect(transformed[0].event_type).toBe("Started");
expect(transformed[1].status).toBe("Pending");
expect(transformed[1].event_type).toBe("Ended");
});
});
describe("createResultTransformer", () => {
it("should create a reusable transformer function", () => {
const transform = createResultTransformer([taskRunsSchema]);
const rows1 = [{ id: "1", status: "COMPLETED_SUCCESSFULLY" }];
const rows2 = [{ id: "2", status: "FAILED" }];
const transformed1 = transform(rows1);
const transformed2 = transform(rows2);
expect(transformed1[0].status).toBe("Completed");
expect(transformed2[0].status).toBe("Failed");
});
it("should return original rows if no valueMap columns exist", () => {
const transform = createResultTransformer([simpleSchema]);
const rows = [{ id: "1", name: "test" }];
const transformed = transform(rows);
expect(transformed).toBe(rows);
});
});
+201
View File
@@ -0,0 +1,201 @@
/**
* Result transformation utilities for TSQL queries
*
* Transforms query result values from internal ClickHouse values
* to user-friendly display names using the column valueMap or fieldMapping.
*/
import type { TableSchema, ColumnSchema, FieldMappings } from "./schema.js";
import { getUserFriendlyValue, hasFieldMapping, getExternalValue } from "./schema.js";
/**
* Options for transforming query results
*/
export interface TransformResultsOptions {
/**
* If true, transform values even if the column was aliased (e.g., SELECT status AS s)
* Default: false (aliased columns are not transformed since the user explicitly chose a different name)
*/
transformAliased?: boolean;
/**
* Runtime field mappings for dynamic value translation.
* Maps internal ClickHouse values to external user-facing values.
* Values not found in the mapping will be returned as null.
*/
fieldMappings?: FieldMappings;
}
/**
* Transform query result rows, mapping internal values to user-friendly display names
*
* This function iterates over result rows and transforms any column values that have
* a `valueMap` or `fieldMapping` defined in their schema, converting internal ClickHouse values
* (e.g., 'COMPLETED_SUCCESSFULLY') back to user-friendly display names (e.g., 'Completed').
*
* For columns with `fieldMapping`, values not found in the mapping will be returned as null.
*
* @param rows - Array of result rows to transform
* @param schema - Array of table schemas containing column definitions with valueMaps
* @param options - Optional transformation options
* @returns New array of rows with transformed values
*
* @example
* ```typescript
* const schema: TableSchema[] = [{
* name: "task_runs",
* clickhouseName: "trigger_dev.task_runs_v2",
* columns: {
* status: {
* name: "status",
* type: "String",
* valueMap: {
* "COMPLETED_SUCCESSFULLY": "Completed",
* "PENDING": "Pending",
* },
* },
* project_ref: {
* name: "project_ref",
* clickhouseName: "project_id",
* type: "String",
* fieldMapping: "project",
* },
* },
* tenantColumns: { organizationId: "organization_id", projectId: "project_id", environmentId: "environment_id" },
* }];
*
* const results = [{ status: "COMPLETED_SUCCESSFULLY", project_ref: "cm12345" }];
* const transformed = transformResults(results, schema, {
* fieldMappings: { project: { "cm12345": "my-project-ref" } },
* });
* // transformed = [{ status: "Completed", project_ref: "my-project-ref" }]
* ```
*/
export function transformResults<T extends Record<string, unknown>>(
rows: T[],
schema: TableSchema[],
options: TransformResultsOptions = {}
): T[] {
// Build a map of column names to their schemas (for columns that have transformations)
const columnTransformMaps = buildColumnTransformMaps(schema);
// If no columns have transformations, return the original rows unchanged
if (columnTransformMaps.size === 0) {
return rows;
}
// Transform each row
return rows.map((row) => transformRow(row, columnTransformMaps, options.fieldMappings));
}
/**
* Build a map of column names to their schemas for columns that have transformations
* (either valueMap or fieldMapping)
*/
function buildColumnTransformMaps(schema: TableSchema[]): Map<string, ColumnSchema> {
const columnMaps = new Map<string, ColumnSchema>();
for (const table of schema) {
for (const [columnName, columnSchema] of Object.entries(table.columns)) {
const hasValueMap = columnSchema.valueMap && Object.keys(columnSchema.valueMap).length > 0;
const hasFieldMap = hasFieldMapping(columnSchema);
if (hasValueMap || hasFieldMap) {
// Use the TSQL-exposed column name (not the ClickHouse name)
columnMaps.set(columnName, columnSchema);
}
}
}
return columnMaps;
}
/**
* Transform a single value using the column's valueMap or fieldMapping
* Returns the transformed value, or the original value if no transformation applies
* For fieldMapping, returns null if the value is not found in the mapping
*/
function transformSingleValue(
columnSchema: ColumnSchema,
value: string,
fieldMappings?: FieldMappings
): string | null {
// First try static valueMap (always returns the original if no match)
if (columnSchema.valueMap && Object.keys(columnSchema.valueMap).length > 0) {
return getUserFriendlyValue(columnSchema, value);
}
// Then try runtime fieldMapping (returns null if not found)
if (hasFieldMapping(columnSchema) && columnSchema.fieldMapping && fieldMappings) {
const externalValue = getExternalValue(fieldMappings, columnSchema.fieldMapping, value);
// For fieldMapping, return null if not found (per user requirement)
return externalValue;
}
return value;
}
/**
* Transform a single row's values using the column valueMaps and fieldMappings
*/
function transformRow<T extends Record<string, unknown>>(
row: T,
columnTransformMaps: Map<string, ColumnSchema>,
fieldMappings?: FieldMappings
): T {
const transformedRow: Record<string, unknown> = {};
let hasChanges = false;
for (const [key, value] of Object.entries(row)) {
const columnSchema = columnTransformMaps.get(key);
if (columnSchema && typeof value === "string") {
const transformedValue = transformSingleValue(columnSchema, value, fieldMappings);
transformedRow[key] = transformedValue;
if (transformedValue !== value) {
hasChanges = true;
}
} else {
transformedRow[key] = value;
}
}
// Return original row if no changes were made (preserves reference equality)
return hasChanges ? (transformedRow as T) : row;
}
/**
* Create a result transformer bound to a specific schema
*
* Useful when you need to transform multiple result sets with the same schema.
*
* @param schema - Array of table schemas
* @param options - Optional transformation options (including fieldMappings)
* @returns A function that transforms result rows
*
* @example
* ```typescript
* const transform = createResultTransformer(schema, {
* fieldMappings: { project: { "cm12345": "my-project-ref" } },
* });
*
* const results1 = await query1();
* const transformed1 = transform(results1);
*
* const results2 = await query2();
* const transformed2 = transform(results2);
* ```
*/
export function createResultTransformer(
schema: TableSchema[],
options: TransformResultsOptions = {}
): <T extends Record<string, unknown>>(rows: T[]) => T[] {
const columnTransformMaps = buildColumnTransformMaps(schema);
return <T extends Record<string, unknown>>(rows: T[]): T[] => {
if (columnTransformMaps.size === 0) {
return rows;
}
return rows.map((row) => transformRow(row, columnTransformMaps, options.fieldMappings));
};
}
@@ -0,0 +1,456 @@
import { describe, it, expect } from "vitest";
import {
column,
getUserFriendlyValue,
getInternalValue,
getAllowedUserValues,
isValidUserValue,
isVirtualColumn,
getVirtualColumnExpression,
hasFieldMapping,
getExternalValue,
getInternalValueFromMapping,
getInternalValueFromMappingCaseInsensitive,
type ColumnSchema,
type FieldMappings,
} from "./schema.js";
describe("Value mapping helper functions", () => {
const columnWithValueMap: ColumnSchema = {
name: "status",
...column("String"),
valueMap: {
COMPLETED_SUCCESSFULLY: "Completed",
COMPLETED_WITH_ERRORS: "Completed with errors",
SYSTEM_FAILURE: "System failure",
PENDING: "Pending",
EXECUTING: "Running",
FAILED: "Failed",
},
};
const columnWithAllowedValues: ColumnSchema = {
name: "status",
...column("String"),
allowedValues: ["completed", "pending", "failed"],
};
const columnWithNoRestrictions: ColumnSchema = {
name: "task_identifier",
...column("String"),
};
describe("getUserFriendlyValue", () => {
it("should return user-friendly value for internal value", () => {
expect(getUserFriendlyValue(columnWithValueMap, "COMPLETED_SUCCESSFULLY")).toBe("Completed");
expect(getUserFriendlyValue(columnWithValueMap, "PENDING")).toBe("Pending");
expect(getUserFriendlyValue(columnWithValueMap, "EXECUTING")).toBe("Running");
});
it("should be case-insensitive for internal value lookup", () => {
expect(getUserFriendlyValue(columnWithValueMap, "completed_successfully")).toBe("Completed");
expect(getUserFriendlyValue(columnWithValueMap, "Completed_Successfully")).toBe("Completed");
expect(getUserFriendlyValue(columnWithValueMap, "COMPLETED_SUCCESSFULLY")).toBe("Completed");
});
it("should return original value if no mapping exists", () => {
expect(getUserFriendlyValue(columnWithValueMap, "UNKNOWN_STATUS")).toBe("UNKNOWN_STATUS");
});
it("should return original value if column has no valueMap", () => {
expect(getUserFriendlyValue(columnWithNoRestrictions, "any_value")).toBe("any_value");
});
});
describe("getInternalValue", () => {
it("should return internal value for user-friendly value", () => {
expect(getInternalValue(columnWithValueMap, "Completed")).toBe("COMPLETED_SUCCESSFULLY");
expect(getInternalValue(columnWithValueMap, "Pending")).toBe("PENDING");
expect(getInternalValue(columnWithValueMap, "Running")).toBe("EXECUTING");
});
it("should be case-insensitive for user-friendly value lookup", () => {
expect(getInternalValue(columnWithValueMap, "completed")).toBe("COMPLETED_SUCCESSFULLY");
expect(getInternalValue(columnWithValueMap, "COMPLETED")).toBe("COMPLETED_SUCCESSFULLY");
expect(getInternalValue(columnWithValueMap, "Completed")).toBe("COMPLETED_SUCCESSFULLY");
});
it("should return original value if no mapping exists", () => {
expect(getInternalValue(columnWithValueMap, "Unknown")).toBe("Unknown");
});
it("should return original value if column has no valueMap", () => {
expect(getInternalValue(columnWithNoRestrictions, "any_value")).toBe("any_value");
});
it("should handle multi-word user-friendly values", () => {
expect(getInternalValue(columnWithValueMap, "Completed with errors")).toBe(
"COMPLETED_WITH_ERRORS"
);
expect(getInternalValue(columnWithValueMap, "completed with errors")).toBe(
"COMPLETED_WITH_ERRORS"
);
expect(getInternalValue(columnWithValueMap, "System failure")).toBe("SYSTEM_FAILURE");
});
});
describe("getAllowedUserValues", () => {
it("should return user-friendly values from valueMap", () => {
const values = getAllowedUserValues(columnWithValueMap);
expect(values).toContain("Completed");
expect(values).toContain("Pending");
expect(values).toContain("Running");
expect(values).toContain("Failed");
expect(values).toContain("Completed with errors");
expect(values).toContain("System failure");
expect(values).toHaveLength(6);
});
it("should return allowedValues if no valueMap exists", () => {
const values = getAllowedUserValues(columnWithAllowedValues);
expect(values).toEqual(["completed", "pending", "failed"]);
});
it("should prefer valueMap over allowedValues", () => {
const columnWithBoth: ColumnSchema = {
name: "status",
...column("String"),
allowedValues: ["internal1", "internal2"],
valueMap: {
internal1: "User 1",
internal2: "User 2",
},
};
const values = getAllowedUserValues(columnWithBoth);
expect(values).toEqual(["User 1", "User 2"]);
});
it("should return empty array for column with no restrictions", () => {
const values = getAllowedUserValues(columnWithNoRestrictions);
expect(values).toEqual([]);
});
});
describe("isValidUserValue", () => {
it("should return true for valid user-friendly values", () => {
expect(isValidUserValue(columnWithValueMap, "Completed")).toBe(true);
expect(isValidUserValue(columnWithValueMap, "Pending")).toBe(true);
expect(isValidUserValue(columnWithValueMap, "Running")).toBe(true);
});
it("should be case-insensitive", () => {
expect(isValidUserValue(columnWithValueMap, "completed")).toBe(true);
expect(isValidUserValue(columnWithValueMap, "COMPLETED")).toBe(true);
expect(isValidUserValue(columnWithValueMap, "running")).toBe(true);
});
it("should return false for invalid values", () => {
expect(isValidUserValue(columnWithValueMap, "Unknown")).toBe(false);
expect(isValidUserValue(columnWithValueMap, "COMPLETED_SUCCESSFULLY")).toBe(false); // internal value, not user-friendly
});
it("should return true for any value if column has no restrictions", () => {
expect(isValidUserValue(columnWithNoRestrictions, "any_value")).toBe(true);
expect(isValidUserValue(columnWithNoRestrictions, "another")).toBe(true);
});
it("should validate against allowedValues if no valueMap", () => {
expect(isValidUserValue(columnWithAllowedValues, "completed")).toBe(true);
expect(isValidUserValue(columnWithAllowedValues, "COMPLETED")).toBe(true);
expect(isValidUserValue(columnWithAllowedValues, "unknown")).toBe(false);
});
it("should handle multi-word values", () => {
expect(isValidUserValue(columnWithValueMap, "Completed with errors")).toBe(true);
expect(isValidUserValue(columnWithValueMap, "completed with errors")).toBe(true);
expect(isValidUserValue(columnWithValueMap, "System failure")).toBe(true);
});
});
});
describe("Virtual column helper functions", () => {
const virtualColumn: ColumnSchema = {
name: "execution_duration",
...column("Nullable(Int64)"),
expression: "dateDiff('millisecond', started_at, completed_at)",
description: "Time between started_at and completed_at in milliseconds",
};
const regularColumn: ColumnSchema = {
name: "status",
...column("String"),
};
const columnWithEmptyExpression: ColumnSchema = {
name: "bad_column",
...column("String"),
expression: "",
};
describe("isVirtualColumn", () => {
it("should return true for columns with expression defined", () => {
expect(isVirtualColumn(virtualColumn)).toBe(true);
});
it("should return false for regular columns without expression", () => {
expect(isVirtualColumn(regularColumn)).toBe(false);
});
it("should return false for columns with empty expression", () => {
expect(isVirtualColumn(columnWithEmptyExpression)).toBe(false);
});
it("should return false for columns with undefined expression", () => {
const col: ColumnSchema = {
name: "test",
...column("String"),
expression: undefined,
};
expect(isVirtualColumn(col)).toBe(false);
});
});
describe("getVirtualColumnExpression", () => {
it("should return the expression for virtual columns", () => {
expect(getVirtualColumnExpression(virtualColumn)).toBe(
"dateDiff('millisecond', started_at, completed_at)"
);
});
it("should return undefined for regular columns", () => {
expect(getVirtualColumnExpression(regularColumn)).toBeUndefined();
});
it("should return undefined for columns with empty expression", () => {
expect(getVirtualColumnExpression(columnWithEmptyExpression)).toBeUndefined();
});
});
describe("virtual column schema definition", () => {
it("should allow defining virtual columns with all standard column options", () => {
const virtualWithOptions: ColumnSchema = {
name: "computed_value",
type: "Float64",
expression: "usage_duration_ms / 1000.0",
selectable: true,
filterable: true,
sortable: true,
groupable: false, // Might not want to group by computed values
description: "Usage duration in seconds",
};
expect(isVirtualColumn(virtualWithOptions)).toBe(true);
expect(virtualWithOptions.groupable).toBe(false);
expect(virtualWithOptions.selectable).toBe(true);
});
it("should support complex expressions with ClickHouse functions", () => {
const complexVirtual: ColumnSchema = {
name: "is_long_running",
...column("UInt8"),
expression:
"if(completed_at IS NOT NULL AND started_at IS NOT NULL, dateDiff('second', started_at, completed_at) > 60, 0)",
};
expect(isVirtualColumn(complexVirtual)).toBe(true);
expect(getVirtualColumnExpression(complexVirtual)).toContain("dateDiff");
expect(getVirtualColumnExpression(complexVirtual)).toContain("if(");
});
});
});
describe("Field mapping helper functions (runtime dynamic mappings)", () => {
const fieldMappings: FieldMappings = {
project: {
cm12345: "my-project-ref",
cm67890: "other-project",
cmABCDE: "Mixed-Case-Project",
},
environment: {
env123: "production",
env456: "staging",
},
};
const columnWithFieldMapping: ColumnSchema = {
name: "project_ref",
clickhouseName: "project_id",
...column("String"),
fieldMapping: "project",
};
const columnWithoutFieldMapping: ColumnSchema = {
name: "status",
...column("String"),
};
const columnWithEmptyFieldMapping: ColumnSchema = {
name: "test",
...column("String"),
fieldMapping: "",
};
describe("hasFieldMapping", () => {
it("should return true for columns with fieldMapping defined", () => {
expect(hasFieldMapping(columnWithFieldMapping)).toBe(true);
});
it("should return false for columns without fieldMapping", () => {
expect(hasFieldMapping(columnWithoutFieldMapping)).toBe(false);
});
it("should return false for columns with empty fieldMapping", () => {
expect(hasFieldMapping(columnWithEmptyFieldMapping)).toBe(false);
});
it("should return false for columns with undefined fieldMapping", () => {
const col: ColumnSchema = {
name: "test",
...column("String"),
fieldMapping: undefined,
};
expect(hasFieldMapping(col)).toBe(false);
});
});
describe("getExternalValue", () => {
it("should return external value for internal value", () => {
expect(getExternalValue(fieldMappings, "project", "cm12345")).toBe("my-project-ref");
expect(getExternalValue(fieldMappings, "project", "cm67890")).toBe("other-project");
expect(getExternalValue(fieldMappings, "environment", "env123")).toBe("production");
});
it("should return null if internal value is not found in mapping", () => {
expect(getExternalValue(fieldMappings, "project", "unknown_id")).toBeNull();
expect(getExternalValue(fieldMappings, "environment", "unknown_env")).toBeNull();
});
it("should return null if mapping name does not exist", () => {
expect(getExternalValue(fieldMappings, "nonexistent", "cm12345")).toBeNull();
});
it("should return null for empty mappings", () => {
expect(getExternalValue({}, "project", "cm12345")).toBeNull();
});
it("should be case-sensitive for internal values", () => {
// Internal IDs should be matched exactly
expect(getExternalValue(fieldMappings, "project", "CM12345")).toBeNull();
expect(getExternalValue(fieldMappings, "project", "cm12345")).toBe("my-project-ref");
});
});
describe("getInternalValueFromMapping", () => {
it("should return internal value for external value", () => {
expect(getInternalValueFromMapping(fieldMappings, "project", "my-project-ref")).toBe(
"cm12345"
);
expect(getInternalValueFromMapping(fieldMappings, "project", "other-project")).toBe(
"cm67890"
);
expect(getInternalValueFromMapping(fieldMappings, "environment", "production")).toBe(
"env123"
);
});
it("should return null if external value is not found", () => {
expect(getInternalValueFromMapping(fieldMappings, "project", "unknown-ref")).toBeNull();
});
it("should return null if mapping name does not exist", () => {
expect(getInternalValueFromMapping(fieldMappings, "nonexistent", "my-project-ref")).toBeNull();
});
it("should return null for empty mappings", () => {
expect(getInternalValueFromMapping({}, "project", "my-project-ref")).toBeNull();
});
it("should be case-sensitive for external values", () => {
// This is the case-sensitive version
expect(getInternalValueFromMapping(fieldMappings, "project", "MY-PROJECT-REF")).toBeNull();
expect(getInternalValueFromMapping(fieldMappings, "project", "my-project-ref")).toBe(
"cm12345"
);
});
});
describe("getInternalValueFromMappingCaseInsensitive", () => {
it("should return internal value for external value (case-insensitive)", () => {
expect(
getInternalValueFromMappingCaseInsensitive(fieldMappings, "project", "my-project-ref")
).toBe("cm12345");
expect(
getInternalValueFromMappingCaseInsensitive(fieldMappings, "project", "MY-PROJECT-REF")
).toBe("cm12345");
expect(
getInternalValueFromMappingCaseInsensitive(fieldMappings, "project", "My-Project-Ref")
).toBe("cm12345");
});
it("should return null if external value is not found (even case-insensitive)", () => {
expect(
getInternalValueFromMappingCaseInsensitive(fieldMappings, "project", "unknown-ref")
).toBeNull();
});
it("should return null if mapping name does not exist", () => {
expect(
getInternalValueFromMappingCaseInsensitive(fieldMappings, "nonexistent", "my-project-ref")
).toBeNull();
});
it("should return null for empty mappings", () => {
expect(
getInternalValueFromMappingCaseInsensitive({}, "project", "my-project-ref")
).toBeNull();
});
it("should handle mixed case external values correctly", () => {
expect(
getInternalValueFromMappingCaseInsensitive(fieldMappings, "project", "mixed-case-project")
).toBe("cmABCDE");
expect(
getInternalValueFromMappingCaseInsensitive(fieldMappings, "project", "MIXED-CASE-PROJECT")
).toBe("cmABCDE");
});
});
describe("field mapping column schema definition", () => {
it("should allow defining columns with fieldMapping", () => {
const col: ColumnSchema = {
name: "project_ref",
clickhouseName: "project_id",
type: "String",
fieldMapping: "project",
description: "Project reference (external identifier)",
};
expect(hasFieldMapping(col)).toBe(true);
expect(col.clickhouseName).toBe("project_id");
expect(col.fieldMapping).toBe("project");
});
it("should allow fieldMapping with all standard column options", () => {
const col: ColumnSchema = {
name: "project_ref",
clickhouseName: "project_id",
...column("String"),
fieldMapping: "project",
selectable: true,
filterable: true,
sortable: true,
groupable: true,
};
expect(hasFieldMapping(col)).toBe(true);
expect(col.selectable).toBe(true);
expect(col.filterable).toBe(true);
});
});
});
+684
View File
@@ -0,0 +1,684 @@
// Schema definitions for TSQL query validation
// Defines allowed tables, columns, and tenant isolation configuration
import { QueryError } from "./errors";
/**
* ClickHouse data types supported by TSQL
*/
export type ClickHouseType =
| "String"
| "UInt8"
| "UInt16"
| "UInt32"
| "UInt64"
| "Int8"
| "Int16"
| "Int32"
| "Int64"
| "Float32"
| "Float64"
| "Date"
| "Date32"
| "DateTime"
| "DateTime64"
| "UUID"
| "Bool"
| "JSON"
| "Nullable(String)"
| "Nullable(UInt8)"
| "Nullable(UInt16)"
| "Nullable(UInt32)"
| "Nullable(UInt64)"
| "Nullable(Int8)"
| "Nullable(Int16)"
| "Nullable(Int32)"
| "Nullable(Int64)"
| "Nullable(Float32)"
| "Nullable(Float64)"
| "Nullable(Date)"
| "Nullable(Date32)"
| "Nullable(DateTime)"
| "Nullable(DateTime64)"
| "Nullable(UUID)"
| "Nullable(Bool)"
| "LowCardinality(String)"
| `Array(${string})`
| `Map(${string}, ${string})`;
/**
* Schema definition for a single column
*/
export interface ColumnSchema {
/** The name of the column as exposed to TSQL queries */
name: string;
/** The actual ClickHouse column name (if different from `name`) */
clickhouseName?: string;
/** The ClickHouse data type */
type: ClickHouseType;
/** Whether this column can be selected */
selectable?: boolean;
/** Whether this column can be used in WHERE clauses */
filterable?: boolean;
/** Whether this column can be used in ORDER BY clauses */
sortable?: boolean;
/** Whether this column can be used in GROUP BY clauses */
groupable?: boolean;
/** Description of the column for documentation/autocomplete */
description?: string;
/** Allowed values for this column (for enum-like columns) */
allowedValues?: string[];
/**
* Map of internal values to user-friendly display names (for enum-like columns)
* Key: internal ClickHouse value (e.g., "COMPLETED_SUCCESSFULLY")
* Value: user-friendly display name (e.g., "Completed")
*
* When set, users can write queries using the user-friendly names,
* and results will display user-friendly names instead of internal values.
*/
valueMap?: Record<string, string>;
/**
* For virtual (computed) columns: the raw ClickHouse SQL expression.
* Use actual ClickHouse column names in the expression.
*
* When set, this column becomes a virtual column that doesn't exist in the
* underlying table but is computed from the expression at query time.
*
* @example
* ```typescript
* {
* name: "execution_duration",
* type: "Nullable(Int64)",
* expression: "dateDiff('millisecond', started_at, completed_at)",
* description: "Time between started_at and completed_at in milliseconds"
* }
* ```
*/
expression?: string;
/**
* Custom render type for UI display.
*
* When set, the UI can use this to render the column with a custom component
* instead of the default renderer based on ClickHouseType.
*
* Common custom render types:
* - "runStatus" - Task run status badges
* - "cost" - Cost formatting (cents to dollars)
* - "duration" - Duration formatting (ms to human-readable)
*
* Custom types can be defined by consumers without modifying this package.
*
* @example
* ```typescript
* {
* name: "status",
* type: "LowCardinality(String)",
* customRenderType: "runStatus",
* }
* ```
*/
customRenderType?: string;
/**
* Example value for documentation purposes.
*
* Used in help/documentation UI to show users what values look like.
*
* @example
* ```typescript
* {
* name: "run_id",
* type: "String",
* example: "run_abc123",
* }
* ```
*/
example?: string;
/**
* Name of the runtime field mapping to use for value translation.
* When set, values are translated using the mapping provided at query time.
*
* Unlike `valueMap` which is static and defined in the schema, `fieldMapping`
* references a mapping that is provided at runtime via `FieldMappings`.
*
* - During query compilation: external values → internal values
* - During result transformation: internal values → external values (or null if unmapped)
*
* @example
* ```typescript
* {
* name: "project_ref",
* clickhouseName: "project_id", // Maps to actual CH column
* type: "String",
* fieldMapping: "project", // Uses runtime "project" mapping
* }
* ```
*/
fieldMapping?: string;
/**
* Transform function for user input values in WHERE clauses.
*
* When set, this function is called to transform user-provided values before
* they are used in comparisons. This is useful for columns where:
* - Users query with prefixed IDs (e.g., "batch_xyz") but the column stores raw values ("xyz")
* - Values need normalization before comparison
*
* The function receives the user's input string and returns the transformed value
* to use in the actual ClickHouse query.
*
* For output transformation (adding prefixes in SELECT), use `expression` instead.
*
* @example
* ```typescript
* {
* name: "batch_id",
* type: "String",
* // Strip "batch_" prefix from user input in WHERE clauses
* whereTransform: (value) => value.replace(/^batch_/, ""),
* // Add prefix back in SELECT output
* expression: "if(batch_id = '', NULL, concat('batch_', batch_id))",
* }
* ```
*/
whereTransform?: (value: string) => string;
/**
* Value to use when comparing to NULL for this column.
*
* When set, NULL comparisons (IS NULL, IS NOT NULL, = NULL, != NULL) are
* transformed to compare against this value instead. This is useful for
* JSON/Object columns where "empty" is represented as '{}' rather than NULL.
*
* @example
* ```typescript
* {
* name: "error",
* type: "JSON",
* nullValue: "'{}'", // error IS NULL → error = '{}'
* }
* ```
*/
nullValue?: string;
}
/**
* Runtime field mappings for dynamic value translation.
*
* Structure: mappingName → (internalValue → externalValue)
*
* @example
* ```typescript
* const fieldMappings: FieldMappings = {
* project: {
* "cm12345": "my-project-ref",
* "cm67890": "other-project-ref",
* },
* };
* ```
*/
export type FieldMappings = Record<string, Record<string, string>>;
/**
* Metadata for a column in query results.
*
* This is returned by the TSQL compiler to describe each column in the SELECT clause,
* allowing the UI to render columns appropriately without inspecting result values.
*/
export interface OutputColumnMetadata {
/** Column name in the result set (after AS aliasing) */
name: string;
/** ClickHouse data type (from schema or inferred for computed expressions) */
type: ClickHouseType;
/**
* Custom render type from schema, if specified.
* When set, the UI should use a custom renderer instead of the default for the ClickHouseType.
*/
customRenderType?: string;
/**
* Description from the schema column definition, if available.
* Only present for columns or virtual columns defined in the table schema.
*/
description?: string;
}
/**
* Configuration for tenant isolation columns
* These columns are automatically added to WHERE clauses
*/
export interface TenantColumnConfig {
/** The column name for organization ID filtering */
organizationId: string;
/** The column name for project ID filtering */
projectId: string;
/** The column name for environment ID filtering */
environmentId: string;
}
/**
* Required filter that is always applied to queries on a table
*/
export interface RequiredFilter {
/** The ClickHouse column name to filter on */
column: string;
/** The value the column must equal */
value: string;
}
/**
* Schema definition for a table
*/
export interface TableSchema {
/** The name of the table as exposed to TSQL queries */
name: string;
/** The fully qualified ClickHouse table name (e.g., "trigger_dev.task_runs_v2") */
clickhouseName: string;
/** Column definitions for this table */
columns: Record<string, ColumnSchema>;
/** Tenant isolation column configuration */
tenantColumns: TenantColumnConfig;
/** Description of the table for documentation/autocomplete */
description?: string;
/** Whether this table can be joined to other tables */
joinable?: boolean;
/**
* Required filters that are always applied to queries on this table.
* These are injected into the WHERE clause automatically, similar to tenant isolation.
*/
requiredFilters?: RequiredFilter[];
}
/**
* Schema registry containing all allowed tables
*/
export interface SchemaRegistry {
/** Map of table names to their schemas */
tables: Record<string, TableSchema>;
/** Default tenant column names (used when a table doesn't specify its own) */
defaultTenantColumns: TenantColumnConfig;
}
/**
* Create a basic column schema with common defaults
*/
export function column(
type: ClickHouseType,
options: Partial<Omit<ColumnSchema, "name" | "type">> = {}
): Omit<ColumnSchema, "name"> {
return {
type,
selectable: true,
filterable: true,
sortable: true,
groupable: true,
...options,
};
}
/**
* Create a schema registry from a list of table schemas
*/
export function createSchemaRegistry(
tables: TableSchema[],
defaultTenantColumns?: TenantColumnConfig
): SchemaRegistry {
const tableMap: Record<string, TableSchema> = {};
for (const table of tables) {
tableMap[table.name] = table;
}
return {
tables: tableMap,
defaultTenantColumns: defaultTenantColumns ?? {
organizationId: "organization_id",
projectId: "project_id",
environmentId: "environment_id",
},
};
}
/**
* Look up a table schema by name
*/
export function findTable(schema: SchemaRegistry, tableName: string): TableSchema | undefined {
return schema.tables[tableName];
}
/**
* Look up a column schema by table and column name
*/
export function findColumn(
schema: SchemaRegistry,
tableName: string,
columnName: string
): ColumnSchema | undefined {
const table = findTable(schema, tableName);
if (!table) return undefined;
return table.columns[columnName];
}
/**
* Validate that a table exists in the schema
* @throws QueryError if the table is not found
*/
export function validateTable(schema: SchemaRegistry, tableName: string): TableSchema {
const table = findTable(schema, tableName);
if (!table) {
const availableTables = Object.keys(schema.tables).join(", ");
throw new QueryError(
`Table "${tableName}" is not accessible. Available tables: ${availableTables || "(none)"}`
);
}
return table;
}
/**
* Validate that a column exists in a table and can be selected
* @throws QueryError if the column is not found or not selectable
*/
export function validateSelectColumn(
schema: SchemaRegistry,
tableName: string,
columnName: string
): ColumnSchema {
const table = validateTable(schema, tableName);
const col = table.columns[columnName];
if (!col) {
const availableColumns = Object.keys(table.columns).join(", ");
throw new QueryError(
`Column "${columnName}" does not exist on table "${tableName}". Available columns: ${availableColumns}`
);
}
if (col.selectable === false) {
throw new QueryError(`Column "${columnName}" on table "${tableName}" is not selectable`);
}
return col;
}
/**
* Validate that a column can be used in a WHERE clause
* @throws QueryError if the column is not filterable
*/
export function validateFilterColumn(
schema: SchemaRegistry,
tableName: string,
columnName: string
): ColumnSchema {
const table = validateTable(schema, tableName);
const col = table.columns[columnName];
if (!col) {
throw new QueryError(`Column "${columnName}" does not exist on table "${tableName}"`);
}
if (col.filterable === false) {
throw new QueryError(`Column "${columnName}" on table "${tableName}" cannot be used in WHERE`);
}
return col;
}
/**
* Validate that a column can be used in ORDER BY
* @throws QueryError if the column is not sortable
*/
export function validateSortColumn(
schema: SchemaRegistry,
tableName: string,
columnName: string
): ColumnSchema {
const table = validateTable(schema, tableName);
const col = table.columns[columnName];
if (!col) {
throw new QueryError(`Column "${columnName}" does not exist on table "${tableName}"`);
}
if (col.sortable === false) {
throw new QueryError(
`Column "${columnName}" on table "${tableName}" cannot be used in ORDER BY`
);
}
return col;
}
/**
* Validate that a column can be used in GROUP BY
* @throws QueryError if the column is not groupable
*/
export function validateGroupColumn(
schema: SchemaRegistry,
tableName: string,
columnName: string
): ColumnSchema {
const table = validateTable(schema, tableName);
const col = table.columns[columnName];
if (!col) {
throw new QueryError(`Column "${columnName}" does not exist on table "${tableName}"`);
}
if (col.groupable === false) {
throw new QueryError(
`Column "${columnName}" on table "${tableName}" cannot be used in GROUP BY`
);
}
return col;
}
/**
* Get the actual ClickHouse column name (handles aliasing)
*/
export function getClickHouseColumnName(col: ColumnSchema): string {
return col.clickhouseName ?? col.name;
}
/**
* Check if a column is a virtual (computed) column
*
* Virtual columns have an expression property that defines how they are computed
* from other columns. They don't exist in the underlying table.
*
* @param col - The column schema to check
* @returns true if the column is virtual, false otherwise
*/
export function isVirtualColumn(col: ColumnSchema): boolean {
return col.expression !== undefined && col.expression.length > 0;
}
/**
* Get the expression for a virtual column
*
* @param col - The column schema
* @returns The expression string, or undefined if not a virtual column
*/
export function getVirtualColumnExpression(col: ColumnSchema): string | undefined {
return isVirtualColumn(col) ? col.expression : undefined;
}
/**
* Get the user-friendly display value for an internal value (case-insensitive)
* Used for transforming query results back to user-friendly format
*
* @param col - The column schema
* @param internalValue - The internal ClickHouse value
* @returns The user-friendly display value, or the original value if no mapping exists
*/
export function getUserFriendlyValue(col: ColumnSchema, internalValue: string): string {
if (!col.valueMap) {
return internalValue;
}
// Direct lookup first (case-sensitive for exact match)
if (col.valueMap[internalValue] !== undefined) {
return col.valueMap[internalValue];
}
// Case-insensitive fallback
const lowerValue = internalValue.toLowerCase();
for (const [internal, friendly] of Object.entries(col.valueMap)) {
if (internal.toLowerCase() === lowerValue) {
return friendly;
}
}
return internalValue;
}
/**
* Get the internal ClickHouse value for a user-friendly value (case-insensitive)
* Used for transforming user queries to internal format
*
* @param col - The column schema
* @param userValue - The user-friendly display value
* @returns The internal ClickHouse value, or the original value if no mapping exists
*/
export function getInternalValue(col: ColumnSchema, userValue: string): string {
if (!col.valueMap) {
return userValue;
}
const lowerUserValue = userValue.toLowerCase();
// Search for matching user-friendly value (case-insensitive)
for (const [internal, friendly] of Object.entries(col.valueMap)) {
if (friendly.toLowerCase() === lowerUserValue) {
return internal;
}
}
return userValue;
}
/**
* Get all allowed user-friendly values for a column
* Used for validation and autocomplete
*
* @param col - The column schema
* @returns Array of allowed user-friendly values, or allowedValues if no valueMap exists
*/
export function getAllowedUserValues(col: ColumnSchema): string[] {
if (col.valueMap) {
return Object.values(col.valueMap);
}
return col.allowedValues ?? [];
}
/**
* Check if a user-provided value is valid for a column (case-insensitive)
*
* @param col - The column schema
* @param userValue - The user-provided value to validate
* @returns true if the value is valid, false otherwise
*/
export function isValidUserValue(col: ColumnSchema, userValue: string): boolean {
const allowedValues = getAllowedUserValues(col);
if (allowedValues.length === 0) {
return true; // No restrictions
}
const lowerUserValue = userValue.toLowerCase();
return allowedValues.some((v) => v.toLowerCase() === lowerUserValue);
}
// ============================================================
// Field Mapping Utilities (Runtime Dynamic Mappings)
// ============================================================
/**
* Check if a column uses a runtime field mapping
*
* @param col - The column schema to check
* @returns true if the column has a fieldMapping defined
*/
export function hasFieldMapping(col: ColumnSchema): boolean {
return col.fieldMapping !== undefined && col.fieldMapping.length > 0;
}
/**
* Get the external (user-facing) value for an internal ClickHouse value
* using a runtime field mapping.
*
* @param mappings - The runtime field mappings
* @param mappingName - The name of the mapping to use (from column's fieldMapping)
* @param internalValue - The internal ClickHouse value
* @returns The external value, or null if not found in the mapping
*/
export function getExternalValue(
mappings: FieldMappings,
mappingName: string,
internalValue: string
): string | null {
const mapping = mappings[mappingName];
if (!mapping) {
return null;
}
const externalValue = mapping[internalValue];
return externalValue !== undefined ? externalValue : null;
}
/**
* Get the internal ClickHouse value for an external (user-facing) value
* using a runtime field mapping. This performs a reverse lookup.
*
* @param mappings - The runtime field mappings
* @param mappingName - The name of the mapping to use (from column's fieldMapping)
* @param externalValue - The external (user-facing) value
* @returns The internal value, or null if not found in the mapping
*/
export function getInternalValueFromMapping(
mappings: FieldMappings,
mappingName: string,
externalValue: string
): string | null {
const mapping = mappings[mappingName];
if (!mapping) {
return null;
}
// Reverse lookup: find internal value by external value
for (const [internal, external] of Object.entries(mapping)) {
if (external === externalValue) {
return internal;
}
}
return null;
}
/**
* Get the internal ClickHouse value for an external value (case-insensitive)
* using a runtime field mapping.
*
* @param mappings - The runtime field mappings
* @param mappingName - The name of the mapping to use
* @param externalValue - The external (user-facing) value
* @returns The internal value, or null if not found
*/
export function getInternalValueFromMappingCaseInsensitive(
mappings: FieldMappings,
mappingName: string,
externalValue: string
): string | null {
const mapping = mappings[mappingName];
if (!mapping) {
return null;
}
const lowerExternal = externalValue.toLowerCase();
// Case-insensitive reverse lookup
for (const [internal, external] of Object.entries(mapping)) {
if (external.toLowerCase() === lowerExternal) {
return internal;
}
}
return null;
}
/**
* Get all column names available for autocomplete
*/
export function getTableColumnNames(schema: SchemaRegistry, tableName: string): string[] {
const table = findTable(schema, tableName);
if (!table) return [];
return Object.keys(table.columns);
}
/**
* Get all table names available for autocomplete
*/
export function getAllTableNames(schema: SchemaRegistry): string[] {
return Object.keys(schema.tables);
}
@@ -0,0 +1,601 @@
/**
* Security Tests for TSQL
*
* These tests verify that the TSQL parser and printer correctly prevent:
* 1. Cross-tenant data access
* 2. SQL injection attacks
*/
import { describe, expect, it } from "vitest";
import { compileTSQL, type CompileTSQLOptions } from "../index.js";
import { column, type TableSchema } from "./schema.js";
/**
* Test schemas
*/
const taskRunsSchema: TableSchema = {
name: "task_runs",
clickhouseName: "trigger_dev.task_runs_v2",
columns: {
id: { name: "id", ...column("String") },
status: { name: "status", ...column("String") },
task_identifier: { name: "task_identifier", ...column("String") },
created_at: { name: "created_at", ...column("DateTime64") },
duration_ms: { name: "duration_ms", ...column("Nullable(UInt64)") },
organization_id: { name: "organization_id", ...column("String") },
project_id: { name: "project_id", ...column("String") },
environment_id: { name: "environment_id", ...column("String") },
payload: { name: "payload", ...column("String") },
},
tenantColumns: {
organizationId: "organization_id",
projectId: "project_id",
environmentId: "environment_id",
},
};
const taskEventsSchema: TableSchema = {
name: "task_events",
clickhouseName: "trigger_dev.task_events_v2",
columns: {
id: { name: "id", ...column("String") },
run_id: { name: "run_id", ...column("String") },
event_type: { name: "event_type", ...column("String") },
timestamp: { name: "timestamp", ...column("DateTime64") },
organization_id: { name: "organization_id", ...column("String") },
project_id: { name: "project_id", ...column("String") },
environment_id: { name: "environment_id", ...column("String") },
},
tenantColumns: {
organizationId: "organization_id",
projectId: "project_id",
environmentId: "environment_id",
},
};
const defaultOptions: CompileTSQLOptions = {
organizationId: "org_tenant1",
projectId: "proj_tenant1",
environmentId: "env_tenant1",
tableSchema: [taskRunsSchema, taskEventsSchema],
};
function compile(query: string, options: Partial<CompileTSQLOptions> = {}) {
return compileTSQL(query, { ...defaultOptions, ...options });
}
describe("Cross-Tenant Security", () => {
describe("Tenant guards are always injected", () => {
it("should inject tenant guards on simple SELECT", () => {
const { sql, params } = compile("SELECT * FROM task_runs");
// Must contain all three tenant columns
expect(sql).toContain("organization_id");
expect(sql).toContain("project_id");
expect(sql).toContain("environment_id");
// Tenant values must be parameterized
expect(Object.values(params)).toContain("org_tenant1");
expect(Object.values(params)).toContain("proj_tenant1");
expect(Object.values(params)).toContain("env_tenant1");
});
it("should inject tenant guards even with user WHERE clause", () => {
const { sql, params } = compile("SELECT * FROM task_runs WHERE status = 'completed'");
// Must still have tenant guards
expect(sql).toContain("organization_id");
expect(sql).toContain("project_id");
expect(sql).toContain("environment_id");
expect(Object.values(params)).toContain("org_tenant1");
});
it("should inject tenant guards on all tables in JOIN", () => {
const { sql } = compile(`
SELECT r.id, e.event_type
FROM task_runs r
JOIN task_events e ON r.id = e.run_id
`);
// Both tables should have tenant guards
// Count occurrences of organization_id - should appear twice (once per table)
const orgIdMatches = sql.match(/organization_id/g) || [];
expect(orgIdMatches.length).toBeGreaterThanOrEqual(2);
});
it("should inject tenant guards in subqueries", () => {
const { sql } = compile(`
SELECT * FROM task_runs
WHERE id IN (SELECT run_id FROM task_events WHERE event_type = 'completed')
`);
// Should have tenant guards in both main query and subquery
const orgIdMatches = sql.match(/organization_id/g) || [];
expect(orgIdMatches.length).toBeGreaterThanOrEqual(2);
});
it("should inject tenant guards on UNION queries", () => {
const { sql } = compile(`
SELECT id, status FROM task_runs WHERE status = 'completed'
UNION ALL
SELECT id, status FROM task_runs WHERE status = 'failed'
`);
// Both sides of UNION should have tenant guards
const orgIdMatches = sql.match(/organization_id/g) || [];
expect(orgIdMatches.length).toBeGreaterThanOrEqual(2);
});
});
describe("Cannot bypass tenant guards", () => {
it("should not allow OR clause to bypass tenant guard", () => {
const { sql, params } = compile("SELECT * FROM task_runs WHERE status = 'completed' OR 1=1");
// The tenant guards should be ANDed with the entire WHERE clause
// So even with OR 1=1, the tenant guard still applies
expect(sql).toContain("organization_id");
expect(Object.values(params)).toContain("org_tenant1");
// The structure should be: and(tenant_guards, or(user_conditions))
// The user's OR should be nested inside the outer AND with tenant guards
// This ensures tenant_guard AND (status='completed' OR 1=1)
// NOT: tenant_guard AND status='completed' OR 1=1 (which would bypass)
// Verify the OR is contained within an outer AND structure
// The tenant guards use and() and the user's OR uses or()
expect(sql).toContain("or(");
expect(sql).toContain("and(");
// The and() should wrap everything - find where tenant columns appear
// They should be at the same level as the user's condition, both inside and()
const whereClause = sql.substring(sql.indexOf("WHERE"));
expect(whereClause).toMatch(/and\([^)]*organization_id/);
});
it("should not allow accessing other tenant's data via explicit condition", () => {
const { sql, params } = compile(
"SELECT * FROM task_runs WHERE organization_id = 'org_other_tenant'"
);
// Even if user specifies a different org_id, our tenant guard should override
// The compiled SQL should still use our tenant's ID
expect(Object.values(params)).toContain("org_tenant1");
});
it("should not allow UNION with unguarded query", () => {
// This should be rejected or the second part should still be guarded
const { sql } = compile(`
SELECT id FROM task_runs
UNION ALL
SELECT id FROM task_runs
`);
// Both parts must have tenant guards
const orgIdMatches = sql.match(/organization_id/g) || [];
expect(orgIdMatches.length).toBeGreaterThanOrEqual(2);
});
it("should not allow subquery to access other tenants", () => {
const { sql, params } = compile(`
SELECT * FROM task_runs
WHERE id IN (
SELECT run_id FROM task_events
)
`);
// Subquery must also have tenant guards
expect(Object.values(params)).toContain("org_tenant1");
const orgIdMatches = sql.match(/organization_id/g) || [];
expect(orgIdMatches.length).toBeGreaterThanOrEqual(2);
});
});
describe("Table allowlisting", () => {
it("should reject queries to unknown tables", () => {
expect(() => {
compile("SELECT * FROM users");
}).toThrow();
});
it("should reject queries to system tables", () => {
expect(() => {
compile("SELECT * FROM system.tables");
}).toThrow();
});
it("should reject queries trying to use database prefix", () => {
expect(() => {
compile("SELECT * FROM other_database.task_runs");
}).toThrow();
});
});
});
describe("SQL Injection Prevention", () => {
describe("String value injection", () => {
it("should reject queries with stacked statements in strings", () => {
// The parser correctly rejects this at parse time
expect(() => {
compile("SELECT * FROM task_runs WHERE status = 'completed'; DROP TABLE task_runs; --'");
}).toThrow();
});
it("should parameterize malicious-looking string values", () => {
const { sql, params } = compile("SELECT * FROM task_runs WHERE status = 'DROP TABLE users'");
// The malicious payload should be in params, not in SQL
expect(sql).not.toContain("DROP TABLE");
expect(Object.values(params)).toContain("DROP TABLE users");
});
it("should handle quote escape attempts", () => {
const { sql, params } = compile("SELECT * FROM task_runs WHERE status = 'test''injection'");
// Should be safely parameterized
expect(Object.values(params).some((v) => typeof v === "string")).toBe(true);
});
it("should handle backslash escape attempts", () => {
const { sql, params } = compile("SELECT * FROM task_runs WHERE status = 'test\\'injection'");
// Should be safely parameterized
expect(sql).not.toContain("injection'");
});
it("should handle unicode characters in strings", () => {
const { sql, params } = compile("SELECT * FROM task_runs WHERE status = 'test™injection'");
// Should be safely parameterized
expect(Object.values(params).some((v) => typeof v === "string")).toBe(true);
});
it("should handle null byte injection", () => {
const { sql, params } = compile("SELECT * FROM task_runs WHERE status = 'test\\0injection'");
expect(Object.values(params).some((v) => typeof v === "string")).toBe(true);
});
});
describe("Comment injection", () => {
it("should not allow -- comments to truncate query", () => {
// The parser should either reject this or handle it safely
const result = compile("SELECT * FROM task_runs WHERE status = 'completed'");
// Tenant guards must still be present
expect(result.sql).toContain("organization_id");
});
it("should not allow /* */ comments for injection", () => {
const result = compile("SELECT * FROM task_runs WHERE status = 'completed'");
// Tenant guards must still be present
expect(result.sql).toContain("organization_id");
});
});
describe("Identifier injection", () => {
it("should reject identifiers with backtick injection", () => {
expect(() => {
compile("SELECT * FROM task_runs WHERE `status`; DROP TABLE users; --` = 'test'");
}).toThrow();
});
it("should not expose column names that could be used for injection", () => {
// Column names in the output are validated identifiers from the schema
// Malicious column names would need to be in the schema first
const { sql } = compile("SELECT id, status FROM task_runs");
// Column names should be simple identifiers without injection
expect(sql).toContain("id");
expect(sql).toContain("status");
expect(sql).not.toContain(";");
});
it("should reject table names with special characters", () => {
expect(() => {
compile("SELECT * FROM `task_runs; DROP TABLE users`");
}).toThrow();
});
});
describe("Numeric injection", () => {
it("should handle numeric values safely", () => {
const { sql } = compile("SELECT * FROM task_runs WHERE duration_ms > 1000");
// Numbers should be safely inlined or parameterized
expect(sql).toContain("1000");
expect(sql).not.toContain(";");
});
it("should handle negative numbers safely", () => {
const { sql } = compile("SELECT * FROM task_runs WHERE duration_ms > -1");
expect(sql).not.toContain(";");
});
it("should handle floating point safely", () => {
const { sql } = compile("SELECT * FROM task_runs WHERE duration_ms > 1.5");
expect(sql).toContain("1.5");
});
});
describe("Function injection", () => {
it("should only allow known safe functions", () => {
// Unknown functions should be rejected
expect(() => {
compile("SELECT file('/etc/passwd') FROM task_runs");
}).toThrow();
});
it("should reject system functions", () => {
expect(() => {
compile("SELECT system.tables() FROM task_runs");
}).toThrow();
});
it("should allow known aggregate functions", () => {
const { sql } = compile("SELECT count(*), sum(duration_ms) FROM task_runs");
expect(sql).toContain("count(*)");
expect(sql).toContain("sum(duration_ms)");
});
});
describe("Stacked query prevention", () => {
it("should not allow semicolon to start new statement", () => {
expect(() => {
compile("SELECT * FROM task_runs; DELETE FROM task_runs");
}).toThrow();
});
it("should not allow multiple statements", () => {
expect(() => {
compile("SELECT * FROM task_runs; SELECT * FROM task_events");
}).toThrow();
});
});
describe("UNION-based injection", () => {
it("should apply tenant guards to all UNION parts", () => {
const { sql, params } = compile(`
SELECT id, status FROM task_runs WHERE status = 'a'
UNION ALL
SELECT id, status FROM task_runs WHERE status = 'b'
`);
// Both parts should have tenant guards
expect(Object.values(params)).toContain("org_tenant1");
const orgIdMatches = sql.match(/organization_id/g) || [];
expect(orgIdMatches.length).toBeGreaterThanOrEqual(2);
});
});
});
describe("Parameter Safety", () => {
it("should use typed parameters", () => {
const { sql } = compile("SELECT * FROM task_runs WHERE status = 'test'");
// Parameters should have type annotations like {param: String}
expect(sql).toMatch(/\{tsql_\w+: \w+\}/);
});
it("should generate unique parameter names", () => {
const { params } = compile(`
SELECT * FROM task_runs
WHERE status = 'a' AND task_identifier = 'b' AND payload = 'c'
`);
// All parameter keys should be unique
const keys = Object.keys(params);
expect(new Set(keys).size).toBe(keys.length);
});
it("should not include raw values in SQL for strings", () => {
const { sql } = compile("SELECT * FROM task_runs WHERE status = 'user_provided_value'");
// The literal string should not appear in SQL
expect(sql).not.toContain("user_provided_value");
});
});
describe("Optional Tenant Filters", () => {
/**
* Helper to extract the WHERE clause from SQL for more precise testing.
* This is needed because SELECT * expansion includes all columns,
* but we only want to check what's in the WHERE clause for tenant filtering.
*/
function getWhereClause(sql: string): string {
const whereMatch = sql.match(/WHERE\s+(.*?)(?:\s+(?:ORDER|GROUP|LIMIT|$))/is);
return whereMatch ? whereMatch[1] : "";
}
describe("Organization ID is always required", () => {
it("should always inject organization guard even with optional project/env", () => {
const { sql, params } = compile("SELECT * FROM task_runs", {
projectId: undefined,
environmentId: undefined,
});
const whereClause = getWhereClause(sql);
// Must contain organization_id in WHERE clause
expect(whereClause).toContain("organization_id");
expect(Object.values(params)).toContain("org_tenant1");
// Should NOT contain project_id or environment_id guards in WHERE clause
expect(whereClause).not.toContain("project_id");
expect(whereClause).not.toContain("environment_id");
});
});
describe("Project ID is optional", () => {
it("should inject org and project guards when project is provided", () => {
const { sql, params } = compile("SELECT * FROM task_runs", {
projectId: "proj_tenant1",
environmentId: undefined,
});
const whereClause = getWhereClause(sql);
// Must contain organization_id and project_id in WHERE clause
expect(whereClause).toContain("organization_id");
expect(whereClause).toContain("project_id");
expect(Object.values(params)).toContain("org_tenant1");
expect(Object.values(params)).toContain("proj_tenant1");
// Should NOT contain environment_id guard in WHERE clause
expect(whereClause).not.toContain("environment_id");
});
it("should allow querying across all projects when projectId is omitted", () => {
const { sql, params } = compile("SELECT * FROM task_runs", {
projectId: undefined,
environmentId: undefined,
});
const whereClause = getWhereClause(sql);
// Only org guard should be present in WHERE clause
expect(whereClause).toContain("organization_id");
expect(Object.values(params)).toContain("org_tenant1");
expect(whereClause).not.toContain("project_id");
});
});
describe("Environment ID is optional", () => {
it("should inject org, project, and env guards when all provided", () => {
const { sql, params } = compile("SELECT * FROM task_runs");
const whereClause = getWhereClause(sql);
// All three should be present in WHERE clause (default options include all)
expect(whereClause).toContain("organization_id");
expect(whereClause).toContain("project_id");
expect(whereClause).toContain("environment_id");
expect(Object.values(params)).toContain("org_tenant1");
expect(Object.values(params)).toContain("proj_tenant1");
expect(Object.values(params)).toContain("env_tenant1");
});
it("should allow querying across all environments when environmentId is omitted", () => {
const { sql, params } = compile("SELECT * FROM task_runs", {
projectId: "proj_tenant1",
environmentId: undefined,
});
const whereClause = getWhereClause(sql);
// Org and project guards should be present in WHERE clause
expect(whereClause).toContain("organization_id");
expect(whereClause).toContain("project_id");
expect(Object.values(params)).toContain("org_tenant1");
expect(Object.values(params)).toContain("proj_tenant1");
// Environment guard should NOT be present in WHERE clause
expect(whereClause).not.toContain("environment_id");
});
});
describe("Cross-tenant security with optional filters", () => {
it("should still prevent cross-org access with org-only filter", () => {
const { sql, params } = compile(
"SELECT * FROM task_runs WHERE organization_id = 'org_other'",
{
projectId: undefined,
environmentId: undefined,
}
);
// Our org guard should still be enforced
expect(Object.values(params)).toContain("org_tenant1");
});
it("should apply org guard to all tables in JOIN when using org-only filter", () => {
const { sql } = compile(
`
SELECT r.id, e.event_type
FROM task_runs r
JOIN task_events e ON r.id = e.run_id
`,
{
projectId: undefined,
environmentId: undefined,
}
);
// Both tables should have org guards
const orgIdMatches = sql.match(/organization_id/g) || [];
expect(orgIdMatches.length).toBeGreaterThanOrEqual(2);
// Project and environment should NOT appear
expect(sql).not.toContain("project_id");
expect(sql).not.toContain("environment_id");
});
it("should apply org guard to UNION queries when using org-only filter", () => {
const { sql } = compile(
`
SELECT id, status FROM task_runs WHERE status = 'completed'
UNION ALL
SELECT id, status FROM task_runs WHERE status = 'failed'
`,
{
projectId: undefined,
environmentId: undefined,
}
);
// Both parts should have org guards
const orgIdMatches = sql.match(/organization_id/g) || [];
expect(orgIdMatches.length).toBeGreaterThanOrEqual(2);
});
it("should apply org guard to subqueries when using org-only filter", () => {
const { sql, params } = compile(
`
SELECT * FROM task_runs
WHERE id IN (SELECT run_id FROM task_events)
`,
{
projectId: undefined,
environmentId: undefined,
}
);
// Both main query and subquery should have org guards
expect(Object.values(params)).toContain("org_tenant1");
const orgIdMatches = sql.match(/organization_id/g) || [];
expect(orgIdMatches.length).toBeGreaterThanOrEqual(2);
});
});
});
describe("Edge Cases", () => {
it("should handle empty string values", () => {
const { params } = compile("SELECT * FROM task_runs WHERE status = ''");
expect(Object.values(params)).toContain("");
});
it("should handle very long strings", () => {
const longString = "a".repeat(10000);
const { params } = compile(`SELECT * FROM task_runs WHERE status = '${longString}'`);
expect(Object.values(params)).toContain(longString);
});
it("should handle strings with newlines", () => {
const { params } = compile("SELECT * FROM task_runs WHERE status = 'line1\nline2'");
// Should handle newlines safely
expect(Object.values(params).some((v) => typeof v === "string" && v.includes("\n"))).toBe(true);
});
it("should handle special SQL keywords in strings", () => {
const { sql, params } = compile("SELECT * FROM task_runs WHERE status = 'SELECT * FROM users'");
// The SQL keywords should be in params, not interpreted
expect(sql).not.toMatch(/SELECT \* FROM users/);
expect(Object.values(params)).toContain("SELECT * FROM users");
});
});
+107
View File
@@ -0,0 +1,107 @@
// TypeScript translation of posthog/hogql/timings.py
/**
* Get performance counter in milliseconds (Node.js equivalent of perf_counter)
* Uses performance.now() which is available in:
* - Node.js 18+ (global)
* - Browser (global)
* - Node.js <18 via perf_hooks module
*/
function getPerformanceNow(): number {
// Check for global performance (Node.js 18+ or browser)
if (typeof globalThis !== 'undefined' && 'performance' in globalThis) {
const perf = (globalThis as any).performance;
if (perf && typeof perf.now === 'function') {
return perf.now();
}
}
// Fallback to Date.now() if performance API is not available
// Note: This is less precise but works everywhere
return Date.now();
}
export interface QueryTiming {
key: string; // Key identifying the timing measurement
time: number; // Time in seconds
}
const TIMING_DECIMAL_PLACES = 3; // round to milliseconds
// Not thread safe.
// See trends_query_runner for an example of how to use for multithreaded queries
export class TSQLTimings {
// Completed time in seconds for different parts of the TSQL query
timings: Record<string, number> = {};
// Used for housekeeping
private _timingPointer: string;
private _timingStarts: Record<string, number> = {};
constructor(_timingPointer: string = '.') {
this._timingPointer = _timingPointer;
this._timingStarts[this._timingPointer] = this.perfCounter();
}
cloneForSubquery(seriesIndex: number): TSQLTimings {
return new TSQLTimings(`${this._timingPointer}/series_${seriesIndex}`);
}
clearTimings(): void {
this.timings = {};
}
/**
* Measure execution time of a function.
* Usage: timings.measure('operation', () => { ... });
*/
measure<T>(key: string, fn: () => T): T {
const lastKey = this._timingPointer;
const fullKey = `${this._timingPointer}/${key}`;
this._timingPointer = fullKey;
this._timingStarts[fullKey] = this.perfCounter();
try {
return fn();
} finally {
const duration = (this.perfCounter() - this._timingStarts[fullKey]) / 1000; // Convert to seconds
this.timings[fullKey] = (this.timings[fullKey] || 0.0) + duration;
delete this._timingStarts[fullKey];
this._timingPointer = lastKey;
}
}
/**
* Get performance counter in milliseconds (Node.js equivalent of perf_counter)
*/
private perfCounter(): number {
return getPerformanceNow();
}
toDict(): Record<string, number> {
const timings = { ...this.timings };
// Process in reverse order to handle nested timings correctly
const keys = Object.keys(this._timingStarts).reverse();
for (const key of keys) {
const start = this._timingStarts[key];
const elapsed = (this.perfCounter() - start) / 1000; // Convert to seconds
timings[key] = this.round((timings[key] || 0.0) + elapsed);
}
return timings;
}
toList(backOutStack: boolean = true): QueryTiming[] {
const timingDict = backOutStack ? this.toDict() : this.timings;
return Object.entries(timingDict).map(([key, time]) => ({
key: key,
time: this.round(time),
}));
}
/**
* Round to specified decimal places (milliseconds precision)
*/
private round(value: number): number {
return Math.round(value * Math.pow(10, TIMING_DECIMAL_PLACES)) / Math.pow(10, TIMING_DECIMAL_PLACES);
}
}
@@ -0,0 +1,124 @@
import { describe, it, expect } from "vitest";
import { validateQuery } from "./validator.js";
import { parseTSQLSelect } from "../index.js";
import { column, type TableSchema } from "./schema.js";
const runsSchema: TableSchema = {
name: "runs",
clickhouseName: "trigger_dev.task_runs_v2",
columns: {
id: { name: "id", ...column("String") },
status: {
name: "status",
...column("String", {
allowedValues: ["PENDING", "COMPLETED", "FAILED"],
}),
},
task_id: { name: "task_id", ...column("String") },
created_at: { name: "created_at", ...column("DateTime64") },
},
tenantColumns: {
organizationId: "organization_id",
projectId: "project_id",
environmentId: "environment_id",
},
};
function validateSQL(query: string, schema: TableSchema[] = [runsSchema]) {
const ast = parseTSQLSelect(query);
return validateQuery(ast, schema);
}
describe("validateQuery", () => {
describe("SELECT aliases", () => {
it("should allow ORDER BY to reference aliased columns", () => {
const result = validateSQL(
"SELECT status, count(*) as count FROM runs GROUP BY status ORDER BY count DESC"
);
expect(result.valid).toBe(true);
expect(result.issues).toHaveLength(0);
});
it("should allow ORDER BY to reference multiple aliased columns", () => {
const result = validateSQL(
"SELECT status, count(*) as total, avg(created_at) as avg_time FROM runs GROUP BY status ORDER BY total DESC, avg_time ASC"
);
expect(result.valid).toBe(true);
expect(result.issues).toHaveLength(0);
});
it("should still report unknown columns that are not aliases", () => {
const result = validateSQL(
"SELECT status, count(*) as count FROM runs GROUP BY status ORDER BY unknown_col DESC"
);
expect(result.valid).toBe(true); // unknown column is a warning, not error
expect(result.issues).toHaveLength(1);
expect(result.issues[0].type).toBe("unknown_column");
expect(result.issues[0].columnName).toBe("unknown_col");
});
it("should allow ORDER BY to reference both aliases and real columns", () => {
const result = validateSQL(
"SELECT status, count(*) as count FROM runs GROUP BY status ORDER BY status ASC, count DESC"
);
expect(result.valid).toBe(true);
expect(result.issues).toHaveLength(0);
});
it("should allow HAVING to reference implicit column names from aggregations", () => {
const result = validateSQL(
"SELECT COUNT(), status FROM runs GROUP BY status HAVING count > 20"
);
expect(result.valid).toBe(true);
expect(result.issues).toHaveLength(0);
});
it("should allow HAVING to reference implicit names from multiple aggregations", () => {
const result = validateSQL(
"SELECT COUNT(), SUM(created_at), status FROM runs GROUP BY status HAVING count > 10 AND sum > 100"
);
expect(result.valid).toBe(true);
expect(result.issues).toHaveLength(0);
});
it("should allow ORDER BY to reference implicit column names", () => {
const result = validateSQL(
"SELECT COUNT(), status FROM runs GROUP BY status ORDER BY count DESC"
);
expect(result.valid).toBe(true);
expect(result.issues).toHaveLength(0);
});
});
describe("column validation", () => {
it("should validate known columns", () => {
const result = validateSQL("SELECT id, status FROM runs LIMIT 10");
expect(result.valid).toBe(true);
expect(result.issues).toHaveLength(0);
});
it("should warn about unknown columns", () => {
const result = validateSQL("SELECT id, unknown_column FROM runs LIMIT 10");
expect(result.valid).toBe(true); // warnings don't affect validity
expect(result.issues).toHaveLength(1);
expect(result.issues[0].type).toBe("unknown_column");
expect(result.issues[0].columnName).toBe("unknown_column");
});
});
describe("enum validation", () => {
it("should validate enum values", () => {
const result = validateSQL("SELECT * FROM runs WHERE status = 'COMPLETED' LIMIT 10");
expect(result.valid).toBe(true);
expect(result.issues).toHaveLength(0);
});
it("should error on invalid enum values", () => {
const result = validateSQL("SELECT * FROM runs WHERE status = 'INVALID_STATUS' LIMIT 10");
expect(result.valid).toBe(false);
expect(result.issues).toHaveLength(1);
expect(result.issues[0].type).toBe("invalid_enum_value");
expect(result.issues[0].invalidValue).toBe("INVALID_STATUS");
});
});
});
@@ -0,0 +1,553 @@
// Schema validation for TSQL queries
// Validates column names and enum values against the schema
import type {
SelectQuery,
SelectSetQuery,
Expression,
Field,
CompareOperation,
Constant,
And,
Or,
Not,
Alias,
OrderExpr,
Call,
JoinExpr,
BetweenExpr,
Array as ASTArray,
ArithmeticOperation,
} from "./ast.js";
import type { TableSchema, ColumnSchema } from "./schema.js";
import { getAllowedUserValues, isValidUserValue } from "./schema.js";
import { CompareOperationOp, ArithmeticOperationOp } from "./ast.js";
/**
* Severity of a validation issue
*/
export type ValidationSeverity = "error" | "warning" | "info";
/**
* A validation issue found in the query
*/
export interface ValidationIssue {
/** The error/warning message */
message: string;
/** Severity of the issue */
severity: ValidationSeverity;
/** The type of issue */
type: "unknown_column" | "unknown_table" | "invalid_enum_value";
/** Optional: the column name that caused the issue */
columnName?: string;
/** Optional: the table name that caused the issue */
tableName?: string;
/** Optional: the invalid value */
invalidValue?: string;
/** Optional: list of allowed values */
allowedValues?: string[];
}
/**
* Result of validating a query
*/
export interface ValidationResult {
/** Whether the query is valid */
valid: boolean;
/** List of issues found */
issues: ValidationIssue[];
}
/**
* Context for tracking tables and columns during validation
*/
interface ValidationContext {
/** Map of table aliases/names to their schemas */
tables: Map<string, TableSchema>;
/** The schema array for lookups */
schema: TableSchema[];
/** Accumulated issues */
issues: ValidationIssue[];
/** Set of column aliases defined in the SELECT clause */
selectAliases: Set<string>;
}
/**
* Validate a parsed TSQL query against a schema
*
* @param ast - The parsed query AST
* @param schema - Array of table schemas to validate against
* @returns Validation result with any issues found
*/
export function validateQuery(
ast: SelectQuery | SelectSetQuery,
schema: TableSchema[]
): ValidationResult {
const context: ValidationContext = {
tables: new Map(),
schema,
issues: [],
selectAliases: new Set(),
};
if (ast.expression_type === "select_set_query") {
validateSelectSetQuery(ast, context);
} else {
validateSelectQuery(ast, context);
}
return {
valid: context.issues.filter((i) => i.severity === "error").length === 0,
issues: context.issues,
};
}
/**
* Get the implicit column name for an expression without an explicit alias.
* This matches the naming used in the printer for result columns.
*
* @param expr - The SELECT expression
* @returns The implicit name, or null if no implicit name applies
*/
function getImplicitName(expr: Expression): string | null {
// Handle Call (function/aggregation) - use lowercase function name
if ((expr as Call).expression_type === "call") {
const call = expr as Call;
return call.name.toLowerCase();
}
// Handle ArithmeticOperation - use operator function name
if ((expr as ArithmeticOperation).expression_type === "arithmetic_operation") {
const arith = expr as ArithmeticOperation;
switch (arith.op) {
case ArithmeticOperationOp.Add:
return "plus";
case ArithmeticOperationOp.Sub:
return "minus";
case ArithmeticOperationOp.Mult:
return "multiply";
case ArithmeticOperationOp.Div:
return "divide";
case ArithmeticOperationOp.Mod:
return "modulo";
default:
return "expression";
}
}
// Handle Constant - use string representation
if ((expr as Constant).expression_type === "constant") {
const constant = expr as Constant;
if (constant.value === null) {
return "NULL";
}
if (typeof constant.value === "string") {
return `'${constant.value}'`;
}
return String(constant.value);
}
// Field expressions don't get implicit names (they use the column name directly)
return null;
}
/**
* Validate a SELECT SET query (UNION, INTERSECT, etc.)
*/
function validateSelectSetQuery(node: SelectSetQuery, context: ValidationContext): void {
if (node.initial_select_query.expression_type === "select_set_query") {
validateSelectSetQuery(node.initial_select_query, context);
} else {
validateSelectQuery(node.initial_select_query, context);
}
for (const subsequent of node.subsequent_select_queries) {
if (subsequent.select_query.expression_type === "select_set_query") {
validateSelectSetQuery(subsequent.select_query as SelectSetQuery, context);
} else {
validateSelectQuery(subsequent.select_query as SelectQuery, context);
}
}
}
/**
* Validate a SELECT query
*/
function validateSelectQuery(node: SelectQuery, context: ValidationContext): void {
// Save parent aliases and create fresh set for this query
const parentAliases = context.selectAliases;
context.selectAliases = new Set();
// First, extract tables from FROM clause to build context
if (node.select_from) {
extractTablesFromJoin(node.select_from, context);
}
// Extract column aliases from SELECT clause before validation
// This allows ORDER BY and HAVING to reference aliased columns
if (node.select) {
for (const expr of node.select) {
if ((expr as Alias).expression_type === "alias") {
// Explicit alias: SELECT ... AS name
context.selectAliases.add((expr as Alias).alias);
} else {
// Check for implicit aliases from expressions without AS
const implicitName = getImplicitName(expr);
if (implicitName) {
context.selectAliases.add(implicitName);
}
}
}
}
// Validate SELECT columns
if (node.select) {
for (const expr of node.select) {
validateExpression(expr, context);
}
}
// Validate WHERE clause
if (node.where) {
validateExpression(node.where, context);
}
// Validate GROUP BY
if (node.group_by) {
for (const expr of node.group_by) {
validateExpression(expr, context);
}
}
// Validate HAVING
if (node.having) {
validateExpression(node.having, context);
}
// Validate ORDER BY
if (node.order_by) {
for (const expr of node.order_by) {
validateExpression(expr, context);
}
}
// Restore parent aliases
context.selectAliases = parentAliases;
}
/**
* Extract table schemas from JOIN expressions
*/
function extractTablesFromJoin(node: JoinExpr, context: ValidationContext): void {
if (node.table) {
const tableExpr = node.table;
if ((tableExpr as Field).expression_type === "field") {
const field = tableExpr as Field;
const tableName = field.chain[0];
if (typeof tableName === "string") {
// Find the table schema
const tableSchema = context.schema.find(
(t) => t.name.toLowerCase() === tableName.toLowerCase()
);
if (tableSchema) {
// Register with alias if provided, otherwise use table name
const key = node.alias || tableName;
context.tables.set(key.toLowerCase(), tableSchema);
} else {
// Unknown table
context.issues.push({
message: `Unknown table "${tableName}". Available tables: ${
context.schema.map((t) => t.name).join(", ") || "(none)"
}`,
severity: "warning",
type: "unknown_table",
tableName,
});
}
}
} else if (
(tableExpr as SelectQuery).expression_type === "select_query" ||
(tableExpr as SelectSetQuery).expression_type === "select_set_query"
) {
// Subquery - validate it recursively
if ((tableExpr as SelectSetQuery).expression_type === "select_set_query") {
validateSelectSetQuery(tableExpr as SelectSetQuery, context);
} else {
validateSelectQuery(tableExpr as SelectQuery, context);
}
}
}
// Process next join in chain
if (node.next_join) {
extractTablesFromJoin(node.next_join, context);
}
}
/**
* Validate an expression and its children
*/
function validateExpression(expr: Expression, context: ValidationContext): void {
if (!expr || typeof expr !== "object") return;
const exprType = expr.expression_type;
switch (exprType) {
case "field":
validateField(expr as Field, context);
break;
case "compare_operation":
validateCompareOperation(expr as CompareOperation, context);
break;
case "and":
for (const e of (expr as And).exprs) {
validateExpression(e, context);
}
break;
case "or":
for (const e of (expr as Or).exprs) {
validateExpression(e, context);
}
break;
case "not":
validateExpression((expr as Not).expr, context);
break;
case "alias":
validateExpression((expr as Alias).expr, context);
break;
case "order_expr":
validateExpression((expr as OrderExpr).expr, context);
break;
case "call":
for (const arg of (expr as Call).args) {
validateExpression(arg, context);
}
break;
case "between_expr":
validateExpression((expr as BetweenExpr).expr, context);
validateExpression((expr as BetweenExpr).low, context);
validateExpression((expr as BetweenExpr).high, context);
break;
case "array":
for (const e of (expr as ASTArray).exprs) {
validateExpression(e, context);
}
break;
// Other expression types that we don't need to deeply validate
case "constant":
case "select_query":
case "select_set_query":
// Skip - constants don't need validation, subqueries are handled separately
break;
}
}
/**
* Validate a field reference
*/
function validateField(field: Field, context: ValidationContext): void {
const chain = field.chain;
if (chain.length === 0) return;
// Handle asterisk
if (chain[0] === "*") return;
if (chain.length === 2 && chain[1] === "*") return;
const firstPart = chain[0];
if (typeof firstPart !== "string") return;
// Case 1: Qualified reference like table.column
if (chain.length >= 2) {
const tableAlias = firstPart.toLowerCase();
const columnName = chain[1];
if (typeof columnName !== "string") return;
const tableSchema = context.tables.get(tableAlias);
if (tableSchema) {
// Check if column exists
if (!tableSchema.columns[columnName]) {
const availableColumns = Object.keys(tableSchema.columns).join(", ");
context.issues.push({
message: `Unknown column "${columnName}" on table "${tableAlias}". Available columns: ${availableColumns}`,
severity: "warning",
type: "unknown_column",
columnName,
tableName: tableAlias,
});
}
}
return;
}
// Case 2: Unqualified reference - try to find in any table or SELECT alias
const columnName = firstPart;
// Check if it's a SELECT alias (e.g., from "count(*) as count")
if (context.selectAliases.has(columnName)) {
return;
}
let found = false;
for (const tableSchema of context.tables.values()) {
if (tableSchema.columns[columnName]) {
found = true;
break;
}
}
if (!found && context.tables.size > 0) {
// Only report if we have tables to check against
const allColumns = new Set<string>();
for (const tableSchema of context.tables.values()) {
for (const col of Object.keys(tableSchema.columns)) {
allColumns.add(col);
}
}
context.issues.push({
message: `Unknown column "${columnName}". Available columns: ${Array.from(allColumns).join(
", "
)}`,
severity: "warning",
type: "unknown_column",
columnName,
});
}
}
/**
* Validate a comparison operation, including enum value checks
*/
function validateCompareOperation(op: CompareOperation, context: ValidationContext): void {
// Validate both sides recursively
validateExpression(op.left, context);
validateExpression(op.right, context);
// Check for enum value validation
// We look for patterns like: column = 'value' or column IN ('value1', 'value2')
const columnInfo = extractColumnFromExpression(op.left, context);
if (!columnInfo) return;
const { columnSchema, columnName, tableName } = columnInfo;
// Only validate if the column has allowedValues or valueMap
const allowedValues = getAllowedUserValues(columnSchema);
if (allowedValues.length === 0) return;
// Check the comparison type
switch (op.op) {
case CompareOperationOp.Eq:
case CompareOperationOp.NotEq:
// Single value comparison
validateEnumValue(op.right, columnSchema, columnName, tableName, context);
break;
case CompareOperationOp.In:
case CompareOperationOp.NotIn:
case CompareOperationOp.GlobalIn:
case CompareOperationOp.GlobalNotIn:
// Array of values
if ((op.right as ASTArray).expression_type === "array") {
for (const elem of (op.right as ASTArray).exprs) {
validateEnumValue(elem, columnSchema, columnName, tableName, context);
}
}
break;
}
}
/**
* Extract column information from an expression if it's a simple column reference
*/
function extractColumnFromExpression(
expr: Expression,
context: ValidationContext
): { columnSchema: ColumnSchema; columnName: string; tableName?: string } | null {
if ((expr as Field).expression_type !== "field") return null;
const field = expr as Field;
const chain = field.chain;
if (chain.length === 0) return null;
const firstPart = chain[0];
if (typeof firstPart !== "string") return null;
// Qualified reference: table.column
if (chain.length >= 2) {
const tableAlias = firstPart.toLowerCase();
const columnName = chain[1];
if (typeof columnName !== "string") return null;
const tableSchema = context.tables.get(tableAlias);
if (!tableSchema) return null;
const columnSchema = tableSchema.columns[columnName];
if (!columnSchema) return null;
return { columnSchema, columnName, tableName: tableAlias };
}
// Unqualified reference
const columnName = firstPart;
for (const [tableName, tableSchema] of context.tables.entries()) {
const columnSchema = tableSchema.columns[columnName];
if (columnSchema) {
return { columnSchema, columnName, tableName };
}
}
return null;
}
/**
* Validate that a value matches the allowed enum values for a column
* Supports both allowedValues and valueMap, with case-insensitive matching
*/
function validateEnumValue(
expr: Expression,
columnSchema: ColumnSchema,
columnName: string,
tableName: string | undefined,
context: ValidationContext
): void {
if ((expr as Constant).expression_type !== "constant") return;
const constant = expr as Constant;
if (typeof constant.value !== "string") return;
const value = constant.value;
// Use isValidUserValue for case-insensitive validation against user-friendly values
if (!isValidUserValue(columnSchema, value)) {
const columnRef = tableName ? `${tableName}.${columnName}` : columnName;
// Show user-friendly values in the error message
const allowedValues = getAllowedUserValues(columnSchema);
context.issues.push({
message: `Invalid value "${value}" for column "${columnRef}". Allowed values: ${allowedValues.join(
", "
)}`,
severity: "error",
type: "invalid_enum_value",
columnName,
tableName,
invalidValue: value,
allowedValues,
});
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2019",
"lib": ["ES2019", "DOM", "DOM.Iterable", "DOM.AsyncIterable"],
"module": "ESNext",
"moduleResolution": "node",
"moduleDetection": "force",
"verbatimModuleSyntax": false,
"types": ["vitest/globals"],
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"preserveWatchOutput": true,
"skipLibCheck": true,
"noEmit": true,
"strict": true,
"paths": {
"@trigger.dev/core": ["../../packages/core/src/index"],
"@trigger.dev/core/*": ["../../packages/core/src/*"],
"@internal/clickhouse": ["../clickhouse/src/index"],
"@internal/clickhouse/*": ["../clickhouse/src/*"]
}
},
"exclude": ["node_modules"]
}
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["**/*.test.ts"],
globals: true,
isolate: true,
fileParallelism: false,
poolOptions: {
threads: {
singleThread: true,
},
},
testTimeout: 60_000,
coverage: {
provider: "v8",
},
},
});
+2 -1
View File
@@ -80,7 +80,8 @@
"redlock@5.0.0-beta.2": "patches/redlock@5.0.0-beta.2.patch",
"@kubernetes/client-node@1.0.0": "patches/@kubernetes__client-node@1.0.0.patch",
"@sentry/remix@9.46.0": "patches/@sentry__remix@9.46.0.patch",
"@upstash/ratelimit@1.1.3": "patches/@upstash__ratelimit.patch"
"@upstash/ratelimit@1.1.3": "patches/@upstash__ratelimit.patch",
"antlr4ts@0.5.0-alpha.4": "patches/antlr4ts@0.5.0-alpha.4.patch"
},
"overrides": {
"express@^4>body-parser": "1.20.3",
+470
View File
@@ -0,0 +1,470 @@
diff --git a/ANTLRInputStream.js b/ANTLRInputStream.js
index eaeb0800e83d2a4bd21699ff5fc8b0bb8920efbb..ed6c85c047d1ead64653e212d579967536dca664 100644
--- a/ANTLRInputStream.js
+++ b/ANTLRInputStream.js
@@ -43,7 +43,7 @@ class ANTLRInputStream {
}
consume() {
if (this.p >= this.n) {
- assert(this.LA(1) === IntStream_1.IntStream.EOF);
+ assert.ok(this.LA(1) === IntStream_1.IntStream.EOF);
throw new Error("cannot consume EOF");
}
//System.out.println("prev p="+p+", c="+(char)data[p]);
diff --git a/BufferedTokenStream.js b/BufferedTokenStream.js
index 9a3c6d19bf959819d96552d73267f93985a6fd67..11511d985b9b2b7dd0b140579032a0929d0a4342 100644
--- a/BufferedTokenStream.js
+++ b/BufferedTokenStream.js
@@ -126,7 +126,7 @@ let BufferedTokenStream = class BufferedTokenStream {
* @see #get(int i)
*/
sync(i) {
- assert(i >= 0);
+ assert.ok(i >= 0);
let n = i - this.tokens.length + 1; // how many more elements we need?
//System.out.println("sync("+i+") needs "+n);
if (n > 0) {
@@ -251,7 +251,7 @@ let BufferedTokenStream = class BufferedTokenStream {
getTokens(start, stop, types) {
this.lazyInit();
if (start === undefined) {
- assert(stop === undefined && types === undefined);
+ assert.ok(stop === undefined && types === undefined);
return this.tokens;
}
else if (stop === undefined) {
diff --git a/CodePointBuffer.js b/CodePointBuffer.js
index 7d50d372da9a1ccae48f55dd6e5ee388f583ace1..f6452956227847a4e2477c8eb1017e56ec9a58d1 100644
--- a/CodePointBuffer.js
+++ b/CodePointBuffer.js
@@ -109,7 +109,7 @@ exports.CodePointBuffer = CodePointBuffer;
}
}
appendArrayByte(utf16In) {
- assert(this.prevHighSurrogate === -1);
+ assert.ok(this.prevHighSurrogate === -1);
let input = utf16In;
let inOffset = 0;
let inLimit = utf16In.length;
@@ -140,7 +140,7 @@ exports.CodePointBuffer = CodePointBuffer;
this.position = outOffset;
}
appendArrayChar(utf16In) {
- assert(this.prevHighSurrogate === -1);
+ assert.ok(this.prevHighSurrogate === -1);
let input = utf16In;
let inOffset = 0;
let inLimit = utf16In.length;
diff --git a/CodePointCharStream.js b/CodePointCharStream.js
index 4c5398f24afdd378b36053391611404018925f71..e507525f0976219da6eae78136625b8b5a42db67 100644
--- a/CodePointCharStream.js
+++ b/CodePointCharStream.js
@@ -28,7 +28,7 @@ class CodePointCharStream {
// construct instances of this type.
constructor(array, position, remaining, name) {
// TODO
- assert(position === 0);
+ assert.ok(position === 0);
this._array = array;
this._size = remaining;
this._name = name;
@@ -55,7 +55,7 @@ class CodePointCharStream {
}
consume() {
if (this._size - this._position === 0) {
- assert(this.LA(1) === IntStream_1.IntStream.EOF);
+ assert.ok(this.LA(1) === IntStream_1.IntStream.EOF);
throw new RangeError("cannot consume EOF");
}
this._position++;
diff --git a/atn/ATN.js b/atn/ATN.js
index 0da2b8e836510546583685253ec828dbdaa0c1db..4d812a850b6b010576396e6e610f6df25cb585c9 100644
--- a/atn/ATN.js
+++ b/atn/ATN.js
@@ -63,7 +63,7 @@ let ATN = class ATN {
return PredictionContext_1.PredictionContext.getCachedContext(context, this.contextCache, new PredictionContext_1.PredictionContext.IdentityHashMap());
}
getDecisionToDFA() {
- assert(this.decisionToDFA != null && this.decisionToDFA.length === this.decisionToState.length);
+ assert.ok(this.decisionToDFA != null && this.decisionToDFA.length === this.decisionToState.length);
return this.decisionToDFA;
}
nextTokens(s, ctx) {
diff --git a/atn/ATNConfig.js b/atn/ATNConfig.js
index 480d569b667f29437aeef8f2b9e6b0d3187c565f..bcacbc91ea61488f4b8e2ef40960c4f25a168758 100644
--- a/atn/ATNConfig.js
+++ b/atn/ATNConfig.js
@@ -61,7 +61,7 @@ const SUPPRESS_PRECEDENCE_FILTER = 0x80000000;
let ATNConfig = class ATNConfig {
constructor(state, altOrConfig, context) {
if (typeof altOrConfig === "number") {
- assert((altOrConfig & 0xFFFFFF) === altOrConfig);
+ assert.ok((altOrConfig & 0xFFFFFF) === altOrConfig);
this._state = state;
this.altAndOuterContextDepth = altOrConfig;
this._context = context;
@@ -120,7 +120,7 @@ let ATNConfig = class ATNConfig {
return (this.altAndOuterContextDepth >>> 24) & 0x7F;
}
set outerContextDepth(outerContextDepth) {
- assert(outerContextDepth >= 0);
+ assert.ok(outerContextDepth >= 0);
// saturate at 0x7F - everything but zero/positive is only used for debug information anyway
outerContextDepth = Math.min(outerContextDepth, 0x7F);
this.altAndOuterContextDepth = ((outerContextDepth << 24) | (this.altAndOuterContextDepth & ~0x7F000000) >>> 0);
diff --git a/atn/ATNConfigSet.js b/atn/ATNConfigSet.js
index 3ce0361c4ce7fef06b07a9a24c23251fd31dc0d1..f92be1dbeeed37a69b2aa3022ac5a7b7b29040f1 100644
--- a/atn/ATNConfigSet.js
+++ b/atn/ATNConfigSet.js
@@ -125,7 +125,7 @@ class ATNConfigSet {
if (this.outermostConfigSet && !outermostConfigSet) {
throw new Error("IllegalStateException");
}
- assert(!outermostConfigSet || !this._dipsIntoOuterContext);
+ assert.ok(!outermostConfigSet || !this._dipsIntoOuterContext);
this.outermostConfigSet = outermostConfigSet;
}
getStates() {
@@ -193,7 +193,7 @@ class ATNConfigSet {
if (!this.mergedConfigs || !this.unmerged) {
throw new Error("Covered by ensureWritable but duplicated here for strict null check limitation");
}
- assert(!this.outermostConfigSet || !e.reachesIntoOuterContext);
+ assert.ok(!this.outermostConfigSet || !e.reachesIntoOuterContext);
if (contextCache == null) {
contextCache = PredictionContextCache_1.PredictionContextCache.UNCACHED;
}
@@ -247,7 +247,7 @@ class ATNConfigSet {
updatePropertiesForMergedConfig(config) {
// merged configs can't change the alt or semantic context
this._dipsIntoOuterContext = this._dipsIntoOuterContext || config.reachesIntoOuterContext;
- assert(!this.outermostConfigSet || !this._dipsIntoOuterContext);
+ assert.ok(!this.outermostConfigSet || !this._dipsIntoOuterContext);
}
updatePropertiesForAddedConfig(config) {
if (this.configs.length === 1) {
@@ -258,7 +258,7 @@ class ATNConfigSet {
}
this._hasSemanticContext = this._hasSemanticContext || !SemanticContext_1.SemanticContext.NONE.equals(config.semanticContext);
this._dipsIntoOuterContext = this._dipsIntoOuterContext || config.reachesIntoOuterContext;
- assert(!this.outermostConfigSet || !this._dipsIntoOuterContext);
+ assert.ok(!this.outermostConfigSet || !this._dipsIntoOuterContext);
}
canMerge(left, leftKey, right) {
if (left.state.stateNumber !== right.state.stateNumber) {
diff --git a/atn/LexerATNSimulator.js b/atn/LexerATNSimulator.js
index d461d2063dddc78bbb15e56e5ae2559aadd3320d..c92994d52e226965e47a7e1fb28e8100252b289b 100644
--- a/atn/LexerATNSimulator.js
+++ b/atn/LexerATNSimulator.js
@@ -258,7 +258,7 @@ let LexerATNSimulator = class LexerATNSimulator extends ATNSimulator_1.ATNSimula
config = c.transform(target, true, lexerActionExecutor);
}
else {
- assert(c.lexerActionExecutor == null);
+ assert.ok(c.lexerActionExecutor == null);
config = c.transform(target, true);
}
let treatEofAsEpsilon = t === IntStream_1.IntStream.EOF;
@@ -543,7 +543,7 @@ let LexerATNSimulator = class LexerATNSimulator extends ATNSimulator_1.ATNSimula
/* the lexer evaluates predicates on-the-fly; by this point configs
* should not contain any configurations with unevaluated predicates.
*/
- assert(!configs.hasSemanticContext);
+ assert.ok(!configs.hasSemanticContext);
let proposed = new DFAState_1.DFAState(configs);
let existing = this.atn.modeToDFA[this.mode].states.get(proposed);
if (existing != null) {
diff --git a/atn/ParserATNSimulator.js b/atn/ParserATNSimulator.js
index c36395116d11c431b9f9ea5fea8afbc458bd0b50..0093ac18c7e31fe15bc0596e4f0886b199f41596 100644
--- a/atn/ParserATNSimulator.js
+++ b/atn/ParserATNSimulator.js
@@ -307,7 +307,7 @@ let ParserATNSimulator = class ParserATNSimulator extends ATNSimulator_1.ATNSimu
useContext = false;
}
let dfa = this.atn.decisionToDFA[decision];
- assert(dfa != null);
+ assert.ok(dfa != null);
if (this.optimize_ll1 && !dfa.isPrecedenceDfa && !dfa.isEmpty) {
let ll_1 = input.LA(1);
if (ll_1 >= 0 && ll_1 <= 0xFFFF) {
@@ -381,7 +381,7 @@ let ParserATNSimulator = class ParserATNSimulator extends ATNSimulator_1.ATNSimu
return undefined;
}
let remainingContext = outerContext;
- assert(outerContext != null);
+ assert.ok(outerContext != null);
let s0;
if (dfa.isPrecedenceDfa) {
s0 = dfa.getPrecedenceStartState(this._parser.precedence, true);
@@ -393,7 +393,7 @@ let ParserATNSimulator = class ParserATNSimulator extends ATNSimulator_1.ATNSimu
remainingContext = this.skipTailCalls(remainingContext);
s0 = s0.getContextTarget(this.getReturnState(remainingContext));
if (remainingContext.isEmpty) {
- assert(s0 == null || !s0.isContextSensitive);
+ assert.ok(s0 == null || !s0.isContextSensitive);
}
else {
remainingContext = remainingContext.parent;
@@ -433,7 +433,7 @@ let ParserATNSimulator = class ParserATNSimulator extends ATNSimulator_1.ATNSimu
let initialState = new SimulatorState_1.SimulatorState(state.outerContext, s, state.useContext, remainingOuterContext);
return this.execATN(dfa, input, startIndex, initialState);
}
- assert(remainingOuterContext != null);
+ assert.ok(remainingOuterContext != null);
remainingOuterContext = remainingOuterContext.parent;
s = next;
}
@@ -456,7 +456,7 @@ let ParserATNSimulator = class ParserATNSimulator extends ATNSimulator_1.ATNSimu
break;
}
// t is not updated if one of these states is reached
- assert(!this.isAcceptState(s, state.useContext));
+ assert.ok(!this.isAcceptState(s, state.useContext));
// if no edge, pop over to ATN interpreter, update DFA and return
let target = this.getExistingTargetState(s, t);
if (target == null) {
@@ -508,7 +508,7 @@ let ParserATNSimulator = class ParserATNSimulator extends ATNSimulator_1.ATNSimu
//}
}
else {
- assert(!state.useContext);
+ assert.ok(!state.useContext);
// Before attempting full context prediction, check to see if there are
// disambiguating or validating predicates to evaluate which allow an
// immediate decision
@@ -664,9 +664,9 @@ let ParserATNSimulator = class ParserATNSimulator extends ATNSimulator_1.ATNSimu
}
let D = nextState.s0;
// predicted alt => accept state
- assert(D.isAcceptState || D.prediction === ATN_1.ATN.INVALID_ALT_NUMBER);
+ assert.ok(D.isAcceptState || D.prediction === ATN_1.ATN.INVALID_ALT_NUMBER);
// conflicted => accept state
- assert(D.isAcceptState || D.configs.conflictInfo == null);
+ assert.ok(D.isAcceptState || D.configs.conflictInfo == null);
if (this.isAcceptState(D, useContext)) {
let conflictingAlts = D.configs.conflictingAlts;
let predictedAlt = conflictingAlts == null ? D.prediction : ATN_1.ATN.INVALID_ALT_NUMBER;
@@ -731,8 +731,8 @@ let ParserATNSimulator = class ParserATNSimulator extends ATNSimulator_1.ATNSimu
return predictedAlt;
}
else {
- assert(!useContext);
- assert(this.isAcceptState(D, false));
+ assert.ok(!useContext);
+ assert.ok(this.isAcceptState(D, false));
if (ParserATNSimulator.debug) {
console.log("RETRY with outerContext=" + outerContext);
}
@@ -877,12 +877,12 @@ let ParserATNSimulator = class ParserATNSimulator extends ATNSimulator_1.ATNSimu
if (next == null) {
break;
}
- assert(remainingGlobalContext != null);
+ assert.ok(remainingGlobalContext != null);
remainingGlobalContext = remainingGlobalContext.parent;
s = next;
}
}
- assert(!this.isAcceptState(s, useContext));
+ assert.ok(!this.isAcceptState(s, useContext));
if (this.isAcceptState(s, useContext)) {
return new SimulatorState_1.SimulatorState(previous.outerContext, s, useContext, remainingGlobalContext);
}
@@ -896,7 +896,7 @@ let ParserATNSimulator = class ParserATNSimulator extends ATNSimulator_1.ATNSimu
if (target === ATNSimulator_1.ATNSimulator.ERROR) {
return undefined;
}
- assert(!useContext || !target.configs.dipsIntoOuterContext);
+ assert.ok(!useContext || !target.configs.dipsIntoOuterContext);
return new SimulatorState_1.SimulatorState(previous.outerContext, target, useContext, remainingGlobalContext);
}
/**
@@ -955,7 +955,7 @@ let ParserATNSimulator = class ParserATNSimulator extends ATNSimulator_1.ATNSimu
console.log("testing " + this.getTokenName(t) + " at " + c.toString());
}
if (c.state instanceof RuleStopState_1.RuleStopState) {
- assert(c.context.isEmpty);
+ assert.ok(c.context.isEmpty);
if (useContext && !c.reachesIntoOuterContext || t === IntStream_1.IntStream.EOF) {
if (skippedStopStates == null) {
skippedStopStates = [];
@@ -1018,7 +1018,7 @@ let ParserATNSimulator = class ParserATNSimulator extends ATNSimulator_1.ATNSimu
* multiple alternatives are viable.
*/
if (skippedStopStates != null && (!useContext || !PredictionMode_1.PredictionMode.hasConfigInRuleStopState(reach))) {
- assert(skippedStopStates.length > 0);
+ assert.ok(skippedStopStates.length > 0);
for (let c of skippedStopStates) {
reach.add(c, contextCache);
}
@@ -1382,7 +1382,7 @@ let ParserATNSimulator = class ParserATNSimulator extends ATNSimulator_1.ATNSimu
for (let i = 1; i < altToPred.length; i++) {
let pred = altToPred[i];
// unpredicated is indicated by SemanticContext.NONE
- assert(pred != null);
+ assert.ok(pred != null);
// find first unpredicated but ambig alternative, if any.
// Only ambiguous alternatives will have SemanticContext.NONE.
// Any unambig alts or ambig naked alts after first ambig naked are ignored
@@ -1497,7 +1497,7 @@ let ParserATNSimulator = class ParserATNSimulator extends ATNSimulator_1.ATNSimu
// Make sure we track that we are now out of context.
c.outerContextDepth = config.outerContextDepth;
c.isPrecedenceFilterSuppressed = config.isPrecedenceFilterSuppressed;
- assert(depth > MIN_INTEGER_VALUE);
+ assert.ok(depth > MIN_INTEGER_VALUE);
this.closureImpl(c, configs, intermediate, closureBusy, collectPredicates, hasMoreContexts, contextCache, depth - 1, treatEofAsEpsilon);
}
if (!hasEmpty || !hasMoreContexts) {
@@ -1587,7 +1587,7 @@ let ParserATNSimulator = class ParserATNSimulator extends ATNSimulator_1.ATNSimu
// avoid infinite recursion for right-recursive rules
continue;
}
- assert(newDepth > MIN_INTEGER_VALUE);
+ assert.ok(newDepth > MIN_INTEGER_VALUE);
newDepth--;
if (ParserATNSimulator.debug) {
console.log("dips into outer ctx: " + c);
@@ -1595,7 +1595,7 @@ let ParserATNSimulator = class ParserATNSimulator extends ATNSimulator_1.ATNSimu
}
else if (t instanceof RuleTransition_1.RuleTransition) {
if (this.optimize_tail_calls && t.optimizedTailCall && (!this.tail_call_preserves_sll || !PredictionContext_1.PredictionContext.isEmptyLocal(config.context))) {
- assert(c.context === config.context);
+ assert.ok(c.context === config.context);
if (newDepth === 0) {
// the pop/push of a tail call would keep the depth
// constant, except we latch if it goes negative
@@ -1926,7 +1926,7 @@ let ParserATNSimulator = class ParserATNSimulator extends ATNSimulator_1.ATNSimu
return false;
}
addDFAEdge(dfa, fromState, t, contextTransitions, toConfigs, contextCache) {
- assert(contextTransitions == null || contextTransitions.isEmpty || dfa.isContextSensitive);
+ assert.ok(contextTransitions == null || contextTransitions.isEmpty || dfa.isContextSensitive);
let from = fromState;
let to = this.addDFAState(dfa, toConfigs, contextCache);
if (contextTransitions != null) {
@@ -1944,7 +1944,7 @@ let ParserATNSimulator = class ParserATNSimulator extends ATNSimulator_1.ATNSimu
continue;
}
next = this.addDFAContextState(dfa, from.configs, context, contextCache);
- assert(context !== PredictionContext_1.PredictionContext.EMPTY_FULL_STATE_KEY || next.configs.isOutermostConfigSet);
+ assert.ok(context !== PredictionContext_1.PredictionContext.EMPTY_FULL_STATE_KEY || next.configs.isOutermostConfigSet);
from.setContextTarget(context, next);
from = next;
}
@@ -1973,7 +1973,7 @@ let ParserATNSimulator = class ParserATNSimulator extends ATNSimulator_1.ATNSimu
return this.addDFAState(dfa, contextConfigs, contextCache);
}
else {
- assert(!configs.isOutermostConfigSet, "Shouldn't be adding a duplicate edge.");
+ assert.ok(!configs.isOutermostConfigSet, "Shouldn't be adding a duplicate edge.");
configs = configs.clone(true);
configs.isOutermostConfigSet = true;
return this.addDFAState(dfa, configs, contextCache);
@@ -2081,7 +2081,7 @@ let ParserATNSimulator = class ParserATNSimulator extends ATNSimulator_1.ATNSimu
}
while (!context.isEmpty) {
let state = this.atn.states[context.invokingState];
- assert(state.numberOfTransitions === 1 && state.transition(0).serializationType === 3 /* RULE */);
+ assert.ok(state.numberOfTransitions === 1 && state.transition(0).serializationType === 3 /* RULE */);
let transition = state.transition(0);
if (!transition.tailCall) {
break;
diff --git a/atn/PredictionContext.js b/atn/PredictionContext.js
index 47d525fde0ca117193dd2b1cd2685420c23683d6..b269295802fc84b7cf7e1ce055cdba0a0c03ec53 100644
--- a/atn/PredictionContext.js
+++ b/atn/PredictionContext.js
@@ -118,7 +118,7 @@ class PredictionContext {
leftIndex++;
}
else {
- assert(context1.getReturnState(rightIndex) < context0.getReturnState(leftIndex));
+ assert.ok(context1.getReturnState(rightIndex) < context0.getReturnState(leftIndex));
parentsList[count] = context1.getParent(rightIndex);
returnStatesList[count] = context1.getReturnState(rightIndex);
canReturnLeft = false;
@@ -374,8 +374,8 @@ __decorate([
let ArrayPredictionContext = class ArrayPredictionContext extends PredictionContext {
constructor(parents, returnStates, hashCode) {
super(hashCode || PredictionContext.calculateHashCode(parents, returnStates));
- assert(parents.length === returnStates.length);
- assert(returnStates.length > 1 || returnStates[0] !== PredictionContext.EMPTY_FULL_STATE_KEY, "Should be using PredictionContext.EMPTY instead.");
+ assert.ok(parents.length === returnStates.length);
+ assert.ok(returnStates.length > 1 || returnStates[0] !== PredictionContext.EMPTY_FULL_STATE_KEY, "Should be using PredictionContext.EMPTY instead.");
this.parents = parents;
this.returnStates = returnStates;
}
@@ -458,7 +458,7 @@ let ArrayPredictionContext = class ArrayPredictionContext extends PredictionCont
result = new SingletonPredictionContext(updatedParents[0], updatedReturnStates[0]);
}
else {
- assert(updatedParents.length > 1);
+ assert.ok(updatedParents.length > 1);
result = new ArrayPredictionContext(updatedParents, updatedReturnStates);
}
if (context.hasEmpty) {
@@ -569,16 +569,16 @@ ArrayPredictionContext = __decorate([
let SingletonPredictionContext = class SingletonPredictionContext extends PredictionContext {
constructor(parent, returnState) {
super(PredictionContext.calculateSingleHashCode(parent, returnState));
- // assert(returnState != PredictionContext.EMPTY_FULL_STATE_KEY && returnState != PredictionContext.EMPTY_LOCAL_STATE_KEY);
+ // assert.ok(returnState != PredictionContext.EMPTY_FULL_STATE_KEY && returnState != PredictionContext.EMPTY_LOCAL_STATE_KEY);
this.parent = parent;
this.returnState = returnState;
}
getParent(index) {
- // assert(index == 0);
+ // assert.ok(index == 0);
return this.parent;
}
getReturnState(index) {
- // assert(index == 0);
+ // assert.ok(index == 0);
return this.returnState;
}
findReturnState(returnState) {
diff --git a/atn/PredictionContextCache.js b/atn/PredictionContextCache.js
index d919815854f0519b2b602a316f93f702f06c9754..ecd697a2b7141ebf5c76198bb49088cff90c4d35 100644
--- a/atn/PredictionContextCache.js
+++ b/atn/PredictionContextCache.js
@@ -104,8 +104,8 @@ PredictionContextCache.UNCACHED = new PredictionContextCache(false);
PredictionContextCache.PredictionContextAndInt = PredictionContextAndInt;
class IdentityCommutativePredictionContextOperands {
constructor(x, y) {
- assert(x != null);
- assert(y != null);
+ assert.ok(x != null);
+ assert.ok(y != null);
this._x = x;
this._y = y;
}
diff --git a/dfa/DFAState.js b/dfa/DFAState.js
index dcdd796eef9a8046033b233d6cc2ca33b198cb9d..d7a095dcc5680d1af58e6e213ebaed98eae4c1b1 100644
--- a/dfa/DFAState.js
+++ b/dfa/DFAState.js
@@ -66,11 +66,11 @@ class DFAState {
return this.contextSymbols.get(symbol);
}
setContextSymbol(symbol) {
- assert(this.isContextSensitive);
+ assert.ok(this.isContextSensitive);
this.contextSymbols.set(symbol);
}
setContextSensitive(atn) {
- assert(!this.configs.isOutermostConfigSet);
+ assert.ok(!this.configs.isOutermostConfigSet);
if (this.isContextSensitive) {
return;
}
diff --git a/misc/Array2DHashSet.js b/misc/Array2DHashSet.js
index 5e9b5dca143df940562734ce158c532a48fed78d..3796235c05d771f985a3e6d5cd9f5f8116188a5f 100644
--- a/misc/Array2DHashSet.js
+++ b/misc/Array2DHashSet.js
@@ -153,7 +153,7 @@ class Array2DHashSet {
newBucket.push(o);
}
}
- assert(this.n === oldSize);
+ assert.ok(this.n === oldSize);
}
add(t) {
let existing = this.getOrAdd(t);
+435 -416
View File
File diff suppressed because it is too large Load Diff