diff --git a/agents/drivers/neo4j-go/main_test.go b/agents/drivers/neo4j-go/main_test.go index cc8a25a0a9..c612cb8ca9 100644 --- a/agents/drivers/neo4j-go/main_test.go +++ b/agents/drivers/neo4j-go/main_test.go @@ -6,6 +6,7 @@ import ( "errors" "net/url" "reflect" + "strings" "testing" neo4j "github.com/neo4j/neo4j-go-driver/v6/neo4j" @@ -230,9 +231,21 @@ func TestClassifyNeo4jErrors(t *testing.T) { syntax := classifyRPCError("execute_query", "session-1", &neo4jdb.Neo4jError{ Code: "Neo.ClientError.Statement.SyntaxError", Msg: "invalid input", }) - if syntax.Data.Category != "sql" || syntax.Data.SQLState == "" { + if syntax.Data.Category != "sql" || syntax.Data.SQLState != "" || syntax.Data.AgentSessionID != "session-1" { t.Fatalf("unexpected syntax classification: %#v", syntax) } + if syntax.Data.ContractVersion != 1 || syntax.Data.Stage != "execute" || syntax.Data.OperationOutcome != "unknown" { + t.Fatalf("invalid structured error contract: %#v", syntax.Data) + } + if syntax.Message == "" || !strings.Contains(syntax.Message, "Neo.ClientError.Statement.SyntaxError") { + t.Fatalf("Neo4j error code was lost: %q", syntax.Message) + } + resource := classifyRPCError("execute_query", "session-1", &neo4jdb.Neo4jError{ + Code: "Neo.TransientError.General.DatabaseUnavailable", Msg: "database unavailable", + }) + if resource.Data.Category != "resource" || resource.Data.SessionDisposition != "replace_runtime" { + t.Fatalf("invalid resource error contract: %#v", resource.Data) + } canceled := classifyRPCError("execute_query", "session-1", context.Canceled) if canceled.Data.Category != "canceled" || canceled.Data.SessionDisposition != "quarantine" { t.Fatalf("unexpected cancellation classification: %#v", canceled) diff --git a/agents/drivers/neo4j-go/protocol_error.go b/agents/drivers/neo4j-go/protocol_error.go index dfee3641ae..181cd0e269 100644 --- a/agents/drivers/neo4j-go/protocol_error.go +++ b/agents/drivers/neo4j-go/protocol_error.go @@ -50,7 +50,7 @@ func classifyRPCError(method, agentSessionID string, err error) *rpcError { var databaseError *neo4jdb.Neo4jError if errors.As(err, &databaseError) { - data.SQLState = safeRPCDiagnostic(databaseError.Code, 160) + // Neo4j error codes are not SQLSTATE values; the code remains in err.Error(). data.Retryable = neo4j.IsRetryable(err) code := strings.ToLower(databaseError.Code) switch { @@ -60,6 +60,9 @@ func classifyRPCError(method, agentSessionID string, err error) *rpcError { case strings.Contains(code, ".transienterror."), strings.Contains(code, "databaseunavailable"): data.Category = "resource" data.Retryable = true + if data.OperationOutcome == "unknown" { + data.SessionDisposition = "replace_runtime" + } case strings.Contains(code, ".statement."), strings.Contains(code, ".schema."), strings.Contains(code, ".procedure."): data.Category = "sql" default: diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index b4b16d2ad5..e1fc0e6f50 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -206,7 +206,16 @@ import { validateConfigName, generateId, type AiConfigItem, type ConfigNameValid import { currentExecutableStatementRange, type SqlTextRange } from "@/lib/sql/sqlStatementRanges"; import { executableStatementRangeCacheForDoc, executableStatementRangeStartingAt, type ExecutableStatementRangeCache } from "@/lib/sql/executableStatementRangeCache"; import { EMPTY_TABLE_COLUMN_TEMPLATE_DATA_TYPE, parseTableColumnTemplateFields, TABLE_COLUMN_TEMPLATE_DATABASE_TYPES, tableColumnTemplateRowsToSettings } from "@/lib/table/tableColumnTemplates"; -import { DEFAULT_SQL_VARIABLE_SYNTAX_TOGGLES, normalizeSqlVariableSyntaxOverrides, SQL_VARIABLE_SYNTAX_DATABASE_TYPES, SQL_VARIABLE_SYNTAX_KEYS, SQL_VARIABLE_SYNTAX_TOKENS, type SqlVariableSyntaxOverrides, type SqlVariableSyntaxToggles } from "@/lib/sql/sqlVariableSyntax"; +import { + DEFAULT_SQL_VARIABLE_SYNTAX_TOGGLES, + normalizeSqlVariableSyntaxOverrides, + resolveSqlVariableSyntaxToggles, + SQL_VARIABLE_SYNTAX_DATABASE_TYPES, + SQL_VARIABLE_SYNTAX_KEYS, + SQL_VARIABLE_SYNTAX_TOKENS, + type SqlVariableSyntaxOverrides, + type SqlVariableSyntaxToggles, +} from "@/lib/sql/sqlVariableSyntax"; import { buildMcpCherryStudioConfig, buildMcpCodexConfig, @@ -772,11 +781,12 @@ function updateExternalSqlEditorMaxMbInput(event: Event) { } function sqlVariableSyntaxToggle(key: keyof SqlVariableSyntaxToggles): boolean { - return editSqlVariableSyntaxOverrides.value[editSqlVariableSyntaxDatabaseType.value]?.[key] ?? true; + return resolveSqlVariableSyntaxToggles(editSqlVariableSyntaxOverrides.value, editSqlVariableSyntaxDatabaseType.value)[key]; } function setSqlVariableSyntaxToggle(key: keyof SqlVariableSyntaxToggles, value: boolean) { const dbType = editSqlVariableSyntaxDatabaseType.value; + if (dbType === "neo4j" && key === "named") return; const merged: SqlVariableSyntaxToggles = { ...DEFAULT_SQL_VARIABLE_SYNTAX_TOGGLES, ...editSqlVariableSyntaxOverrides.value[dbType], @@ -6798,7 +6808,7 @@ onUnmounted(() => {
{{ t("settings.sqlVariableSyntax") }}
-

+

{{ t("settings.sqlVariableSyntaxDescription") }}

@@ -6832,7 +6842,13 @@ onUnmounted(() => { {{ t(`settings.sqlVariableSyntax_${key}Description`) }}

- + diff --git a/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts b/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts index 58545a9944..5fad8d1b43 100644 --- a/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts +++ b/apps/desktop/src/composables/__tests__/useSqlExecution.spec.ts @@ -140,6 +140,30 @@ describe("useSqlExecution", () => { vi.mocked(objectMetadataCache.invalidateObjectMetadataCache).mockClear(); }); + it("executes Neo4j graph patterns without a SQL parameter dialog", async () => { + const sql = 'MATCH (p:Person)-[:WORK_IN]->(c:Company{name:"星云科技"})\nRETURN p.name, p.job, c.name'; + const activeTab = ref({ ...queryTab("neo4j"), sql }); + const activeConnection = ref(connection("neo4j")); + const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result"); + const queryStore = useQueryStore(); + const executeCurrentSql = vi.spyOn(queryStore, "executeCurrentSql").mockImplementation(async () => { + if (activeTab.value) activeTab.value.result = { columns: ["p.name"], rows: [["Ada"]], affected_rows: 0, execution_time_ms: 1 }; + }); + vi.spyOn(useHistoryStore(), "add").mockResolvedValue(undefined); + + const execution = useSqlExecution({ + activeTab: computed(() => activeTab.value), + activeConnection: computed(() => activeConnection.value), + executableSql: computed(() => sql), + activeOutputView, + }); + + await execution.tryExecute(); + + expect(execution.showSqlParameterDialog.value).toBe(false); + expect(executeCurrentSql).toHaveBeenCalledWith(sql, { tabId: "tab-1" }); + }); + it("invalidates object metadata after successful connection-level DDL", async () => { const sql = "CREATE DATABASE app_db"; const activeTab = ref({ ...queryTab(), sql }); diff --git a/apps/desktop/src/lib/__tests__/sql/sqlParameters.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlParameters.spec.ts index 914eee67cc..666932ceaa 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlParameters.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlParameters.spec.ts @@ -34,6 +34,16 @@ describe("extractSqlParameters", () => { { key: "params", name: "params", syntax: "named", token: ":params" }, { key: "context", name: "context", syntax: "sqlserver", token: "@context" }, ]); + expect(extractSqlParameters("select :customer_id", { databaseType: "mysql" })).toEqual(["customer_id"]); + }); + + it("preserves Neo4j pattern labels and relationship types", () => { + const cypher = 'MATCH (p:Person)-[:WORK_IN]->(c:Company{name:"星云科技"})\nRETURN p.name, p.job, c.name'; + const options = { databaseType: "neo4j" as const }; + expect(extractSqlParameterDescriptors(cypher, options)).toEqual([]); + expect(substituteSqlParameters(cypher, {}, options)).toBe(cypher); + expect(extractSqlParameterDescriptors(cypher, { ...options, enabledSyntaxes: ["named"] as const })).toEqual([]); + expect(extractSqlParameters("MATCH (p:Person {name:${name}}) RETURN p", options)).toEqual(["name"]); }); it("extracts unique template parameters in order", () => { diff --git a/apps/desktop/src/lib/__tests__/sql/sqlVariableSyntax.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlVariableSyntax.spec.ts index 03ce6c92cb..c0944c726d 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlVariableSyntax.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlVariableSyntax.spec.ts @@ -12,6 +12,13 @@ describe("resolveSqlVariableSyntaxToggles", () => { expect(resolveSqlVariableSyntaxToggles({ mysql: { shell: false } }, "postgres")).toEqual(DEFAULT_SQL_VARIABLE_SYNTAX_TOGGLES); }); + it("disables SQL-style named parameters for Neo4j patterns", () => { + const toggles = resolveSqlVariableSyntaxToggles(undefined, "neo4j"); + expect(toggles).toEqual({ ...DEFAULT_SQL_VARIABLE_SYNTAX_TOGGLES, named: false }); + expect(enabledSqlParameterSyntaxes(toggles)).not.toContain("named"); + expect(resolveSqlVariableSyntaxToggles({ neo4j: { shell: false } }, "neo4j")).toEqual({ ...toggles, shell: false }); + }); + it("enables every syntax when the database type is unknown", () => { expect(resolveSqlVariableSyntaxToggles({ mysql: { shell: false } }, undefined)).toEqual(DEFAULT_SQL_VARIABLE_SYNTAX_TOGGLES); }); diff --git a/apps/desktop/src/lib/sql/sqlParameters.ts b/apps/desktop/src/lib/sql/sqlParameters.ts index 6bb2fc4dd8..b2d19da228 100644 --- a/apps/desktop/src/lib/sql/sqlParameters.ts +++ b/apps/desktop/src/lib/sql/sqlParameters.ts @@ -378,7 +378,7 @@ function findSqlParameterOccurrences(sql: string, options?: SqlParameterOptions) const occurrences: ParameterOccurrence[] = []; const databaseType = options?.databaseType; const nativeSqlServerParameters = collectNativeSqlServerParameters(sql, databaseType); - const supportsNamedParameters = databaseType !== "saphana"; + const supportsNamedParameters = databaseType !== "saphana" && databaseType !== "neo4j"; const enabledSyntaxes = options?.enabledSyntaxes ? new Set(options.enabledSyntaxes) : null; const isSyntaxEnabled = (syntax: SqlParameterSyntax) => !enabledSyntaxes || enabledSyntaxes.has(syntax); const complexTypeFieldSeparators = supportsNamedParameters && isSyntaxEnabled("named") ? collectComplexTypeFieldSeparators(sql, databaseType) : new Set(); diff --git a/apps/desktop/src/lib/sql/sqlVariableSyntax.ts b/apps/desktop/src/lib/sql/sqlVariableSyntax.ts index 4761aa6d9e..8d7b0bf852 100644 --- a/apps/desktop/src/lib/sql/sqlVariableSyntax.ts +++ b/apps/desktop/src/lib/sql/sqlVariableSyntax.ts @@ -3,11 +3,11 @@ // DBX runs two client-side substitution systems before sending SQL to a backend: // the placeholder parameter dialog (`sqlParameters.ts`, five syntaxes) and the // `@set name = value;` expansion (`sqlVariables.ts`). This module lets users opt -// out of individual syntaxes per database type. Every toggle defaults to `true`, -// so an empty/absent config reproduces the historical "always substitute" behaviour. +// out of individual syntaxes per database type. SQL syntaxes default to `true`, +// except Neo4j's `:label` patterns, which are not named parameters. // // Storage is sparse: only syntaxes explicitly turned off (`false`) are persisted, -// keyed by database type. Anything not stored resolves to enabled. +// keyed by database type. Unset values use the database-specific defaults. import type { DatabaseType } from "@/types/database"; import type { SqlParameterSyntax } from "@/lib/sql/sqlParameters"; @@ -72,7 +72,7 @@ export function resolveSqlVariableSyntaxToggles(overrides: SqlVariableSyntaxOver const partial = dbType ? overrides?.[dbType] : undefined; return { positional: partial?.positional ?? true, - named: partial?.named ?? true, + named: dbType === "neo4j" ? false : (partial?.named ?? true), shell: partial?.shell ?? true, mybatis: partial?.mybatis ?? true, sqlserver: partial?.sqlserver ?? true, diff --git a/docs/public/screenshots/neo4j-parameter-settings.png b/docs/public/screenshots/neo4j-parameter-settings.png new file mode 100644 index 0000000000..32ba9701fc Binary files /dev/null and b/docs/public/screenshots/neo4j-parameter-settings.png differ