49df40cb11
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
214 lines
5.3 KiB
TypeScript
214 lines
5.3 KiB
TypeScript
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";
|
|
}
|
|
}
|
|
|