diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 403a39cbf0..768e48b8f7 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -94,6 +94,7 @@ import { buildHoverTableSql, ddlForHoverPreview, hoverTableMatchesScope, normali import { constrainSqlHoverLayout } from "@/lib/editor/sqlHoverLayout"; import { lineColumnToOffset, sqlErrorDecorationRange as resolveSqlErrorDecorationRange } from "@/lib/sql/sqlDiagnostics"; import { analyzeMysqlRoutineSyntax, supportsMysqlRoutineSyntaxDiagnostics } from "@/lib/sql/mysqlRoutineSyntaxDiagnostics"; +import { buildOracleSyntaxDiagnostics } from "@/lib/sql/oracleSyntaxDiagnostics"; import { DBX_TABLE_REFERENCE_MIME, DBX_TABLE_REFERENCE_DROP_EVENT, @@ -3175,6 +3176,13 @@ async function refreshSemanticDiagnostics(options: { preserveOutsideRanges?: boo } const nextDiagnostics: SqlSemanticDiagnostic[] = []; + const oracleSyntaxDiagnostics = buildOracleSyntaxDiagnostics(sql, props.databaseType); + nextDiagnostics.push( + ...oracleSyntaxDiagnostics.filter((diagnostic) => { + const diagnosticRange = sqlTextSpanToRange(sql, diagnostic.span); + return !!diagnosticRange && diagnosticRanges.some((range) => rangesOverlap(diagnosticRange, range)); + }), + ); const mysqlRoutineAnalysis = props.databaseType === "mysql" && supportsMysqlRoutineSyntaxDiagnostics(sqlDriverProfile.value) ? analyzeMysqlRoutineSyntax(sql) : null; if (mysqlRoutineAnalysis) { nextDiagnostics.push( diff --git a/apps/desktop/src/lib/sql/oracleSyntaxDiagnostics.ts b/apps/desktop/src/lib/sql/oracleSyntaxDiagnostics.ts new file mode 100644 index 0000000000..f22375fef4 --- /dev/null +++ b/apps/desktop/src/lib/sql/oracleSyntaxDiagnostics.ts @@ -0,0 +1,281 @@ +import { executableStatementRanges } from "@/lib/sql/sqlStatementRanges"; +import type { SqlSemanticDiagnostic } from "@/lib/sql/semantic/diagnostics"; +import type { DatabaseType, SqlTextSpan } from "@/types/database"; + +const ORACLE_SYNTAX_DATABASE_TYPES = new Set(["oracle", "oceanbase-oracle"]); +const TABLE_CONSTRAINT_STARTERS = new Set(["CONSTRAINT", "PRIMARY", "UNIQUE", "FOREIGN", "CHECK", "EXCLUDE", "SUPPLEMENTAL", "PERIOD", "PARTITION", "SUBPARTITION"]); +const NON_TABLE_CREATE_OBJECTS = new Set(["AS", "SELECT", "WITH", "VIEW", "INDEX", "SEQUENCE", "TRIGGER", "PROCEDURE", "FUNCTION", "PACKAGE"]); +const ALTER_TABLE_NON_ADD_ACTIONS = new Set(["MODIFY", "DROP", "RENAME", "ENABLE", "DISABLE"]); +const MESSAGE = "Oracle DEFAULT clause must appear before NOT NULL"; + +type OracleSyntaxTokenKind = "word" | "symbol"; + +interface OracleSyntaxToken { + kind: OracleSyntaxTokenKind; + value: string; + from: number; + to: number; +} + +interface TokenRange { + from: number; + to: number; +} + +export function supportsOracleSyntaxDiagnostics(databaseType?: DatabaseType): boolean { + return databaseType !== undefined && ORACLE_SYNTAX_DATABASE_TYPES.has(databaseType); +} + +export function buildOracleSyntaxDiagnostics(source: string, databaseType?: DatabaseType): SqlSemanticDiagnostic[] { + if (!supportsOracleSyntaxDiagnostics(databaseType)) return []; + + const diagnostics: SqlSemanticDiagnostic[] = []; + for (const statement of executableStatementRanges(source, databaseType)) { + const tokens = tokenizeOracleSyntax(statement.sql, statement.from); + const definitionRange = columnDefinitionRange(tokens); + if (!definitionRange) continue; + + for (const definition of splitColumnDefinitions(tokens, definitionRange)) { + if (isTableConstraint(definition)) continue; + const offendingDefault = defaultAfterNotNull(definition); + if (offendingDefault) diagnostics.push(diagnosticAtToken(source, offendingDefault)); + } + } + return diagnostics; +} + +function columnDefinitionRange(tokens: readonly OracleSyntaxToken[]): TokenRange | null { + if (tokens[0]?.kind !== "word") return null; + + if (tokens[0].value === "CREATE") { + const tableIndex = createTableTokenIndex(tokens); + if (tableIndex === -1) return null; + const openIndex = firstColumnListOpeningParen(tokens, tableIndex + 1); + if (openIndex === -1) return null; + return tokenRangeInsideParentheses(tokens, openIndex); + } + + if (tokens[0].value !== "ALTER") return null; + const addIndex = alterTableAddTokenIndex(tokens); + if (addIndex === -1) return null; + + let definitionStart = addIndex + 1; + if (tokens[definitionStart]?.kind === "word" && tokens[definitionStart].value === "COLUMN") definitionStart += 1; + if (tokens[definitionStart]?.kind === "symbol" && tokens[definitionStart].value === "(") { + return tokenRangeInsideParentheses(tokens, definitionStart); + } + return definitionStart < tokens.length ? { from: definitionStart, to: tokens.length } : null; +} + +function createTableTokenIndex(tokens: readonly OracleSyntaxToken[]): number { + for (let index = 1; index < tokens.length; index += 1) { + const token = tokens[index]; + if (token.kind === "symbol" && token.value === "(") return -1; + if (token.kind !== "word") continue; + if (token.value === "TABLE") return index; + if (NON_TABLE_CREATE_OBJECTS.has(token.value)) return -1; + } + return -1; +} + +function firstColumnListOpeningParen(tokens: readonly OracleSyntaxToken[], from: number): number { + for (let index = from; index < tokens.length; index += 1) { + const token = tokens[index]; + if (token.kind === "symbol" && token.value === "(") return index; + if (token.kind === "word" && NON_TABLE_CREATE_OBJECTS.has(token.value)) return -1; + } + return -1; +} + +function alterTableAddTokenIndex(tokens: readonly OracleSyntaxToken[]): number { + let tableSeen = false; + for (let index = 1; index < tokens.length; index += 1) { + const token = tokens[index]; + if (token.kind !== "word") continue; + if (!tableSeen) { + if (token.value === "TABLE") tableSeen = true; + continue; + } + if (token.value === "ADD") return index; + if (ALTER_TABLE_NON_ADD_ACTIONS.has(token.value)) return -1; + } + return -1; +} + +function tokenRangeInsideParentheses(tokens: readonly OracleSyntaxToken[], openIndex: number): TokenRange { + const closeIndex = matchingRightParen(tokens, openIndex); + return { from: openIndex + 1, to: closeIndex === -1 ? tokens.length : closeIndex }; +} + +function matchingRightParen(tokens: readonly OracleSyntaxToken[], openIndex: number): number { + let depth = 0; + for (let index = openIndex; index < tokens.length; index += 1) { + const token = tokens[index]; + if (token.kind !== "symbol") continue; + if (token.value === "(") depth += 1; + else if (token.value === ")") { + depth -= 1; + if (depth === 0) return index; + } + } + return -1; +} + +function splitColumnDefinitions(tokens: readonly OracleSyntaxToken[], range: TokenRange): OracleSyntaxToken[][] { + const definitions: OracleSyntaxToken[][] = []; + let start = range.from; + let depth = 0; + + for (let index = range.from; index < range.to; index += 1) { + const token = tokens[index]; + if (token.kind === "symbol" && token.value === "(") depth += 1; + else if (token.kind === "symbol" && token.value === ")") depth = Math.max(depth - 1, 0); + else if (token.kind === "symbol" && token.value === "," && depth === 0) { + if (start < index) definitions.push(tokens.slice(start, index)); + start = index + 1; + } + } + + if (start < range.to) definitions.push(tokens.slice(start, range.to)); + return definitions; +} + +function isTableConstraint(definition: readonly OracleSyntaxToken[]): boolean { + const firstWord = definition.find((token) => token.kind === "word")?.value; + return firstWord !== undefined && TABLE_CONSTRAINT_STARTERS.has(firstWord); +} + +function defaultAfterNotNull(definition: readonly OracleSyntaxToken[]): OracleSyntaxToken | null { + const notNullIndex = definition.findIndex((token, index) => token.kind === "word" && token.value === "NOT" && definition[index + 1]?.kind === "word" && definition[index + 1].value === "NULL"); + if (notNullIndex === -1) return null; + return definition.slice(notNullIndex + 2).find((token) => token.kind === "word" && token.value === "DEFAULT") ?? null; +} + +function diagnosticAtToken(source: string, token: OracleSyntaxToken): SqlSemanticDiagnostic { + const start = offsetToPosition(source, token.from); + const end = offsetToPosition(source, token.to); + const span: SqlTextSpan = { + start_line: start.line, + start_column: start.column, + end_line: end.line, + end_column: Math.max(end.column - 1, start.column), + }; + return { span, message: MESSAGE, severity: "error" }; +} + +function offsetToPosition(source: string, offset: number): { line: number; column: number } { + const boundedOffset = Math.max(0, Math.min(offset, source.length)); + let line = 1; + let lineStart = 0; + for (let index = 0; index < boundedOffset; index += 1) { + if (source[index] !== "\n") continue; + line += 1; + lineStart = index + 1; + } + return { line, column: boundedOffset - lineStart + 1 }; +} + +function tokenizeOracleSyntax(source: string, baseOffset = 0): OracleSyntaxToken[] { + const tokens: OracleSyntaxToken[] = []; + + for (let index = 0; index < source.length; ) { + const ignoredEnd = skipIgnorableOracleSyntax(source, index); + if (ignoredEnd !== null) { + index = ignoredEnd; + continue; + } + + const quotedEnd = skipQuotedOracleSyntax(source, index); + if (quotedEnd !== null) { + index = quotedEnd; + continue; + } + + const char = source[index]; + const word = /^[A-Za-z_][A-Za-z0-9_$#]*/.exec(source.slice(index))?.[0]; + if (word) { + tokens.push({ kind: "word", value: word.toUpperCase(), from: baseOffset + index, to: baseOffset + index + word.length }); + index += word.length; + continue; + } + + tokens.push({ kind: "symbol", value: char, from: baseOffset + index, to: baseOffset + index + 1 }); + index += 1; + } + + return tokens; +} + +function skipIgnorableOracleSyntax(source: string, index: number): number | null { + const char = source[index]; + const next = source[index + 1] ?? ""; + if (/\s/.test(char)) return index + 1; + if (char === "-" && next === "-") return skipLineComment(source, index + 2); + if (char === "/" && next === "*") return skipBlockComment(source, index + 2); + return null; +} + +function skipQuotedOracleSyntax(source: string, index: number): number | null { + const char = source[index]; + const next = source[index + 1] ?? ""; + if ((char === "q" || char === "Q") && next === "'") return skipOracleQuotedString(source, index); + if (char === "'" || char === '"' || char === "`") return skipQuoted(source, index, char); + return null; +} + +function skipLineComment(source: string, from: number): number { + const newline = source.indexOf("\n", from); + return newline === -1 ? source.length : newline + 1; +} + +function skipBlockComment(source: string, from: number): number { + let depth = 1; + let index = from; + for (; index < source.length && depth > 0; index += 1) { + if (source.startsWith("/*", index)) { + depth += 1; + index += 1; + } else if (source.startsWith("*/", index)) { + depth -= 1; + index += 1; + } + } + return index; +} + +function oracleQuoteCloser(opener: string): string { + switch (opener) { + case "[": + return "]"; + case "(": + return ")"; + case "{": + return "}"; + case "<": + return ">"; + default: + return opener; + } +} + +function skipOracleQuotedString(source: string, start: number): number { + const closer = oracleQuoteCloser(source[start + 2] ?? ""); + if (!closer) return source.length; + + for (let index = start + 3; index < source.length; index += 1) { + if (source[index] === closer && source[index + 1] === "'") return index + 2; + } + return source.length; +} + +function skipQuoted(source: string, start: number, quote: string): number { + for (let index = start + 1; index < source.length; index += 1) { + if (source[index] !== quote) continue; + if (source[index + 1] === quote) { + index += 1; + continue; + } + return index + 1; + } + return source.length; +} diff --git a/packages/app-tests/oracleSyntaxDiagnostics.test.ts b/packages/app-tests/oracleSyntaxDiagnostics.test.ts new file mode 100644 index 0000000000..5f3af03f59 --- /dev/null +++ b/packages/app-tests/oracleSyntaxDiagnostics.test.ts @@ -0,0 +1,90 @@ +import { expect, test } from "vitest"; +import { buildOracleSyntaxDiagnostics, supportsOracleSyntaxDiagnostics } from "../../apps/desktop/src/lib/sql/oracleSyntaxDiagnostics.ts"; + +const ORACLE_DEFAULT_ORDER_MESSAGE = "Oracle DEFAULT clause must appear before NOT NULL"; + +function diagnosticMessages(sql: string, databaseType: "oracle" | "oceanbase-oracle" | "mysql" = "oracle") { + return buildOracleSyntaxDiagnostics(sql, databaseType).map((diagnostic) => diagnostic.message); +} + +function spanAt(sql: string, token: string, occurrence = 0) { + const upperSql = sql.toUpperCase(); + const upperToken = token.toUpperCase(); + let offset = -1; + let from = 0; + for (let index = 0; index <= occurrence; index += 1) { + offset = upperSql.indexOf(upperToken, from); + from = offset + token.length; + } + expect(offset).not.toBe(-1); + const lineStart = sql.lastIndexOf("\n", offset - 1) + 1; + return { + start_line: sql.slice(0, offset).split(/\r?\n/).length, + start_column: offset - lineStart + 1, + end_line: sql.slice(0, offset).split(/\r?\n/).length, + end_column: offset - lineStart + token.length, + }; +} + +test("reports DEFAULT after NOT NULL and points at DEFAULT", () => { + const sql = "CREATE TABLE t (id NUMBER NOT NULL DEFAULT 0)"; + const diagnostics = buildOracleSyntaxDiagnostics(sql, "oracle"); + + expect(diagnostics.map((diagnostic) => diagnostic.message)).toEqual([ORACLE_DEFAULT_ORDER_MESSAGE]); + expect(diagnostics[0]?.span).toEqual(spanAt(sql, "DEFAULT")); + expect(diagnostics[0]?.severity).toBe("error"); +}); + +test("handles multiline, mixed-case, and multiple column definitions", () => { + const sql = `CREATE TABLE t ( + id NUMBER + NOT NULL + DEFAULT 0, + status NUMBER DEFAULT 1 NOT NULL, + archived NUMBER not null default 0 +)`; + const diagnostics = buildOracleSyntaxDiagnostics(sql, "oracle"); + + expect(diagnostics.map((diagnostic) => diagnostic.message)).toEqual([ORACLE_DEFAULT_ORDER_MESSAGE, ORACLE_DEFAULT_ORDER_MESSAGE]); + expect(diagnostics.map((diagnostic) => diagnostic.span)).toEqual([spanAt(sql, "DEFAULT"), spanAt(sql, "DEFAULT", 2)]); +}); + +test.each([ + "CREATE TABLE t (id NUMBER DEFAULT 0 NOT NULL)", + "CREATE TABLE t (id NUMBER DEFAULT ON NULL 0 NOT NULL)", + "CREATE TABLE t (id NUMBER NOT NULL)", + "CREATE TABLE t (id NUMBER DEFAULT 0)", + "CREATE TABLE t (value VARCHAR2(100) DEFAULT 'NOT NULL DEFAULT' NOT NULL)", + "CREATE TABLE t (id NUMBER /* NOT NULL DEFAULT */ DEFAULT 0 NOT NULL)", + "SELECT 'NOT NULL DEFAULT' FROM dual", + "CREATE TABLE t (id NUMBER DEFAULT CASE WHEN 1 IS NOT NULL THEN 1 ELSE 0 END NOT NULL)", + "CREATE TABLE t (id NUMBER NOT NULL, CONSTRAINT c CHECK (id IS NOT NULL))", +])("does not report valid or unrelated SQL: %s", (sql) => { + expect(diagnosticMessages(sql)).toEqual([]); +}); + +test("covers Oracle ALTER TABLE ADD column definitions", () => { + const sql = `ALTER TABLE t ADD ( + valid_col NUMBER DEFAULT 0 NOT NULL, + invalid_col NUMBER NOT NULL DEFAULT 1 +)`; + + const diagnostics = buildOracleSyntaxDiagnostics(sql, "oracle"); + expect(diagnostics.map((diagnostic) => diagnostic.message)).toEqual([ORACLE_DEFAULT_ORDER_MESSAGE]); + expect(diagnostics[0]?.span).toEqual(spanAt(sql, "DEFAULT", 1)); +}); + +test("supports single-column ALTER TABLE ADD syntax", () => { + expect(diagnosticMessages("ALTER TABLE t ADD id NUMBER NOT NULL DEFAULT 0")).toEqual([ORACLE_DEFAULT_ORDER_MESSAGE]); + expect(diagnosticMessages("ALTER TABLE t ADD id NUMBER DEFAULT 0 NOT NULL")).toEqual([]); +}); + +test("keeps the rule isolated to Oracle-family database types", () => { + const sql = "CREATE TABLE t (id NUMBER NOT NULL DEFAULT 0)"; + + expect(supportsOracleSyntaxDiagnostics("oracle")).toBe(true); + expect(supportsOracleSyntaxDiagnostics("oceanbase-oracle")).toBe(true); + expect(supportsOracleSyntaxDiagnostics("mysql")).toBe(false); + expect(supportsOracleSyntaxDiagnostics()).toBe(false); + expect(buildOracleSyntaxDiagnostics(sql, "mysql")).toEqual([]); +});