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`) }}
- setSqlVariableSyntaxToggle(key, value as boolean)" />
+ setSqlVariableSyntaxToggle(key, value as boolean)"
+ />
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