mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-02 02:34:42 +08:00
fix(neo4j): preserve cypher labels during query execution
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(() => {
|
||||
<div class="text-sm font-medium text-muted-foreground">
|
||||
{{ t("settings.sqlVariableSyntax") }}
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
<p v-if="editSqlVariableSyntaxDatabaseType !== 'neo4j'" class="text-xs text-muted-foreground">
|
||||
{{ t("settings.sqlVariableSyntaxDescription") }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -6832,7 +6842,13 @@ onUnmounted(() => {
|
||||
{{ t(`settings.sqlVariableSyntax_${key}Description`) }}
|
||||
</p>
|
||||
</div>
|
||||
<Switch :id="`sql-var-syntax-${key}`" :model-value="sqlVariableSyntaxToggle(key)" :disabled="!editSqlVariableSubstitutionEnabled" class="mt-0.5 shrink-0" @update:model-value="(value) => setSqlVariableSyntaxToggle(key, value as boolean)" />
|
||||
<Switch
|
||||
:id="`sql-var-syntax-${key}`"
|
||||
:model-value="sqlVariableSyntaxToggle(key)"
|
||||
:disabled="!editSqlVariableSubstitutionEnabled || (editSqlVariableSyntaxDatabaseType === 'neo4j' && key === 'named')"
|
||||
class="mt-0.5 shrink-0"
|
||||
@update:model-value="(value) => setSqlVariableSyntaxToggle(key, value as boolean)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 | undefined>({ ...queryTab("neo4j"), sql });
|
||||
const activeConnection = ref<ConnectionConfig | undefined>(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 | undefined>({ ...queryTab(), sql });
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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<number>();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Reference in New Issue
Block a user