mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(salesforce): add Salesforce SOQL data source
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" role="img" aria-label="Salesforce">
|
||||
<path fill="#00A1E0" d="M82 60c4.5-11 15.5-19 28-19 10 0 19 5 24 13 3-1 6-2 9-2 13 0 23 10 23 23 0 2 0 3-.5 5 9 4 15 13 15 23 0 15-12 27-27 27H58c-18 0-33-15-33-33 0-16 12-30 27-33 3-12 15-21 28-21 1 0 2 0 2 0Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 321 B |
@@ -36,6 +36,8 @@ import type {
|
||||
import { CONNECTION_PICKER_OPTIONS, CONNECTION_PROFILES, CONNECTION_PROFILE_ICONS, type ConnectionPickerOption, type ConnectionProfileCategory, type ConnectionProfileDefinition } from "@/types/generated/connectionProfiles";
|
||||
import type { InfluxDbExternalConfig, InfluxDbVersion } from "@/types/influxdb";
|
||||
import type { VictoriaMetricsExternalConfig } from "@/types/victoriametrics";
|
||||
import type { SalesforceAuthContext, SalesforceAuthMode, SalesforceEnvironment, SalesforceExternalConfig, SalesforceOAuthAuthorizeParams, SalesforceOAuthDevicePollResult } from "@/types/salesforce";
|
||||
import { SALESFORCE_OAUTH_CALLBACK_URL } from "@/types/salesforce";
|
||||
import type { MqAdminConfig, MqAuth, MqSystemKind } from "@/types/mq";
|
||||
import type { MqttConnectionConfig } from "@/types/mqtt";
|
||||
import type { NacosAdminConfig, NacosApiPlane, NacosAuthConfig, NacosImplementation, NacosMetricsMode, NacosNamespaceInfo, NacosRNacosConsoleAuth, NacosVersionMode } from "@/types/nacos";
|
||||
@@ -1574,6 +1576,390 @@ function buildVictoriaMetricsExternalConfig(): VictoriaMetricsExternalConfig {
|
||||
return { apiPath, lookback };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Salesforce OAuth / device-code flow state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const salesforceAuthMode = ref<SalesforceAuthMode>("token");
|
||||
const salesforceEnvironment = ref<SalesforceEnvironment>("production");
|
||||
const salesforceLoginUrl = ref("");
|
||||
const salesforceClientId = ref("");
|
||||
const salesforceClientSecret = ref("");
|
||||
// Username-password (ROPC) mode credentials. The password is never
|
||||
// round-tripped from the backend; blank on edit unless re-typed.
|
||||
const salesforceUsername = ref("");
|
||||
const salesforceUserPassword = ref("");
|
||||
|
||||
const salesforceOauthRunning = ref(false);
|
||||
const salesforceOauthError = ref("");
|
||||
const salesforceOauthSuccess = ref("");
|
||||
// When editing an existing connection whose credentials live in the backend's
|
||||
// secret store, refreshToken/clientSecret are intentionally not sent back to
|
||||
// the UI. Track "we have previously authorized" separately from whether we
|
||||
// still hold the tokens in-memory so the form can show a status chip.
|
||||
const salesforceOauthPreviouslyAuthorized = ref(false);
|
||||
|
||||
type SalesforceDevicePhase = "idle" | "polling" | "success" | "expired" | "denied" | "error";
|
||||
const salesforceDevicePhase = ref<SalesforceDevicePhase>("idle");
|
||||
const salesforceDeviceUserCode = ref("");
|
||||
const salesforceDeviceVerificationUri = ref("");
|
||||
const salesforceDeviceCode = ref("");
|
||||
const salesforceDeviceIntervalSecs = ref(5);
|
||||
const salesforceDeviceSecondsLeft = ref(0);
|
||||
const salesforceDeviceError = ref("");
|
||||
const salesforceDeviceUserCodeCopied = ref(false);
|
||||
const salesforceDeviceVerificationCopied = ref(false);
|
||||
|
||||
let salesforceDevicePollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let salesforceDevicePollAbortId = 0;
|
||||
let salesforceDeviceExpiresTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function salesforceAuthModeFromConfig(externalConfig: unknown, fallbackPassword?: string): SalesforceAuthMode {
|
||||
const cfg = (externalConfig as SalesforceExternalConfig | undefined) ?? undefined;
|
||||
const mode = cfg?.auth?.mode;
|
||||
if (mode === "oauth" || mode === "device" || mode === "password") return mode;
|
||||
if (cfg?.auth && (cfg.auth.refreshToken || cfg.auth.clientId || cfg.auth.authorizedAt)) return "oauth";
|
||||
// No persisted auth context: if a password/token is present, treat as manual token.
|
||||
if (fallbackPassword?.trim()) return "token";
|
||||
return "token";
|
||||
}
|
||||
|
||||
function resetSalesforceOAuthFields(externalConfig?: unknown, fallbackPassword?: string) {
|
||||
const cfg = (externalConfig as SalesforceExternalConfig | undefined) ?? undefined;
|
||||
const auth = cfg?.auth;
|
||||
salesforceAuthMode.value = salesforceAuthModeFromConfig(externalConfig, fallbackPassword);
|
||||
salesforceEnvironment.value = (auth?.environment as SalesforceEnvironment) || "production";
|
||||
salesforceLoginUrl.value = auth?.loginUrl?.trim() || "";
|
||||
salesforceClientId.value = auth?.clientId?.trim() || "";
|
||||
// Client secret is never round-tripped from the backend for security; the
|
||||
// input is blank on edit and only sent when the user re-types it.
|
||||
salesforceClientSecret.value = "";
|
||||
salesforceUsername.value = auth?.username?.trim() || "";
|
||||
salesforceUserPassword.value = "";
|
||||
salesforceOauthRunning.value = false;
|
||||
salesforceOauthError.value = "";
|
||||
salesforceOauthSuccess.value = "";
|
||||
// A Client ID alone only means the form was filled in, not that anybody ever
|
||||
// signed in; claiming "authorized" from it showed a false reassurance banner.
|
||||
salesforceOauthPreviouslyAuthorized.value = !!(auth?.refreshToken || auth?.authorizedAt);
|
||||
salesforceDevicePhase.value = "idle";
|
||||
salesforceDeviceUserCode.value = "";
|
||||
salesforceDeviceVerificationUri.value = "";
|
||||
salesforceDeviceCode.value = "";
|
||||
salesforceDeviceIntervalSecs.value = 5;
|
||||
salesforceDeviceSecondsLeft.value = 0;
|
||||
salesforceDeviceError.value = "";
|
||||
salesforceDeviceUserCodeCopied.value = false;
|
||||
salesforceDeviceVerificationCopied.value = false;
|
||||
salesforceDeviceStopPolling();
|
||||
}
|
||||
|
||||
function hydrateSalesforceOAuthFields(value: unknown, fallbackPassword?: string) {
|
||||
resetSalesforceOAuthFields(value, fallbackPassword);
|
||||
}
|
||||
|
||||
function salesforceBuildAuthorizeParams(): SalesforceOAuthAuthorizeParams {
|
||||
const clientId = salesforceClientId.value.trim();
|
||||
if (!clientId) throw new Error(t("connection.salesforceOauthClientIdRequired"));
|
||||
const environment = salesforceEnvironment.value;
|
||||
const loginUrl = environment === "custom" ? salesforceLoginUrl.value.trim() : undefined;
|
||||
if (environment === "custom" && !loginUrl) {
|
||||
throw new Error(t("connection.salesforceOauthLoginUrlRequired"));
|
||||
}
|
||||
const clientSecret = salesforceClientSecret.value.trim() || undefined;
|
||||
return { environment, loginUrl, clientId, clientSecret };
|
||||
}
|
||||
|
||||
function salesforceApplyTokenToForm(token: { accessToken: string; instanceUrl: string }) {
|
||||
form.value.password = token.accessToken;
|
||||
// The backend normalizes bare hostnames; we mirror it so the card preview
|
||||
// shows the final instance URL even before the backend canonicalizes it.
|
||||
let instanceUrl = token.instanceUrl.trim();
|
||||
if (instanceUrl && !/^https?:\/\//i.test(instanceUrl)) {
|
||||
instanceUrl = `https://${instanceUrl.replace(/\/+$/, "")}`;
|
||||
}
|
||||
if (instanceUrl) {
|
||||
form.value.host = instanceUrl.replace(/\/+$/, "");
|
||||
}
|
||||
}
|
||||
|
||||
function salesforceBuildAuthContext(mode: SalesforceAuthMode, token?: { refreshToken?: string }): SalesforceAuthContext | undefined {
|
||||
if (mode === "token") return undefined;
|
||||
const context: SalesforceAuthContext = {
|
||||
mode,
|
||||
environment: salesforceEnvironment.value,
|
||||
clientId: salesforceClientId.value.trim() || undefined,
|
||||
authorizedAt: new Date().toISOString(),
|
||||
};
|
||||
if (salesforceEnvironment.value === "custom") {
|
||||
context.loginUrl = salesforceLoginUrl.value.trim() || undefined;
|
||||
}
|
||||
// Only forward the client secret when the user explicitly typed it in this
|
||||
// session — otherwise we would overwrite the backend-stored secret with "".
|
||||
if (salesforceClientSecret.value.trim()) {
|
||||
context.clientSecret = salesforceClientSecret.value.trim();
|
||||
}
|
||||
if (mode === "password") {
|
||||
context.username = salesforceUsername.value.trim() || undefined;
|
||||
if (salesforceUserPassword.value) {
|
||||
context.password = salesforceUserPassword.value;
|
||||
}
|
||||
}
|
||||
if (token?.refreshToken) {
|
||||
context.refreshToken = token.refreshToken;
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
function salesforceMergeAuthIntoExternalConfig(mode: SalesforceAuthMode, token?: { refreshToken?: string }) {
|
||||
const existing = (form.value.external_config && typeof form.value.external_config === "object" ? (form.value.external_config as Record<string, unknown>) : {}) as SalesforceExternalConfig & Record<string, unknown>;
|
||||
const auth = salesforceBuildAuthContext(mode, token);
|
||||
if (!auth) {
|
||||
const { auth: _dropped, ...rest } = existing;
|
||||
form.value.external_config = Object.keys(rest).length > 0 ? rest : undefined;
|
||||
return;
|
||||
}
|
||||
// Preserve an existing refreshToken when the user re-authorizes without the
|
||||
// backend returning a new one (e.g. refresh flow that omits a rotation).
|
||||
if (!auth.refreshToken && existing.auth?.refreshToken) {
|
||||
auth.refreshToken = existing.auth.refreshToken;
|
||||
}
|
||||
// Same for the ROPC password: it is not round-tripped for display, so an
|
||||
// edit-without-retype must not clobber the stored credential.
|
||||
if (!auth.password && existing.auth?.password) {
|
||||
auth.password = existing.auth.password;
|
||||
}
|
||||
form.value.external_config = { ...existing, auth };
|
||||
}
|
||||
|
||||
async function salesforceStartBrowserAuthorize() {
|
||||
salesforceOauthError.value = "";
|
||||
salesforceOauthSuccess.value = "";
|
||||
salesforceOauthPreviouslyAuthorized.value = false;
|
||||
let params: SalesforceOAuthAuthorizeParams;
|
||||
try {
|
||||
params = salesforceBuildAuthorizeParams();
|
||||
} catch (e) {
|
||||
salesforceOauthError.value = errorMessage(e);
|
||||
return;
|
||||
}
|
||||
salesforceOauthRunning.value = true;
|
||||
try {
|
||||
const token = await api.salesforceOauthBrowserAuthorize(params);
|
||||
salesforceApplyTokenToForm(token);
|
||||
salesforceMergeAuthIntoExternalConfig("oauth", token);
|
||||
salesforceOauthSuccess.value = t("connection.salesforceOauthSuccess", { instanceUrl: token.instanceUrl });
|
||||
salesforceOauthPreviouslyAuthorized.value = true;
|
||||
} catch (e) {
|
||||
salesforceOauthError.value = errorMessage(e);
|
||||
} finally {
|
||||
salesforceOauthRunning.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function salesforceStartPasswordLogin() {
|
||||
salesforceOauthError.value = "";
|
||||
salesforceOauthSuccess.value = "";
|
||||
salesforceOauthPreviouslyAuthorized.value = false;
|
||||
let params: SalesforceOAuthAuthorizeParams;
|
||||
try {
|
||||
params = salesforceBuildAuthorizeParams();
|
||||
} catch (e) {
|
||||
salesforceOauthError.value = errorMessage(e);
|
||||
return;
|
||||
}
|
||||
const username = salesforceUsername.value.trim();
|
||||
if (!username) {
|
||||
salesforceOauthError.value = t("connection.salesforceUsernameRequired");
|
||||
return;
|
||||
}
|
||||
if (!salesforceUserPassword.value) {
|
||||
salesforceOauthError.value = t("connection.salesforceUserPasswordRequired");
|
||||
return;
|
||||
}
|
||||
salesforceOauthRunning.value = true;
|
||||
try {
|
||||
const token = await api.salesforceOauthPasswordLogin(params, username, salesforceUserPassword.value);
|
||||
salesforceApplyTokenToForm(token);
|
||||
salesforceMergeAuthIntoExternalConfig("password", token);
|
||||
salesforceOauthSuccess.value = t("connection.salesforceOauthSuccess", { instanceUrl: token.instanceUrl });
|
||||
salesforceOauthPreviouslyAuthorized.value = true;
|
||||
} catch (e) {
|
||||
salesforceOauthError.value = errorMessage(e);
|
||||
} finally {
|
||||
salesforceOauthRunning.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function salesforceDeviceStopPolling() {
|
||||
salesforceDevicePollAbortId++;
|
||||
if (salesforceDevicePollTimer) {
|
||||
clearTimeout(salesforceDevicePollTimer);
|
||||
salesforceDevicePollTimer = null;
|
||||
}
|
||||
if (salesforceDeviceExpiresTimer) {
|
||||
clearInterval(salesforceDeviceExpiresTimer);
|
||||
salesforceDeviceExpiresTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function salesforceDeviceStartExpiryCountdown(expiresInSecs: number) {
|
||||
salesforceDeviceSecondsLeft.value = Math.max(0, Math.floor(expiresInSecs));
|
||||
if (salesforceDeviceExpiresTimer) clearInterval(salesforceDeviceExpiresTimer);
|
||||
salesforceDeviceExpiresTimer = setInterval(() => {
|
||||
salesforceDeviceSecondsLeft.value = Math.max(0, salesforceDeviceSecondsLeft.value - 1);
|
||||
if (salesforceDeviceSecondsLeft.value <= 0 && salesforceDevicePhase.value === "polling") {
|
||||
salesforceDeviceStopPolling();
|
||||
salesforceDevicePhase.value = "expired";
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
async function salesforceDeviceRequestCode() {
|
||||
salesforceOauthError.value = "";
|
||||
salesforceOauthSuccess.value = "";
|
||||
salesforceOauthPreviouslyAuthorized.value = false;
|
||||
salesforceDeviceError.value = "";
|
||||
salesforceDeviceUserCode.value = "";
|
||||
salesforceDeviceVerificationUri.value = "";
|
||||
salesforceDeviceUserCodeCopied.value = false;
|
||||
salesforceDeviceVerificationCopied.value = false;
|
||||
let params: SalesforceOAuthAuthorizeParams;
|
||||
try {
|
||||
params = salesforceBuildAuthorizeParams();
|
||||
} catch (e) {
|
||||
salesforceDeviceError.value = errorMessage(e);
|
||||
salesforceDevicePhase.value = "error";
|
||||
return;
|
||||
}
|
||||
salesforceOauthRunning.value = true;
|
||||
salesforceDevicePhase.value = "idle";
|
||||
try {
|
||||
const start = await api.salesforceOauthDeviceStart(params);
|
||||
salesforceDeviceUserCode.value = start.userCode;
|
||||
salesforceDeviceVerificationUri.value = start.verificationUri;
|
||||
salesforceDeviceCode.value = start.deviceCode;
|
||||
salesforceDeviceIntervalSecs.value = Math.max(1, start.intervalSecs || 5);
|
||||
salesforceDevicePhase.value = "polling";
|
||||
salesforceDeviceStartExpiryCountdown(start.expiresInSecs);
|
||||
await salesforceDevicePollLoop(params, start.deviceCode, salesforceDeviceIntervalSecs.value, salesforceDevicePollAbortId);
|
||||
} catch (e) {
|
||||
salesforceDeviceError.value = errorMessage(e);
|
||||
salesforceDevicePhase.value = "error";
|
||||
salesforceDeviceStopPolling();
|
||||
} finally {
|
||||
salesforceOauthRunning.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function salesforceDevicePollLoop(params: SalesforceOAuthAuthorizeParams, deviceCode: string, intervalSecs: number, abortId: number): Promise<void> {
|
||||
while (salesforceDevicePhase.value === "polling" && abortId === salesforceDevicePollAbortId) {
|
||||
await new Promise<void>((resolve) => {
|
||||
salesforceDevicePollTimer = setTimeout(() => resolve(), intervalSecs * 1000);
|
||||
});
|
||||
if (abortId !== salesforceDevicePollAbortId) return;
|
||||
let result: SalesforceOAuthDevicePollResult;
|
||||
try {
|
||||
result = await api.salesforceOauthDevicePoll(params, deviceCode, intervalSecs);
|
||||
} catch (e) {
|
||||
// Transient network error: keep polling until the code expires.
|
||||
if (salesforceDeviceSecondsLeft.value <= 0) {
|
||||
salesforceDevicePhase.value = "expired";
|
||||
salesforceDeviceStopPolling();
|
||||
return;
|
||||
}
|
||||
salesforceDeviceError.value = errorMessage(e);
|
||||
continue;
|
||||
}
|
||||
if (abortId !== salesforceDevicePollAbortId) return;
|
||||
if (result.status === "pending") {
|
||||
salesforceDeviceIntervalSecs.value = Math.max(1, result.intervalSecs || intervalSecs);
|
||||
continue;
|
||||
}
|
||||
salesforceDeviceStopPolling();
|
||||
if (result.status === "success") {
|
||||
salesforceApplyTokenToForm(result.token);
|
||||
salesforceMergeAuthIntoExternalConfig("device", result.token);
|
||||
salesforceOauthSuccess.value = t("connection.salesforceOauthSuccess", { instanceUrl: result.token.instanceUrl });
|
||||
salesforceOauthPreviouslyAuthorized.value = true;
|
||||
salesforceDevicePhase.value = "success";
|
||||
return;
|
||||
}
|
||||
if (result.status === "expired") {
|
||||
salesforceDevicePhase.value = "expired";
|
||||
return;
|
||||
}
|
||||
if (result.status === "denied") {
|
||||
salesforceDeviceError.value = result.reason || t("connection.salesforceDeviceDenied");
|
||||
salesforceDevicePhase.value = "denied";
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function salesforceDeviceCancel() {
|
||||
salesforceDeviceStopPolling();
|
||||
salesforceDevicePhase.value = "idle";
|
||||
salesforceDeviceUserCode.value = "";
|
||||
salesforceDeviceVerificationUri.value = "";
|
||||
salesforceDeviceCode.value = "";
|
||||
}
|
||||
|
||||
function salesforceDeviceRestart() {
|
||||
salesforceDeviceCancel();
|
||||
salesforceDeviceError.value = "";
|
||||
salesforceOauthError.value = "";
|
||||
}
|
||||
|
||||
async function salesforceCopyUserCode() {
|
||||
if (!salesforceDeviceUserCode.value) return;
|
||||
await copyToClipboard(salesforceDeviceUserCode.value);
|
||||
salesforceDeviceUserCodeCopied.value = true;
|
||||
setTimeout(() => {
|
||||
salesforceDeviceUserCodeCopied.value = false;
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
async function salesforceCopyVerificationUri() {
|
||||
if (!salesforceDeviceVerificationUri.value) return;
|
||||
await copyToClipboard(salesforceDeviceVerificationUri.value);
|
||||
salesforceDeviceVerificationCopied.value = true;
|
||||
setTimeout(() => {
|
||||
salesforceDeviceVerificationCopied.value = false;
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
function salesforceOpenVerificationPage() {
|
||||
if (!salesforceDeviceVerificationUri.value) return;
|
||||
// Same pattern as the update-flow "open release notes" action: defer to the
|
||||
// shell helper that picks the right opener for desktop vs. web.
|
||||
void (async () => {
|
||||
try {
|
||||
if (isDesktop) {
|
||||
const { open } = await import("@tauri-apps/plugin-shell");
|
||||
await open(salesforceDeviceVerificationUri.value);
|
||||
} else {
|
||||
globalThis.open(salesforceDeviceVerificationUri.value, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
} catch (e) {
|
||||
salesforceDeviceError.value = errorMessage(e);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
function salesforceSetAuthMode(mode: SalesforceAuthMode) {
|
||||
// Switching modes resets any in-flight state so stale tokens from one flow
|
||||
// never leak into another.
|
||||
if (salesforceOauthRunning.value && mode !== salesforceAuthMode.value) return;
|
||||
salesforceAuthMode.value = mode;
|
||||
salesforceOauthError.value = "";
|
||||
salesforceOauthSuccess.value = "";
|
||||
if (mode !== "device") {
|
||||
salesforceDeviceCancel();
|
||||
salesforceDeviceError.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
watch(influxDbVersion, (version, previousVersion) => {
|
||||
if (form.value.db_type !== "influxdb") return;
|
||||
if (version === "2" || version === "3") {
|
||||
@@ -2546,6 +2932,9 @@ function applyProfile(val: string, preserveConnectionFields = false) {
|
||||
form.value.connection_string = undefined;
|
||||
form.value.url_params = "";
|
||||
}
|
||||
if (profile.type === "salesforce") {
|
||||
resetSalesforceOAuthFields(form.value.external_config, form.value.password);
|
||||
}
|
||||
resetHiveKerberosFields(profile.type === "hive" || profile.type === "argo" || profile.type === "kyuubi" || profile.type === "impala" ? form.value : undefined);
|
||||
}
|
||||
if (profile.type === "meilisearch") {
|
||||
@@ -2777,6 +3166,11 @@ watch(
|
||||
} else {
|
||||
resetVictoriaMetricsFields();
|
||||
}
|
||||
if (config.db_type === "salesforce") {
|
||||
hydrateSalesforceOAuthFields(config.external_config, config.password);
|
||||
} else {
|
||||
resetSalesforceOAuthFields(undefined, undefined);
|
||||
}
|
||||
resetElasticsearchProxyFields(config.db_type === "elasticsearch" ? config.external_config : undefined);
|
||||
resetHiveKerberosFields(config.db_type === "hive" || config.db_type === "argo" || config.db_type === "kyuubi" || config.db_type === "impala" ? config : undefined);
|
||||
resetDamengJvmOptions(config.db_type === "dameng" ? config : undefined);
|
||||
@@ -4426,6 +4820,51 @@ function connectionConfigForSubmit(id: string, generatedName = "", validatePlugi
|
||||
config.connection_string = undefined;
|
||||
config.database = "metrics";
|
||||
config.username = config.username.trim();
|
||||
} else if (config.db_type === "salesforce") {
|
||||
// Token-mode connections never carry auth context (the access token itself is
|
||||
// the credential in `password`). OAuth/device modes merge the auth context
|
||||
// into `external_config.auth`; the backend handles refreshToken encryption.
|
||||
config.connection_string = undefined;
|
||||
config.database = undefined;
|
||||
config.username = config.username.trim();
|
||||
const existing = (config.external_config && typeof config.external_config === "object" ? (config.external_config as Record<string, unknown>) : {}) as SalesforceExternalConfig & Record<string, unknown>;
|
||||
if (salesforceAuthMode.value === "token") {
|
||||
const { auth: _dropped, ...rest } = existing;
|
||||
config.external_config = Object.keys(rest).length > 0 ? rest : undefined;
|
||||
} else {
|
||||
// Preserve an existing refreshToken/clientSecret when the user saves the
|
||||
// form without re-authorizing or re-typing them (they're not round-tripped
|
||||
// from the backend). The hydrate step flagged this via
|
||||
// `salesforceOauthPreviouslyAuthorized`.
|
||||
const auth: SalesforceAuthContext = {
|
||||
mode: salesforceAuthMode.value,
|
||||
environment: salesforceEnvironment.value,
|
||||
clientId: salesforceClientId.value.trim() || existing.auth?.clientId,
|
||||
authorizedAt: existing.auth?.authorizedAt,
|
||||
};
|
||||
if (salesforceEnvironment.value === "custom") {
|
||||
auth.loginUrl = salesforceLoginUrl.value.trim() || existing.auth?.loginUrl;
|
||||
}
|
||||
if (salesforceClientSecret.value.trim()) {
|
||||
auth.clientSecret = salesforceClientSecret.value.trim();
|
||||
} else if (existing.auth?.clientSecret) {
|
||||
// Preserve secret indicator without leaking the value to the client.
|
||||
auth.clientSecret = existing.auth.clientSecret;
|
||||
}
|
||||
if (salesforceAuthMode.value === "password") {
|
||||
auth.username = salesforceUsername.value.trim() || existing.auth?.username;
|
||||
if (salesforceUserPassword.value) {
|
||||
auth.password = salesforceUserPassword.value;
|
||||
} else if (existing.auth?.password) {
|
||||
// Stored ROPC credential (scrubbed into the backend secret store);
|
||||
// keep it when the user edits the connection without retyping.
|
||||
auth.password = existing.auth.password;
|
||||
}
|
||||
} else if (existing.auth?.refreshToken) {
|
||||
auth.refreshToken = existing.auth.refreshToken;
|
||||
}
|
||||
config.external_config = { ...existing, auth };
|
||||
}
|
||||
} else if (config.db_type === "elasticsearch") {
|
||||
config.external_config = buildElasticsearchExternalConfig(elasticsearchConnectionMode.value, elasticsearchKibanaBasePath.value, elasticsearchConnectivityCheckPath.value, elasticsearchIndexGroupingPattern.value, elasticsearchConnectivityCheckDisabled.value);
|
||||
} else if (config.db_type === "meilisearch") {
|
||||
@@ -5476,6 +5915,7 @@ function resetForm(options: { preservePickerState?: boolean } = {}) {
|
||||
appliedConnectionUrlInput.value = "";
|
||||
resetMeilisearchHostInput();
|
||||
oracleTnsAdminPath.value = "";
|
||||
resetSalesforceOAuthFields(undefined, undefined);
|
||||
if (!options.preservePickerState) {
|
||||
dialogStep.value = "select";
|
||||
dbSearchQuery.value = "";
|
||||
@@ -6366,6 +6806,9 @@ onMounted(async () => {
|
||||
onUnmounted(() => {
|
||||
unlistenAgentInstallProgress?.();
|
||||
unlistenAgentInstallProgress = null;
|
||||
// Stop any in-flight Salesforce device-code poll / expiry countdown so
|
||||
// closing the dialog does not leave orphan timers running.
|
||||
salesforceDeviceStopPolling();
|
||||
});
|
||||
|
||||
function openExternalUrl(url: string) {
|
||||
@@ -6521,7 +6964,7 @@ function openExternalUrl(url: string) {
|
||||
|
||||
<TabsContent value="connection" class="m-0 flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div class="connection-form-body grid min-h-0 flex-1 scroll-pb-6 gap-4 overflow-y-auto pt-4 pr-2 pb-6" :class="{ 'connection-form-body--nacos': form.db_type === 'nacos' }">
|
||||
<div v-if="!isPluginConnection && !isJdbcConnection && form.db_type !== 'nacos' && form.db_type !== 'consul' && form.db_type !== 'mq'" class="grid grid-cols-4 items-center gap-4">
|
||||
<div v-if="!isPluginConnection && !isJdbcConnection && form.db_type !== 'nacos' && form.db_type !== 'consul' && form.db_type !== 'mq' && form.db_type !== 'salesforce'" class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.connectionUrlOptional") }}</Label>
|
||||
<div class="col-span-3 flex items-center gap-1">
|
||||
<Input v-model="connectionUrlInput" class="flex-1" :placeholder="connectionUrlPlaceholder" @keydown.enter.prevent="applyConnectionUrl" />
|
||||
@@ -7943,6 +8386,289 @@ function openExternalUrl(url: string) {
|
||||
<CloudflareD1ConnectionFields v-model:account-id="form.host" v-model:database-id="form.database" v-model:api-token="form.password" />
|
||||
</template>
|
||||
|
||||
<!-- Salesforce: instance URL + access token (SOQL) -->
|
||||
<template v-else-if="form.db_type === 'salesforce'">
|
||||
<!-- Instance URL (always visible so the user can confirm the
|
||||
target org; OAuth/device flows overwrite it after a
|
||||
successful authorize) -->
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<Label :class="connectionLabelSmallClass">{{ t("connection.salesforceInstanceUrl") }}</Label>
|
||||
<div class="col-span-3 space-y-1.5">
|
||||
<div class="flex gap-2">
|
||||
<Input v-model="form.host" class="flex-1" :placeholder="t('connection.salesforceInstanceUrlPlaceholder')" />
|
||||
<Input v-model.number="form.port" type="number" class="w-24 shrink-0" />
|
||||
</div>
|
||||
<p class="text-xs leading-5 text-muted-foreground">{{ t("connection.salesforceInstanceUrlHint") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Authentication mode picker -->
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelSmallClass">{{ t("connection.salesforceAuthMode") }}</Label>
|
||||
<div class="col-span-3 grid h-8 grid-cols-4 overflow-hidden rounded-md border border-input bg-muted/30 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
class="h-7 rounded-sm px-2 text-xs transition-colors"
|
||||
:class="salesforceAuthMode === 'token' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
|
||||
:aria-pressed="salesforceAuthMode === 'token'"
|
||||
:disabled="salesforceOauthRunning"
|
||||
@click="salesforceSetAuthMode('token')"
|
||||
>
|
||||
{{ t("connection.salesforceAuthModeToken") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="h-7 rounded-sm px-2 text-xs transition-colors"
|
||||
:class="salesforceAuthMode === 'oauth' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground disabled:text-muted-foreground/50'"
|
||||
:aria-pressed="salesforceAuthMode === 'oauth'"
|
||||
:disabled="salesforceOauthRunning || !isDesktop"
|
||||
:title="!isDesktop ? t('connection.salesforceAuthModeOauthDesktopOnly') : undefined"
|
||||
@click="salesforceSetAuthMode('oauth')"
|
||||
>
|
||||
{{ t("connection.salesforceAuthModeOauth") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="h-7 rounded-sm px-2 text-xs transition-colors"
|
||||
:class="salesforceAuthMode === 'device' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
|
||||
:aria-pressed="salesforceAuthMode === 'device'"
|
||||
:disabled="salesforceOauthRunning"
|
||||
@click="salesforceSetAuthMode('device')"
|
||||
>
|
||||
{{ t("connection.salesforceAuthModeDevice") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="h-7 rounded-sm px-2 text-xs transition-colors"
|
||||
:class="salesforceAuthMode === 'password' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
|
||||
:aria-pressed="salesforceAuthMode === 'password'"
|
||||
:disabled="salesforceOauthRunning"
|
||||
@click="salesforceSetAuthMode('password')"
|
||||
>
|
||||
{{ t("connection.salesforceAuthModePassword") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!isDesktop && salesforceAuthMode === 'oauth'" class="grid grid-cols-4 items-start gap-4">
|
||||
<span />
|
||||
<p class="col-span-3 text-xs leading-5 text-muted-foreground">{{ t("connection.salesforceAuthModeOauthDesktopOnly") }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Manual access token -->
|
||||
<template v-if="salesforceAuthMode === 'token'">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.salesforceAccessToken") }}</Label>
|
||||
<PasswordInput v-model="form.password" class="col-span-3" :placeholder="t('connection.salesforceAccessTokenPlaceholder')" />
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<span />
|
||||
<p class="col-span-3 text-xs leading-5 text-muted-foreground">{{ t("connection.salesforceAccessTokenHint") }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Shared OAuth / device-code fields -->
|
||||
<template v-else>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelSmallClass">{{ t("connection.salesforceEnvironment") }}</Label>
|
||||
<Select v-model="salesforceEnvironment" :disabled="salesforceOauthRunning">
|
||||
<SelectTrigger class="col-span-3">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="production">{{ t("connection.salesforceEnvironmentProduction") }}</SelectItem>
|
||||
<SelectItem value="sandbox">{{ t("connection.salesforceEnvironmentSandbox") }}</SelectItem>
|
||||
<SelectItem value="custom">{{ t("connection.salesforceEnvironmentCustom") }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div v-if="salesforceEnvironment === 'custom'" class="grid grid-cols-4 items-start gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.salesforceLoginUrl") }}</Label>
|
||||
<div class="col-span-3 space-y-1.5">
|
||||
<Input v-model="salesforceLoginUrl" :placeholder="t('connection.salesforceLoginUrlPlaceholder')" :disabled="salesforceOauthRunning" />
|
||||
<p class="text-xs leading-5 text-muted-foreground">{{ t("connection.salesforceLoginUrlHint") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<Label :class="connectionLabelSmallClass">
|
||||
{{ t("connection.salesforceClientId") }}
|
||||
<span class="ml-1 text-destructive">*</span>
|
||||
</Label>
|
||||
<div class="col-span-3 space-y-1.5">
|
||||
<Input v-model="salesforceClientId" :placeholder="t('connection.salesforceClientIdPlaceholder')" :disabled="salesforceOauthRunning" autocomplete="off" />
|
||||
<!-- The callback URL only matters for the browser redirect flow; the
|
||||
other two modes never see one, so keep it out of their way. -->
|
||||
<p v-if="salesforceAuthMode === 'oauth'" class="text-xs leading-5 text-muted-foreground">
|
||||
{{ t("connection.salesforceClientIdHint", { callbackUrl: SALESFORCE_OAUTH_CALLBACK_URL }) }}
|
||||
</p>
|
||||
<p v-else class="text-xs leading-5 text-muted-foreground">{{ t("connection.salesforceClientIdHintShared") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.salesforceClientSecret") }}</Label>
|
||||
<div class="col-span-3 space-y-1.5">
|
||||
<PasswordInput v-model="salesforceClientSecret" :placeholder="t('connection.salesforceClientSecretPlaceholder')" :disabled="salesforceOauthRunning" autocomplete="new-password" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Username & password (ROPC) credentials -->
|
||||
<template v-if="salesforceAuthMode === 'password'">
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.salesforceUsernameLabel") }}</Label>
|
||||
<div class="col-span-3 space-y-1.5">
|
||||
<Input v-model="salesforceUsername" :placeholder="t('connection.salesforceUsernamePlaceholder')" :disabled="salesforceOauthRunning" autocomplete="off" />
|
||||
<p class="text-xs leading-5 text-muted-foreground">{{ t("connection.salesforceUsernameHint") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<Label :class="connectionLabelClass">{{ t("connection.salesforceUserPasswordLabel") }}</Label>
|
||||
<div class="col-span-3 space-y-1.5">
|
||||
<PasswordInput v-model="salesforceUserPassword" :placeholder="t('connection.salesforceUserPasswordPlaceholder')" :disabled="salesforceOauthRunning" autocomplete="new-password" />
|
||||
<p class="text-xs leading-5 text-muted-foreground">{{ t("connection.salesforceUserPasswordHint") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Previously-authorized banner (edit flow) -->
|
||||
<div v-if="salesforceOauthPreviouslyAuthorized && !salesforceOauthRunning && salesforceDevicePhase !== 'polling'" class="grid grid-cols-4 items-center gap-4">
|
||||
<span />
|
||||
<div class="col-span-3 flex items-center gap-2 rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-xs text-emerald-700 dark:text-emerald-300">
|
||||
<ShieldCheck class="h-4 w-4 shrink-0" />
|
||||
<span>{{ t("connection.salesforceOauthAuthorized") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Browser OAuth action -->
|
||||
<template v-if="salesforceAuthMode === 'oauth'">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<span />
|
||||
<div class="col-span-3 flex items-center gap-2">
|
||||
<Button type="button" :disabled="salesforceOauthRunning || !salesforceClientId.trim() || !isDesktop" @click="salesforceStartBrowserAuthorize">
|
||||
<Loader2 v-if="salesforceOauthRunning" class="mr-2 h-4 w-4 animate-spin" />
|
||||
{{ salesforceOauthRunning ? t("connection.salesforceOauthAuthorizing") : salesforceOauthPreviouslyAuthorized ? t("connection.salesforceOauthReauthorize") : t("connection.salesforceOauthAuthorize") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="salesforceOauthRunning" class="grid grid-cols-4 items-start gap-4">
|
||||
<span />
|
||||
<p class="col-span-3 text-xs leading-5 text-muted-foreground">{{ t("connection.salesforceOauthAuthorizingHint") }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Username-password sign in -->
|
||||
<template v-if="salesforceAuthMode === 'password'">
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<span />
|
||||
<div class="col-span-3 flex items-center gap-2">
|
||||
<Button type="button" :disabled="salesforceOauthRunning || !salesforceClientId.trim() || !salesforceUsername.trim() || !salesforceUserPassword" @click="salesforceStartPasswordLogin">
|
||||
<Loader2 v-if="salesforceOauthRunning" class="mr-2 h-4 w-4 animate-spin" />
|
||||
{{ salesforceOauthRunning ? t("connection.salesforceOauthAuthorizing") : salesforceOauthPreviouslyAuthorized ? t("connection.salesforcePasswordSignInAgain") : t("connection.salesforcePasswordSignIn") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<span />
|
||||
<p class="col-span-3 text-xs leading-5 text-muted-foreground">{{ t("connection.salesforcePasswordRopcNote") }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Device-code flow -->
|
||||
<template v-if="salesforceAuthMode === 'device'">
|
||||
<div v-if="salesforceDevicePhase === 'idle' || salesforceDevicePhase === 'error'" class="grid grid-cols-4 items-center gap-4">
|
||||
<span />
|
||||
<div class="col-span-3 flex items-center gap-2">
|
||||
<Button type="button" :disabled="salesforceOauthRunning || !salesforceClientId.trim()" @click="salesforceDeviceRequestCode">
|
||||
<Loader2 v-if="salesforceOauthRunning" class="mr-2 h-4 w-4 animate-spin" />
|
||||
{{ t("connection.salesforceDeviceGetCode") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="salesforceDevicePhase === 'idle' || salesforceDevicePhase === 'error'" class="grid grid-cols-4 items-start gap-4">
|
||||
<span />
|
||||
<p class="col-span-3 text-xs leading-5 text-muted-foreground">{{ t("connection.salesforceDeviceFlowNote") }}</p>
|
||||
</div>
|
||||
|
||||
<template v-if="salesforceDevicePhase === 'polling'">
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<Label :class="connectionLabelSmallClass">{{ t("connection.salesforceDeviceUserCodeLabel") }}</Label>
|
||||
<div class="col-span-3 space-y-1.5">
|
||||
<div class="flex items-center gap-2 rounded-md border border-input bg-muted/40 px-3 py-2">
|
||||
<span class="font-mono text-lg font-semibold tracking-widest">{{ salesforceDeviceUserCode }}</span>
|
||||
<Button type="button" variant="ghost" size="icon" class="h-7 w-7" @click="salesforceCopyUserCode">
|
||||
<Check v-if="salesforceDeviceUserCodeCopied" class="h-4 w-4 text-emerald-600" />
|
||||
<Copy v-else class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="salesforceDeviceVerificationUri" class="flex items-center gap-2 text-xs">
|
||||
<a class="text-primary underline underline-offset-2 hover:no-underline" href="#" @click.prevent="salesforceOpenVerificationPage">{{ t("connection.salesforceDeviceOpenVerification") }}</a>
|
||||
<code class="truncate rounded bg-muted px-1 py-0.5 text-[11px]">{{ salesforceDeviceVerificationUri }}</code>
|
||||
<Button type="button" variant="ghost" size="icon" class="h-6 w-6" @click="salesforceCopyVerificationUri">
|
||||
<Check v-if="salesforceDeviceVerificationCopied" class="h-3.5 w-3.5 text-emerald-600" />
|
||||
<Copy v-else class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<span />
|
||||
<div class="col-span-3 flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
<span>{{ t("connection.salesforceDevicePolling") }}</span>
|
||||
<span v-if="salesforceDeviceSecondsLeft > 0">· {{ t("connection.salesforceDeviceSecondsLeft", { seconds: salesforceDeviceSecondsLeft }) }}</span>
|
||||
<Button type="button" variant="outline" size="sm" class="ml-auto" @click="salesforceDeviceCancel">
|
||||
{{ t("connection.salesforceDeviceCancel") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 items-start gap-4">
|
||||
<span />
|
||||
<p class="col-span-3 text-xs leading-5 text-muted-foreground">{{ t("connection.salesforceDevicePollingHint") }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else-if="salesforceDevicePhase === 'expired'" class="grid grid-cols-4 items-center gap-4">
|
||||
<span />
|
||||
<div class="col-span-3 flex flex-col gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs">
|
||||
<span class="text-amber-700 dark:text-amber-300">{{ t("connection.salesforceDeviceExpired") }}</span>
|
||||
<Button type="button" size="sm" variant="outline" class="self-start" @click="salesforceDeviceRestart">
|
||||
{{ t("connection.salesforceDeviceRestart") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="salesforceDevicePhase === 'denied'" class="grid grid-cols-4 items-center gap-4">
|
||||
<span />
|
||||
<div class="col-span-3 flex flex-col gap-2 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs">
|
||||
<span class="text-destructive">{{ salesforceDeviceError || t("connection.salesforceDeviceDenied") }}</span>
|
||||
<Button type="button" size="sm" variant="outline" class="self-start" @click="salesforceDeviceRestart">
|
||||
{{ t("connection.salesforceDeviceRestart") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Shared status / error strip (oauth success + any error) -->
|
||||
<div v-if="salesforceOauthSuccess && (salesforceAuthMode === 'oauth' || salesforceAuthMode === 'password' || salesforceDevicePhase === 'success')" class="grid grid-cols-4 items-center gap-4">
|
||||
<span />
|
||||
<div class="col-span-3 flex items-center gap-2 rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-xs text-emerald-700 dark:text-emerald-300">
|
||||
<ShieldCheck class="h-4 w-4 shrink-0" />
|
||||
<span>{{ salesforceOauthSuccess }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="salesforceOauthError && (salesforceAuthMode === 'oauth' || salesforceAuthMode === 'password')" class="grid grid-cols-4 items-center gap-4">
|
||||
<span />
|
||||
<div class="col-span-3 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
|
||||
{{ salesforceOauthError }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="salesforceDeviceError && salesforceAuthMode === 'device' && salesforceDevicePhase !== 'polling' && salesforceDevicePhase !== 'denied'" class="grid grid-cols-4 items-center gap-4">
|
||||
<span />
|
||||
<div class="col-span-3 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
|
||||
{{ salesforceDeviceError }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- MySQL / PostgreSQL: host, port, user, password, database -->
|
||||
<template v-else>
|
||||
<div v-if="form.db_type === 'ignite' || form.db_type === 'ignite3'" class="grid grid-cols-4 items-start gap-4">
|
||||
|
||||
Vendored
+266
@@ -0,0 +1,266 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, nextTick, reactive, type App } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "@/i18n";
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
|
||||
const { store, settings, backend } = vi.hoisted(() => ({
|
||||
store: {
|
||||
connectionGroupOptions: [],
|
||||
newConnectionGroupId: null,
|
||||
selectedConnectionGroupId: null,
|
||||
addConnection: vi.fn(),
|
||||
updateConnection: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
ensureConnected: vi.fn(),
|
||||
addEphemeralConnection: vi.fn(),
|
||||
removeConnection: vi.fn(),
|
||||
startEditing: vi.fn(),
|
||||
stopEditing: vi.fn(),
|
||||
clearConnectionError: vi.fn(),
|
||||
updateConnectionDatabaseInfo: vi.fn(),
|
||||
applyGlobalTimeouts: vi.fn((config) => config),
|
||||
getConfig: vi.fn(),
|
||||
},
|
||||
settings: {
|
||||
editorSettings: { sidebarShowConnectionNotes: false, globalConnectTimeoutSecs: 10, globalQueryTimeoutSecs: 0 },
|
||||
rememberedDatabaseForConnection: vi.fn(() => ""),
|
||||
persistEditorSettings: vi.fn(),
|
||||
updateEditorSettings: vi.fn(),
|
||||
updateEditorSettingsAndPersist: vi.fn(),
|
||||
},
|
||||
backend: {
|
||||
connectDb: vi.fn(),
|
||||
disconnectDb: vi.fn(),
|
||||
testConnectionWithInfo: vi.fn(),
|
||||
listDatabases: vi.fn(),
|
||||
listPlugins: vi.fn(),
|
||||
listJdbcDrivers: vi.fn(),
|
||||
listJdbcMavenBundles: vi.fn(),
|
||||
listJdbcLocalBundles: vi.fn(),
|
||||
listSshConfigHosts: vi.fn(),
|
||||
listInstalledAgentsLocal: vi.fn(),
|
||||
listenAgentInstallProgress: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/connectionStore", () => ({ useConnectionStore: () => store, CONNECTION_ATTEMPT_CANCELLED_MESSAGE: "cancelled" }));
|
||||
vi.mock("@/stores/settingsStore", async (original) => ({ ...(await original<typeof import("@/stores/settingsStore")>()), useSettingsStore: () => settings }));
|
||||
vi.mock("@/stores/tunnelProfileStore", () => ({ useTunnelProfileStore: () => ({ profiles: [], profileById: () => undefined, init: vi.fn() }) }));
|
||||
vi.mock("@/lib/backend/api", async (original) => ({ ...(await original<typeof import("@/lib/backend/api")>()), ...backend }));
|
||||
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) }));
|
||||
|
||||
vi.mock("@/components/ui/dialog", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
const passthrough = defineComponent({
|
||||
setup:
|
||||
(_props, { slots }) =>
|
||||
() =>
|
||||
h("div", slots.default?.()),
|
||||
});
|
||||
const Dialog = defineComponent({
|
||||
props: { open: Boolean },
|
||||
emits: ["update:open"],
|
||||
setup:
|
||||
(props, { slots, emit }) =>
|
||||
() =>
|
||||
props.open ? h("section", [h("button", { "data-testid": "dismiss-dialog", onClick: () => emit("update:open", false) }, "Dismiss"), slots.default?.()]) : null,
|
||||
});
|
||||
return { Dialog, DialogContent: passthrough, DialogHeader: passthrough, DialogTitle: passthrough, DialogFooter: passthrough };
|
||||
});
|
||||
vi.mock("@/components/ui/button", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return {
|
||||
Button: defineComponent({
|
||||
inheritAttrs: false,
|
||||
setup:
|
||||
(_props, { attrs, slots }) =>
|
||||
() =>
|
||||
h("button", attrs, slots.default?.()),
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock("@/components/ui/input", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return {
|
||||
Input: defineComponent({
|
||||
props: ["modelValue"],
|
||||
emits: ["update:modelValue"],
|
||||
setup:
|
||||
(props, { attrs, emit }) =>
|
||||
() =>
|
||||
h("input", { ...attrs, value: props.modelValue, onInput: (event: Event) => emit("update:modelValue", (event.target as HTMLInputElement).value) }),
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock("@/components/ui/PasswordInput.vue", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
return {
|
||||
default: defineComponent({
|
||||
props: ["modelValue"],
|
||||
setup:
|
||||
(props, { attrs }) =>
|
||||
() =>
|
||||
h("input", { ...attrs, type: "password", value: props.modelValue }),
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock("@/components/ui/tabs", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
const pass = defineComponent({
|
||||
setup:
|
||||
(_props, { slots }) =>
|
||||
() =>
|
||||
h("div", slots.default?.()),
|
||||
});
|
||||
const TabsContent = defineComponent({
|
||||
props: ["value"],
|
||||
setup:
|
||||
(props, { slots }) =>
|
||||
() =>
|
||||
props.value === "connection" ? h("div", slots.default?.()) : null,
|
||||
});
|
||||
return { Tabs: pass, TabsContent, TabsList: pass, TabsTrigger: pass };
|
||||
});
|
||||
vi.mock("@/components/ui/tooltip", async () => {
|
||||
const { defineComponent, h } = await import("vue");
|
||||
const pass = defineComponent({
|
||||
setup:
|
||||
(_props, { slots }) =>
|
||||
() =>
|
||||
h("span", slots.default?.()),
|
||||
});
|
||||
return { HelpTooltip: pass, Tooltip: pass, TooltipContent: pass, TooltipTrigger: pass };
|
||||
});
|
||||
|
||||
import ConnectionDialog from "@/components/connection/ConnectionDialog.vue";
|
||||
|
||||
const mountedApps: App[] = [];
|
||||
|
||||
function salesforceConnection(overrides: Partial<ConnectionConfig> = {}): ConnectionConfig {
|
||||
return {
|
||||
id: "sfdc-sandbox",
|
||||
name: "Salesforce sandbox",
|
||||
db_type: "salesforce",
|
||||
driver_profile: "salesforce",
|
||||
driver_label: "Salesforce",
|
||||
host: "https://acme--qas1.sandbox.my.salesforce.com",
|
||||
port: 443,
|
||||
password: "saved-access-token",
|
||||
ssl: true,
|
||||
save_password: true,
|
||||
...overrides,
|
||||
} as ConnectionConfig;
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
for (let i = 0; i < 8; i++) await nextTick();
|
||||
}
|
||||
|
||||
async function mountDialog(config: ConnectionConfig) {
|
||||
const props = reactive({ open: true, editConfig: config });
|
||||
store.getConfig.mockReturnValue(config);
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup: () => () =>
|
||||
h(ConnectionDialog, {
|
||||
...props,
|
||||
"onUpdate:open": (value: boolean) => {
|
||||
props.open = value;
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
app.use(i18n);
|
||||
mountedApps.push(app);
|
||||
app.mount(container);
|
||||
await settle();
|
||||
return props;
|
||||
}
|
||||
|
||||
function findModeButton(label: string): HTMLButtonElement | undefined {
|
||||
return [...document.querySelectorAll("button")].find((button) => button.textContent?.trim() === label) as HTMLButtonElement | undefined;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
i18n.global.locale.value = "en";
|
||||
for (const fn of Object.values(backend)) fn.mockResolvedValue([]);
|
||||
backend.listenAgentInstallProgress.mockResolvedValue(() => {});
|
||||
store.updateConnection.mockResolvedValue(undefined);
|
||||
store.addConnection.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const app of mountedApps.splice(0)) app.unmount();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("ConnectionDialog Salesforce username-password mode", () => {
|
||||
it("switches to password mode and reveals the credential inputs", async () => {
|
||||
await mountDialog(salesforceConnection());
|
||||
|
||||
const passwordTab = findModeButton("Username & Password");
|
||||
expect(passwordTab, "password mode button should exist").toBeTruthy();
|
||||
expect(passwordTab!.disabled, "password mode button should be clickable").toBe(false);
|
||||
|
||||
passwordTab!.click();
|
||||
await settle();
|
||||
|
||||
expect(passwordTab!.getAttribute("aria-pressed")).toBe("true");
|
||||
const username = [...document.querySelectorAll("input")].find((input) => input.getAttribute("placeholder") === "user@example.com");
|
||||
expect(username, "username input should render in password mode").toBeTruthy();
|
||||
expect((username as HTMLInputElement).disabled).toBe(false);
|
||||
const signIn = findModeButton("Sign In");
|
||||
expect(signIn, "sign-in button should render in password mode").toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps the password mode button enabled while a browser OAuth authorize is not running", async () => {
|
||||
await mountDialog(salesforceConnection({ external_config: { auth: { mode: "oauth", environment: "sandbox", clientId: "consumer-key", authorizedAt: "2026-09-21T00:00:00.000Z" } } } as Partial<ConnectionConfig>));
|
||||
|
||||
const passwordTab = findModeButton("Username & Password");
|
||||
expect(passwordTab).toBeTruthy();
|
||||
expect(passwordTab!.disabled).toBe(false);
|
||||
passwordTab!.click();
|
||||
await settle();
|
||||
expect(passwordTab!.getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
|
||||
it("shows each mode only the guidance that applies to it", async () => {
|
||||
const bodyText = () => document.body.textContent ?? "";
|
||||
|
||||
// Token mode carries no Connected App at all: neither client-ID hint renders,
|
||||
// and Salesforce has no TLS toggle (the driver always speaks HTTPS).
|
||||
await mountDialog(salesforceConnection());
|
||||
expect(bodyText()).not.toContain("The Connected App consumer key.");
|
||||
expect(bodyText()).not.toContain("Create a Connected App in Salesforce Setup");
|
||||
expect(bodyText()).not.toContain("Enable encrypted connection");
|
||||
|
||||
const passwordTab = findModeButton("Username & Password");
|
||||
passwordTab!.click();
|
||||
await settle();
|
||||
expect(bodyText()).toContain("The Connected App consumer key.");
|
||||
expect(bodyText()).toContain("No callback URL needed");
|
||||
expect(bodyText()).not.toContain("Create a Connected App in Salesforce Setup");
|
||||
|
||||
const deviceTab = findModeButton("Device Code");
|
||||
deviceTab!.click();
|
||||
await settle();
|
||||
expect(bodyText()).toContain("If requesting the code fails");
|
||||
expect(bodyText()).not.toContain("No callback URL needed");
|
||||
});
|
||||
|
||||
it("reserves the callback-URL guidance for browser OAuth", async () => {
|
||||
await mountDialog(salesforceConnection({ external_config: { auth: { mode: "oauth", environment: "sandbox", clientId: "consumer-key" } } } as Partial<ConnectionConfig>));
|
||||
await settle();
|
||||
|
||||
const text = document.body.textContent ?? "";
|
||||
expect(text).toContain("Create a Connected App in Salesforce Setup");
|
||||
expect(text).not.toContain("The Connected App consumer key.");
|
||||
expect(text).not.toContain("If requesting the code fails");
|
||||
});
|
||||
});
|
||||
@@ -114,7 +114,7 @@ let initializingPrefillGeneration = 0;
|
||||
let componentUnmounted = false;
|
||||
let shownSessionError = "";
|
||||
|
||||
const sqlConnections = computed(() => store.connections.filter((connection) => !["redis", "mongodb", "elasticsearch", "easysearch", "meilisearch", "solr", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos"].includes(connection.db_type)));
|
||||
const sqlConnections = computed(() => store.connections.filter((connection) => !["redis", "mongodb", "elasticsearch", "easysearch", "meilisearch", "solr", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos", "salesforce"].includes(connection.db_type)));
|
||||
const selectedSourceTableNames = computed(() => sourceTables.value.filter((table) => selectedSourceTables.value.has(table)));
|
||||
const isBatchCompare = computed(() => selectedSourceTableNames.value.length > 1);
|
||||
// Bridge the shared TableMultiSelect `string[]` v-model with the Set-based selection store.
|
||||
|
||||
@@ -84,7 +84,7 @@ const targetSchemas = ref<string[]>([]);
|
||||
const sourceDbVersion = ref<string | null>(null);
|
||||
const targetDbVersion = ref<string | null>(null);
|
||||
|
||||
const sqlConnections = computed(() => store.connections.filter((c: any) => !["mongodb", "redis", "elasticsearch", "easysearch", "meilisearch", "solr", "etcd", "zookeeper", "consul", "mq", "nacos"].includes(c.db_type)));
|
||||
const sqlConnections = computed(() => store.connections.filter((c: any) => !["mongodb", "redis", "elasticsearch", "easysearch", "meilisearch", "solr", "etcd", "zookeeper", "consul", "mq", "nacos", "salesforce"].includes(c.db_type)));
|
||||
|
||||
const sourceConfig = computed(() => store.getConfig(props.sourceConnectionId));
|
||||
const targetConfig = computed(() => store.getConfig(props.targetConnectionId));
|
||||
|
||||
@@ -3471,6 +3471,7 @@ async function saveMcpPolicy(
|
||||
databaseScope: "all" | "selected" | "none";
|
||||
allowedDatabases: string[];
|
||||
databasePolicies: { databaseName: string; readOnly: boolean; allowDangerousSql: boolean }[];
|
||||
allowSalesforceDml: boolean;
|
||||
}[];
|
||||
groupPolicies?: McpGroupPolicy[];
|
||||
queryTimeoutSecs?: number | null;
|
||||
@@ -3659,6 +3660,14 @@ function onMcpGroupExecutionModeChange(groupId: string, mode: McpConnectionExecu
|
||||
void saveMcpPolicy({ groupPolicies });
|
||||
}
|
||||
|
||||
// A connection rule is only worth persisting when it actually changes something: an
|
||||
// explicit execution mode, a narrowed database scope, a per-database override, or the
|
||||
// Salesforce DML opt-in. Rules that merely restate the inherited defaults are dropped so
|
||||
// the stored policy stays readable and keeps following later global changes.
|
||||
function mcpConnectionPolicyIsMeaningful(rule: McpConnectionPolicy): boolean {
|
||||
return rule.executionModeConfigured || rule.databaseScope !== "all" || rule.databasePolicies.length > 0 || rule.allowSalesforceDml;
|
||||
}
|
||||
|
||||
function onMcpConnectionExecutionModeChange(connectionId: string, mode: McpConnectionExecutionMode | "inherit") {
|
||||
if (mode === "high_risk_write" && !window.confirm(t("settings.mcpExecutionModeHighRiskConfirm"))) return;
|
||||
const existing = settingsStore.mcpGlobalPolicy.connectionPolicies.find((item) => item.connectionId === connectionId);
|
||||
@@ -3682,8 +3691,38 @@ function onMcpConnectionExecutionModeChange(connectionId: string, mode: McpConne
|
||||
databaseScope: existing?.databaseScope ?? ("all" as const),
|
||||
allowedDatabases: existing?.allowedDatabases ?? [],
|
||||
databasePolicies: selectedMode.databasePolicies,
|
||||
// Switching a connection to read-only silently revokes the DML opt-in, matching
|
||||
// the backend's ceiling: an agent must never keep a write path it lost.
|
||||
allowSalesforceDml: (existing?.allowSalesforceDml ?? false) && !selectedMode.readOnly,
|
||||
};
|
||||
if (next.executionModeConfigured || next.databaseScope !== "all" || next.databasePolicies.length > 0) rules.push(next);
|
||||
if (mcpConnectionPolicyIsMeaningful(next)) rules.push(next);
|
||||
void saveMcpPolicy({ connectionPolicies: rules });
|
||||
}
|
||||
|
||||
function onMcpConnectionSalesforceDmlChange(connectionId: string, allowed: boolean) {
|
||||
const existing = settingsStore.mcpGlobalPolicy.connectionPolicies.find((item) => item.connectionId === connectionId);
|
||||
if (allowed) {
|
||||
// Read-only is a hard ceiling on the server too, so letting the box stay checked
|
||||
// here would only advertise a write path that every prepare call then refuses.
|
||||
const effectiveMode = mcpEffectiveExecutionMode(mcpInheritedGroupExecutionMode(connectionId), mcpConnectionExecutionMode(connectionId), "inherit");
|
||||
if (effectiveMode === "read_only") {
|
||||
toast(t("settings.mcpConnectionPolicyAllowSalesforceDmlReadOnlyBlocked"), 5000);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const rules = settingsStore.mcpGlobalPolicy.connectionPolicies.filter((item) => item.connectionId !== connectionId);
|
||||
const next: McpConnectionPolicy = {
|
||||
connectionId,
|
||||
readOnly: existing?.readOnly ?? false,
|
||||
allowDangerousSql: existing?.allowDangerousSql ?? false,
|
||||
executionModeConfigured: existing?.executionModeConfigured ?? false,
|
||||
executionModePolicyVersion: existing?.executionModePolicyVersion ?? null,
|
||||
databaseScope: existing?.databaseScope ?? "all",
|
||||
allowedDatabases: existing?.allowedDatabases ?? [],
|
||||
databasePolicies: existing?.databasePolicies ?? [],
|
||||
allowSalesforceDml: allowed,
|
||||
};
|
||||
if (mcpConnectionPolicyIsMeaningful(next)) rules.push(next);
|
||||
void saveMcpPolicy({ connectionPolicies: rules });
|
||||
}
|
||||
|
||||
@@ -3703,7 +3742,7 @@ function onMcpDatabaseExecutionModeChange(connectionId: string, databaseName: st
|
||||
databasePolicies,
|
||||
};
|
||||
const rules = settingsStore.mcpGlobalPolicy.connectionPolicies.filter((item) => item.connectionId !== connectionId);
|
||||
if (next.executionModeConfigured || next.databaseScope !== "all" || next.databasePolicies.length > 0) rules.push(next);
|
||||
if (mcpConnectionPolicyIsMeaningful(next)) rules.push(next);
|
||||
void saveMcpPolicy({ connectionPolicies: rules });
|
||||
}
|
||||
|
||||
@@ -9938,6 +9977,7 @@ LIMIT 100;</pre
|
||||
@update:scope="onMcpResourceScopeChange"
|
||||
@set:group-policy="onMcpGroupExecutionModeChange"
|
||||
@set:connection-policy="onMcpConnectionExecutionModeChange"
|
||||
@set:connection-salesforce-dml="onMcpConnectionSalesforceDmlChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -139,7 +139,9 @@ import type { SqlCompletionColumn, SqlCompletionContext, SqlCompletionReferenced
|
||||
const props = defineProps<QueryEditorProps>();
|
||||
|
||||
function sqlBehaviorDialect(): "mysql" | "postgres" | "sqlserver" | undefined {
|
||||
return props.syntaxDialect === "clickhouse" ? props.dialect : (props.syntaxDialect ?? props.dialect);
|
||||
// clickhouse and soql ride the SQL editor but have no matching behavior dialect;
|
||||
// fall back to the connection's dialect (undefined for Salesforce).
|
||||
return props.syntaxDialect === "clickhouse" || props.syntaxDialect === "soql" ? props.dialect : (props.syntaxDialect ?? props.dialect);
|
||||
}
|
||||
|
||||
function queryEditorSelectionLanguage(): "sql" | "text" {
|
||||
|
||||
@@ -9,9 +9,10 @@ import { type ElasticsearchCompletionItem } from "@/lib/elasticsearch/elasticsea
|
||||
import { type MongoCompletionItem } from "@/lib/mongo/mongoCompletion";
|
||||
import { batchColumnSelectionColumnList, batchColumnSelectionInsertReplacement, batchColumnSelectionReplaceTo, isBatchColumnSelectionCompletionActive } from "@/lib/editor/batchColumnSelection";
|
||||
import { type RedisCompletionItem } from "@/lib/redis/redisCompletion";
|
||||
import { type SoqlCompletionItem } from "@/lib/soql/soqlCompletion";
|
||||
import type { SqlCompletionItem } from "@/lib/sql/sqlCompletion";
|
||||
|
||||
export type QueryCompletionItem = SqlCompletionItem | ElasticsearchCompletionItem | RedisCompletionItem | MongoCompletionItem;
|
||||
export type QueryCompletionItem = SqlCompletionItem | ElasticsearchCompletionItem | RedisCompletionItem | MongoCompletionItem | SoqlCompletionItem;
|
||||
|
||||
export interface BatchColumnSelectionActionItem {
|
||||
label: string;
|
||||
|
||||
@@ -25,6 +25,7 @@ import { sqlCompletionContextFromSemantic } from "@/lib/sql/semantic/completion"
|
||||
import { buildSqlSemanticModel } from "@/lib/sql/semantic/model";
|
||||
import { buildElasticsearchCompletionItemsFromContext, elasticsearchCompletionNeedsFields, getElasticsearchCompletionContext, getElasticsearchCompletionResultValidFor, shouldAutoOpenElasticsearchCompletion, type ElasticsearchCompletionField } from "@/lib/elasticsearch/elasticsearchCompletion";
|
||||
import { buildMongoCompletionItemsFromContext, getMongoCompletionContext, getMongoCompletionResultValidFor, mongoCompletionNeedsCollections, mongoCompletionNeedsFields, shouldAutoOpenMongoCompletion } from "@/lib/mongo/mongoCompletion";
|
||||
import { buildSoqlCompletionItems, getSoqlCompletionContext, getSoqlCompletionResultValidFor, resolveSoqlFieldCandidates, resolveSoqlValueField, shouldAutoOpenSoqlCompletion, soqlCompletionNeedsObjects, type SoqlCompletionField, type SoqlCompletionObject } from "@/lib/soql/soqlCompletion";
|
||||
import {
|
||||
buildSqlServerUseDatabaseCompletionItems,
|
||||
mergeSqlCompletionQualifierNames,
|
||||
@@ -361,7 +362,16 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
|
||||
}
|
||||
|
||||
function shouldInsertSqlCompletionSpace(): boolean {
|
||||
return props.databaseType !== "mongodb" && props.databaseType !== "redis" && props.databaseType !== "elasticsearch" && props.databaseType !== "easysearch" && props.databaseType !== "meilisearch" && props.databaseType !== "solr" && props.databaseType !== "victoriametrics";
|
||||
return (
|
||||
props.databaseType !== "mongodb" &&
|
||||
props.databaseType !== "redis" &&
|
||||
props.databaseType !== "elasticsearch" &&
|
||||
props.databaseType !== "easysearch" &&
|
||||
props.databaseType !== "meilisearch" &&
|
||||
props.databaseType !== "solr" &&
|
||||
props.databaseType !== "victoriametrics" &&
|
||||
props.databaseType !== "salesforce"
|
||||
);
|
||||
}
|
||||
|
||||
// Snippet expansion normally follows from the item type; a provider can also
|
||||
@@ -591,6 +601,59 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
|
||||
};
|
||||
}
|
||||
|
||||
async function provideSoqlCompletions(currentState: import("@codemirror/state").EditorState, position: number, explicit: boolean) {
|
||||
if (!props.connectionId) return null;
|
||||
const epoch = ++completionEpoch;
|
||||
const fullDoc = currentState.doc.toString();
|
||||
if (!explicit && !shouldAutoOpenSoqlCompletion(fullDoc, position)) return null;
|
||||
|
||||
const completionContext = getSoqlCompletionContext(fullDoc, position);
|
||||
if (completionContext.mode === "none") return null;
|
||||
// The Salesforce backend ignores the database parameter for metadata (the whole
|
||||
// org is one synthesized database), so completion must not gate on props.database
|
||||
// being populated — a restored/toolbar-opened tab can carry an empty database
|
||||
// while the connection works fine. Pass it through (possibly "") and let the
|
||||
// store/backend resolve it.
|
||||
const database = props.database ?? "";
|
||||
|
||||
let objects: SoqlCompletionObject[] = [];
|
||||
let fields: SoqlCompletionField[] = [];
|
||||
let valueField: SoqlCompletionField | null = null;
|
||||
|
||||
// Field loader backed by the store (which reads the backend describe cache), so
|
||||
// relationship traversal costs at most one describe per distinct sObject.
|
||||
const loadFields = (objectName: string) => connectionStore.listSoqlCompletionFields(props.connectionId!, database, objectName);
|
||||
|
||||
try {
|
||||
if (soqlCompletionNeedsObjects(completionContext.mode)) {
|
||||
objects = await connectionStore.listSoqlCompletionObjects(props.connectionId, database);
|
||||
}
|
||||
if (completionContext.mode === "field") {
|
||||
fields = await resolveSoqlFieldCandidates(completionContext, loadFields);
|
||||
}
|
||||
if (completionContext.mode === "value") {
|
||||
valueField = await resolveSoqlValueField(completionContext, loadFields);
|
||||
}
|
||||
} catch (error) {
|
||||
// Metadata load failed (offline, describe error, no cache yet): fall back to
|
||||
// keyword/function-only completion. Logged so a persistent empty field list is
|
||||
// diagnosable instead of silently looking like "custom fields are excluded".
|
||||
console.debug("[soql-completion] metadata load failed", error);
|
||||
objects = [];
|
||||
fields = [];
|
||||
valueField = null;
|
||||
}
|
||||
if (epoch !== completionEpoch) return null;
|
||||
|
||||
const items = buildSoqlCompletionItems(completionContext, { objects, fields, valueField });
|
||||
if (items.length === 0) return null;
|
||||
return {
|
||||
from: completionContext.from,
|
||||
options: items.map((item) => completionOptionForItem(item)),
|
||||
validFor: getSoqlCompletionResultValidFor(completionContext),
|
||||
};
|
||||
}
|
||||
|
||||
async function provideSqlCompletions(context: CompletionContext) {
|
||||
const currentState = context.state;
|
||||
const position = context.pos;
|
||||
@@ -612,6 +675,9 @@ export function useQueryEditorCompletion(options: QueryEditorCompletionOptions)
|
||||
return provideRedisCompletions(currentState, position, explicit);
|
||||
}
|
||||
if (props.databaseType === "victoriametrics") return null;
|
||||
if (props.databaseType === "salesforce") {
|
||||
return provideSoqlCompletions(currentState, position, explicit);
|
||||
}
|
||||
const hasDatabase = props.database != null;
|
||||
const sequenceLiteralContext = getPostgresSequenceLiteralCompletionContext(fullDoc, position, props.databaseType);
|
||||
const databaseLinkContext = oracleDatabaseLinkCompletionContext(fullDoc, position, props.databaseType);
|
||||
|
||||
@@ -195,7 +195,7 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions
|
||||
}
|
||||
|
||||
function shouldSkipSqlSemanticDiagnostics() {
|
||||
return props.databaseType === "victoriametrics" || (props.databaseType !== "redis" && props.databaseType !== "mongodb" && !settingsStore.editorSettings.sqlSemanticDiagnosticsEnabled);
|
||||
return props.databaseType === "victoriametrics" || props.databaseType === "salesforce" || (props.databaseType !== "redis" && props.databaseType !== "mongodb" && !settingsStore.editorSettings.sqlSemanticDiagnosticsEnabled);
|
||||
}
|
||||
|
||||
function rangesOverlap(left: { from: number; to: number }, right: { from: number; to: number }): boolean {
|
||||
@@ -337,7 +337,7 @@ export function useQueryEditorDiagnostics(options: QueryEditorDiagnosticsOptions
|
||||
setSemanticDiagnostics([]);
|
||||
return;
|
||||
}
|
||||
if (props.databaseType === "elasticsearch" || props.databaseType === "easysearch" || props.databaseType === "meilisearch" || props.databaseType === "solr" || props.databaseType === "victoriametrics") {
|
||||
if (props.databaseType === "elasticsearch" || props.databaseType === "easysearch" || props.databaseType === "meilisearch" || props.databaseType === "solr" || props.databaseType === "victoriametrics" || props.databaseType === "salesforce") {
|
||||
setSemanticDiagnostics([]);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ const exportElapsedText = computed(() => {
|
||||
return formatDataTransferDuration((exportFinishedAt.value ?? currentTime.value) - exportStartedAt.value);
|
||||
});
|
||||
|
||||
const sqlConnections = computed(() => store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "easysearch", "meilisearch", "solr", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos"].includes(c.db_type)));
|
||||
const sqlConnections = computed(() => store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "easysearch", "meilisearch", "solr", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos", "salesforce"].includes(c.db_type)));
|
||||
|
||||
const canExport = computed(() => {
|
||||
const hasContent = includeStructure.value || includeData.value || includeObjects.value;
|
||||
|
||||
@@ -119,6 +119,8 @@ import {
|
||||
hiveTablePropertiesIndicateTransactional,
|
||||
isClickHouseExistingRowReadonlyColumn,
|
||||
isHiddenGridColumn,
|
||||
isSalesforceExistingRowReadonlyColumn,
|
||||
isSalesforceNewRowReadonlyColumn,
|
||||
isTdengineExistingRowReadonlyColumn,
|
||||
shouldIncludeSyntheticRowId,
|
||||
} from "@/lib/table/tableEditing";
|
||||
@@ -369,6 +371,7 @@ import { useDataGridColumnFormatter } from "@/composables/useDataGridColumnForma
|
||||
import { useDataGridTableMetadataLoaders } from "@/composables/useDataGridTableMetadataLoaders";
|
||||
import { DATA_GRID_SERVER_COLUMN_FILTER_LIMIT, useDataGridColumnFilters } from "@/composables/useDataGridColumnFilters";
|
||||
import { useDataGridLargeValues } from "@/composables/useDataGridLargeValues";
|
||||
import { useSalesforceSaveConfirmation } from "@/composables/useSalesforceSaveConfirmation";
|
||||
|
||||
const SqlPreviewPanel = defineAsyncComponent(() => import("@/components/editor/SqlPreviewPanel.vue"));
|
||||
const ImagePreviewDialog = defineAsyncComponent(() => import("@/components/grid/ImagePreviewDialog.vue"));
|
||||
@@ -3797,6 +3800,57 @@ async function refreshSavedRows(request: { dirtyRows: ReadonlyMap<number, Readon
|
||||
return true;
|
||||
}
|
||||
|
||||
// Salesforce applies one REST call per record with no transaction, so every grid
|
||||
// save is reviewed here first. The identity lines are advisory only — Salesforce
|
||||
// enforces the real object/field permissions, and a failed identity lookup just
|
||||
// omits the line rather than blocking the write.
|
||||
const {
|
||||
open: salesforceSaveConfirmOpen,
|
||||
updates: salesforceSaveConfirmUpdates,
|
||||
inserts: salesforceSaveConfirmInserts,
|
||||
deletes: salesforceSaveConfirmDeletes,
|
||||
total: salesforceSaveConfirmTotal,
|
||||
targetLabel: salesforceSaveConfirmTarget,
|
||||
statements: salesforceSaveConfirmStatements,
|
||||
request: requestSalesforceSaveConfirmation,
|
||||
confirm: confirmSalesforceSave,
|
||||
} = useSalesforceSaveConfirmation();
|
||||
const isSalesforceGrid = computed(() => resolvedDatabaseType.value === "salesforce");
|
||||
const salesforceIdentity = computed(() => (isSalesforceGrid.value && props.connectionId ? connectionStore.salesforceCurrentUser(props.connectionId) : null));
|
||||
const salesforceIdentityLabel = computed(() => {
|
||||
const identity = salesforceIdentity.value;
|
||||
if (!identity) return "";
|
||||
return identity.username || identity.name || identity.email;
|
||||
});
|
||||
const salesforceSaveConfirmSummary = computed(() => {
|
||||
const parts: string[] = [];
|
||||
if (salesforceSaveConfirmUpdates.value > 0) parts.push(t("grid.salesforceSaveUpdates", { count: salesforceSaveConfirmUpdates.value }));
|
||||
if (salesforceSaveConfirmInserts.value > 0) parts.push(t("grid.salesforceSaveInserts", { count: salesforceSaveConfirmInserts.value }));
|
||||
if (salesforceSaveConfirmDeletes.value > 0) parts.push(t("grid.salesforceSaveDeletes", { count: salesforceSaveConfirmDeletes.value }));
|
||||
return parts.join(" · ");
|
||||
});
|
||||
// The save dialog names the profile alongside the user: writability comes from
|
||||
// the profile's FLS plus record sharing, not from the admin flag alone.
|
||||
const salesforceIdentityProfile = computed(() => {
|
||||
const profileName = salesforceIdentity.value?.profileName;
|
||||
return profileName ? t("grid.salesforceSaveProfile", { name: profileName }) : t("toolbar.salesforceIdentityUnknownProfile");
|
||||
});
|
||||
const salesforceSaveConfirmDetails = computed(() => {
|
||||
const lines = [salesforceSaveConfirmSummary.value, t("grid.salesforceSaveTarget", { object: salesforceSaveConfirmTarget.value || t("grid.salesforceSaveUnknownObject") })];
|
||||
if (salesforceIdentity.value) {
|
||||
const identity = { user: salesforceIdentityLabel.value, profile: salesforceIdentityProfile.value };
|
||||
if (salesforceIdentity.value.isAdmin === true) lines.push(t("grid.salesforceSaveAdminIdentity", identity));
|
||||
else if (salesforceIdentity.value.isAdmin === false) lines.push(t("grid.salesforceSaveNonAdminIdentity", identity));
|
||||
else lines.push(t("grid.salesforceSaveUnknownRights", identity));
|
||||
}
|
||||
return lines.filter((line) => !!line).join("\n");
|
||||
});
|
||||
const salesforceSaveConfirmSql = computed(() => salesforceSaveConfirmStatements.value.join("\n"));
|
||||
watch(salesforceSaveConfirmOpen, (isOpen) => {
|
||||
if (!isOpen || !props.connectionId) return;
|
||||
void connectionStore.loadSalesforceCurrentUser(props.connectionId);
|
||||
});
|
||||
|
||||
const editor = useDataGridEditor({
|
||||
result: computed(() => props.result),
|
||||
editable: computed(() => props.editable),
|
||||
@@ -3821,6 +3875,8 @@ const editor = useDataGridEditor({
|
||||
rowStatusFilter,
|
||||
dataGridQuickEntryEnabled: computed(() => settingsStore.editorSettings.dataGridQuickEntry),
|
||||
confirmDangerousRowDeletion: computed(() => settingsStore.editorSettings.confirmDangerousSqlExecution),
|
||||
// Only Salesforce opts in: its writes cannot be rolled back once issued.
|
||||
confirmSaveRequest: computed(() => (isSalesforceGrid.value ? requestSalesforceSaveConfirmation : undefined)),
|
||||
includeDatabaseNameInSaveSql: computed(() => settingsStore.editorSettings.generateSqlIncludeDatabaseName),
|
||||
initialEditColumn: firstVisibleColumnIndex,
|
||||
cellEditorText: cellEditorTextForValue,
|
||||
@@ -4018,10 +4074,14 @@ function canEditCellItem(item: RowItem | undefined, columnIndex: number): boolea
|
||||
if (isSavingNewRow(item)) return false;
|
||||
const column = props.result.columns[columnIndex] ?? "";
|
||||
if (customReadonlyColumns.value.has(column.toLowerCase())) return false;
|
||||
if (!item?.isNew && !item?.isDraft) {
|
||||
const sourceColumn = props.sourceColumns?.[columnIndex] ?? column;
|
||||
const sourceColumn = props.sourceColumns?.[columnIndex] ?? column;
|
||||
if (item?.isNew || item?.isDraft) {
|
||||
// A new Salesforce record cannot carry non-createable fields (Id, CreatedDate, …).
|
||||
if (isSalesforceNewRowReadonlyColumn(props.databaseType, sourceColumn, props.tableMeta?.columns ?? [])) return false;
|
||||
} else {
|
||||
if (isClickHouseExistingRowReadonlyColumn(props.databaseType, sourceColumn, props.tableMeta?.primaryKeys ?? [], props.tableMeta?.columns ?? [])) return false;
|
||||
if (isTdengineExistingRowReadonlyColumn(props.databaseType, column, props.tableMeta?.columns ?? [])) return false;
|
||||
if (isSalesforceExistingRowReadonlyColumn(props.databaseType, sourceColumn, props.tableMeta?.primaryKeys ?? [], props.tableMeta?.columns ?? [])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -14211,6 +14271,18 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
|
||||
:close-on-confirm="false"
|
||||
@confirm="confirmDropAllMongoIndexes"
|
||||
/>
|
||||
<!-- Salesforce applies each record with its own REST call and cannot roll back, so the
|
||||
save is reviewed here first. Cancel (or closing) denies it and keeps the edits staged. -->
|
||||
<DangerConfirmDialog
|
||||
v-model:open="salesforceSaveConfirmOpen"
|
||||
:title="t('grid.salesforceSaveConfirmTitle')"
|
||||
:message="t('grid.salesforceSaveConfirmMessage', { count: salesforceSaveConfirmTotal })"
|
||||
:details-text="salesforceSaveConfirmDetails"
|
||||
:sql="salesforceSaveConfirmSql"
|
||||
:confirm-label="t('grid.salesforceSaveConfirm')"
|
||||
:close-on-confirm="false"
|
||||
@confirm="confirmSalesforceSave"
|
||||
/>
|
||||
<ImagePreviewDialog v-if="imagePreviewMounted" v-model:open="imagePreviewOpen" :src="imagePreviewSrc" :title="imagePreviewTitle" />
|
||||
<component v-if="previewDialogOpen && previewDialogConfig" :is="previewDialogConfig.component" v-model:open="previewDialogOpen" v-bind="previewDialogConfig.props" />
|
||||
<ExportProgressDialog
|
||||
|
||||
@@ -123,6 +123,7 @@ const assetIcons: Record<string, string> = {
|
||||
jdbcx: "jdbcx",
|
||||
mqtt: "mqtt",
|
||||
dolt: "dolt",
|
||||
salesforce: "salesforce",
|
||||
};
|
||||
|
||||
const normalizedType = computed(() => (props.dbType || "").toLowerCase().replace(/[\s-]+/g, "_"));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onUnmounted, ref, watch, watchEffect } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Play, CirclePlay, Loader2, Square, Database, Check, Table2, AlignLeft, GitBranch, Save, FolderOpen, X, Shield, Download, RotateCcw, AlertTriangle, ClipboardPaste, Minimize2, SpellCheck2, Layers, MoreHorizontal, BetweenVerticalStart, Eye, WrapText, RefreshCw } from "@lucide/vue";
|
||||
import { Play, CirclePlay, Loader2, Square, Database, Check, Table2, AlignLeft, GitBranch, Save, FolderOpen, X, Shield, Download, RotateCcw, AlertTriangle, ClipboardPaste, Minimize2, SpellCheck2, Layers, MoreHorizontal, BetweenVerticalStart, Eye, WrapText, RefreshCw, UserRound } from "@lucide/vue";
|
||||
import { supportsInsertValueHints } from "@/lib/editor/codemirrorInsertValueHints";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||
@@ -215,6 +215,38 @@ const showConnectionProductionBadge = computed(() => activeProductionContext.val
|
||||
const showDatabaseProductionBadge = computed(() => activeProductionContext.value.reason === "database");
|
||||
const activeConnectionValue = computed(() => props.activeConnection?.id || "");
|
||||
const activeSchemaValue = computed(() => props.activeTab.schema || "");
|
||||
// Salesforce identity badge. A Salesforce tab talks to one live org and every grid
|
||||
// save is a real REST write, so the signed-in user stays visible next to the
|
||||
// connection controls. Advisory only: Salesforce enforces the actual permissions.
|
||||
const isSalesforceTab = computed(() => props.activeConnection?.db_type === "salesforce");
|
||||
const salesforceIdentity = computed(() => (props.activeConnection ? connectionStore.salesforceCurrentUser(props.activeConnection.id) : null));
|
||||
const salesforceIdentityLabel = computed(() => {
|
||||
const identity = salesforceIdentity.value;
|
||||
if (!identity) return "";
|
||||
const user = identity.username || identity.name || identity.email;
|
||||
const org = identity.orgName || identity.organizationId;
|
||||
if (user && org) return `${user} @ ${org}`;
|
||||
return user || org;
|
||||
});
|
||||
const salesforceIdentityTooltip = computed(() => {
|
||||
const identity = salesforceIdentity.value;
|
||||
if (!identity) return "";
|
||||
const lines = [identity.name || identity.username || identity.email, identity.profileName || t("toolbar.salesforceIdentityUnknownProfile")];
|
||||
if (identity.isAdmin === true) lines.push(t("toolbar.salesforceIdentityAdmin"));
|
||||
else if (identity.isAdmin === false) lines.push(t("toolbar.salesforceIdentityNonAdmin"));
|
||||
else lines.push(t("toolbar.salesforceIdentityUnknownRights"));
|
||||
return lines.filter((line) => !!line).join("\n");
|
||||
});
|
||||
watch(
|
||||
() => [isSalesforceTab.value, props.activeConnection?.id ?? "", connectionStore.connectedIds.has(props.activeConnection?.id ?? "")] as const,
|
||||
([salesforce, connectionId, connected]) => {
|
||||
// Never connect on a tab switch: only resolve the identity once the user has
|
||||
// established the connection themselves. A failed lookup hides the badge.
|
||||
if (!salesforce || !connected || !connectionId) return;
|
||||
void connectionStore.loadSalesforceCurrentUser(connectionId);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
const supportsExplain = computed(() => {
|
||||
const dbType = props.activeConnection?.db_type;
|
||||
return (
|
||||
@@ -233,7 +265,8 @@ const supportsExplain = computed(() => {
|
||||
dbType !== "consul" &&
|
||||
dbType !== "mq" &&
|
||||
dbType !== "nacos" &&
|
||||
dbType !== "victoriametrics"
|
||||
dbType !== "victoriametrics" &&
|
||||
dbType !== "salesforce"
|
||||
);
|
||||
});
|
||||
const isSingleDb = computed(() => isSingleDatabase(props.activeConnection?.db_type));
|
||||
@@ -285,7 +318,7 @@ const sqlSemanticDiagnosticsEnabled = computed(() => settingsStore.editorSetting
|
||||
const sqlSemanticDiagnosticsToggleTooltip = computed(() => (sqlSemanticDiagnosticsEnabled.value ? t("toolbar.sqlSemanticDiagnosticsToggleOn") : t("toolbar.sqlSemanticDiagnosticsToggleOff")));
|
||||
const supportsSqlSemanticDiagnosticsToggle = computed(() => {
|
||||
const dbType = props.activeConnection?.db_type;
|
||||
return dbType !== "redis" && dbType !== "victoriametrics";
|
||||
return dbType !== "redis" && dbType !== "victoriametrics" && dbType !== "salesforce";
|
||||
});
|
||||
function toggleSqlSemanticDiagnostics() {
|
||||
settingsStore.updateEditorSettings({
|
||||
@@ -926,6 +959,11 @@ async function changeCatalog(selectedCatalog: string) {
|
||||
<Table2 class="h-3.5 w-3.5 shrink-0" />
|
||||
<span class="truncate">{{ activeTab.tableMeta.columns.length }} {{ t("tree.columns") }}</span>
|
||||
</div>
|
||||
<div v-if="salesforceIdentity" data-testid="salesforce-identity-badge" class="ml-2 inline-flex max-w-[20rem] shrink-0 items-center gap-1 rounded border border-border bg-muted/30 px-2 py-0.5 font-medium text-muted-foreground" :title="salesforceIdentityTooltip">
|
||||
<Shield v-if="salesforceIdentity.isAdmin === true" class="h-3.5 w-3.5 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
<UserRound v-else class="h-3.5 w-3.5 shrink-0" />
|
||||
<span class="truncate">{{ salesforceIdentityLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="autoCommitTxnRolledBack" data-auto-commit-txn-rolled-back class="flex items-center gap-2 px-3 py-1 text-xs bg-amber-500/10 text-amber-700 dark:text-amber-300 border-b border-amber-500/20">
|
||||
<AlertTriangle class="h-3.5 w-3.5 shrink-0" />
|
||||
|
||||
@@ -89,6 +89,7 @@ function policyFor(connectionId: string): McpConnectionPolicy {
|
||||
databaseScope: "all",
|
||||
allowedDatabases: [],
|
||||
databasePolicies: [],
|
||||
allowSalesforceDml: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ const props = withDefaults(
|
||||
allowedGroupIds: readonly string[];
|
||||
allowedConnectionIds: readonly string[] | null;
|
||||
groupPolicies?: readonly { groupId: string; readOnly: boolean; allowDangerousSql: boolean }[];
|
||||
connectionPolicies?: readonly { connectionId: string; readOnly: boolean; allowDangerousSql: boolean; executionModeConfigured?: boolean }[];
|
||||
connectionPolicies?: readonly { connectionId: string; readOnly: boolean; allowDangerousSql: boolean; executionModeConfigured?: boolean; allowSalesforceDml?: boolean }[];
|
||||
disabled?: boolean;
|
||||
busy?: boolean;
|
||||
}>(),
|
||||
@@ -48,6 +48,7 @@ const emit = defineEmits<{
|
||||
"update:scope": [value: { allowedGroupIds: string[]; allowedConnectionIds: string[] | null }];
|
||||
"set:group-policy": [groupId: string, mode: ExecutionMode | "inherit"];
|
||||
"set:connection-policy": [connectionId: string, mode: ExecutionMode | "inherit"];
|
||||
"set:connection-salesforce-dml": [connectionId: string, allowed: boolean];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -264,6 +265,18 @@ function groupPolicyMode(groupId: string): ExecutionMode | "inherit" {
|
||||
function connectionPolicyMode(connectionId: string): ExecutionMode | "inherit" {
|
||||
return policyMode(props.connectionPolicies?.find((policy) => policy.connectionId === connectionId));
|
||||
}
|
||||
|
||||
// Salesforce writes are the one permission that cannot be expressed as an execution
|
||||
// mode: SOQL has no write verb, so a DML toggle is the only gate an agent can be given.
|
||||
// It is per-connection because it is per-org, and it stays hidden for every other driver.
|
||||
function isSalesforceConnection(node: ResourceNode): boolean {
|
||||
return node.type === "connection" && node.connection.db_type === "salesforce";
|
||||
}
|
||||
|
||||
function connectionAllowsSalesforceDml(connectionId: string): boolean {
|
||||
const policy = props.connectionPolicies?.find((item) => item.connectionId === connectionId);
|
||||
return policy?.allowSalesforceDml === true && policy.readOnly !== true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -349,6 +362,23 @@ function connectionPolicyMode(connectionId: string): ExecutionMode | "inherit" {
|
||||
<SelectItem value="high_risk_write">{{ t("settings.mcpConnectionPolicyHighRiskWrite") }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<label
|
||||
v-if="isSalesforceConnection(node) && (explicitConnectionIds.has(node.id) || Boolean(selectedAncestor(node)))"
|
||||
class="flex h-7 shrink-0 cursor-pointer items-center gap-1 rounded border bg-background px-1.5 text-[11px]"
|
||||
:class="connectionAllowsSalesforceDml(node.id) ? 'border-primary/50 text-foreground' : 'text-muted-foreground'"
|
||||
:title="t('settings.mcpConnectionPolicyAllowSalesforceDmlHint')"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="size-3"
|
||||
:checked="connectionAllowsSalesforceDml(node.id)"
|
||||
:disabled="disabled || busy"
|
||||
:aria-label="t('settings.mcpConnectionPolicyAllowSalesforceDml')"
|
||||
@click.stop
|
||||
@change="emit('set:connection-salesforce-dml', node.id, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
{{ t("settings.mcpConnectionPolicyAllowSalesforceDml") }}
|
||||
</label>
|
||||
<Badge v-if="selectedAncestor(node)" variant="secondary" class="shrink-0 rounded font-normal">{{ t("settings.mcpResourceScopeInherited", { group: groupLabel(selectedAncestor(node)!) }) }}</Badge>
|
||||
<Badge v-else-if="node.type === 'group' && selectedGroupIds.has(node.id)" variant="outline" class="shrink-0 rounded font-normal">{{ t("settings.mcpResourceScopeDynamic") }}</Badge>
|
||||
<Badge v-else-if="node.type === 'group' && groupHasSelectedDescendant(node)" variant="secondary" class="shrink-0 rounded font-normal">{{ t("settings.mcpResourceScopePartial") }}</Badge>
|
||||
|
||||
@@ -90,6 +90,7 @@ function emptyPolicy(connectionId: string, scope: McpConnectionPolicy["databaseS
|
||||
databaseScope: scope,
|
||||
allowedDatabases: [],
|
||||
databasePolicies: [],
|
||||
allowSalesforceDml: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+69
-1
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { dispatch, findOne, mountComponent } from "@/components/grid/__tests__/vueHostHarness";
|
||||
import { dispatch, findAll, findOne, mountComponent } from "@/components/grid/__tests__/vueHostHarness";
|
||||
import type { ConnectionConfig, SidebarLayout } from "@/types/database";
|
||||
|
||||
vi.mock("vue-i18n", () => ({
|
||||
@@ -44,6 +44,12 @@ function connection(id: string): ConnectionConfig {
|
||||
};
|
||||
}
|
||||
|
||||
const DML_LABEL = "settings.mcpConnectionPolicyAllowSalesforceDml";
|
||||
|
||||
function salesforceDmlCheckboxes(root: ReturnType<typeof mountComponent>["root"]) {
|
||||
return findAll(root, (node) => node.type === "input" && node.props["aria-label"] === DML_LABEL);
|
||||
}
|
||||
|
||||
const layout: SidebarLayout = {
|
||||
groups: [{ id: "g1", name: "Group 1", collapsed: false }],
|
||||
order: [
|
||||
@@ -169,3 +175,65 @@ describe("McpResourceScopePicker", () => {
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("McpResourceScopePicker Salesforce DML opt-in", () => {
|
||||
const sfdc = { ...connection("c2"), db_type: "salesforce" } as unknown as ConnectionConfig;
|
||||
|
||||
function mountWithSalesforce(props: Record<string, unknown> = {}) {
|
||||
const setDml = vi.fn();
|
||||
const mounted = mountComponent(McpResourceScopePicker, {
|
||||
layout,
|
||||
connections: [connection("c1"), sfdc],
|
||||
allowedGroupIds: [],
|
||||
allowedConnectionIds: ["c1", "c2"],
|
||||
"onSet:connection-salesforce-dml": setDml,
|
||||
...props,
|
||||
});
|
||||
return { mounted, setDml };
|
||||
}
|
||||
|
||||
it("offers the write opt-in only on Salesforce connections", () => {
|
||||
const { mounted } = mountWithSalesforce();
|
||||
|
||||
const checkboxes = salesforceDmlCheckboxes(mounted.root);
|
||||
expect(checkboxes).toHaveLength(1);
|
||||
expect(checkboxes[0].props.checked).toBe(false);
|
||||
});
|
||||
|
||||
it("hides the opt-in until the connection is inside the exposed scope", async () => {
|
||||
const { mounted } = mountWithSalesforce({ allowedConnectionIds: ["c1"] });
|
||||
expect(salesforceDmlCheckboxes(mounted.root)).toHaveLength(0);
|
||||
|
||||
await mounted.setProps({ allowedConnectionIds: ["c1", "c2"] });
|
||||
expect(salesforceDmlCheckboxes(mounted.root)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("emits the connection id and the requested state", () => {
|
||||
const { mounted, setDml } = mountWithSalesforce();
|
||||
|
||||
dispatch(salesforceDmlCheckboxes(mounted.root)[0], "change", { target: { checked: true } });
|
||||
|
||||
expect(setDml).toHaveBeenCalledWith("c2", true);
|
||||
});
|
||||
|
||||
it("shows a persisted opt-in as checked and a read-only rule as off", () => {
|
||||
const optedIn = mountWithSalesforce({
|
||||
connectionPolicies: [{ connectionId: "c2", readOnly: false, allowDangerousSql: false, allowSalesforceDml: true }],
|
||||
});
|
||||
expect(salesforceDmlCheckboxes(optedIn.mounted.root)[0].props.checked).toBe(true);
|
||||
|
||||
// Read-only is a hard ceiling server-side, so the switch must not look enabled here.
|
||||
const readOnly = mountWithSalesforce({
|
||||
connectionPolicies: [{ connectionId: "c2", readOnly: true, allowDangerousSql: false, allowSalesforceDml: true }],
|
||||
});
|
||||
expect(salesforceDmlCheckboxes(readOnly.mounted.root)[0].props.checked).toBe(false);
|
||||
});
|
||||
|
||||
it("does not emit while the policy controls are disabled", () => {
|
||||
const { mounted, setDml } = mountWithSalesforce({ disabled: true });
|
||||
|
||||
dispatch(salesforceDmlCheckboxes(mounted.root)[0], "change", { target: { checked: true } });
|
||||
|
||||
expect(setDml).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4689,7 +4689,7 @@ const canOpenSqlFileExecution = computed(() => {
|
||||
const canExportAllDatabases = computed(() => {
|
||||
if (activeNode.value.type !== "connection" || !activeNode.value.connectionId) return false;
|
||||
const dbType = connectionStore.getConfig(activeNode.value.connectionId)?.db_type;
|
||||
return !["redis", "mongodb", "dynamodb", "elasticsearch", "easysearch", "solr", "meilisearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos", "plugin"].includes(dbType || "");
|
||||
return !["redis", "mongodb", "dynamodb", "elasticsearch", "easysearch", "meilisearch", "solr", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos", "plugin", "salesforce"].includes(dbType || "");
|
||||
});
|
||||
|
||||
const canOpenScheduledBackups = computed(() => {
|
||||
|
||||
@@ -169,7 +169,7 @@ function resetPerFileState() {
|
||||
currentFileName.value = "";
|
||||
}
|
||||
|
||||
const sqlConnections = computed(() => store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "easysearch", "meilisearch", "solr", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos"].includes(c.db_type)));
|
||||
const sqlConnections = computed(() => store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "easysearch", "meilisearch", "solr", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos", "salesforce"].includes(c.db_type)));
|
||||
// Mirrors the core executor gate (`relational_constraint_bypass_kind` in
|
||||
// sql_file_import.rs): MySQL-family types use the session-scoped
|
||||
// FOREIGN_KEY_CHECKS toggle, PostgreSQL-family types use DISABLE/ENABLE
|
||||
|
||||
@@ -948,11 +948,13 @@ describe("useDataGridEditor saveChanges reload", () => {
|
||||
currentPage?: Ref<number>;
|
||||
prepareFullReload?: () => void;
|
||||
customSaveHandler?: { save: ReturnType<typeof vi.fn> };
|
||||
confirmSaveRequest?: (request: import("@/composables/useDataGridEditor").DataGridSaveConfirmationRequest) => Promise<boolean>;
|
||||
manualTransactionSessionId?: string;
|
||||
ensureManualTransactionSession?: () => Promise<string>;
|
||||
refreshSavedRows?: ReturnType<typeof vi.fn>;
|
||||
onManualTransactionMutation?: ReturnType<typeof vi.fn>;
|
||||
connectionId?: string;
|
||||
databaseType?: import("@/types/database").DatabaseType;
|
||||
primaryKeys?: string[];
|
||||
onExecuteSql?: (sql: string) => Promise<void>;
|
||||
} = {},
|
||||
@@ -971,7 +973,7 @@ describe("useDataGridEditor saveChanges reload", () => {
|
||||
const editor = useDataGridEditor({
|
||||
result: computed(() => result.value),
|
||||
editable: computed(() => true),
|
||||
databaseType: computed(() => "mysql"),
|
||||
databaseType: computed(() => options.databaseType ?? "mysql"),
|
||||
connectionId: computed(() => ("connectionId" in options ? options.connectionId : "connection-1")),
|
||||
database: computed(() => "app"),
|
||||
tableMeta: computed(() => ({
|
||||
@@ -986,6 +988,7 @@ describe("useDataGridEditor saveChanges reload", () => {
|
||||
joinedWriteTargets: computed(() => options.joinedWriteTargets),
|
||||
onExecuteSql: computed(() => options.onExecuteSql),
|
||||
customSaveHandler: computed(() => options.customSaveHandler),
|
||||
confirmSaveRequest: computed(() => options.confirmSaveRequest),
|
||||
manualTransactionSessionId: computed(() => options.manualTransactionSessionId),
|
||||
ensureManualTransactionSession: computed(() => ensureManualTransactionSession.value),
|
||||
onManualTransactionMutation: options.onManualTransactionMutation,
|
||||
@@ -1390,6 +1393,62 @@ describe("useDataGridEditor saveChanges reload", () => {
|
||||
expect(prepareFullReload).not.toHaveBeenCalled();
|
||||
expect(emit).not.toHaveBeenCalledWith("reload", expect.anything());
|
||||
});
|
||||
|
||||
// Engines that cannot roll a partial batch back (Salesforce: one REST call per
|
||||
// record) hand the operation list to the host for review before it is written.
|
||||
it("summarizes inserts, updates and deletes for the host save confirmation and writes once accepted", async () => {
|
||||
mocks.prepareDataGridSave.mockResolvedValue({ statements: ["stmt-1", "stmt-2", "stmt-3"], rollbackStatements: [] });
|
||||
mocks.executeBatch.mockResolvedValue({ affected_rows: 1 });
|
||||
const confirmSaveRequest = vi.fn().mockResolvedValue(true);
|
||||
|
||||
const { editor } = createSaveTestEditor({ confirmSaveRequest, databaseType: "salesforce" });
|
||||
editor.dirtyRows.value.set(0, new Map([[1, "shipped"]]));
|
||||
editor.addRows(1);
|
||||
editor.deletedRows.value.add(1);
|
||||
|
||||
await editor.saveChanges();
|
||||
|
||||
expect(confirmSaveRequest).toHaveBeenCalledTimes(1);
|
||||
expect(confirmSaveRequest.mock.calls[0]?.[0]).toMatchObject({
|
||||
updates: 1,
|
||||
inserts: 1,
|
||||
deletes: 1,
|
||||
targetLabel: "orders_test",
|
||||
statements: ["stmt-1", "stmt-2", "stmt-3"],
|
||||
});
|
||||
expect(mocks.executeBatch).toHaveBeenCalledTimes(1);
|
||||
expect(editor.saveError.value).toBeFalsy();
|
||||
});
|
||||
|
||||
it("keeps every edit staged and reports no error when the host declines the save", async () => {
|
||||
mocks.prepareDataGridSave.mockResolvedValue({ statements: ["stmt-1"], rollbackStatements: [] });
|
||||
const confirmSaveRequest = vi.fn().mockResolvedValue(false);
|
||||
|
||||
const { editor } = createSaveTestEditor({ confirmSaveRequest, databaseType: "salesforce" });
|
||||
editor.dirtyRows.value.set(0, new Map([[1, "shipped"]]));
|
||||
|
||||
await editor.saveChanges();
|
||||
|
||||
expect(confirmSaveRequest).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.executeBatch).not.toHaveBeenCalled();
|
||||
expect(editor.dirtyRows.value.get(0)?.get(1)).toBe("shipped");
|
||||
expect(editor.isSaving.value).toBe(false);
|
||||
expect(editor.saveError.value).toBeFalsy();
|
||||
});
|
||||
|
||||
it("never writes an auto-save past a required confirmation", async () => {
|
||||
mocks.prepareDataGridSave.mockResolvedValue({ statements: ["stmt-1"], rollbackStatements: [] });
|
||||
const confirmSaveRequest = vi.fn().mockResolvedValue(true);
|
||||
|
||||
const { editor } = createSaveTestEditor({ confirmSaveRequest, databaseType: "salesforce" });
|
||||
editor.dirtyRows.value.set(0, new Map([[1, "shipped"]]));
|
||||
|
||||
await editor.saveChanges({ autoSave: true });
|
||||
|
||||
expect(confirmSaveRequest).not.toHaveBeenCalled();
|
||||
expect(mocks.executeBatch).not.toHaveBeenCalled();
|
||||
expect(editor.dirtyRows.value.get(0)?.get(1)).toBe("shipped");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useDataGridEditor cell edit focus", () => {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { effectScope, type EffectScope } from "vue";
|
||||
import { useSalesforceSaveConfirmation } from "@/composables/useSalesforceSaveConfirmation";
|
||||
import type { DataGridSaveConfirmationRequest } from "@/composables/useDataGridEditor";
|
||||
|
||||
const saveRequest: DataGridSaveConfirmationRequest = {
|
||||
updates: 2,
|
||||
inserts: 1,
|
||||
deletes: 0,
|
||||
targetLabel: "Account",
|
||||
statements: ['DBX SALESFORCE DML\n{"op":"update","object":"Account","id":"001xx000003DGbY","fields":{"Name":"Acme"}}'],
|
||||
};
|
||||
|
||||
function createConfirmation(): { scope: EffectScope; confirmation: ReturnType<typeof useSalesforceSaveConfirmation> } {
|
||||
const scope = effectScope();
|
||||
const confirmation = scope.run(() => useSalesforceSaveConfirmation());
|
||||
if (!confirmation) throw new Error("useSalesforceSaveConfirmation must run inside an effect scope");
|
||||
return { scope, confirmation };
|
||||
}
|
||||
|
||||
describe("useSalesforceSaveConfirmation", () => {
|
||||
it("parks the save until the dialog confirms and exposes the batch summary", async () => {
|
||||
const { confirmation } = createConfirmation();
|
||||
const decision = confirmation.request(saveRequest);
|
||||
|
||||
expect(confirmation.open.value).toBe(true);
|
||||
expect(confirmation.pending.value).toEqual(saveRequest);
|
||||
expect(confirmation.updates.value).toBe(2);
|
||||
expect(confirmation.inserts.value).toBe(1);
|
||||
expect(confirmation.deletes.value).toBe(0);
|
||||
expect(confirmation.total.value).toBe(3);
|
||||
expect(confirmation.targetLabel.value).toBe("Account");
|
||||
expect(confirmation.statements.value).toEqual(saveRequest.statements);
|
||||
// The save stays parked: nothing is written to Salesforce until the operator decides.
|
||||
await Promise.resolve();
|
||||
expect(confirmation.open.value).toBe(true);
|
||||
|
||||
confirmation.confirm();
|
||||
|
||||
await expect(decision).resolves.toBe(true);
|
||||
expect(confirmation.open.value).toBe(false);
|
||||
expect(confirmation.pending.value).toBeNull();
|
||||
});
|
||||
|
||||
it("denies the save when the dialog is dismissed", async () => {
|
||||
const { confirmation } = createConfirmation();
|
||||
const decision = confirmation.request(saveRequest);
|
||||
|
||||
// `v-model:open` writes false on Cancel, Escape and backdrop clicks alike.
|
||||
confirmation.open.value = false;
|
||||
|
||||
await expect(decision).resolves.toBe(false);
|
||||
expect(confirmation.pending.value).toBeNull();
|
||||
});
|
||||
|
||||
it("denies a second concurrent save instead of leaving it waiting on a hidden dialog", async () => {
|
||||
const { confirmation } = createConfirmation();
|
||||
const first = confirmation.request(saveRequest);
|
||||
|
||||
await expect(confirmation.request({ ...saveRequest, updates: 5 })).resolves.toBe(false);
|
||||
|
||||
expect(confirmation.open.value).toBe(true);
|
||||
expect(confirmation.updates.value).toBe(2);
|
||||
confirmation.confirm();
|
||||
await expect(first).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("denies a parked save when the grid is torn down mid-review", async () => {
|
||||
const { scope, confirmation } = createConfirmation();
|
||||
const decision = confirmation.request(saveRequest);
|
||||
|
||||
scope.stop();
|
||||
|
||||
await expect(decision).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("reports an empty summary before any save is requested", () => {
|
||||
const { confirmation } = createConfirmation();
|
||||
|
||||
expect(confirmation.open.value).toBe(false);
|
||||
expect(confirmation.total.value).toBe(0);
|
||||
expect(confirmation.statements.value).toEqual([]);
|
||||
expect(confirmation.targetLabel.value).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -87,6 +87,22 @@ export interface CustomSaveHandler {
|
||||
targetLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Summary handed to `confirmSaveRequest` so an engine whose writes cannot be
|
||||
* rolled back (Salesforce REST: one record per request, no transactions) can
|
||||
* show the operator exactly what is about to happen before anything is sent.
|
||||
*/
|
||||
export interface DataGridSaveConfirmationRequest {
|
||||
/** Rows with edited cells (one write per row). */
|
||||
updates: number;
|
||||
inserts: number;
|
||||
deletes: number;
|
||||
/** Save target name (table / sObject), when known. */
|
||||
targetLabel?: string;
|
||||
/** The statements about to be executed — for Salesforce these are `DBX SALESFORCE DML` pseudo-commands. */
|
||||
statements: string[];
|
||||
}
|
||||
|
||||
export interface UseDataGridEditorOptions {
|
||||
result: ComputedRef<{ columns: string[]; rows: CellValue[][] }>;
|
||||
editable: ComputedRef<boolean | undefined>;
|
||||
@@ -119,6 +135,12 @@ export interface UseDataGridEditorOptions {
|
||||
rowStatusFilter: Ref<RowStatusFilter>;
|
||||
dataGridQuickEntryEnabled?: ComputedRef<boolean>;
|
||||
confirmDangerousRowDeletion?: ComputedRef<boolean>;
|
||||
/**
|
||||
* Optional operator review before a save is executed. Return `false` to cancel:
|
||||
* the pending edits stay in the grid and nothing is sent. Engines whose writes
|
||||
* cannot be rolled back (Salesforce REST) use this; auto-save never bypasses it.
|
||||
*/
|
||||
confirmSaveRequest?: ComputedRef<((request: DataGridSaveConfirmationRequest) => Promise<boolean>) | undefined>;
|
||||
/** `生成 SQL 时包含数据库名` — qualify saved tables with their database. */
|
||||
includeDatabaseNameInSaveSql?: ComputedRef<boolean>;
|
||||
initialEditColumn?: ComputedRef<number>;
|
||||
@@ -1994,6 +2016,28 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Engines without transactional rollback (Salesforce: one REST call per record,
|
||||
// a failure leaves earlier records applied) let the host review the operation
|
||||
// list first. Auto-save never writes without that review, matching the
|
||||
// production-database interlock above.
|
||||
const confirmSaveRequest = options.confirmSaveRequest?.value;
|
||||
if (confirmSaveRequest) {
|
||||
if (saveOptions.autoSave) {
|
||||
await finishInterruptedSaveChanges(snapshot);
|
||||
return;
|
||||
}
|
||||
const saveConfirmed = await confirmSaveRequest({
|
||||
updates: snapshot.dirtyRows.size,
|
||||
inserts: snapshot.newRows.length,
|
||||
deletes: snapshot.deletedRows.size,
|
||||
targetLabel: tableMeta.value?.tableName,
|
||||
statements: stmts,
|
||||
});
|
||||
if (!saveConfirmed) {
|
||||
await finishInterruptedSaveChanges(snapshot);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!editable.value || stmtOptions?.tableMeta !== tableMeta.value) {
|
||||
await finishInterruptedSaveChanges(snapshot);
|
||||
return;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { connectionObjectTreeNodeSchema, effectiveDatabaseTypeForConnection, met
|
||||
import { invalidateTableMetadataCache, loadTableMetadata } from "@/lib/metadata/tableMetadataCache";
|
||||
import { canApplyDataTabMetadata, canReuseActiveMongoTab, type DataTabReuseMode } from "@/lib/sidebar/dataTabOpenPolicy";
|
||||
import { isNoSnapshotErrorResult, isQueryExecutionErrorResult } from "@/lib/query/queryResultError";
|
||||
import { buildTableSelectSql } from "@/lib/table/tableSelectSql";
|
||||
import { buildTableSelectSql, requiresEagerTableMetadataForDataOpen } from "@/lib/table/tableSelectSql";
|
||||
import { resolveTableDefaultSort, applyTableDefaultSortResult } from "@/lib/table/tableDefaultSort";
|
||||
import { tableDataLargeValuePreviewOptions } from "@/lib/dataGrid/dataGridLargeValues";
|
||||
import { editableRowIdentifierColumns, physicalTablePrimaryKeys, shouldIncludeSyntheticRowId } from "@/lib/table/tableEditing";
|
||||
@@ -179,7 +179,7 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab
|
||||
return;
|
||||
}
|
||||
let eagerMetadata: Awaited<ReturnType<typeof loadTableMetadata>> | undefined;
|
||||
if (effectiveDbType === "mysql" || effectiveDbType === "postgres" || (settingsStore.editorSettings.tableOpenSortMode ?? "none") !== "none") {
|
||||
if (requiresEagerTableMetadataForDataOpen(effectiveDbType) || (settingsStore.editorSettings.tableOpenSortMode ?? "none") !== "none") {
|
||||
try {
|
||||
eagerMetadata = await loadTableMetadata({
|
||||
connectionId: target.connectionId,
|
||||
|
||||
@@ -25,7 +25,7 @@ const INITIAL_SQL_FILE_LIMIT = 20;
|
||||
const CONTENT_SEARCH_DEBOUNCE_MS = 200;
|
||||
const CONTENT_SEARCH_MAX_RESULTS = 500;
|
||||
|
||||
const REMOTE_SEARCH_UNSUPPORTED_TYPES = new Set<ConnectionConfig["db_type"]>(["redis", "mongodb", "elasticsearch", "easysearch", "meilisearch", "solr", "qdrant", "milvus", "weaviate", "chromadb", "neo4j", "influxdb", "victoriametrics", "etcd", "zookeeper", "mq", "nacos", "consul"]);
|
||||
const REMOTE_SEARCH_UNSUPPORTED_TYPES = new Set<ConnectionConfig["db_type"]>(["redis", "mongodb", "elasticsearch", "easysearch", "meilisearch", "solr", "qdrant", "milvus", "weaviate", "chromadb", "neo4j", "influxdb", "victoriametrics", "etcd", "zookeeper", "mq", "nacos", "consul", "salesforce"]);
|
||||
|
||||
export interface QuickOpenItem {
|
||||
id: string;
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { computed, onScopeDispose, ref, type ComputedRef, type Ref, type WritableComputedRef } from "vue";
|
||||
import type { DataGridSaveConfirmationRequest } from "@/composables/useDataGridEditor";
|
||||
|
||||
/**
|
||||
* Salesforce writes go out as one REST call per record and cannot be rolled back,
|
||||
* so every grid save is reviewed before it runs. This composable turns the
|
||||
* `confirmSaveRequest` hook from `useDataGridEditor` into a dialog decision:
|
||||
* `request()` parks the save on a promise the dialog settles.
|
||||
*
|
||||
* The pending request is transient — never persisted and never outliving the
|
||||
* component — so a dismissed grid denies the write instead of leaving it hanging.
|
||||
*/
|
||||
export function useSalesforceSaveConfirmation(): {
|
||||
pending: Ref<DataGridSaveConfirmationRequest | null>;
|
||||
open: WritableComputedRef<boolean>;
|
||||
updates: ComputedRef<number>;
|
||||
inserts: ComputedRef<number>;
|
||||
deletes: ComputedRef<number>;
|
||||
total: ComputedRef<number>;
|
||||
targetLabel: ComputedRef<string | undefined>;
|
||||
statements: ComputedRef<string[]>;
|
||||
request: (request: DataGridSaveConfirmationRequest) => Promise<boolean>;
|
||||
confirm: () => void;
|
||||
cancel: () => void;
|
||||
} {
|
||||
const pending = ref<DataGridSaveConfirmationRequest | null>(null);
|
||||
let resolvePending: ((confirmed: boolean) => void) | undefined;
|
||||
|
||||
const open = computed({
|
||||
get: () => !!pending.value,
|
||||
set: (next: boolean) => {
|
||||
if (!next) settle(false);
|
||||
},
|
||||
});
|
||||
|
||||
const updates = computed(() => pending.value?.updates ?? 0);
|
||||
const inserts = computed(() => pending.value?.inserts ?? 0);
|
||||
const deletes = computed(() => pending.value?.deletes ?? 0);
|
||||
const total = computed(() => updates.value + inserts.value + deletes.value);
|
||||
const targetLabel = computed(() => pending.value?.targetLabel);
|
||||
const statements = computed(() => pending.value?.statements ?? []);
|
||||
|
||||
function settle(confirmed: boolean) {
|
||||
const resolve = resolvePending;
|
||||
resolvePending = undefined;
|
||||
pending.value = null;
|
||||
resolve?.(confirmed);
|
||||
}
|
||||
|
||||
function request(saveRequest: DataGridSaveConfirmationRequest): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
if (pending.value) {
|
||||
// A grid only saves once at a time, but never strand a second caller:
|
||||
// deny it so that save unwinds instead of waiting on a dialog it cannot see.
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
pending.value = saveRequest;
|
||||
resolvePending = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
settle(true);
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
settle(false);
|
||||
}
|
||||
|
||||
// The dialog is the only way to settle the promise; if it goes away first (tab
|
||||
// closed mid-review) the parked save must be denied, not leaked.
|
||||
onScopeDispose(() => settle(false));
|
||||
|
||||
return { pending, open, updates, inserts, deletes, total, targetLabel, statements, request, confirm, cancel };
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { getCachedTableMetadata, loadTableMetadata, TABLE_METADATA_CACHE_TTL_MS,
|
||||
import { canApplyDataTabMetadata, dataTabMetadataNeedsRefresh, findExistingDataTabCandidate, isDataTabMetadataLifecycleStale, type DataTabOpenMode, type DataTabReuseMode } from "@/lib/sidebar/dataTabOpenPolicy";
|
||||
import type { SidebarDataOpenRequest } from "@/lib/sidebar/sidebarDataOpenCoordinator";
|
||||
import { hasTreeNodeDatabaseContext } from "@/lib/sidebar/treeNodeContext";
|
||||
import { buildTableSelectSql } from "@/lib/table/tableSelectSql";
|
||||
import { buildTableSelectSql, requiresEagerTableMetadataForDataOpen } from "@/lib/table/tableSelectSql";
|
||||
import { resolveTableDefaultSort, applyTableDefaultSortResult } from "@/lib/table/tableDefaultSort";
|
||||
import { physicalTablePrimaryKeys, shouldIncludeSyntheticRowId } from "@/lib/table/tableEditing";
|
||||
import { tableOpenPageLimit } from "@/lib/table/tableOpenPageLimit";
|
||||
@@ -354,7 +354,7 @@ export function useSidebarDataOpenRuntime() {
|
||||
}
|
||||
|
||||
const metadataRefresh = shouldRefreshTableMeta && !deferTableMetaRefresh ? refreshTableMetaInBackground(tabId) : undefined;
|
||||
if (!cachedTableMeta && (effectiveDbType === "mysql" || effectiveDbType === "postgres" || defaultSortMode !== "none")) {
|
||||
if (!cachedTableMeta && (requiresEagerTableMetadataForDataOpen(effectiveDbType) || defaultSortMode !== "none")) {
|
||||
// No query is in flight yet, so deferred drivers can safely load metadata serially.
|
||||
if (deferTableMetaRefresh) await refreshTableMetaInBackground(tabId);
|
||||
await metadataRefresh;
|
||||
|
||||
@@ -981,7 +981,7 @@ export function supportsSqlTemplateParameters(connection: Pick<ConnectionConfig,
|
||||
if (!connection) return false;
|
||||
if (connection.db_type === "meilisearch" || connection.db_type === "solr") return false;
|
||||
if (connection.db_type === "elasticsearch" || connection.db_type === "easysearch") return !isElasticsearchRestRequestText(sql);
|
||||
return connection.db_type !== "redis" && connection.db_type !== "mongodb" && connection.db_type !== "victoriametrics";
|
||||
return connection.db_type !== "redis" && connection.db_type !== "mongodb" && connection.db_type !== "victoriametrics" && connection.db_type !== "salesforce";
|
||||
}
|
||||
|
||||
export function requiresDatabaseSelection(tab: QueryTab, connection: ConnectionConfig | undefined, _sql = ""): boolean {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import en from "../locales/en";
|
||||
import es from "../locales/es";
|
||||
import zhCN from "../locales/zh-CN";
|
||||
|
||||
// The Salesforce save dialog and the toolbar identity badge explain *why* a
|
||||
// write may fail. The first wording claimed a non-administrator's records "are
|
||||
// rejected by Salesforce row by row", which conflates two independent layers:
|
||||
// field writability comes from the profile's field-level security (the grid
|
||||
// already renders those columns read-only from describe's `updateable` /
|
||||
// `createable`, which Salesforce computes for the current user), while
|
||||
// record-level failures come from sharing rules, validation rules, Flow or
|
||||
// Apex. The `Modify All Data` flag only says whether the profile bypasses
|
||||
// those layers — it never decides whether a field is editable.
|
||||
//
|
||||
// These assertions pin the corrected contract so the misleading claim cannot
|
||||
// silently return: every identity line names the profile as well as the user,
|
||||
// the non-administrator line talks about permissions instead of rank, and the
|
||||
// old key is gone rather than left behind unused.
|
||||
const locales: Array<[string, Record<string, unknown>]> = [
|
||||
["en", en as Record<string, unknown>],
|
||||
["es", es],
|
||||
["zh-CN", zhCN],
|
||||
];
|
||||
|
||||
const saveIdentityKeys = ["salesforceSaveAdminIdentity", "salesforceSaveNonAdminIdentity", "salesforceSaveUnknownRights"] as const;
|
||||
|
||||
/** Words that would put the admin flag back at the centre of a field-level warning. */
|
||||
const adminWords = ["administrator", "administrador", "administrateur", "系统管理员", "管理员"] as const;
|
||||
|
||||
function entry(locale: Record<string, unknown>, namespace: string, key: string): string | undefined {
|
||||
const section = locale[namespace];
|
||||
if (!section || typeof section !== "object") return undefined;
|
||||
const value = (section as Record<string, unknown>)[key];
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
describe("salesforce identity copy", () => {
|
||||
it.each(locales)("%s interpolates user and profile into every save identity line", (name, locale) => {
|
||||
for (const key of saveIdentityKeys) {
|
||||
const value = entry(locale, "grid", key);
|
||||
expect(value, `${name} grid.${key} missing`).toBeTypeOf("string");
|
||||
expect(value, `${name} grid.${key} must name the user`).toContain("{user}");
|
||||
expect(value, `${name} grid.${key} must name the profile`).toContain("{profile}");
|
||||
}
|
||||
expect(entry(locale, "grid", "salesforceSaveProfile"), `${name} grid.salesforceSaveProfile missing`).toContain("{name}");
|
||||
});
|
||||
|
||||
it.each(locales)("%s dropped the admin-blaming save warning", (name, locale) => {
|
||||
// The renamed key must not linger: a stale copy invites someone to reuse it.
|
||||
expect(entry(locale, "grid", "salesforceSaveNonAdminWarning"), `${name} still ships the old key`).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(locales)("%s explains the non-administrator save case with permissions, not rank", (name, locale) => {
|
||||
const value = entry(locale, "grid", "salesforceSaveNonAdminIdentity") ?? "";
|
||||
for (const word of adminWords) {
|
||||
expect(value.toLowerCase(), `${name} grid.salesforceSaveNonAdminIdentity mentions "${word}"`).not.toContain(word.toLowerCase());
|
||||
}
|
||||
});
|
||||
|
||||
it.each(locales)("%s attributes the toolbar rights line to the profile", (name, locale) => {
|
||||
for (const key of ["salesforceIdentityAdmin", "salesforceIdentityNonAdmin", "salesforceIdentityUnknownRights"] as const) {
|
||||
expect(entry(locale, "toolbar", key), `${name} toolbar.${key} missing`).toBeTypeOf("string");
|
||||
}
|
||||
// `is_admin` is read from `Profile.PermissionsModifyAllData` only, so a
|
||||
// permission set can still grant the right: the wording has to stay about
|
||||
// the profile, never a flat "this user is not an administrator".
|
||||
const nonAdmin = (entry(locale, "toolbar", "salesforceIdentityNonAdmin") ?? "").toLowerCase();
|
||||
expect(
|
||||
["profile", "perfil", "简档"].some((word) => nonAdmin.includes(word)),
|
||||
`${name} toolbar.salesforceIdentityNonAdmin should scope the claim to the profile`,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("zh-CN translates Profile as 简档, not 配置文件", () => {
|
||||
// "配置文件" reads as a config file; Salesforce's Profile is 简档.
|
||||
expect(entry(zhCN, "toolbar", "salesforceIdentityUnknownProfile")).toBe("简档未知");
|
||||
expect(entry(zhCN, "grid", "salesforceSaveProfile")).toContain("简档");
|
||||
});
|
||||
});
|
||||
@@ -8111,6 +8111,10 @@ export default withEnglishFallback({
|
||||
mcpConnectionPolicyReadOnly: "Yalnız oxuma",
|
||||
mcpConnectionPolicySafeWrite: "Təhlükəsiz yazma əməliyyatlarına icazə ver",
|
||||
mcpConnectionPolicyHighRiskWrite: "Yüksək riskli əməliyyatlara icazə ver",
|
||||
mcpConnectionPolicyAllowSalesforceDml: "DML-ə icazə ver",
|
||||
mcpConnectionPolicyAllowSalesforceDmlHint:
|
||||
"Süni intellekt agentinə iki mərhələli təsdiqlə (əvvəlcə hazırla, sonra tətbiq et) bu orqanizasyonda tək Salesforce qeydlərini yaratmağa, yeniləməyə və ya silməyə imkan verir. Yalnız oxumaq rejimləri bu açarı nəzərə almır. Salesforce yazılışları geri qaytarıla bilməz.",
|
||||
mcpConnectionPolicyAllowSalesforceDmlReadOnlyBlocked: "Bu bağlantı yalnız oxumaq üçün olduğundan Salesforce DML bağlı qalır. Əvvəlcə yazmağa icazə verən icra rejimini seçin.",
|
||||
mcpToolPermissionsTitle: "MCP alət icazələri",
|
||||
mcpToolPermissionsDescription: "Yalnız ehtiyacınız olan MCP alətlərini təqdim edin. Seçim ləğv edildikdə, müştərilər alət təriflərini keşdə saxlamış olsalar belə, server onların müraciətlərini rədd edir.",
|
||||
mcpToolListConnections: "Əlaqələri siyahıla",
|
||||
@@ -8128,6 +8132,9 @@ export default withEnglishFallback({
|
||||
mcpToolRollbackTransaction: "Tranzaksiyanı geri qaytar",
|
||||
mcpToolCloseSession: "Sorğu sessiyasını bağla",
|
||||
mcpToolExecuteRedisCommand: "Redis əmrini icra et",
|
||||
mcpToolSalesforceCurrentUser: "Salesforce cari istifadəçisi",
|
||||
mcpToolSalesforcePrepareWrite: "Salesforce yazılışını hazırla",
|
||||
mcpToolSalesforceApplyWrite: "Salesforce yazılışını tətbiq et",
|
||||
mcpToolPeekMessages: "Kafka mesajlarını oxu",
|
||||
mcpToolSendMessage: "Mesaj növbəsinə mesaj göndər",
|
||||
mcpToolAddConnection: "Əlaqə əlavə et",
|
||||
|
||||
@@ -400,6 +400,10 @@ export default {
|
||||
commit: "Commit",
|
||||
rollback: "Rollback",
|
||||
txnAutoRolledBack: "Transaction auto-rolled back after 5 minutes of inactivity",
|
||||
salesforceIdentityAdmin: "Profile has “Modify All Data” — bypasses field-level security and record sharing",
|
||||
salesforceIdentityNonAdmin: "Profile has no “Modify All Data” — field permissions and record sharing apply",
|
||||
salesforceIdentityUnknownRights: "Could not determine whether the profile has “Modify All Data”",
|
||||
salesforceIdentityUnknownProfile: "Profile unknown",
|
||||
autoCommitTxnRolledBack: 'An explicit transaction left open by this tab was rolled back automatically; turn on "Keep explicit auto-commit transactions" to keep it across executions',
|
||||
autoCommitSessionTxnRolledBack: "This connection runs with auto-commit off (SET autocommit = 0), so its implicit transaction was rolled back after the execution; DBX reports that once per connection",
|
||||
previewSql: "Preview SQL",
|
||||
@@ -857,6 +861,65 @@ export default {
|
||||
victoriametricsLookbackHint: "Used for metric discovery and label columns, for example 1h or 7d. Metric previews use 1h and remain editable.",
|
||||
victoriametricsInvalidApiPath: "The VictoriaMetrics API path must be empty or start with /.",
|
||||
victoriametricsInvalidLookback: "Invalid VictoriaMetrics lookback. Use a value such as 15m, 1h, or 7d.",
|
||||
salesforceInstanceUrl: "Instance URL",
|
||||
salesforceInstanceUrlPlaceholder: "https://acme.my.salesforce.com",
|
||||
salesforceInstanceUrlHint: "Org hostname or full instance URL; refreshed automatically from the token response after a successful sign-in.",
|
||||
salesforceAccessToken: "Access Token",
|
||||
salesforceAccessTokenPlaceholder: "Session ID or access token",
|
||||
salesforceAccessTokenHint: "Paste a Salesforce access token or session ID. Use the OAuth options below for browser-based login or device-code authorization.",
|
||||
salesforceApiVersion: "API Version",
|
||||
salesforceApiVersionHint: "Salesforce REST API version, e.g. v62.0. Leave empty to use the default pinned version.",
|
||||
salesforceAuthMode: "Authentication",
|
||||
salesforceAuthModeToken: "Access Token",
|
||||
salesforceAuthModeOauth: "Browser OAuth",
|
||||
salesforceAuthModeDevice: "Device Code",
|
||||
salesforceAuthModePassword: "Username & Password",
|
||||
salesforceAuthModeOauthDesktopOnly: "Browser OAuth is only available in the desktop app.",
|
||||
salesforceEnvironment: "Environment",
|
||||
salesforceEnvironmentProduction: "Production",
|
||||
salesforceEnvironmentSandbox: "Sandbox",
|
||||
salesforceEnvironmentCustom: "Custom",
|
||||
salesforceLoginUrl: "Login URL",
|
||||
salesforceLoginUrlPlaceholder: "https://login.salesforce.com",
|
||||
salesforceLoginUrlHint: "Override the Salesforce login URL when your org uses a custom identity endpoint.",
|
||||
salesforceClientId: "Client ID",
|
||||
salesforceClientIdPlaceholder: "Consumer Key from your Connected App",
|
||||
salesforceClientIdHint: "Create a Connected App in Salesforce Setup → App Manager, enable OAuth with PKCE, and set the Callback URL to {callbackUrl}.",
|
||||
salesforceClientIdHintShared: "The Connected App consumer key. These two modes use no callback URL, so an app your admin already created works as-is.",
|
||||
salesforceClientSecret: "Client Secret",
|
||||
salesforceClientSecretPlaceholder: "Optional — confidential Connected Apps only; leave empty for PKCE public clients",
|
||||
salesforceOauthAuthorize: "Authorize with Salesforce",
|
||||
salesforceOauthAuthorizing: "Waiting for browser login…",
|
||||
salesforceOauthAuthorizingHint: "A browser window should be open. Complete the login in Salesforce; this view will update automatically when it finishes.",
|
||||
salesforceOauthAuthorized: "Authorized (credentials saved)",
|
||||
salesforceOauthReauthorize: "Re-authorize",
|
||||
salesforceOauthClientIdRequired: "Salesforce Client ID is required.",
|
||||
salesforceOauthLoginUrlRequired: "Salesforce custom Login URL is required.",
|
||||
salesforceDeviceGetCode: "Get Device Code",
|
||||
salesforceDeviceFlowNote: "If requesting the code fails with an unsupported or disabled-flow error, an admin must enable Device Flow in the Connected App's OAuth settings.",
|
||||
salesforceDeviceUserCodeLabel: "User Code",
|
||||
salesforceDeviceUserCodeCopied: "User code copied",
|
||||
salesforceDeviceOpenVerification: "Open verification page",
|
||||
salesforceDeviceVerificationCopied: "Verification URL copied",
|
||||
salesforceDevicePolling: "Waiting for authorization…",
|
||||
salesforceDevicePollingHint: "Open the verification page in any browser and enter the user code shown above.",
|
||||
salesforceDeviceCancel: "Cancel",
|
||||
salesforceDeviceExpired: "Device code expired. Click below to request a new one.",
|
||||
salesforceDeviceDenied: "Authorization denied",
|
||||
salesforceDeviceRestart: "Start over",
|
||||
salesforceDeviceSecondsLeft: "{seconds}s remaining",
|
||||
salesforceUsernameLabel: "Username",
|
||||
salesforceUsernamePlaceholder: "user{'@'}example.com",
|
||||
salesforceUsernameHint: "Salesforce username. For sandboxes, include the sandbox suffix (e.g. user{'@'}example.com.qas1).",
|
||||
salesforceUserPasswordLabel: "User Password",
|
||||
salesforceUserPasswordPlaceholder: "Password (optionally + security token)",
|
||||
salesforceUserPasswordHint: "Your Salesforce password. If your IP is not trusted by the org, append your security token directly after the password. Sessions are re-authenticated automatically with these saved credentials.",
|
||||
salesforcePasswordRopcNote: "No callback URL needed — an existing Connected App works. Your profile needs API Enabled and the app's user policy must cover you. The password never returns to this form, so the sign-in button needs it typed again.",
|
||||
salesforcePasswordSignIn: "Sign In",
|
||||
salesforcePasswordSignInAgain: "Sign In Again",
|
||||
salesforceUsernameRequired: "Salesforce username is required.",
|
||||
salesforceUserPasswordRequired: "Salesforce password is required.",
|
||||
salesforceOauthSuccess: "Connected to {instanceUrl}",
|
||||
driverInstallHintPrefix: "Install the required driver from ",
|
||||
driverInstallHintSuffix: " in the top toolbar before connecting.",
|
||||
driverName: "Driver Name",
|
||||
@@ -2675,6 +2738,18 @@ export default {
|
||||
rollback: "Rollback",
|
||||
transactionSaveHint: "Commit {count} pending change(s) in a transaction.",
|
||||
nonTransactionalSaveHint: "Save {count} pending change(s) one by one. If one fails, earlier successful changes will not be rolled back.",
|
||||
salesforceSaveConfirmTitle: "Apply Changes to Salesforce",
|
||||
salesforceSaveConfirmMessage: "{count} pending change(s) are sent to Salesforce as one REST call per record. There is no transaction: if a call fails partway, the records already written stay changed.",
|
||||
salesforceSaveUpdates: "{count} update(s)",
|
||||
salesforceSaveInserts: "{count} new record(s)",
|
||||
salesforceSaveDeletes: "{count} delete(s)",
|
||||
salesforceSaveTarget: "Object: {object}",
|
||||
salesforceSaveUnknownObject: "unknown",
|
||||
salesforceSaveProfile: "profile {name}",
|
||||
salesforceSaveAdminIdentity: "Writing as {user} ({profile}); this profile has “Modify All Data”, which bypasses field-level security and record sharing — please double-check.",
|
||||
salesforceSaveNonAdminIdentity: "Signed in as {user} ({profile}).\nFields you cannot write are already read-only here;\nfailures from record sharing or validation rules come back per row, in Salesforce’s own words.",
|
||||
salesforceSaveUnknownRights: "Signed in as {user} ({profile}); could not determine whether this profile has “Modify All Data” — Salesforce still enforces its own permissions and reports failures per row.",
|
||||
salesforceSaveConfirm: "Apply to Salesforce",
|
||||
keylessEditWarning: "No primary key",
|
||||
keylessEditWarningHint: "This table has no primary key. Updates and deletes use all original row values in the WHERE clause; exact duplicate rows may be affected together.",
|
||||
queryEditReadOnly: "Read-only result",
|
||||
@@ -8678,6 +8753,9 @@ export default {
|
||||
mcpConnectionPolicyReadOnly: "Read only",
|
||||
mcpConnectionPolicySafeWrite: "Allow safe writes",
|
||||
mcpConnectionPolicyHighRiskWrite: "Allow high-risk operations",
|
||||
mcpConnectionPolicyAllowSalesforceDml: "Allow DML",
|
||||
mcpConnectionPolicyAllowSalesforceDmlHint: "Lets an AI agent create, update, or delete single Salesforce records in this org through a two-step confirmation (prepare, then apply). Read-only modes ignore this switch. Salesforce writes cannot be rolled back.",
|
||||
mcpConnectionPolicyAllowSalesforceDmlReadOnlyBlocked: "This connection is read-only, so Salesforce DML stays disabled. Choose a write-capable execution mode first.",
|
||||
mcpToolPermissionsTitle: "MCP tool permissions",
|
||||
mcpToolPermissionsDescription: "Expose only the MCP tools you need. Once deselected, clients are rejected by the server even if they cached the tool definitions.",
|
||||
mcpToolListConnections: "List connections",
|
||||
@@ -8695,6 +8773,9 @@ export default {
|
||||
mcpToolRollbackTransaction: "Roll back transaction",
|
||||
mcpToolCloseSession: "Close query session",
|
||||
mcpToolExecuteRedisCommand: "Execute Redis command",
|
||||
mcpToolSalesforceCurrentUser: "Salesforce current user",
|
||||
mcpToolSalesforcePrepareWrite: "Prepare Salesforce write",
|
||||
mcpToolSalesforceApplyWrite: "Apply Salesforce write",
|
||||
mcpToolPeekMessages: "Read Kafka messages",
|
||||
mcpToolSendMessage: "Send message queue message",
|
||||
mcpToolAddConnection: "Add connection",
|
||||
|
||||
@@ -391,6 +391,10 @@ export default withEnglishFallback({
|
||||
commit: "Confirmar",
|
||||
rollback: "Revertir",
|
||||
txnAutoRolledBack: "Transacción revertida automáticamente tras 5 minutos de inactividad",
|
||||
salesforceIdentityAdmin: "El perfil tiene “Modify All Data”: omite la seguridad a nivel de campo y el uso compartido de registros",
|
||||
salesforceIdentityNonAdmin: "El perfil no tiene “Modify All Data”: los permisos de campos y el uso compartido siguen aplicándose",
|
||||
salesforceIdentityUnknownRights: "No se pudo determinar si el perfil tiene “Modify All Data”",
|
||||
salesforceIdentityUnknownProfile: "Perfil desconocido",
|
||||
autoCommitTxnRolledBack: 'La transacción explícita que esta pestaña dejó abierta se revirtió automáticamente; activa "Mantener transacciones explícitas en modo autocommit" para conservarla entre ejecuciones',
|
||||
autoCommitSessionTxnRolledBack: "Esta conexión trabaja con autocommit desactivado (SET autocommit = 0), así que su transacción implícita se revirtió tras la ejecución; DBX lo avisa una vez por conexión",
|
||||
previewSql: "Vista previa de SQL",
|
||||
@@ -837,6 +841,65 @@ export default withEnglishFallback({
|
||||
victoriametricsLookbackHint: "Se usa para descubrir métricas y columnas de etiquetas, por ejemplo, 1h o 7d. Las vistas previas de métricas usan 1h y se pueden editar.",
|
||||
victoriametricsInvalidApiPath: "La ruta de la API de VictoriaMetrics debe estar vacía o comenzar por /.",
|
||||
victoriametricsInvalidLookback: "El intervalo retrospectivo de VictoriaMetrics no es válido. Use un valor como 15m, 1h o 7d.",
|
||||
salesforceInstanceUrl: "URL de la instancia",
|
||||
salesforceInstanceUrlPlaceholder: "https://acme.my.salesforce.com",
|
||||
salesforceInstanceUrlHint: "Nombre de host del org o URL de instancia completa; se rellena sola desde la respuesta del token tras iniciar sesión.",
|
||||
salesforceAccessToken: "Token de acceso",
|
||||
salesforceAccessTokenPlaceholder: "ID de sesión o token de acceso",
|
||||
salesforceAccessTokenHint: "Pega un token de acceso o ID de sesión de Salesforce. También puedes usar las opciones OAuth de abajo para iniciar sesión en el navegador o mediante código de dispositivo.",
|
||||
salesforceApiVersion: "Versión de la API",
|
||||
salesforceApiVersionHint: "Versión de la API REST de Salesforce, por ejemplo v62.0. Déjalo vacío para usar la versión fija por defecto.",
|
||||
salesforceAuthMode: "Autenticación",
|
||||
salesforceAuthModeToken: "Token de acceso",
|
||||
salesforceAuthModeOauth: "OAuth en navegador",
|
||||
salesforceAuthModeDevice: "Código de dispositivo",
|
||||
salesforceAuthModePassword: "Usuario y contraseña",
|
||||
salesforceAuthModeOauthDesktopOnly: "OAuth en navegador solo está disponible en la aplicación de escritorio.",
|
||||
salesforceEnvironment: "Entorno",
|
||||
salesforceEnvironmentProduction: "Producción",
|
||||
salesforceEnvironmentSandbox: "Sandbox",
|
||||
salesforceEnvironmentCustom: "Personalizado",
|
||||
salesforceLoginUrl: "URL de inicio de sesión",
|
||||
salesforceLoginUrlPlaceholder: "https://login.salesforce.com",
|
||||
salesforceLoginUrlHint: "Sobrescribe la URL de inicio de sesión de Salesforce cuando tu organización usa un endpoint de identidad personalizado.",
|
||||
salesforceClientId: "Client ID",
|
||||
salesforceClientIdPlaceholder: "Consumer Key de tu Connected App",
|
||||
salesforceClientIdHint: "Crea una Connected App en Salesforce Setup → App Manager, habilita OAuth con PKCE y establece la Callback URL en {callbackUrl}.",
|
||||
salesforceClientIdHintShared: "La consumer key de la Connected App. Estos dos modos no usan callback URL, así que sirve una app que tu admin ya haya creado.",
|
||||
salesforceClientSecret: "Client Secret",
|
||||
salesforceClientSecretPlaceholder: "Opcional — solo Connected Apps confidenciales; déjalo vacío en clientes públicos con PKCE",
|
||||
salesforceOauthAuthorize: "Autorizar con Salesforce",
|
||||
salesforceOauthAuthorizing: "Esperando el inicio de sesión en el navegador…",
|
||||
salesforceOauthAuthorizingHint: "Debería haber una ventana del navegador abierta. Completa el inicio de sesión en Salesforce; esta vista se actualizará automáticamente al terminar.",
|
||||
salesforceOauthAuthorized: "Autorizado (credenciales guardadas)",
|
||||
salesforceOauthReauthorize: "Volver a autorizar",
|
||||
salesforceOauthClientIdRequired: "El Client ID de Salesforce es obligatorio.",
|
||||
salesforceOauthLoginUrlRequired: "La URL de inicio de sesión personalizada de Salesforce es obligatoria.",
|
||||
salesforceDeviceGetCode: "Obtener código de dispositivo",
|
||||
salesforceDeviceFlowNote: "Si al pedir el código falla con un error de flujo no soportado o deshabilitado, un admin debe habilitar Device Flow en los ajustes OAuth de la Connected App.",
|
||||
salesforceDeviceUserCodeLabel: "Código de usuario",
|
||||
salesforceDeviceUserCodeCopied: "Código de usuario copiado",
|
||||
salesforceDeviceOpenVerification: "Abrir página de verificación",
|
||||
salesforceDeviceVerificationCopied: "URL de verificación copiada",
|
||||
salesforceDevicePolling: "Esperando la autorización…",
|
||||
salesforceDevicePollingHint: "Abre la página de verificación en cualquier navegador e introduce el código de usuario mostrado arriba.",
|
||||
salesforceDeviceCancel: "Cancelar",
|
||||
salesforceDeviceExpired: "El código de dispositivo ha expirado. Haz clic abajo para solicitar uno nuevo.",
|
||||
salesforceDeviceDenied: "Autorización denegada",
|
||||
salesforceDeviceRestart: "Empezar de nuevo",
|
||||
salesforceDeviceSecondsLeft: "Quedan {seconds} s",
|
||||
salesforceUsernameLabel: "Usuario",
|
||||
salesforceUsernamePlaceholder: "user{'@'}example.com",
|
||||
salesforceUsernameHint: "Usuario de Salesforce. En sandboxes, incluye el sufijo del sandbox (p. ej. user{'@'}example.com.qas1).",
|
||||
salesforceUserPasswordLabel: "Contraseña",
|
||||
salesforceUserPasswordPlaceholder: "Contraseña (opcional + token de seguridad)",
|
||||
salesforceUserPasswordHint: "Tu contraseña de Salesforce. Si tu IP no es de confianza para la organización, añade el token de seguridad justo después de la contraseña. Las sesiones se reautentican automáticamente con estas credenciales guardadas.",
|
||||
salesforcePasswordRopcNote: "No hace falta callback URL: sirve una Connected App existente. Tu perfil necesita API Enabled y la política de usuarios de la app debe cubrirte. La contraseña no vuelve a este formulario, así que el botón de inicio de sesión necesita que la escribas de nuevo.",
|
||||
salesforcePasswordSignIn: "Iniciar sesión",
|
||||
salesforcePasswordSignInAgain: "Volver a iniciar sesión",
|
||||
salesforceUsernameRequired: "El usuario de Salesforce es obligatorio.",
|
||||
salesforceUserPasswordRequired: "La contraseña de Salesforce es obligatoria.",
|
||||
salesforceOauthSuccess: "Conectado a {instanceUrl}",
|
||||
driverInstallHintPrefix: "Instala el driver requerido desde ",
|
||||
driverInstallHintSuffix: " en la barra superior antes de conectar.",
|
||||
driverName: "Nombre del driver",
|
||||
@@ -2499,6 +2562,18 @@ export default withEnglishFallback({
|
||||
rollback: "Revertir",
|
||||
transactionSaveHint: "Confirma {count} cambio(s) pendiente(s) en una transacción.",
|
||||
nonTransactionalSaveHint: "Guarda {count} cambio(s) pendiente(s) uno por uno. Si uno falla, los cambios anteriores correctos no se revierten.",
|
||||
salesforceSaveConfirmTitle: "Aplicar cambios en Salesforce",
|
||||
salesforceSaveConfirmMessage: "{count} cambio(s) pendiente(s) se envían a Salesforce con una llamada REST por registro. No hay transacción: si una llamada falla a mitad de camino, los registros ya escritos permanecen modificados.",
|
||||
salesforceSaveUpdates: "{count} actualización(es)",
|
||||
salesforceSaveInserts: "{count} registro(s) nuevo(s)",
|
||||
salesforceSaveDeletes: "{count} eliminación(es)",
|
||||
salesforceSaveTarget: "Objeto: {object}",
|
||||
salesforceSaveUnknownObject: "desconocido",
|
||||
salesforceSaveProfile: "perfil {name}",
|
||||
salesforceSaveAdminIdentity: "Se escribirá como {user} ({profile}); este perfil tiene “Modify All Data”, que omite la seguridad a nivel de campo y el uso compartido de registros. Confirma con cuidado.",
|
||||
salesforceSaveNonAdminIdentity: "Sesión iniciada como {user} ({profile}).\nLos campos que no puedes escribir ya están en solo lectura;\nlos fallos por uso compartido de registros o reglas de validación se devuelven fila por fila, con el mensaje original de Salesforce.",
|
||||
salesforceSaveUnknownRights: "Sesión iniciada como {user} ({profile}); no se pudo determinar si este perfil tiene “Modify All Data”. Salesforce sigue aplicando sus propios permisos e informa los fallos fila por fila.",
|
||||
salesforceSaveConfirm: "Aplicar en Salesforce",
|
||||
keylessEditWarning: "Sin clave primaria",
|
||||
keylessEditWarningHint: "Esta tabla no tiene clave primaria. Las actualizaciones y eliminaciones usan todos los valores originales de la fila en WHERE; filas duplicadas exactas pueden verse afectadas juntas.",
|
||||
queryEditReadOnly: "Resultado de solo lectura",
|
||||
@@ -8011,6 +8086,10 @@ export default withEnglishFallback({
|
||||
mcpConnectionPolicyReadOnly: "Solo lectura",
|
||||
mcpConnectionPolicySafeWrite: "Permitir escrituras seguras",
|
||||
mcpConnectionPolicyHighRiskWrite: "Permitir operaciones de alto riesgo",
|
||||
mcpConnectionPolicyAllowSalesforceDml: "Permitir DML",
|
||||
mcpConnectionPolicyAllowSalesforceDmlHint:
|
||||
"Permite que un agente de IA cree, actualice o elimine registros individuales de Salesforce en esta organización mediante una confirmación en dos pasos (preparar y luego aplicar). Los modos de solo lectura ignoran este interruptor. Las escrituras en Salesforce no se pueden revertir.",
|
||||
mcpConnectionPolicyAllowSalesforceDmlReadOnlyBlocked: "Esta conexión es de solo lectura, por lo que DML de Salesforce permanece desactivado. Elija primero un modo de ejecución que permita escrituras.",
|
||||
mcpToolPermissionsTitle: "Permisos de herramientas MCP",
|
||||
mcpToolPermissionsDescription: "Expón solo las herramientas MCP que necesitas. Al desmarcarlas, el servidor rechaza las solicitudes aunque el cliente haya cacheado las definiciones.",
|
||||
mcpToolListConnections: "Listar conexiones",
|
||||
@@ -8028,6 +8107,9 @@ export default withEnglishFallback({
|
||||
mcpToolRollbackTransaction: "Revertir transacción",
|
||||
mcpToolCloseSession: "Cerrar sesión de consulta",
|
||||
mcpToolExecuteRedisCommand: "Ejecutar comando Redis",
|
||||
mcpToolSalesforceCurrentUser: "Usuario actual de Salesforce",
|
||||
mcpToolSalesforcePrepareWrite: "Preparar escritura en Salesforce",
|
||||
mcpToolSalesforceApplyWrite: "Aplicar escritura en Salesforce",
|
||||
mcpToolPeekMessages: "Leer mensajes de Kafka",
|
||||
mcpToolSendMessage: "Enviar mensaje de cola de mensajes",
|
||||
mcpToolAddConnection: "Añadir conexión",
|
||||
|
||||
@@ -8010,6 +8010,10 @@ export default withEnglishFallback({
|
||||
mcpConnectionPolicyReadOnly: "Sola lettura",
|
||||
mcpConnectionPolicySafeWrite: "Consenti scritture sicure",
|
||||
mcpConnectionPolicyHighRiskWrite: "Consenti operazioni ad alto rischio",
|
||||
mcpConnectionPolicyAllowSalesforceDml: "Consenti DML",
|
||||
mcpConnectionPolicyAllowSalesforceDmlHint:
|
||||
"Consente a un agente IA di creare, aggiornare o eliminare singoli record Salesforce in questa organizzazione tramite una conferma in due passaggi (prepara, poi applica). Le modalità di sola lettura ignorano questa opzione. Le scritture Salesforce non possono essere annullate.",
|
||||
mcpConnectionPolicyAllowSalesforceDmlReadOnlyBlocked: "Questa connessione è in sola lettura, quindi DML Salesforce resta disattivato. Scegli prima una modalità di esecuzione che consenta le scritture.",
|
||||
mcpToolPermissionsTitle: "Permessi degli strumenti MCP",
|
||||
mcpToolPermissionsDescription: "Esporre solo gli strumenti MCP necessari. Dopo la deselezione, le richieste vengono rifiutate dal server anche se il client ha le definizioni in cache.",
|
||||
mcpToolListConnections: "Elenca connessioni",
|
||||
@@ -8027,6 +8031,9 @@ export default withEnglishFallback({
|
||||
mcpToolRollbackTransaction: "Annulla transazione",
|
||||
mcpToolCloseSession: "Chiudi sessione di query",
|
||||
mcpToolExecuteRedisCommand: "Esegui comando Redis",
|
||||
mcpToolSalesforceCurrentUser: "Utente Salesforce corrente",
|
||||
mcpToolSalesforcePrepareWrite: "Prepara scrittura Salesforce",
|
||||
mcpToolSalesforceApplyWrite: "Applica scrittura Salesforce",
|
||||
mcpToolPeekMessages: "Leggi messaggi Kafka",
|
||||
mcpToolSendMessage: "Invia messaggio in coda",
|
||||
mcpToolAddConnection: "Aggiungi connessione",
|
||||
|
||||
@@ -8014,6 +8014,9 @@ export default withEnglishFallback({
|
||||
mcpConnectionPolicyReadOnly: "読み取り専用のみ",
|
||||
mcpConnectionPolicySafeWrite: "安全な書き込みを許可",
|
||||
mcpConnectionPolicyHighRiskWrite: "高リスク操作を許可",
|
||||
mcpConnectionPolicyAllowSalesforceDml: "DML を許可",
|
||||
mcpConnectionPolicyAllowSalesforceDmlHint: "AI エージェントが 2 段階の確認(準備してから適用)を経て、この組織の Salesforce レコードを 1 件ずつ作成・更新・削除できるようにします。読み取り専用モードではこのスイッチは無視されます。Salesforce の書き込みはロールバックできません。",
|
||||
mcpConnectionPolicyAllowSalesforceDmlReadOnlyBlocked: "この接続は読み取り専用のため、Salesforce DML は無効のままです。先に書き込みを許可する実行モードを選択してください。",
|
||||
mcpToolPermissionsTitle: "MCP ツール権限",
|
||||
mcpToolPermissionsDescription: "必要な MCP ツールのみ公開します。選択を解除すると、ツール定義をキャッシュしたクライアントもサーバー側で拒否されます。",
|
||||
mcpToolListConnections: "接続を一覧",
|
||||
@@ -8031,6 +8034,9 @@ export default withEnglishFallback({
|
||||
mcpToolRollbackTransaction: "トランザクションをロールバック",
|
||||
mcpToolCloseSession: "クエリセッションを閉じる",
|
||||
mcpToolExecuteRedisCommand: "Redis コマンドを実行",
|
||||
mcpToolSalesforceCurrentUser: "Salesforce の現在のユーザー",
|
||||
mcpToolSalesforcePrepareWrite: "Salesforce 書き込みを準備",
|
||||
mcpToolSalesforceApplyWrite: "Salesforce 書き込みを適用",
|
||||
mcpToolPeekMessages: "Kafka メッセージを読み取る",
|
||||
mcpToolSendMessage: "メッセージキューのメッセージを送信",
|
||||
mcpToolAddConnection: "接続を追加",
|
||||
|
||||
@@ -7789,6 +7789,9 @@ export default withEnglishFallback({
|
||||
mcpConnectionPolicyReadOnly: "읽기 전용만",
|
||||
mcpConnectionPolicySafeWrite: "안전한 쓰기 허용",
|
||||
mcpConnectionPolicyHighRiskWrite: "고위험 작업 허용",
|
||||
mcpConnectionPolicyAllowSalesforceDml: "DML 허용",
|
||||
mcpConnectionPolicyAllowSalesforceDmlHint: "AI 에이전트가 2단계 확인(준비 후 적용)을 거쳐 이 조직의 Salesforce 레코드를 하나씩 생성·수정·삭제할 수 있게 합니다. 읽기 전용 모드에서는 이 스위치가 무시됩니다. Salesforce 쓰기는 롤백할 수 없습니다.",
|
||||
mcpConnectionPolicyAllowSalesforceDmlReadOnlyBlocked: "이 연결은 읽기 전용이므로 Salesforce DML은 비활성 상태로 유지됩니다. 먼저 쓰기를 허용하는 실행 모드를 선택하세요.",
|
||||
mcpToolPermissionsTitle: "MCP 도구 권한",
|
||||
mcpToolPermissionsDescription: "필요한 MCP 도구만 노출하세요. 선택 해제하면 도구 정의를 캐시한 클라이언트도 서버에서 거부됩니다.",
|
||||
mcpToolListConnections: "연결 나열",
|
||||
@@ -7806,6 +7809,9 @@ export default withEnglishFallback({
|
||||
mcpToolRollbackTransaction: "트랜잭션 롤백",
|
||||
mcpToolCloseSession: "쿼리 세션 닫기",
|
||||
mcpToolExecuteRedisCommand: "Redis 명령 실행",
|
||||
mcpToolSalesforceCurrentUser: "Salesforce 현재 사용자",
|
||||
mcpToolSalesforcePrepareWrite: "Salesforce 쓰기 준비",
|
||||
mcpToolSalesforceApplyWrite: "Salesforce 쓰기 적용",
|
||||
mcpToolPeekMessages: "Kafka 메시지 읽기",
|
||||
mcpToolSendMessage: "메시지 큐 메시지 전송",
|
||||
mcpToolAddConnection: "연결 추가",
|
||||
|
||||
@@ -8012,6 +8012,10 @@ export default withEnglishFallback({
|
||||
mcpConnectionPolicyReadOnly: "Somente leitura",
|
||||
mcpConnectionPolicySafeWrite: "Permitir escritas seguras",
|
||||
mcpConnectionPolicyHighRiskWrite: "Permitir operações de alto risco",
|
||||
mcpConnectionPolicyAllowSalesforceDml: "Permitir DML",
|
||||
mcpConnectionPolicyAllowSalesforceDmlHint:
|
||||
"Permite que um agente de IA crie, atualize ou exclua registros individuais do Salesforce nesta organização por meio de uma confirmação em duas etapas (preparar e depois aplicar). Modos somente leitura ignoram esta opção. Gravações no Salesforce não podem ser revertidas.",
|
||||
mcpConnectionPolicyAllowSalesforceDmlReadOnlyBlocked: "Esta conexão é somente leitura, então o DML do Salesforce permanece desativado. Escolha primeiro um modo de execução que permita gravações.",
|
||||
mcpToolPermissionsTitle: "Permissões de ferramentas MCP",
|
||||
mcpToolPermissionsDescription: "Exponha apenas as ferramentas MCP necessárias. Ao desmarcá-las, o servidor recusa as solicitações mesmo que o cliente tenha as definições em cache.",
|
||||
mcpToolListConnections: "Listar conexões",
|
||||
@@ -8029,6 +8033,9 @@ export default withEnglishFallback({
|
||||
mcpToolRollbackTransaction: "Reverter transação",
|
||||
mcpToolCloseSession: "Fechar sessão de consulta",
|
||||
mcpToolExecuteRedisCommand: "Executar comando Redis",
|
||||
mcpToolSalesforceCurrentUser: "Usuário atual do Salesforce",
|
||||
mcpToolSalesforcePrepareWrite: "Preparar gravação no Salesforce",
|
||||
mcpToolSalesforceApplyWrite: "Aplicar gravação no Salesforce",
|
||||
mcpToolPeekMessages: "Ler mensagens Kafka",
|
||||
mcpToolSendMessage: "Enviar mensagem da fila",
|
||||
mcpToolAddConnection: "Adicionar conexão",
|
||||
|
||||
@@ -8009,6 +8009,10 @@ export default withEnglishFallback({
|
||||
mcpConnectionPolicyReadOnly: "Salt okunur",
|
||||
mcpConnectionPolicySafeWrite: "Güvenli yazmalara izin ver",
|
||||
mcpConnectionPolicyHighRiskWrite: "Yüksek riskli işlemlere izin ver",
|
||||
mcpConnectionPolicyAllowSalesforceDml: "DML'ye izin ver",
|
||||
mcpConnectionPolicyAllowSalesforceDmlHint:
|
||||
"Bir yapay zekâ aracısının iki adımlı onay (önce hazırla, sonra uygula) ile bu kuruluştaki tek tek Salesforce kayıtlarını oluşturmasına, güncellemesine veya silmesine izin verir. Salt okunur modlar bu anahtarı yok sayar. Salesforce yazmaları geri alınamaz.",
|
||||
mcpConnectionPolicyAllowSalesforceDmlReadOnlyBlocked: "Bu bağlantı salt okunur olduğundan Salesforce DML devre dışı kalır. Önce yazmaya izin veren bir çalıştırma modu seçin.",
|
||||
mcpToolPermissionsTitle: "MCP araç yetkileri",
|
||||
mcpToolPermissionsDescription: "Yalnızca ihtiyacınız olan MCP araçlarını açın. Seçim kaldırıldığında, araç tanımlarını önbelleğe almış olsalar bile istemciler sunucu tarafından reddedilir.",
|
||||
mcpToolListConnections: "Bağlantıları listele",
|
||||
@@ -8026,6 +8030,9 @@ export default withEnglishFallback({
|
||||
mcpToolRollbackTransaction: "İşlemi geri al",
|
||||
mcpToolCloseSession: "Sorgu oturumunu kapat",
|
||||
mcpToolExecuteRedisCommand: "Redis komutunu çalıştır",
|
||||
mcpToolSalesforceCurrentUser: "Salesforce geçerli kullanıcısı",
|
||||
mcpToolSalesforcePrepareWrite: "Salesforce yazmasını hazırla",
|
||||
mcpToolSalesforceApplyWrite: "Salesforce yazmasını uygula",
|
||||
mcpToolPeekMessages: "Kafka mesajlarını oku",
|
||||
mcpToolSendMessage: "Mesaj kuyruğu mesajı gönder",
|
||||
mcpToolAddConnection: "Bağlantı ekle",
|
||||
|
||||
@@ -316,6 +316,10 @@ export default withEnglishFallback({
|
||||
commit: "提交",
|
||||
rollback: "回滚",
|
||||
txnAutoRolledBack: "事务已因 5 分钟无操作而自动回滚",
|
||||
salesforceIdentityAdmin: "简档具有“修改所有数据”权限——会绕过字段级安全与记录共享",
|
||||
salesforceIdentityNonAdmin: "简档不含“修改所有数据”权限——字段权限与记录共享照常生效",
|
||||
salesforceIdentityUnknownRights: "无法确认简档是否具有“修改所有数据”权限",
|
||||
salesforceIdentityUnknownProfile: "简档未知",
|
||||
autoCommitTxnRolledBack: "该标签页遗留的显式事务已被自动回滚;如需跨多次执行保留事务,可在设置中开启「自动提交模式下保留显式事务」",
|
||||
autoCommitSessionTxnRolledBack: "该连接处于 autocommit=0,本次执行留下的隐式事务已被自动回滚;同一连接只提示一次",
|
||||
previewSql: "预览 SQL",
|
||||
@@ -773,6 +777,65 @@ export default withEnglishFallback({
|
||||
victoriametricsLookbackHint: "用于指标列表和标签列,例如 1h、7d。双击指标默认预览 1h,并可直接修改查询。",
|
||||
victoriametricsInvalidApiPath: "VictoriaMetrics API 路径必须为空或以 / 开头。",
|
||||
victoriametricsInvalidLookback: "VictoriaMetrics 回看范围格式无效,请使用 15m、1h、7d 等格式。",
|
||||
salesforceInstanceUrl: "实例 URL",
|
||||
salesforceInstanceUrlPlaceholder: "https://acme.my.salesforce.com",
|
||||
salesforceInstanceUrlHint: "org 域名或完整实例 URL;登录成功后由令牌响应自动回填。",
|
||||
salesforceAccessToken: "访问令牌 (Access Token)",
|
||||
salesforceAccessTokenPlaceholder: "Session ID 或访问令牌",
|
||||
salesforceAccessTokenHint: "粘贴 Salesforce 访问令牌或 Session ID;也可使用下方的 OAuth 浏览器授权或设备码方式登录。",
|
||||
salesforceApiVersion: "API 版本",
|
||||
salesforceApiVersionHint: "Salesforce REST API 版本,例如 v62.0。留空则使用默认固定版本。",
|
||||
salesforceAuthMode: "认证方式",
|
||||
salesforceAuthModeToken: "访问令牌",
|
||||
salesforceAuthModeOauth: "浏览器 OAuth",
|
||||
salesforceAuthModeDevice: "设备码",
|
||||
salesforceAuthModePassword: "用户名密码",
|
||||
salesforceAuthModeOauthDesktopOnly: "浏览器 OAuth 仅在桌面应用中可用。",
|
||||
salesforceEnvironment: "环境",
|
||||
salesforceEnvironmentProduction: "生产",
|
||||
salesforceEnvironmentSandbox: "沙箱",
|
||||
salesforceEnvironmentCustom: "自定义",
|
||||
salesforceLoginUrl: "登录 URL",
|
||||
salesforceLoginUrlPlaceholder: "https://login.salesforce.com",
|
||||
salesforceLoginUrlHint: "当您的组织使用自定义身份端点时,可在此覆盖 Salesforce 登录 URL。",
|
||||
salesforceClientId: "客户端 ID (Client ID)",
|
||||
salesforceClientIdPlaceholder: "Connected App 的 Consumer Key",
|
||||
salesforceClientIdHint: "在 Salesforce Setup → App Manager 创建 Connected App,启用 OAuth 并勾选 PKCE,回调 URL 设置为 {callbackUrl}。",
|
||||
salesforceClientIdHintShared: "Connected App 的 Consumer Key。这两种方式不使用回调 URL,可直接复用管理员已创建的 Connected App。",
|
||||
salesforceClientSecret: "客户端密钥 (Client Secret)",
|
||||
salesforceClientSecretPlaceholder: "可选 — 仅机密 Connected App 需要;PKCE 公共客户端留空",
|
||||
salesforceOauthAuthorize: "通过 Salesforce 授权",
|
||||
salesforceOauthAuthorizing: "正在等待浏览器登录…",
|
||||
salesforceOauthAuthorizingHint: "浏览器窗口应当已打开。请在 Salesforce 完成登录,完成后此界面会自动更新。",
|
||||
salesforceOauthAuthorized: "已授权(凭据已保存)",
|
||||
salesforceOauthReauthorize: "重新授权",
|
||||
salesforceOauthClientIdRequired: "需要填写 Salesforce 客户端 ID。",
|
||||
salesforceOauthLoginUrlRequired: "需要填写 Salesforce 自定义登录 URL。",
|
||||
salesforceDeviceGetCode: "获取设备码",
|
||||
salesforceDeviceFlowNote: "若获取设备码时报 unsupported / disabled 类错误,需管理员在该 Connected App 的 OAuth 设置中启用 Device Flow。",
|
||||
salesforceDeviceUserCodeLabel: "用户码",
|
||||
salesforceDeviceUserCodeCopied: "已复制用户码",
|
||||
salesforceDeviceOpenVerification: "打开验证页面",
|
||||
salesforceDeviceVerificationCopied: "已复制验证 URL",
|
||||
salesforceDevicePolling: "正在等待授权…",
|
||||
salesforceDevicePollingHint: "在任意浏览器打开验证页面,然后输入上方显示的用户码。",
|
||||
salesforceDeviceCancel: "取消",
|
||||
salesforceDeviceExpired: "设备码已过期。点击下方重新获取。",
|
||||
salesforceDeviceDenied: "授权被拒绝",
|
||||
salesforceDeviceRestart: "重新开始",
|
||||
salesforceDeviceSecondsLeft: "剩余 {seconds} 秒",
|
||||
salesforceUsernameLabel: "用户名",
|
||||
salesforceUsernamePlaceholder: "user{'@'}example.com",
|
||||
salesforceUsernameHint: "Salesforce 用户名。沙盒环境需带上沙盒后缀(例如 user{'@'}example.com.qas1)。",
|
||||
salesforceUserPasswordLabel: "用户密码",
|
||||
salesforceUserPasswordPlaceholder: "密码(可选附加安全令牌)",
|
||||
salesforceUserPasswordHint: "你的 Salesforce 密码。若当前 IP 未被组织信任,请在密码后直接拼接安全令牌。会话过期时会用已保存的凭据自动重新登录。",
|
||||
salesforcePasswordRopcNote: "无需回调 URL,复用已有 Connected App 即可;要求你的 Profile 有 API Enabled,且该 app 的授权策略覆盖当前用户。密码不会回传到界面,「重新登录」需重新输入密码。",
|
||||
salesforcePasswordSignIn: "登录",
|
||||
salesforcePasswordSignInAgain: "重新登录",
|
||||
salesforceUsernameRequired: "请填写 Salesforce 用户名。",
|
||||
salesforceUserPasswordRequired: "请填写 Salesforce 密码。",
|
||||
salesforceOauthSuccess: "已连接 {instanceUrl}",
|
||||
driverInstallHintPrefix: "需要在顶部导航栏「",
|
||||
driverInstallHintSuffix: "」中安装对应的驱动才能连接。",
|
||||
driverName: "驱动名称",
|
||||
@@ -2588,6 +2651,18 @@ export default withEnglishFallback({
|
||||
rollback: "回滚",
|
||||
transactionSaveHint: "在事务中提交 {count} 项待保存更改。",
|
||||
nonTransactionalSaveHint: "逐条保存 {count} 项更改;如果中途失败,前面已成功的更改不会回滚。",
|
||||
salesforceSaveConfirmTitle: "将更改写入 Salesforce",
|
||||
salesforceSaveConfirmMessage: "{count} 项待保存更改会逐条以 REST 调用写入 Salesforce。没有事务:如果中途某条失败,之前已写入的记录不会回滚。",
|
||||
salesforceSaveUpdates: "更新 {count} 条",
|
||||
salesforceSaveInserts: "新增 {count} 条",
|
||||
salesforceSaveDeletes: "删除 {count} 条",
|
||||
salesforceSaveTarget: "对象:{object}",
|
||||
salesforceSaveUnknownObject: "未知",
|
||||
salesforceSaveProfile: "简档 {name}",
|
||||
salesforceSaveAdminIdentity: "将以 {user}({profile})身份写入;该简档具有“修改所有数据”权限,会绕过字段级安全与记录共享——请格外确认。",
|
||||
salesforceSaveNonAdminIdentity: "当前身份:{user}({profile})\n不可写字段已按你的权限设为只读;\n记录级共享或验证规则导致的失败会按行返回 Salesforce 原文。",
|
||||
salesforceSaveUnknownRights: "当前身份:{user}({profile});未能确认该简档是否具有“修改所有数据”权限——Salesforce 仍会按自身权限校验,失败按行返回原文。",
|
||||
salesforceSaveConfirm: "写入 Salesforce",
|
||||
keylessEditWarning: "无主键",
|
||||
keylessEditWarningHint: "这张表没有主键。保存更新或删除时会使用整行原始值作为 WHERE 条件;如果存在完全重复的行,可能会影响多行。",
|
||||
queryEditReadOnly: "只读结果",
|
||||
@@ -8651,6 +8726,9 @@ export default withEnglishFallback({
|
||||
mcpConnectionPolicyReadOnly: "仅只读",
|
||||
mcpConnectionPolicySafeWrite: "允许安全写入",
|
||||
mcpConnectionPolicyHighRiskWrite: "允许高风险操作",
|
||||
mcpConnectionPolicyAllowSalesforceDml: "允许 DML",
|
||||
mcpConnectionPolicyAllowSalesforceDmlHint: "允许 AI 代理通过两步确认(先准备、再应用)在该组织中新增、更新或删除单条 Salesforce 记录。只读模式会忽略此开关。Salesforce 写入无法回滚。",
|
||||
mcpConnectionPolicyAllowSalesforceDmlReadOnlyBlocked: "该连接为只读,Salesforce DML 保持关闭。请先选择允许写入的执行模式。",
|
||||
mcpToolPermissionsTitle: "MCP 工具权限",
|
||||
mcpToolPermissionsDescription: "只暴露当前需要的 MCP 工具。取消选择后,客户端即使缓存了工具定义也会被服务端拒绝。",
|
||||
mcpToolListConnections: "列出连接",
|
||||
@@ -8668,6 +8746,9 @@ export default withEnglishFallback({
|
||||
mcpToolRollbackTransaction: "回滚事务",
|
||||
mcpToolCloseSession: "关闭查询会话",
|
||||
mcpToolExecuteRedisCommand: "执行 Redis 命令",
|
||||
mcpToolSalesforceCurrentUser: "Salesforce 当前用户",
|
||||
mcpToolSalesforcePrepareWrite: "准备 Salesforce 写入",
|
||||
mcpToolSalesforceApplyWrite: "应用 Salesforce 写入",
|
||||
mcpToolPeekMessages: "读取 Kafka 消息",
|
||||
mcpToolSendMessage: "发送消息队列消息",
|
||||
mcpToolAddConnection: "新增连接",
|
||||
|
||||
@@ -7373,6 +7373,9 @@ export default withEnglishFallback({
|
||||
mcpConnectionPolicyReadOnly: "僅唯讀",
|
||||
mcpConnectionPolicySafeWrite: "允許安全寫入",
|
||||
mcpConnectionPolicyHighRiskWrite: "允許高風險操作",
|
||||
mcpConnectionPolicyAllowSalesforceDml: "允許 DML",
|
||||
mcpConnectionPolicyAllowSalesforceDmlHint: "允許 AI 代理透過兩步驟確認(先準備、再套用)在該組織中新增、更新或刪除單筆 Salesforce 記錄。唯讀模式會忽略此開關。Salesforce 寫入無法復原。",
|
||||
mcpConnectionPolicyAllowSalesforceDmlReadOnlyBlocked: "該連線為唯讀,Salesforce DML 保持關閉。請先選擇允許寫入的執行模式。",
|
||||
mcpToolPermissionsTitle: "MCP 工具權限",
|
||||
mcpToolPermissionsDescription: "只暴露目前需要的 MCP 工具。取消選擇後,用戶端即使快取了工具定義也會被服務端拒絕。",
|
||||
mcpToolListConnections: "列出連線",
|
||||
@@ -7390,6 +7393,9 @@ export default withEnglishFallback({
|
||||
mcpToolRollbackTransaction: "回滾交易",
|
||||
mcpToolCloseSession: "關閉查詢工作階段",
|
||||
mcpToolExecuteRedisCommand: "執行 Redis 命令",
|
||||
mcpToolSalesforceCurrentUser: "Salesforce 目前使用者",
|
||||
mcpToolSalesforcePrepareWrite: "準備 Salesforce 寫入",
|
||||
mcpToolSalesforceApplyWrite: "套用 Salesforce 寫入",
|
||||
mcpToolPeekMessages: "讀取 Kafka 訊息",
|
||||
mcpToolSendMessage: "傳送訊息佇列訊息",
|
||||
mcpToolAddConnection: "新增連線",
|
||||
|
||||
@@ -89,6 +89,19 @@ describe("MCP tool permission selection", () => {
|
||||
expect(MCP_TOOL_OPTIONS.map((tool) => tool.name).sort()).toEqual(registeredToolNames);
|
||||
});
|
||||
|
||||
it("keeps the Salesforce tools individually switchable, writes included", () => {
|
||||
expect(MCP_TOOL_OPTIONS.filter((tool) => tool.name.startsWith("dbx_salesforce_")).map((tool) => [tool.name, tool.labelKey])).toEqual([
|
||||
["dbx_salesforce_current_user", "settings.mcpToolSalesforceCurrentUser"],
|
||||
["dbx_salesforce_prepare_write", "settings.mcpToolSalesforcePrepareWrite"],
|
||||
["dbx_salesforce_apply_write", "settings.mcpToolSalesforceApplyWrite"],
|
||||
]);
|
||||
|
||||
// An admin can expose reading (identity + SOQL) without handing over the write path.
|
||||
const readOnlySfdc = toggleMcpAllowedToolName(null, "dbx_salesforce_apply_write", false);
|
||||
expect(toggleMcpAllowedToolName(readOnlySfdc, "dbx_salesforce_prepare_write", false)).not.toContain("dbx_salesforce_prepare_write");
|
||||
expect(readOnlySfdc).toContain("dbx_salesforce_current_user");
|
||||
});
|
||||
|
||||
it("keeps batch execution allowed when allow-all becomes an explicit allowlist", () => {
|
||||
const next = toggleMcpAllowedToolName(null, "dbx_send_message", false);
|
||||
|
||||
|
||||
@@ -12,8 +12,11 @@ import {
|
||||
hasCompleteTdengineRowIdentity,
|
||||
isClickHouseExistingRowReadonlyColumn,
|
||||
isHiddenGridColumn,
|
||||
isSalesforceExistingRowReadonlyColumn,
|
||||
isSalesforceNewRowReadonlyColumn,
|
||||
isTdengineExistingRowReadonlyColumn,
|
||||
isTableDataEditable,
|
||||
parseSalesforceColumnExtra,
|
||||
supportsDataGridTransaction,
|
||||
shouldIncludeSyntheticRowId,
|
||||
usesSyntheticRowIdKey,
|
||||
@@ -171,4 +174,62 @@ describe("tableEditing", () => {
|
||||
expect(isClickHouseExistingRowReadonlyColumn("clickhouse", "event_date", ["id"], [{ ...column("event_date"), extra: "partition_key" }])).toBe(true);
|
||||
expect(isClickHouseExistingRowReadonlyColumn("postgres", "id", ["id"])).toBe(false);
|
||||
});
|
||||
|
||||
describe("salesforce", () => {
|
||||
const describeColumns = [
|
||||
{ ...column("Id", true), extra: JSON.stringify({ updateable: false, createable: false, custom: false, label: "Record ID" }) },
|
||||
{ ...column("Name"), extra: JSON.stringify({ updateable: true, createable: true, custom: false, label: "Account Name" }) },
|
||||
{ ...column("CreatedDate"), extra: JSON.stringify({ updateable: false, createable: false, custom: false, label: "Created Date" }) },
|
||||
{ ...column("Revenue_Rollup__c"), extra: JSON.stringify({ updateable: false, createable: false, custom: true, label: "Revenue Rollup" }) },
|
||||
{ ...column("First_Name__c"), extra: JSON.stringify({ updateable: true, createable: true, custom: true, label: "First Name" }) },
|
||||
{ ...column("OwnerId"), extra: JSON.stringify({ updateable: true, createable: true, custom: false, label: "Owner ID", referenceTo: ["User"], relationshipName: "Owner" }) },
|
||||
];
|
||||
|
||||
it("reads describe flags defensively", () => {
|
||||
expect(parseSalesforceColumnExtra('{"updateable":false,"label":"Created Date"}')).toEqual({ updateable: false, label: "Created Date" });
|
||||
// ClickHouse packs a bare marker string; a non-object must never be read as flags.
|
||||
expect(parseSalesforceColumnExtra("partition_key")).toBeNull();
|
||||
expect(parseSalesforceColumnExtra("[1,2]")).toBeNull();
|
||||
expect(parseSalesforceColumnExtra("")).toBeNull();
|
||||
expect(parseSalesforceColumnExtra(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the record Id and non-updateable fields readonly on existing rows", () => {
|
||||
expect(isSalesforceExistingRowReadonlyColumn("salesforce", "Id", ["Id"], describeColumns)).toBe(true);
|
||||
expect(isSalesforceExistingRowReadonlyColumn("salesforce", "id", ["Id"], describeColumns)).toBe(true);
|
||||
expect(isSalesforceExistingRowReadonlyColumn("salesforce", "CreatedDate", ["Id"], describeColumns)).toBe(true);
|
||||
expect(isSalesforceExistingRowReadonlyColumn("salesforce", "Revenue_Rollup__c", ["Id"], describeColumns)).toBe(true);
|
||||
expect(isSalesforceExistingRowReadonlyColumn("salesforce", "Name", ["Id"], describeColumns)).toBe(false);
|
||||
expect(isSalesforceExistingRowReadonlyColumn("salesforce", "first_name__c", ["Id"], describeColumns)).toBe(false);
|
||||
expect(isSalesforceExistingRowReadonlyColumn("salesforce", "OwnerId", ["Id"], describeColumns)).toBe(false);
|
||||
expect(isSalesforceExistingRowReadonlyColumn("postgres", "Id", ["Id"], describeColumns)).toBe(false);
|
||||
});
|
||||
|
||||
it("fails open when describe metadata is missing so Salesforce decides", () => {
|
||||
expect(isSalesforceExistingRowReadonlyColumn("salesforce", "Unknown__c", ["Id"], describeColumns)).toBe(false);
|
||||
expect(isSalesforceExistingRowReadonlyColumn("salesforce", "Name", ["Id"])).toBe(false);
|
||||
expect(isSalesforceNewRowReadonlyColumn("salesforce", "Unknown__c", describeColumns)).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks non-createable fields on new rows only", () => {
|
||||
expect(isSalesforceNewRowReadonlyColumn("salesforce", "Id", describeColumns)).toBe(true);
|
||||
expect(isSalesforceNewRowReadonlyColumn("salesforce", "CreatedDate", describeColumns)).toBe(true);
|
||||
expect(isSalesforceNewRowReadonlyColumn("salesforce", "Revenue_Rollup__c", describeColumns)).toBe(true);
|
||||
expect(isSalesforceNewRowReadonlyColumn("salesforce", "Name", describeColumns)).toBe(false);
|
||||
expect(isSalesforceNewRowReadonlyColumn("salesforce", "First_Name__c", describeColumns)).toBe(false);
|
||||
expect(isSalesforceNewRowReadonlyColumn("mysql", "Id", describeColumns)).toBe(false);
|
||||
// `Id` is not updateable either, but the primary-key rule covers existing rows.
|
||||
expect(isSalesforceExistingRowReadonlyColumn("salesforce", "Id", [], describeColumns)).toBe(true);
|
||||
});
|
||||
|
||||
it("edits table data row by row without a transaction", () => {
|
||||
expect(isTableDataEditable("salesforce", ["Id"], "TABLE")).toBe(true);
|
||||
expect(canEditExistingTableRows("salesforce", undefined, ["Id"])).toBe(true);
|
||||
expect(canEditExistingTableRows("salesforce", undefined, [])).toBe(false);
|
||||
expect(canInsertTableRows("salesforce")).toBe(true);
|
||||
expect(supportsDataGridTransaction("salesforce")).toBe(false);
|
||||
expect(canUseKeylessRowPredicate("salesforce", [])).toBe(false);
|
||||
expect(canDeleteExistingTdengineRows("salesforce", ["Id"])).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+23
-1
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { encodeSqlServerLinkedSchema } from "@/lib/database/sqlServerLinkedServers";
|
||||
import { qualifiedTableName, qualifyTableReferencesInSql, quoteTableDataIdentifier, quoteTableIdentifier, quoteTableIdentifierIfNeeded, tableMetaWithoutOptionalDatabaseQualifier } from "@/lib/table/tableSelectSql";
|
||||
import { qualifiedTableName, qualifyTableReferencesInSql, quoteTableDataIdentifier, quoteTableIdentifier, quoteTableIdentifierIfNeeded, requiresEagerTableMetadataForDataOpen, tableMetaWithoutOptionalDatabaseQualifier } from "@/lib/table/tableSelectSql";
|
||||
|
||||
describe("qualifiedTableName — Doris/StarRocks multi-catalog", () => {
|
||||
it("prefixes external catalog for Doris (no schema)", () => {
|
||||
@@ -305,6 +305,28 @@ describe("quoteTableIdentifier", () => {
|
||||
expect(quoteTableIdentifier("bigquery", "a`b")).toBe("`a\\`b`");
|
||||
});
|
||||
|
||||
it("never quotes Salesforce SOQL identifiers", () => {
|
||||
// SOQL has no delimited identifiers and reads `"` as a string literal, so the
|
||||
// grid's ORDER BY / filter must send object and field API names bare — the
|
||||
// quoted form the backend used to produce failed with MALFORMED_QUERY.
|
||||
expect(quoteTableIdentifier("salesforce", "Account")).toBe("Account");
|
||||
expect(quoteTableIdentifier("salesforce", "First_Name__c")).toBe("First_Name__c");
|
||||
expect(quoteTableDataIdentifier("salesforce", "Account")).toBe("Account");
|
||||
// An org is a single scope: no schema or database qualifier.
|
||||
expect(qualifiedTableName({ databaseType: "salesforce", schema: "sales", database: "org", tableName: "Account" })).toBe("Account");
|
||||
});
|
||||
|
||||
it("awaits table metadata before building Salesforce data-preview SQL", () => {
|
||||
// Without a column list the backend falls back to `FIELDS(ALL)`, which the org
|
||||
// only accepts with LIMIT 200 or less, so the describe has to land first.
|
||||
expect(requiresEagerTableMetadataForDataOpen("salesforce")).toBe(true);
|
||||
expect(requiresEagerTableMetadataForDataOpen("mysql")).toBe(true);
|
||||
expect(requiresEagerTableMetadataForDataOpen("postgres")).toBe(true);
|
||||
// Drivers whose preview works from `SELECT *` keep loading metadata lazily.
|
||||
expect(requiresEagerTableMetadataForDataOpen("sqlite")).toBe(false);
|
||||
expect(requiresEagerTableMetadataForDataOpen(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("backtick-quotes Cloud Spanner GoogleSQL identifiers by default", () => {
|
||||
expect(quoteTableIdentifier("spanner", "order")).toBe("`order`");
|
||||
expect(quoteTableIdentifier("spanner", "a`b")).toBe("`a\\`b`");
|
||||
|
||||
@@ -58,6 +58,12 @@ function forward<K extends keyof Backend>(name: K): Backend[K] {
|
||||
export const testConnection = forward("testConnection");
|
||||
export const testSshTunnel = forward("testSshTunnel");
|
||||
export const testConnectionWithInfo = forward("testConnectionWithInfo");
|
||||
export const salesforceOauthBrowserAuthorize = forward("salesforceOauthBrowserAuthorize");
|
||||
export const salesforceOauthDeviceStart = forward("salesforceOauthDeviceStart");
|
||||
export const salesforceOauthDevicePoll = forward("salesforceOauthDevicePoll");
|
||||
export const salesforceOauthRefresh = forward("salesforceOauthRefresh");
|
||||
export const salesforceOauthPasswordLogin = forward("salesforceOauthPasswordLogin");
|
||||
export const salesforceCurrentUser = forward("salesforceCurrentUser");
|
||||
export const connectDb = forward("connectDb");
|
||||
export const connectionDatabaseInfo = forward("connectionDatabaseInfo");
|
||||
export const saveConnectionDatabaseInfo = forward("saveConnectionDatabaseInfo");
|
||||
|
||||
@@ -84,6 +84,7 @@ import type { SchemaDiffPreparation, SchemaDiffPreparationOptions, SchemaSyncSql
|
||||
import type { SidebarObjectKind } from "@/lib/database/databaseObjectCapabilities";
|
||||
import type { AiConfig, AiTestConnectionResult } from "@/stores/settingsStore";
|
||||
import type { AiChatSelectionState, AiEffortCapability } from "@/types/ai";
|
||||
import type { SalesforceCurrentUser, SalesforceOAuthAuthorizeParams, SalesforceOAuthDevicePollResult, SalesforceOAuthDeviceStartResult, SalesforceOAuthRefreshResult, SalesforceOAuthToken } from "@/types/salesforce";
|
||||
import type {
|
||||
AgentDriverInfo,
|
||||
AiCompletionRequest,
|
||||
@@ -463,6 +464,39 @@ export async function testConnectionWithInfo(config: ConnectionConfig): Promise<
|
||||
return normalizeConnectionTestResult(await response.json(), config);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Salesforce OAuth (browser redirect + device-code flows)
|
||||
// ---------------------------------------------------------------------------
|
||||
// Web mode only supports the device-code flow end-to-end; the browser-redirect
|
||||
// flow is gated by the desktop shell (it binds a localhost callback server).
|
||||
// The HTTP endpoints mirror the Tauri commands one-for-one so `api.ts` can
|
||||
// forward both transports through the same surface.
|
||||
|
||||
export async function salesforceOauthBrowserAuthorize(params: SalesforceOAuthAuthorizeParams): Promise<SalesforceOAuthToken> {
|
||||
return post("/api/connection/salesforce-oauth-browser-authorize", { params });
|
||||
}
|
||||
|
||||
export async function salesforceOauthDeviceStart(params: SalesforceOAuthAuthorizeParams): Promise<SalesforceOAuthDeviceStartResult> {
|
||||
return post("/api/connection/salesforce-oauth-device-start", { params });
|
||||
}
|
||||
|
||||
export async function salesforceOauthDevicePoll(params: SalesforceOAuthAuthorizeParams, deviceCode: string, intervalSecs: number): Promise<SalesforceOAuthDevicePollResult> {
|
||||
return post("/api/connection/salesforce-oauth-device-poll", { params, deviceCode, intervalSecs });
|
||||
}
|
||||
|
||||
export async function salesforceOauthRefresh(params: SalesforceOAuthAuthorizeParams, refreshToken: string): Promise<SalesforceOAuthRefreshResult> {
|
||||
return post("/api/connection/salesforce-oauth-refresh", { params, refreshToken });
|
||||
}
|
||||
|
||||
export async function salesforceOauthPasswordLogin(params: SalesforceOAuthAuthorizeParams, username: string, password: string): Promise<SalesforceOAuthToken> {
|
||||
return post("/api/connection/salesforce-oauth-password-login", { params, username, password });
|
||||
}
|
||||
|
||||
/** Identity of the user an established Salesforce connection is authenticated as (cached backend-side). */
|
||||
export async function salesforceCurrentUser(connectionId: string): Promise<SalesforceCurrentUser> {
|
||||
return get(`/api/salesforce/current-user?${qs({ connection_id: connectionId })}`);
|
||||
}
|
||||
|
||||
export async function connectDb(config: ConnectionConfig, clientAttempt?: number): Promise<string> {
|
||||
return post("/api/connection/connect", { config, clientAttempt });
|
||||
}
|
||||
|
||||
@@ -113,6 +113,7 @@ import type { AnnotationFile, SchemaSnapshot } from "@/docs/types";
|
||||
import type { CollectionInfo } from "@/types/database";
|
||||
import type { SidebarObjectKind } from "@/lib/database/databaseObjectCapabilities";
|
||||
import type { AiChatSelectionState, AiConfig, AiConfigItem, AiEffortCapability, AiEffortLevel, AiTestConnectionResult } from "@/types/ai";
|
||||
import type { SalesforceCurrentUser, SalesforceOAuthAuthorizeParams, SalesforceOAuthDevicePollResult, SalesforceOAuthDeviceStartResult, SalesforceOAuthRefreshResult, SalesforceOAuthToken } from "@/types/salesforce";
|
||||
import type { QueryEditability } from "@/lib/sql/sqlAnalysis";
|
||||
import { isTerminalTransferProgress } from "@/lib/backend/transferProgress";
|
||||
import type {
|
||||
@@ -349,6 +350,8 @@ export interface McpConnectionPolicy {
|
||||
databaseScope: "all" | "selected" | "none";
|
||||
allowedDatabases: string[];
|
||||
databasePolicies: McpDatabasePolicy[];
|
||||
/** Opt-in for AI-agent DML against a Salesforce org; forced off by `readOnly`. */
|
||||
allowSalesforceDml: boolean;
|
||||
}
|
||||
|
||||
export interface McpDatabasePolicy {
|
||||
@@ -1317,6 +1320,35 @@ export async function testConnectionWithInfo(config: ConnectionConfig): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Salesforce OAuth (browser redirect + device-code flows)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function salesforceOauthBrowserAuthorize(params: SalesforceOAuthAuthorizeParams): Promise<SalesforceOAuthToken> {
|
||||
return invokeBackend("salesforce_oauth_browser_authorize", { params });
|
||||
}
|
||||
|
||||
export async function salesforceOauthDeviceStart(params: SalesforceOAuthAuthorizeParams): Promise<SalesforceOAuthDeviceStartResult> {
|
||||
return invokeBackend("salesforce_oauth_device_start", { params });
|
||||
}
|
||||
|
||||
export async function salesforceOauthDevicePoll(params: SalesforceOAuthAuthorizeParams, deviceCode: string, intervalSecs: number): Promise<SalesforceOAuthDevicePollResult> {
|
||||
return invokeBackend("salesforce_oauth_device_poll", { params, deviceCode, intervalSecs });
|
||||
}
|
||||
|
||||
export async function salesforceOauthRefresh(params: SalesforceOAuthAuthorizeParams, refreshToken: string): Promise<SalesforceOAuthRefreshResult> {
|
||||
return invokeBackend("salesforce_oauth_refresh", { params, refreshToken });
|
||||
}
|
||||
|
||||
export async function salesforceOauthPasswordLogin(params: SalesforceOAuthAuthorizeParams, username: string, password: string): Promise<SalesforceOAuthToken> {
|
||||
return invokeBackend("salesforce_oauth_password_login", { params, username, password });
|
||||
}
|
||||
|
||||
/** Identity of the user an established Salesforce connection is authenticated as (cached backend-side). */
|
||||
export async function salesforceCurrentUser(connectionId: string): Promise<SalesforceCurrentUser> {
|
||||
return invokeBackend("salesforce_current_user", { connectionId });
|
||||
}
|
||||
|
||||
export async function connectDb(config: ConnectionConfig, clientAttempt?: number): Promise<string> {
|
||||
return invokeBackend("connect_db", { config, clientAttempt });
|
||||
}
|
||||
|
||||
@@ -152,6 +152,7 @@ export function connectionDisplayUrlScheme(connection: Pick<ConnectionConfig, "d
|
||||
case "dynamodb":
|
||||
case "mq":
|
||||
case "consul":
|
||||
case "salesforce":
|
||||
return connection.ssl ? "https" : "http";
|
||||
case "cloudflare-d1":
|
||||
return "https";
|
||||
@@ -273,6 +274,9 @@ export function connectionUrlPlaceholder(dbType: DatabaseType, driverProfile?: s
|
||||
case "victoriametrics":
|
||||
return "http://user:password@host:port/prometheus";
|
||||
|
||||
case "salesforce":
|
||||
return "https://acme.my.salesforce.com";
|
||||
|
||||
case "jdbc":
|
||||
return "jdbc:mysql://host:3306/database";
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@ const SCHEME_PROFILES: Record<string, ConnectionProfile> = {
|
||||
iotdb: { type: "iotdb", profile: "iotdb", label: "Apache IoTDB", defaultPort: 6667 },
|
||||
iris: { type: "iris", profile: "iris", label: "IRIS", defaultPort: 1972 },
|
||||
victoriametrics: { type: "victoriametrics", profile: "victoriametrics", label: "VictoriaMetrics", defaultPort: 8428 },
|
||||
salesforce: { type: "salesforce", profile: "salesforce", label: "Salesforce", defaultPort: 443 },
|
||||
};
|
||||
|
||||
const OCEANBASE_ORACLE_PROFILE: ConnectionProfile = {
|
||||
@@ -105,6 +106,7 @@ const HTTP_SELECTED_PROFILES: Record<string, ConnectionProfile> = {
|
||||
weaviate: SCHEME_PROFILES.weaviate,
|
||||
chromadb: SCHEME_PROFILES.chromadb,
|
||||
victoriametrics: SCHEME_PROFILES.victoriametrics,
|
||||
salesforce: SCHEME_PROFILES.salesforce,
|
||||
consul: SCHEME_PROFILES.consul,
|
||||
"nacos-v2": SCHEME_PROFILES["nacos-v2"],
|
||||
"nacos-v3": SCHEME_PROFILES["nacos-v3"],
|
||||
|
||||
@@ -76,7 +76,7 @@ const STANDARD_URL_SCHEMES: Partial<Record<DatabaseType, string>> = {
|
||||
};
|
||||
|
||||
/** Schemes that are plain HTTP(S) endpoints. */
|
||||
const HTTP_URL_DB_TYPES = new Set<DatabaseType>(["elasticsearch", "easysearch", "meilisearch", "solr", "qdrant", "milvus", "weaviate", "chromadb", "rqlite", "consul", "victoriametrics", "influxdb", "influxdb3", "dynamodb"]);
|
||||
const HTTP_URL_DB_TYPES = new Set<DatabaseType>(["elasticsearch", "easysearch", "meilisearch", "solr", "qdrant", "milvus", "weaviate", "chromadb", "rqlite", "consul", "victoriametrics", "influxdb", "influxdb3", "dynamodb", "salesforce"]);
|
||||
|
||||
/**
|
||||
* Generic `prefix://host:port/db` JDBC dialects. Dialects with a different URL
|
||||
|
||||
@@ -5,7 +5,7 @@ const DRAFT_VISIBLE_DATABASES_PREFIX = "__visible_draft_";
|
||||
|
||||
// Turso and Cloudflare D1 target one fixed SQLite-compatible `main` namespace;
|
||||
// non-database services expose their own root objects rather than database namespaces.
|
||||
const UNSUPPORTED_VISIBLE_DATABASE_TYPES = new Set<DatabaseType>(["turso", "cloudflare-d1", "dynamodb", "elasticsearch", "easysearch", "meilisearch", "solr", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "nacos", "consul", "plugin"]);
|
||||
const UNSUPPORTED_VISIBLE_DATABASE_TYPES = new Set<DatabaseType>(["turso", "cloudflare-d1", "dynamodb", "elasticsearch", "easysearch", "meilisearch", "solr", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "nacos", "consul", "plugin", "salesforce"]);
|
||||
|
||||
type VisibleDatabaseConnectionFields = Pick<
|
||||
ConnectionConfig,
|
||||
|
||||
@@ -25,7 +25,7 @@ export function isColumnNameCopySeparator(value: unknown): value is ColumnNameCo
|
||||
}
|
||||
|
||||
// SQL 执行能力是产品能力,不代表数据库支持 SQL 标识符引用。
|
||||
const NON_SQL_DATABASE_TYPES = new Set<DatabaseType>(["redis", "mongodb", "elasticsearch", "easysearch", "meilisearch", "solr", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos", "victoriametrics"]);
|
||||
const NON_SQL_DATABASE_TYPES = new Set<DatabaseType>(["redis", "mongodb", "elasticsearch", "easysearch", "meilisearch", "solr", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos", "victoriametrics", "salesforce"]);
|
||||
// quoteTableIdentifier 对 jdbc/iotdb 原样返回(无引用字符可用)。
|
||||
const UNQUOTABLE_DATABASE_TYPES = new Set<DatabaseType>(["jdbc", "iotdb"]);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
|
||||
const NON_SQL_PREVIEW_DATABASE_TYPES: ReadonlySet<DatabaseType> = new Set<DatabaseType>(["mongodb", "elasticsearch", "easysearch", "meilisearch", "solr"]);
|
||||
const NON_SQL_PREVIEW_DATABASE_TYPES: ReadonlySet<DatabaseType> = new Set<DatabaseType>(["mongodb", "elasticsearch", "easysearch", "meilisearch", "solr", "salesforce"]);
|
||||
|
||||
export function dataGridPreviewLabelKey(databaseType?: DatabaseType): "toolbar.previewQuery" | "toolbar.previewSql" {
|
||||
return databaseType && NON_SQL_PREVIEW_DATABASE_TYPES.has(databaseType) ? "toolbar.previewQuery" : "toolbar.previewSql";
|
||||
|
||||
@@ -128,7 +128,7 @@ export function supportsClearableQuerySchema(dbType?: DatabaseType): boolean {
|
||||
* hidden there as well (issue #9609).
|
||||
*/
|
||||
export function supportsConnectionQueryActions(dbType?: DatabaseType): boolean {
|
||||
return dbType !== "nacos" && dbType !== "consul" && dbType !== "hbase" && dbType !== "zookeeper" && dbType !== "plugin" && dbType !== "mq" && dbType !== "mqtt" && dbType !== "meilisearch";
|
||||
return dbType !== "nacos" && dbType !== "consul" && dbType !== "hbase" && dbType !== "zookeeper" && dbType !== "plugin" && dbType !== "mq" && dbType !== "mqtt" && dbType !== "meilisearch" && dbType !== "salesforce";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -250,7 +250,9 @@ export function supportsObjectBrowserTreeNode(dbType: DatabaseType | undefined,
|
||||
}
|
||||
|
||||
export function supportsTableTruncate(dbType?: DatabaseType): boolean {
|
||||
return !!dbType && dbType !== "impala" && dbType !== "sqlite" && dbType !== "rqlite" && dbType !== "turso" && dbType !== "cloudflare-d1" && dbType !== "duckdb" && dbType !== "influxdb" && dbType !== "influxdb3" && dbType !== "victoriametrics" && dbType !== "manticoresearch";
|
||||
return (
|
||||
!!dbType && dbType !== "impala" && dbType !== "sqlite" && dbType !== "rqlite" && dbType !== "turso" && dbType !== "cloudflare-d1" && dbType !== "duckdb" && dbType !== "influxdb" && dbType !== "influxdb3" && dbType !== "victoriametrics" && dbType !== "manticoresearch" && dbType !== "salesforce"
|
||||
);
|
||||
}
|
||||
|
||||
export function supportsTableVacuum(dbType?: DatabaseType): boolean {
|
||||
|
||||
@@ -32,6 +32,7 @@ export const DATABASE_NAMESPACE_CREATION_MATRIX = {
|
||||
elasticsearch: { deferred: "index creation is not modeled as database creation" },
|
||||
easysearch: { deferred: "index creation is not modeled as database creation" },
|
||||
meilisearch: { deferred: "index creation is not modeled as database creation" },
|
||||
salesforce: { deferred: "Salesforce orgs do not expose database creation through SOQL" },
|
||||
solr: { deferred: "core creation is not modeled as database creation" },
|
||||
hbase: { deferred: "namespace creation needs dedicated HBase namespace options" },
|
||||
qdrant: { deferred: "collection creation is separate from database creation" },
|
||||
|
||||
@@ -100,6 +100,7 @@ const DATABASE_TYPE_OBJECTS = new Map<DatabaseType, SidebarObjectKind[]>([
|
||||
["influxdb3", ["TABLE"]],
|
||||
["victoriametrics", ["TABLE"]],
|
||||
["hbase", ["TABLE"]],
|
||||
["salesforce", ["TABLE"]],
|
||||
["questdb", ["TABLE", "VIEW", "MATERIALIZED_VIEW"]],
|
||||
["manticoresearch", ["TABLE", "FUNCTION"]],
|
||||
["databend", ["TABLE", "VIEW", "PROCEDURE"]],
|
||||
|
||||
@@ -33,6 +33,7 @@ export const DATABASE_PROPERTY_EDITING_MATRIX = {
|
||||
elasticsearch: { deferred: "index settings are not database properties" },
|
||||
easysearch: { deferred: "index settings are not database properties" },
|
||||
meilisearch: { deferred: "index settings are not database properties" },
|
||||
salesforce: { deferred: "sObject metadata is managed in Salesforce Setup" },
|
||||
solr: { deferred: "core settings are not database properties" },
|
||||
hbase: { deferred: "namespace and table properties need a dedicated HBase workflow" },
|
||||
qdrant: { deferred: "collection settings are not database properties" },
|
||||
|
||||
@@ -235,6 +235,25 @@ const DATABASE_CAPABILITY_OVERRIDES: Partial<Record<DatabaseType, Partial<Databa
|
||||
readonly: true,
|
||||
},
|
||||
},
|
||||
salesforce: {
|
||||
// SOQL has no DML. A grid save becomes one `DBX SALESFORCE DML` pseudo-command
|
||||
// per record (built by dbx-sql) which the driver turns into a REST call:
|
||||
// PATCH /sobjects/{object}/{id}, POST /sobjects/{object}, DELETE /sobjects/{object}/{id}.
|
||||
// Row identity is the `Id` column (the driver marks it is_primary_key), so both
|
||||
// update and delete require it. REST has no transactions: records are written
|
||||
// one by one, a failure leaves the earlier records applied, and nothing can be
|
||||
// rolled back — the grid therefore asks for confirmation before saving.
|
||||
tableData: {
|
||||
insert: true,
|
||||
updateRequiresPrimaryKey: true,
|
||||
deleteRequiresPrimaryKey: true,
|
||||
keylessRowPredicate: false,
|
||||
requiresTransactionalTableForExistingRows: false,
|
||||
existingRowsReadonly: false,
|
||||
transaction: false,
|
||||
readonly: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function defaultTableDataCapability(dbType?: DatabaseType): TableDataCapability {
|
||||
|
||||
@@ -117,6 +117,7 @@ const SQL_EXECUTION_TARGET_REGISTRY: Partial<Record<DatabaseType, SqlExecutionTa
|
||||
chromadb: { scope: "connection" },
|
||||
etcd: { scope: "connection" },
|
||||
zookeeper: { scope: "connection" },
|
||||
salesforce: { scope: "connection" },
|
||||
doris: { supportsCatalog: true },
|
||||
starrocks: { supportsCatalog: true },
|
||||
"cloudflare-d1": { defaultDatabase: () => "main", allowDefaultWhenDatabaseListEmpty: true },
|
||||
|
||||
@@ -70,7 +70,7 @@ function decorationsForHints(hints: readonly InsertValueHint[]): DecorationSet {
|
||||
* keep this list in sync instead of re-inlining it at call sites.
|
||||
*/
|
||||
export function supportsInsertValueHints(databaseType: string | undefined | null): boolean {
|
||||
return databaseType !== "redis" && databaseType !== "mongodb" && databaseType !== "elasticsearch" && databaseType !== "easysearch" && databaseType !== "meilisearch" && databaseType !== "solr" && databaseType !== "victoriametrics";
|
||||
return databaseType !== "redis" && databaseType !== "mongodb" && databaseType !== "elasticsearch" && databaseType !== "easysearch" && databaseType !== "meilisearch" && databaseType !== "solr" && databaseType !== "victoriametrics" && databaseType !== "salesforce";
|
||||
}
|
||||
|
||||
export function buildInsertValueHintDecorations(hints: readonly InsertValueHint[]): DecorationSet {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { SQLDialect } from "@codemirror/lang-sql";
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
import { driverProfileSqlBuiltinTerms } from "@/lib/database/driverProfileExtensions";
|
||||
|
||||
export type CodeMirrorSqlDialectName = "mysql" | "postgres" | "sqlserver" | "clickhouse";
|
||||
export type CodeMirrorSqlDialectName = "mysql" | "postgres" | "sqlserver" | "clickhouse" | "soql";
|
||||
|
||||
type CodeMirrorSqlLanguageModule = Pick<typeof import("@codemirror/lang-sql"), "Cassandra" | "MSSQL" | "MySQL" | "PLSQL" | "PostgreSQL" | "SQLite" | "SQLDialect" | "StandardSQL">;
|
||||
|
||||
@@ -162,7 +162,132 @@ const CLICKHOUSE_TYPES = [
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
|
||||
const CLICKHOUSE_BUILTINS = ["now", "today", "toDate", "toDateTime", "toDateTime64", "toYYYYMM", "count", "sum", "avg", "min", "max", "uniq", "uniqExact", "argMin", "argMax", "groupArray", "arrayJoin", "mapKeys", "mapValues", "JSONExtract", "JSONExtractString"].join(" ").toLowerCase();
|
||||
const CLICKHOUSE_BUILTINS = ["now", "today", "toDate", "toDateTime", "toDateTime64", "toYYYYMM", "count", "sum", "avg", "min", "max", "uniq", "uniqExact", "argMin", "argMax", "groupArray", "arrayJoin", "mapKeys", "mapValues", "JSONExtract", "JSONExtractString", "mapFilter", "mapContains"]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
|
||||
// Salesforce SOQL is a distinct query language: no JOINs, no DDL/DML keywords, no
|
||||
// identifier quoting, single-quoted strings only, and its own clause/operator set.
|
||||
// CodeMirror's tokenizer lowercases each word before lookup (lang-sql keywords()),
|
||||
// so every entry below is stored lowercase to guarantee a match. true/false/null are
|
||||
// omitted on purpose — lang-sql pre-registers them as Bool/Null tokens.
|
||||
const SOQL_KEYWORDS = [
|
||||
// Clauses & query structure
|
||||
"select",
|
||||
"from",
|
||||
"where",
|
||||
"and",
|
||||
"or",
|
||||
"not",
|
||||
"in",
|
||||
"like",
|
||||
"includes",
|
||||
"excludes",
|
||||
"order",
|
||||
"by",
|
||||
"group",
|
||||
"having",
|
||||
"limit",
|
||||
"offset",
|
||||
"asc",
|
||||
"desc",
|
||||
"nulls",
|
||||
"first",
|
||||
"last",
|
||||
"all",
|
||||
"distinct",
|
||||
// TYPEOF (polymorphic field selection)
|
||||
"typeof",
|
||||
"when",
|
||||
"then",
|
||||
"else",
|
||||
"end",
|
||||
// WITH clauses
|
||||
"with",
|
||||
"security_enforced",
|
||||
"user_mode",
|
||||
"system_mode",
|
||||
"fields",
|
||||
"standard",
|
||||
"custom",
|
||||
"data",
|
||||
"category",
|
||||
"using",
|
||||
"scope",
|
||||
"above",
|
||||
"below",
|
||||
"above_or_below",
|
||||
// FOR clauses (tracking / viewstat)
|
||||
"for",
|
||||
"view",
|
||||
"reference",
|
||||
"update",
|
||||
"tracking",
|
||||
"viewstat",
|
||||
// Date literals (the non-parameterized forms; :n variants highlight the stem)
|
||||
"yesterday",
|
||||
"today",
|
||||
"tomorrow",
|
||||
"last_week",
|
||||
"this_week",
|
||||
"next_week",
|
||||
"last_month",
|
||||
"this_month",
|
||||
"next_month",
|
||||
"last_90_days",
|
||||
"next_90_days",
|
||||
"last_n_days",
|
||||
"next_n_days",
|
||||
"last_quarter",
|
||||
"this_quarter",
|
||||
"next_quarter",
|
||||
"last_n_quarters",
|
||||
"next_n_quarters",
|
||||
"last_year",
|
||||
"this_year",
|
||||
"next_year",
|
||||
"last_n_years",
|
||||
"next_n_years",
|
||||
"last_fiscal_quarter",
|
||||
"this_fiscal_quarter",
|
||||
"next_fiscal_quarter",
|
||||
"last_n_fiscal_quarters",
|
||||
"next_n_fiscal_quarters",
|
||||
"last_fiscal_year",
|
||||
"this_fiscal_year",
|
||||
"next_fiscal_year",
|
||||
"last_n_fiscal_years",
|
||||
"next_n_fiscal_years",
|
||||
].join(" ");
|
||||
|
||||
// SOQL aggregate + date/convert functions. Highlighted as builtins (callable names).
|
||||
const SOQL_BUILTINS = [
|
||||
"count",
|
||||
"count_distinct",
|
||||
"sum",
|
||||
"avg",
|
||||
"min",
|
||||
"max",
|
||||
"grouping",
|
||||
"convertcurrency",
|
||||
"converttimezone",
|
||||
"tolabel",
|
||||
"format",
|
||||
"calendar_month",
|
||||
"calendar_quarter",
|
||||
"calendar_year",
|
||||
"day_in_month",
|
||||
"day_in_week",
|
||||
"day_in_year",
|
||||
"day_only",
|
||||
"fiscal_month",
|
||||
"fiscal_quarter",
|
||||
"fiscal_year",
|
||||
"hour_in_day",
|
||||
"week_in_month",
|
||||
"week_only",
|
||||
"month_in_year",
|
||||
].join(" ");
|
||||
|
||||
// COUNT is stripped from Postgres keywords by postgresKeywordSyntaxTerms() (it's a
|
||||
// valid Postgres identifier name), so it's re-added here as a builtin function instead.
|
||||
@@ -228,6 +353,19 @@ function codeMirrorBaseDialect(langSql: CodeMirrorSqlLanguageModule, dialectName
|
||||
}
|
||||
|
||||
export function createDbxCodeMirrorSqlDialect(langSql: CodeMirrorSqlLanguageModule, dialectName: CodeMirrorSqlDialectName = "mysql", databaseType?: DatabaseType, driverProfile?: string): SQLDialect {
|
||||
// SOQL is its own language, not a SQL superset: define it from a precise keyword
|
||||
// set with no identifier quoting, single-quoted strings, and backslash escapes.
|
||||
if (databaseType === "salesforce" || dialectName === "soql") {
|
||||
return langSql.SQLDialect.define({
|
||||
keywords: SOQL_KEYWORDS,
|
||||
builtin: [SOQL_BUILTINS, driverProfileSqlBuiltinTerms(driverProfile)].filter(Boolean).join(" ") || undefined,
|
||||
backslashEscapes: true,
|
||||
doubleQuotedStrings: false,
|
||||
caseInsensitiveIdentifiers: true,
|
||||
identifierQuotes: "",
|
||||
doubleDollarQuotedStrings: false,
|
||||
});
|
||||
}
|
||||
const baseDialect = codeMirrorBaseDialect(langSql, dialectName, databaseType);
|
||||
const isPostgres = baseDialect === langSql.PostgreSQL;
|
||||
const isMysql = baseDialect === langSql.MySQL;
|
||||
|
||||
@@ -37,6 +37,9 @@ export const MCP_TOOL_OPTIONS = [
|
||||
{ name: "dbx_rollback_transaction", labelKey: "settings.mcpToolRollbackTransaction" },
|
||||
{ name: "dbx_close_session", labelKey: "settings.mcpToolCloseSession" },
|
||||
{ name: "dbx_execute_redis_command", labelKey: "settings.mcpToolExecuteRedisCommand" },
|
||||
{ name: "dbx_salesforce_current_user", labelKey: "settings.mcpToolSalesforceCurrentUser" },
|
||||
{ name: "dbx_salesforce_prepare_write", labelKey: "settings.mcpToolSalesforcePrepareWrite" },
|
||||
{ name: "dbx_salesforce_apply_write", labelKey: "settings.mcpToolSalesforceApplyWrite" },
|
||||
{ name: "dbx_peek_messages", labelKey: "settings.mcpToolPeekMessages" },
|
||||
{ name: "dbx_send_message", labelKey: "settings.mcpToolSendMessage" },
|
||||
{ name: "dbx_add_connection", labelKey: "settings.mcpToolAddConnection" },
|
||||
|
||||
@@ -71,7 +71,18 @@ function orderSidebarTreeChildrenForParent(parent: Pick<TreeNode, "type">, child
|
||||
const regularChildren = normalized.filter((child) => child.type !== "user-admin" && child.type !== "dameng-users" && child.type !== "dameng-roles" && child.type !== "saved-sql-root");
|
||||
const withConnectionUtilityOrder = (children: TreeNode[]) => [...savedSqlNodes, ...children, ...userAdminNodes];
|
||||
|
||||
if (databaseType === "mongodb" || databaseType === "elasticsearch" || databaseType === "easysearch" || databaseType === "meilisearch" || databaseType === "solr" || databaseType === "qdrant" || databaseType === "milvus" || databaseType === "weaviate" || databaseType === "chromadb") {
|
||||
if (
|
||||
databaseType === "mongodb" ||
|
||||
databaseType === "elasticsearch" ||
|
||||
databaseType === "easysearch" ||
|
||||
databaseType === "meilisearch" ||
|
||||
databaseType === "solr" ||
|
||||
databaseType === "qdrant" ||
|
||||
databaseType === "milvus" ||
|
||||
databaseType === "weaviate" ||
|
||||
databaseType === "chromadb" ||
|
||||
databaseType === "salesforce"
|
||||
) {
|
||||
const meilisearchSystem = regularChildren.filter((child) => child.type === "meilisearch-system");
|
||||
const databaseObjects = regularChildren.filter((child) => child.type !== "meilisearch-system");
|
||||
return withConnectionUtilityOrder([...sortByLabel(databaseObjects), ...meilisearchSystem]);
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildSoqlCompletionItems,
|
||||
getSoqlCompletionContext,
|
||||
getSoqlCompletionResultValidFor,
|
||||
resolveSoqlFieldCandidates,
|
||||
resolveSoqlValueField,
|
||||
shouldAutoOpenSoqlCompletion,
|
||||
soqlCompletionNeedsFields,
|
||||
soqlCompletionNeedsObjects,
|
||||
type SoqlCompletionField,
|
||||
type SoqlCompletionObject,
|
||||
} from "@/lib/soql/soqlCompletion";
|
||||
|
||||
const ACCOUNT_FIELDS: SoqlCompletionField[] = [
|
||||
{ name: "Id", label: "Account ID", type: "id" },
|
||||
{ name: "Name", label: "Account Name", type: "string" },
|
||||
{ name: "OwnerId", label: "Owner ID", type: "reference", relationshipName: "Owner", referenceTo: ["User"] },
|
||||
{ name: "ParentId", label: "Parent Account ID", type: "reference", relationshipName: "Parent", referenceTo: ["Account"] },
|
||||
{ name: "Industry", label: "Industry", type: "picklist", picklistValues: ["Agriculture", "Banking", "Technology"] },
|
||||
{ name: "IsActive", label: "Active", type: "boolean" },
|
||||
{ name: "CreatedDate", label: "Created Date", type: "datetime" },
|
||||
{ name: "AnnualRevenue", label: "Annual Revenue", type: "currency" },
|
||||
];
|
||||
|
||||
const USER_FIELDS: SoqlCompletionField[] = [
|
||||
{ name: "Id", type: "id" },
|
||||
{ name: "Name", label: "Full Name", type: "string" },
|
||||
{ name: "Email", label: "Email", type: "email" },
|
||||
{ name: "ManagerId", type: "reference", relationshipName: "Manager", referenceTo: ["User"] },
|
||||
];
|
||||
|
||||
const OBJECTS: SoqlCompletionObject[] = [
|
||||
{ name: "Account", label: "Account" },
|
||||
{ name: "Contact", label: "Contact" },
|
||||
{ name: "Opportunity", label: "Opportunity" },
|
||||
{ name: "MyObj__c", label: "My Object" },
|
||||
];
|
||||
|
||||
function loader(map: Record<string, SoqlCompletionField[]>) {
|
||||
return async (objectName: string) => map[objectName] ?? [];
|
||||
}
|
||||
|
||||
describe("getSoqlCompletionContext", () => {
|
||||
it("returns keyword mode for an empty statement", () => {
|
||||
const ctx = getSoqlCompletionContext("", 0);
|
||||
expect(ctx.mode).toBe("keyword");
|
||||
expect(ctx.clause).toBeNull();
|
||||
});
|
||||
|
||||
it("classifies a partial leading keyword as keyword mode", () => {
|
||||
const text = "SEL";
|
||||
const ctx = getSoqlCompletionContext(text, text.length);
|
||||
expect(ctx.mode).toBe("keyword");
|
||||
expect(ctx.prefix).toBe("SEL");
|
||||
});
|
||||
|
||||
it("resolves the FROM object and reports object mode after FROM", () => {
|
||||
const text = "SELECT Id FROM Acc";
|
||||
const ctx = getSoqlCompletionContext(text, text.length);
|
||||
expect(ctx.mode).toBe("object");
|
||||
expect(ctx.prefix).toBe("Acc");
|
||||
expect(ctx.clause).toBe("from");
|
||||
});
|
||||
|
||||
it("reports field mode in SELECT and finds a FROM object written later", () => {
|
||||
const text = "SELECT Na FROM Account";
|
||||
const cursor = "SELECT Na".length;
|
||||
const ctx = getSoqlCompletionContext(text, cursor);
|
||||
expect(ctx.mode).toBe("field");
|
||||
expect(ctx.clause).toBe("select");
|
||||
expect(ctx.prefix).toBe("Na");
|
||||
expect(ctx.fromObject).toBe("Account");
|
||||
expect(ctx.relationshipPath).toEqual([]);
|
||||
});
|
||||
|
||||
it("splits a relationship path into path + prefix", () => {
|
||||
const text = "SELECT Owner.Na FROM Account";
|
||||
const ctx = getSoqlCompletionContext(text, "SELECT Owner.Na".length);
|
||||
expect(ctx.mode).toBe("field");
|
||||
expect(ctx.relationshipPath).toEqual(["Owner"]);
|
||||
expect(ctx.prefix).toBe("Na");
|
||||
expect(ctx.from).toBe("SELECT Owner.".length);
|
||||
});
|
||||
|
||||
it("detects value mode inside an open single-quoted string", () => {
|
||||
const text = "SELECT Id FROM Account WHERE Industry = 'Ban";
|
||||
const ctx = getSoqlCompletionContext(text, text.length);
|
||||
expect(ctx.mode).toBe("value");
|
||||
expect(ctx.insideString).toBe(true);
|
||||
expect(ctx.valueField).toEqual({ path: [], name: "Industry" });
|
||||
expect(ctx.prefix).toBe("Ban");
|
||||
});
|
||||
|
||||
it("detects value mode after an operator without a quote yet", () => {
|
||||
const text = "SELECT Id FROM Account WHERE IsActive = ";
|
||||
const ctx = getSoqlCompletionContext(text, text.length);
|
||||
expect(ctx.mode).toBe("value");
|
||||
expect(ctx.insideString).toBeFalsy();
|
||||
expect(ctx.valueField).toEqual({ path: [], name: "IsActive" });
|
||||
});
|
||||
|
||||
it("detects value mode on a relationship field path", () => {
|
||||
const text = "SELECT Id FROM Account WHERE Owner.Name = ";
|
||||
const ctx = getSoqlCompletionContext(text, text.length);
|
||||
expect(ctx.mode).toBe("value");
|
||||
expect(ctx.valueField).toEqual({ path: ["Owner"], name: "Name" });
|
||||
});
|
||||
|
||||
it("does not treat a bare field in WHERE as a value", () => {
|
||||
const text = "SELECT Id FROM Account WHERE Ind";
|
||||
const ctx = getSoqlCompletionContext(text, text.length);
|
||||
expect(ctx.mode).toBe("field");
|
||||
expect(ctx.clause).toBe("where");
|
||||
expect(ctx.prefix).toBe("Ind");
|
||||
});
|
||||
|
||||
it("reports orderby clause", () => {
|
||||
const text = "SELECT Id FROM Account ORDER BY Na";
|
||||
const ctx = getSoqlCompletionContext(text, text.length);
|
||||
expect(ctx.mode).toBe("field");
|
||||
expect(ctx.clause).toBe("orderby");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSoqlCompletionContext statement boundaries", () => {
|
||||
it("does not inherit a value context from a completed statement", () => {
|
||||
// The previous statement's `> 1000` (unquoted literal) must not make the new
|
||||
// statement's `SELECT F|` a value context.
|
||||
const text = "SELECT Id FROM Account WHERE AnnualRevenue > 1000; SELECT F FROM Account";
|
||||
const cursor = "SELECT Id FROM Account WHERE AnnualRevenue > 1000; SELECT F".length;
|
||||
const ctx = getSoqlCompletionContext(text, cursor);
|
||||
expect(ctx.mode).toBe("field");
|
||||
expect(ctx.clause).toBe("select");
|
||||
expect(ctx.prefix).toBe("F");
|
||||
expect(ctx.fromObject).toBe("Account");
|
||||
expect(ctx.from).toBe(cursor - 1);
|
||||
});
|
||||
|
||||
it("starts a fresh keyword context right after a semicolon", () => {
|
||||
const text = "SELECT Id FROM Account; SEL";
|
||||
const ctx = getSoqlCompletionContext(text, text.length);
|
||||
expect(ctx.mode).toBe("keyword");
|
||||
expect(ctx.prefix).toBe("SEL");
|
||||
expect(ctx.from).toBe("SELECT Id FROM Account; ".length);
|
||||
});
|
||||
|
||||
it("scopes FROM detection to the current statement", () => {
|
||||
const text = "SELECT Id FROM Contact; SELECT Na FROM Account";
|
||||
const cursor = "SELECT Id FROM Contact; SELECT Na".length;
|
||||
const ctx = getSoqlCompletionContext(text, cursor);
|
||||
expect(ctx.mode).toBe("field");
|
||||
expect(ctx.fromObject).toBe("Account");
|
||||
});
|
||||
|
||||
it("does not borrow the previous statement's FROM when the current one has none yet", () => {
|
||||
const text = "SELECT Id FROM Contact; SELECT Na";
|
||||
const ctx = getSoqlCompletionContext(text, text.length);
|
||||
expect(ctx.mode).toBe("field");
|
||||
expect(ctx.fromObject).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps absolute from positions for value mode inside a later statement's string", () => {
|
||||
const text = "SELECT Id FROM Account; SELECT Id FROM Opportunity WHERE StageName = 'Clo";
|
||||
const ctx = getSoqlCompletionContext(text, text.length);
|
||||
expect(ctx.mode).toBe("value");
|
||||
expect(ctx.insideString).toBe(true);
|
||||
expect(ctx.from).toBe(text.indexOf("'Clo") + 1);
|
||||
expect(ctx.valueField).toEqual({ path: [], name: "StageName" });
|
||||
expect(ctx.fromObject).toBe("Opportunity");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSoqlFieldCandidates", () => {
|
||||
const load = loader({ Account: ACCOUNT_FIELDS, User: USER_FIELDS });
|
||||
|
||||
it("returns base object fields with no relationship path", async () => {
|
||||
const ctx = getSoqlCompletionContext("SELECT FROM Account", "SELECT ".length);
|
||||
const fields = await resolveSoqlFieldCandidates(ctx, load);
|
||||
expect(fields).toEqual(ACCOUNT_FIELDS);
|
||||
});
|
||||
|
||||
it("traverses a single relationship segment", async () => {
|
||||
const ctx = getSoqlCompletionContext("SELECT Owner. FROM Account", "SELECT Owner.".length);
|
||||
const fields = await resolveSoqlFieldCandidates(ctx, load);
|
||||
expect(fields).toEqual(USER_FIELDS);
|
||||
});
|
||||
|
||||
it("traverses a multi-segment relationship path", async () => {
|
||||
const ctx = getSoqlCompletionContext("SELECT Owner.Manager. FROM Account", "SELECT Owner.Manager.".length);
|
||||
const fields = await resolveSoqlFieldCandidates(ctx, load);
|
||||
expect(fields).toEqual(USER_FIELDS);
|
||||
});
|
||||
|
||||
it("returns empty when a path segment is not a relationship", async () => {
|
||||
const ctx = getSoqlCompletionContext("SELECT Name. FROM Account", "SELECT Name.".length);
|
||||
const fields = await resolveSoqlFieldCandidates(ctx, load);
|
||||
expect(fields).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty when there is no FROM object", async () => {
|
||||
const ctx = getSoqlCompletionContext("SELECT ", "SELECT ".length);
|
||||
const fields = await resolveSoqlFieldCandidates(ctx, load);
|
||||
expect(fields).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSoqlValueField", () => {
|
||||
const load = loader({ Account: ACCOUNT_FIELDS, User: USER_FIELDS });
|
||||
|
||||
it("resolves a direct field for value completion", async () => {
|
||||
const ctx = getSoqlCompletionContext("SELECT Id FROM Account WHERE Industry = '", "SELECT Id FROM Account WHERE Industry = '".length);
|
||||
const field = await resolveSoqlValueField(ctx, load);
|
||||
expect(field?.name).toBe("Industry");
|
||||
expect(field?.picklistValues).toContain("Banking");
|
||||
});
|
||||
|
||||
it("resolves a field across a relationship path", async () => {
|
||||
const ctx = getSoqlCompletionContext("SELECT Id FROM Account WHERE Owner.Email = '", "SELECT Id FROM Account WHERE Owner.Email = '".length);
|
||||
const field = await resolveSoqlValueField(ctx, load);
|
||||
expect(field?.name).toBe("Email");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSoqlCompletionItems", () => {
|
||||
it("offers clause keywords in keyword mode", () => {
|
||||
const ctx = getSoqlCompletionContext("SEL", 3);
|
||||
const items = buildSoqlCompletionItems(ctx, {});
|
||||
expect(items.map((i) => i.label)).toContain("SELECT");
|
||||
});
|
||||
|
||||
it("offers objects in object mode, standard before custom", () => {
|
||||
const ctx = getSoqlCompletionContext("SELECT Id FROM ", "SELECT Id FROM ".length);
|
||||
const items = buildSoqlCompletionItems(ctx, { objects: OBJECTS });
|
||||
expect(items.map((i) => i.label)).toEqual(["Account", "Contact", "Opportunity", "MyObj__c"]);
|
||||
expect(items[0].type).toBe("table");
|
||||
});
|
||||
|
||||
it("offers fields and marks reference fields as traversal with a trailing dot", () => {
|
||||
const ctx = getSoqlCompletionContext("SELECT FROM Account", "SELECT ".length);
|
||||
const items = buildSoqlCompletionItems(ctx, { fields: ACCOUNT_FIELDS });
|
||||
const owner = items.find((i) => i.label === "OwnerId");
|
||||
expect(owner?.type).toBe("property");
|
||||
expect(owner?.apply).toBe("Owner.");
|
||||
const name = items.find((i) => i.label === "Name");
|
||||
expect(name?.type).toBe("column");
|
||||
expect(name?.apply).toBe("Name");
|
||||
});
|
||||
|
||||
it("offers aggregate functions and FIELDS selectors in SELECT", () => {
|
||||
const ctx = getSoqlCompletionContext("SELECT FROM Account", "SELECT ".length);
|
||||
const items = buildSoqlCompletionItems(ctx, { fields: ACCOUNT_FIELDS });
|
||||
const labels = items.map((i) => i.label);
|
||||
expect(labels).toContain("COUNT");
|
||||
expect(labels).toContain("FIELDS(ALL)");
|
||||
});
|
||||
|
||||
it("offers picklist values (quoted) for a picklist value context outside a string", () => {
|
||||
const ctx = getSoqlCompletionContext("SELECT Id FROM Account WHERE Industry = ", "SELECT Id FROM Account WHERE Industry = ".length);
|
||||
const field = ACCOUNT_FIELDS.find((f) => f.name === "Industry")!;
|
||||
const items = buildSoqlCompletionItems(ctx, { valueField: field });
|
||||
const banking = items.find((i) => i.label === "Banking");
|
||||
expect(banking?.apply).toBe("'Banking'");
|
||||
});
|
||||
|
||||
it("inserts bare picklist values and consumes the closing quote inside a string", () => {
|
||||
const text = "SELECT Id FROM Account WHERE Industry = '";
|
||||
const ctx = getSoqlCompletionContext(text, text.length);
|
||||
const field = ACCOUNT_FIELDS.find((f) => f.name === "Industry")!;
|
||||
const items = buildSoqlCompletionItems(ctx, { valueField: field });
|
||||
const banking = items.find((i) => i.label === "Banking");
|
||||
expect(banking?.apply).toBe("Banking");
|
||||
expect(banking?.replaceClosingQuote).toBe("'");
|
||||
});
|
||||
|
||||
it("offers TRUE/FALSE for a boolean field and date literals for datetime", () => {
|
||||
const boolCtx = getSoqlCompletionContext("SELECT Id FROM Account WHERE IsActive = ", "SELECT Id FROM Account WHERE IsActive = ".length);
|
||||
const boolField = ACCOUNT_FIELDS.find((f) => f.name === "IsActive")!;
|
||||
expect(buildSoqlCompletionItems(boolCtx, { valueField: boolField }).map((i) => i.label)).toContain("TRUE");
|
||||
|
||||
const dateCtx = getSoqlCompletionContext("SELECT Id FROM Account WHERE CreatedDate > ", "SELECT Id FROM Account WHERE CreatedDate > ".length);
|
||||
const dateField = ACCOUNT_FIELDS.find((f) => f.name === "CreatedDate")!;
|
||||
const dateLabels = buildSoqlCompletionItems(dateCtx, { valueField: dateField }).map((i) => i.label);
|
||||
expect(dateLabels).toContain("TODAY");
|
||||
expect(dateLabels).toContain("LAST_N_DAYS:n");
|
||||
});
|
||||
|
||||
it("filters by prefix case-insensitively on name and label", () => {
|
||||
const ctx = getSoqlCompletionContext("SELECT ann FROM Account", "SELECT ann".length);
|
||||
const items = buildSoqlCompletionItems(ctx, { fields: ACCOUNT_FIELDS });
|
||||
// "ann" matches AnnualRevenue by name prefix (case-insensitive)
|
||||
expect(items.map((i) => i.label)).toContain("AnnualRevenue");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mode predicates and helpers", () => {
|
||||
it("routes object vs field metadata needs", () => {
|
||||
expect(soqlCompletionNeedsObjects("object")).toBe(true);
|
||||
expect(soqlCompletionNeedsObjects("field")).toBe(false);
|
||||
expect(soqlCompletionNeedsFields("field")).toBe(true);
|
||||
expect(soqlCompletionNeedsFields("value")).toBe(true);
|
||||
expect(soqlCompletionNeedsFields("keyword")).toBe(false);
|
||||
});
|
||||
|
||||
it("auto-opens on relationship dot, quote, and identifier typing", () => {
|
||||
expect(shouldAutoOpenSoqlCompletion("SELECT Owner.", "SELECT Owner.".length)).toBe(true);
|
||||
expect(shouldAutoOpenSoqlCompletion("SELECT Id FROM Account WHERE Industry = '", "SELECT Id FROM Account WHERE Industry = '".length)).toBe(true);
|
||||
expect(shouldAutoOpenSoqlCompletion("SELECT Na", "SELECT Na".length)).toBe(true);
|
||||
expect(shouldAutoOpenSoqlCompletion("", 0)).toBe(false);
|
||||
});
|
||||
|
||||
it("gives a value-mode validFor that stops at a quote", () => {
|
||||
const text = "SELECT Id FROM Account WHERE Industry = 'Ban";
|
||||
const ctx = getSoqlCompletionContext(text, text.length);
|
||||
const re = getSoqlCompletionResultValidFor(ctx);
|
||||
expect(re.test("Ban")).toBe(true);
|
||||
expect(re.test("Ban'")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,541 @@
|
||||
// Salesforce SOQL completion engine.
|
||||
//
|
||||
// SOQL is SQL-like but narrower: a single object per query (FROM), relationship
|
||||
// traversal via `Relationship.Field` paths, no JOINs, no DDL/DML. This module is a
|
||||
// PURE function library mirroring lib/mongo/mongoCompletion.ts: a context scanner
|
||||
// reads the text under the cursor, a builder turns resolved metadata into
|
||||
// completion items, and an async resolver walks relationship paths using a
|
||||
// caller-supplied field loader. QueryEditor.vue supplies the store-backed loader
|
||||
// (which reads the backend describe cache), so nothing here touches I/O directly.
|
||||
|
||||
export interface SoqlCompletionObject {
|
||||
/** sObject API name, e.g. "Account". */
|
||||
name: string;
|
||||
/** Human label, e.g. "Account" or a custom object's label. */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface SoqlCompletionField {
|
||||
/** Field API name, e.g. "StageName". */
|
||||
name: string;
|
||||
/** Field label, e.g. "Stage". */
|
||||
label?: string;
|
||||
/** Salesforce describe type: picklist, reference, string, boolean, date, datetime, currency, … */
|
||||
type?: string;
|
||||
/** Active picklist values (from describe enum_values). */
|
||||
picklistValues?: string[];
|
||||
/** Relationship name used to traverse a reference field, e.g. "Owner". */
|
||||
relationshipName?: string;
|
||||
/** Referenced sObject API names, e.g. ["User"]. */
|
||||
referenceTo?: string[];
|
||||
}
|
||||
|
||||
export type SoqlCompletionItemType = "column" | "property" | "table" | "function" | "keyword" | "snippet" | "text";
|
||||
|
||||
export interface SoqlCompletionItem {
|
||||
label: string;
|
||||
type: SoqlCompletionItemType;
|
||||
detail?: string;
|
||||
info?: string;
|
||||
apply?: string;
|
||||
filterText?: string;
|
||||
boost: number;
|
||||
/** When completing inside an open string, consume the matching closing quote. */
|
||||
replaceClosingQuote?: "'";
|
||||
}
|
||||
|
||||
export type SoqlCompletionMode = "none" | "keyword" | "object" | "field" | "value";
|
||||
|
||||
export type SoqlClause = "select" | "from" | "where" | "orderby" | "groupby" | "having" | null;
|
||||
|
||||
export interface SoqlValueTarget {
|
||||
/** Relationship path segments before the field, e.g. ["Owner"] for `Owner.Name = '…'`. */
|
||||
path: string[];
|
||||
/** The field being compared, e.g. "StageName". */
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SoqlCompletionContext {
|
||||
mode: SoqlCompletionMode;
|
||||
/** Partial identifier immediately before the cursor (last dot segment). */
|
||||
prefix: string;
|
||||
/** Document position where the completion replacement starts. */
|
||||
from: number;
|
||||
/** Resolved FROM object API name for the current statement, if any. */
|
||||
fromObject?: string;
|
||||
/** Relationship path segments before `prefix` (field mode), e.g. ["Owner"] for `Owner.Na|`. */
|
||||
relationshipPath?: string[];
|
||||
/** Clause the cursor sits in. */
|
||||
clause?: SoqlClause;
|
||||
/** Value-mode target field (path + name). */
|
||||
valueField?: SoqlValueTarget;
|
||||
/** Cursor is inside an open single-quoted string (value already being typed). */
|
||||
insideString?: boolean;
|
||||
}
|
||||
|
||||
export interface SoqlCompletionInput {
|
||||
objects?: SoqlCompletionObject[];
|
||||
/** Fields of the object the cursor resolves to (after relationship traversal). */
|
||||
fields?: SoqlCompletionField[];
|
||||
/** For value mode: the specific field being compared (carries picklistValues/type). */
|
||||
valueField?: SoqlCompletionField | null;
|
||||
}
|
||||
|
||||
/** SOQL clause keywords offered at statement start / after a completed clause. */
|
||||
const SOQL_CLAUSE_KEYWORDS: Array<{ label: string; detail: string; boost: number }> = [
|
||||
{ label: "SELECT", detail: "Projection clause", boost: 100 },
|
||||
{ label: "FROM", detail: "Object clause", boost: 98 },
|
||||
{ label: "WHERE", detail: "Filter clause", boost: 96 },
|
||||
{ label: "ORDER BY", detail: "Sort clause", boost: 90 },
|
||||
{ label: "GROUP BY", detail: "Grouping clause", boost: 88 },
|
||||
{ label: "HAVING", detail: "Aggregate filter", boost: 84 },
|
||||
{ label: "LIMIT", detail: "Row limit", boost: 80 },
|
||||
{ label: "OFFSET", detail: "Row offset", boost: 78 },
|
||||
{ label: "WITH SECURITY_ENFORCED", detail: "Enforce CRUD/FLS", boost: 70 },
|
||||
{ label: "NULLS FIRST", detail: "Sort nulls first", boost: 60 },
|
||||
{ label: "NULLS LAST", detail: "Sort nulls last", boost: 60 },
|
||||
{ label: "ASC", detail: "Ascending", boost: 58 },
|
||||
{ label: "DESC", detail: "Descending", boost: 58 },
|
||||
{ label: "AND", detail: "Logical AND", boost: 66 },
|
||||
{ label: "OR", detail: "Logical OR", boost: 64 },
|
||||
{ label: "NOT", detail: "Logical NOT", boost: 62 },
|
||||
{ label: "IN", detail: "Set membership", boost: 60 },
|
||||
{ label: "LIKE", detail: "Pattern match", boost: 60 },
|
||||
{ label: "INCLUDES", detail: "Multi-picklist includes", boost: 58 },
|
||||
{ label: "EXCLUDES", detail: "Multi-picklist excludes", boost: 58 },
|
||||
];
|
||||
|
||||
/** Aggregate / conversion functions valid in SELECT, GROUP BY, HAVING. */
|
||||
const SOQL_FUNCTIONS: Array<{ label: string; detail: string; apply?: string }> = [
|
||||
{ label: "COUNT", detail: "Row count", apply: "COUNT()" },
|
||||
{ label: "COUNT_DISTINCT", detail: "Distinct count", apply: "COUNT_DISTINCT()" },
|
||||
{ label: "SUM", detail: "Sum", apply: "SUM()" },
|
||||
{ label: "AVG", detail: "Average", apply: "AVG()" },
|
||||
{ label: "MIN", detail: "Minimum", apply: "MIN()" },
|
||||
{ label: "MAX", detail: "Maximum", apply: "MAX()" },
|
||||
{ label: "GROUPING", detail: "Grouping indicator", apply: "GROUPING()" },
|
||||
{ label: "convertCurrency", detail: "Convert to user currency", apply: "convertCurrency()" },
|
||||
{ label: "convertTimezone", detail: "Convert datetime to user TZ", apply: "convertTimezone()" },
|
||||
{ label: "toLabel", detail: "Return picklist/reference label", apply: "toLabel()" },
|
||||
{ label: "CALENDAR_YEAR", detail: "Year from date", apply: "CALENDAR_YEAR()" },
|
||||
{ label: "CALENDAR_MONTH", detail: "Month from date", apply: "CALENDAR_MONTH()" },
|
||||
{ label: "CALENDAR_QUARTER", detail: "Quarter from date", apply: "CALENDAR_QUARTER()" },
|
||||
{ label: "DAY_ONLY", detail: "Date portion of datetime", apply: "DAY_ONLY()" },
|
||||
{ label: "FISCAL_YEAR", detail: "Fiscal year from date", apply: "FISCAL_YEAR()" },
|
||||
{ label: "FISCAL_QUARTER", detail: "Fiscal quarter from date", apply: "FISCAL_QUARTER()" },
|
||||
];
|
||||
|
||||
/** FIELDS() selectors — SOQL's "expand all fields" (LIMIT ≤ 200; the driver auto-appends LIMIT 200 when missing). */
|
||||
const SOQL_FIELDS_SELECTORS: Array<{ label: string; detail: string }> = [
|
||||
{ label: "FIELDS(ALL)", detail: "All fields (LIMIT 200 auto-added if missing)" },
|
||||
{ label: "FIELDS(STANDARD)", detail: "All standard fields" },
|
||||
{ label: "FIELDS(CUSTOM)", detail: "All custom fields (LIMIT 200 auto-added if missing)" },
|
||||
];
|
||||
|
||||
/** SOQL date literals offered in value position for date/datetime fields. */
|
||||
const SOQL_DATE_LITERALS: string[] = [
|
||||
"YESTERDAY",
|
||||
"TODAY",
|
||||
"TOMORROW",
|
||||
"LAST_WEEK",
|
||||
"THIS_WEEK",
|
||||
"NEXT_WEEK",
|
||||
"LAST_MONTH",
|
||||
"THIS_MONTH",
|
||||
"NEXT_MONTH",
|
||||
"LAST_90_DAYS",
|
||||
"NEXT_90_DAYS",
|
||||
"LAST_N_DAYS:n",
|
||||
"NEXT_N_DAYS:n",
|
||||
"LAST_QUARTER",
|
||||
"THIS_QUARTER",
|
||||
"NEXT_QUARTER",
|
||||
"LAST_N_QUARTERS:n",
|
||||
"NEXT_N_QUARTERS:n",
|
||||
"LAST_YEAR",
|
||||
"THIS_YEAR",
|
||||
"NEXT_YEAR",
|
||||
"LAST_N_YEARS:n",
|
||||
"NEXT_N_YEARS:n",
|
||||
"LAST_FISCAL_QUARTER",
|
||||
"THIS_FISCAL_QUARTER",
|
||||
"NEXT_FISCAL_QUARTER",
|
||||
"LAST_FISCAL_YEAR",
|
||||
"THIS_FISCAL_YEAR",
|
||||
"NEXT_FISCAL_YEAR",
|
||||
];
|
||||
|
||||
const SOQL_AGGREGATE_CLAUSES = new Set<SoqlClause>(["select", "groupby", "having"]);
|
||||
|
||||
const WORD_CHAR = /[A-Za-z0-9_]/;
|
||||
|
||||
/**
|
||||
* Scan the text under the cursor and classify the SOQL completion context.
|
||||
* Pure and synchronous — metadata resolution happens in the provider.
|
||||
*
|
||||
* All backward scanning is scoped to the CURRENT statement (text after the last
|
||||
* `;`): a completed statement's `WHERE x > 1` must not make the next statement's
|
||||
* `SELECT F|` look like a value context, and its FROM/clauses must not leak in.
|
||||
* Returned `from` positions are always absolute document offsets.
|
||||
*/
|
||||
export function getSoqlCompletionContext(text: string, cursor: number): SoqlCompletionContext {
|
||||
const safeCursor = Math.max(0, Math.min(cursor, text.length));
|
||||
const beforeCursor = text.slice(0, safeCursor);
|
||||
const statementStart = beforeCursor.lastIndexOf(";") + 1;
|
||||
const statement = beforeCursor.slice(statementStart);
|
||||
|
||||
const fromObject = findFromObject(text, safeCursor);
|
||||
const clause = findClause(statement);
|
||||
|
||||
// Value context: cursor sits inside an open single-quoted string, or right after
|
||||
// a comparison operator (optionally a just-opened quote). `stringStart` is
|
||||
// statement-relative; convert to an absolute offset before returning.
|
||||
const stringStart = openSingleQuoteStart(statement);
|
||||
const valueTarget = parseValueTarget(statement, stringStart);
|
||||
if (valueTarget) {
|
||||
const insideString = stringStart != null;
|
||||
const trailing = readTrailingPrefix(statement);
|
||||
return {
|
||||
mode: "value",
|
||||
prefix: insideString ? statement.slice(stringStart + 1) : trailing.prefix,
|
||||
from: insideString ? statementStart + stringStart + 1 : safeCursor - trailing.prefix.length,
|
||||
fromObject,
|
||||
clause,
|
||||
valueField: { path: valueTarget.path, name: valueTarget.name },
|
||||
insideString,
|
||||
};
|
||||
}
|
||||
|
||||
// Object context: the cursor is in the FROM clause completing an sObject name.
|
||||
if (clause === "from") {
|
||||
const { prefix, from } = readTrailingPrefix(statement);
|
||||
return { mode: "object", prefix, from: statementStart + from, fromObject, clause };
|
||||
}
|
||||
|
||||
// Field context: SELECT / WHERE / ORDER BY / GROUP BY / HAVING (clause === "from"
|
||||
// already returned above, so any remaining clause is a field clause).
|
||||
if (clause) {
|
||||
const { prefix, from, path } = readTrailingPrefix(statement);
|
||||
return { mode: "field", prefix, from: statementStart + from, fromObject, relationshipPath: path, clause };
|
||||
}
|
||||
|
||||
// No clause yet (empty/fresh statement, or only whitespace typed): offer clauses.
|
||||
const { prefix, from } = readTrailingPrefix(statement);
|
||||
if (prefix || statement.trim().length === 0) {
|
||||
return { mode: "keyword", prefix, from: statementStart + from, fromObject, clause: null };
|
||||
}
|
||||
return { mode: "none", prefix: "", from: safeCursor, fromObject, clause };
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the FROM object of the statement containing the cursor. Scoped to the
|
||||
* current statement (`;`-separated) so a neighbouring query's FROM never leaks
|
||||
* field candidates into this one; within the statement prefer the nearest FROM
|
||||
* before the cursor, else the first one after it (the user is often still
|
||||
* typing the projection when FROM exists only ahead of the cursor).
|
||||
*/
|
||||
function findFromObject(text: string, cursor: number): string | undefined {
|
||||
const statementStart = text.lastIndexOf(";", cursor - 1) + 1;
|
||||
const semicolonAfter = text.indexOf(";", cursor);
|
||||
const statementEnd = semicolonAfter === -1 ? text.length : semicolonAfter;
|
||||
const statement = text.slice(statementStart, statementEnd);
|
||||
const re = /\bfrom\s+([A-Za-z_][A-Za-z0-9_]*)/gi;
|
||||
let lastBefore: string | undefined;
|
||||
let firstAfter: string | undefined;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = re.exec(statement)) !== null) {
|
||||
const name = match[1];
|
||||
if (statementStart + match.index < cursor) lastBefore = name;
|
||||
else if (firstAfter == null) firstAfter = name;
|
||||
}
|
||||
return lastBefore ?? firstAfter;
|
||||
}
|
||||
|
||||
/** Identify the clause the cursor is in from the last clause keyword before it. */
|
||||
function findClause(beforeCursor: string): SoqlClause {
|
||||
const upper = beforeCursor.toUpperCase();
|
||||
const markers: Array<{ kw: RegExp; clause: SoqlClause }> = [
|
||||
{ kw: /\bORDER\s+BY\b/g, clause: "orderby" },
|
||||
{ kw: /\bGROUP\s+BY\b/g, clause: "groupby" },
|
||||
{ kw: /\bHAVING\b/g, clause: "having" },
|
||||
{ kw: /\bWHERE\b/g, clause: "where" },
|
||||
{ kw: /\bFROM\b/g, clause: "from" },
|
||||
{ kw: /\bSELECT\b/g, clause: "select" },
|
||||
];
|
||||
let best: { index: number; clause: SoqlClause } | null = null;
|
||||
for (const { kw, clause } of markers) {
|
||||
kw.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = kw.exec(upper)) !== null) {
|
||||
if (!best || m.index > best.index) best = { index: m.index, clause };
|
||||
if (m.index === kw.lastIndex) kw.lastIndex++;
|
||||
}
|
||||
}
|
||||
return best ? best.clause : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* If the cursor is inside an unterminated single-quoted string, return the index of
|
||||
* the opening quote; otherwise null. Handles backslash escapes.
|
||||
*/
|
||||
function openSingleQuoteStart(beforeCursor: string): number | null {
|
||||
let open: number | null = null;
|
||||
for (let i = 0; i < beforeCursor.length; i++) {
|
||||
const ch = beforeCursor[i];
|
||||
if (ch === "\\") {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === "'") {
|
||||
if (open == null) open = i;
|
||||
else open = null;
|
||||
}
|
||||
}
|
||||
return open;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `<fieldPath> <op> ['…]` immediately before the cursor. Returns the field path
|
||||
* and name when the cursor is in a value position, else null.
|
||||
*/
|
||||
function parseValueTarget(beforeCursor: string, stringStart: number | null): { path: string[]; name: string } | null {
|
||||
// Text up to (and excluding) the operator, dropping any partial string content.
|
||||
const head = stringStart != null ? beforeCursor.slice(0, stringStart) : beforeCursor;
|
||||
const m = /([A-Za-z_][A-Za-z0-9_]*(?:\s*\.\s*[A-Za-z_][A-Za-z0-9_]*)*)\s*(?:=|!=|<>|<=|>=|<|>|\bLIKE\b|\bIN\b|\bINCLUDES\b|\bEXCLUDES\b)\s*'?[^']*$/i.exec(head);
|
||||
if (!m) return null;
|
||||
// When not inside a string, only treat as a value context if the operator is complete
|
||||
// and followed by optional whitespace/quote (avoid matching `Name` in SELECT).
|
||||
const segments = m[1].split(/\s*\.\s*/).filter(Boolean);
|
||||
if (segments.length === 0) return null;
|
||||
const name = segments[segments.length - 1];
|
||||
return { path: segments.slice(0, -1), name };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the trailing dotted identifier under the cursor. Returns the last segment as
|
||||
* `prefix`, the replacement start `from`, and preceding segments as `path`.
|
||||
*/
|
||||
function readTrailingPrefix(beforeCursor: string): { prefix: string; from: number; path: string[] } {
|
||||
let i = beforeCursor.length;
|
||||
while (i > 0 && (WORD_CHAR.test(beforeCursor[i - 1]) || beforeCursor[i - 1] === ".")) i--;
|
||||
const span = beforeCursor.slice(i);
|
||||
if (!span) return { prefix: "", from: beforeCursor.length, path: [] };
|
||||
const segments = span.split(".");
|
||||
const prefix = segments[segments.length - 1] ?? "";
|
||||
const path = segments.slice(0, -1).filter((seg) => seg.length > 0);
|
||||
const from = beforeCursor.length - prefix.length;
|
||||
return { prefix, from, path };
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a relationship path from the FROM object, returning the fields of the final
|
||||
* object. `loadFields` reads the (cached) describe metadata for one sObject.
|
||||
*/
|
||||
export async function resolveSoqlFieldCandidates(context: SoqlCompletionContext, loadFields: (objectName: string) => Promise<SoqlCompletionField[]>): Promise<SoqlCompletionField[]> {
|
||||
if (!context.fromObject) return [];
|
||||
let fields = await loadFields(context.fromObject);
|
||||
for (const segment of context.relationshipPath ?? []) {
|
||||
const rel = fields.find((f) => (f.relationshipName ?? "").toLowerCase() === segment.toLowerCase() || f.name.toLowerCase() === segment.toLowerCase());
|
||||
const next = rel?.referenceTo?.[0];
|
||||
if (!next) return [];
|
||||
fields = await loadFields(next);
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the specific field a value-mode cursor compares against (walking its
|
||||
* relationship path), so the builder can offer its picklist values / type-aware literals.
|
||||
*/
|
||||
export async function resolveSoqlValueField(context: SoqlCompletionContext, loadFields: (objectName: string) => Promise<SoqlCompletionField[]>): Promise<SoqlCompletionField | null> {
|
||||
if (!context.fromObject || !context.valueField) return null;
|
||||
let fields = await loadFields(context.fromObject);
|
||||
for (const segment of context.valueField.path) {
|
||||
const rel = fields.find((f) => (f.relationshipName ?? "").toLowerCase() === segment.toLowerCase() || f.name.toLowerCase() === segment.toLowerCase());
|
||||
const next = rel?.referenceTo?.[0];
|
||||
if (!next) return null;
|
||||
fields = await loadFields(next);
|
||||
}
|
||||
return fields.find((f) => f.name.toLowerCase() === context.valueField!.name.toLowerCase()) ?? null;
|
||||
}
|
||||
|
||||
/** True when the given mode's items come from the object list. */
|
||||
export function soqlCompletionNeedsObjects(mode: SoqlCompletionMode): boolean {
|
||||
return mode === "object";
|
||||
}
|
||||
|
||||
/** True when the given mode's items come from field metadata. */
|
||||
export function soqlCompletionNeedsFields(mode: SoqlCompletionMode): boolean {
|
||||
return mode === "field" || mode === "value";
|
||||
}
|
||||
|
||||
/**
|
||||
* Build completion items for a resolved context. `input.fields` must already be the
|
||||
* fields of the object the cursor resolves to (see resolveSoqlFieldCandidates);
|
||||
* `input.valueField` is the compared field for value mode.
|
||||
*/
|
||||
export function buildSoqlCompletionItems(context: SoqlCompletionContext, input: SoqlCompletionInput = {}): SoqlCompletionItem[] {
|
||||
const { prefix } = context;
|
||||
switch (context.mode) {
|
||||
case "none":
|
||||
return [];
|
||||
case "keyword":
|
||||
return keywordItems(prefix);
|
||||
case "object":
|
||||
return objectItems(prefix, input.objects ?? []);
|
||||
case "value":
|
||||
return valueItems(prefix, input.valueField ?? null, context.insideString === true);
|
||||
case "field":
|
||||
return fieldItems(prefix, context, input.fields ?? []);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function keywordItems(prefix: string): SoqlCompletionItem[] {
|
||||
const lower = prefix.toLowerCase();
|
||||
return SOQL_CLAUSE_KEYWORDS.filter((k) => matchesPrefix(k.label, lower)).map((k) => ({
|
||||
label: k.label,
|
||||
type: "keyword" as const,
|
||||
detail: k.detail,
|
||||
boost: k.boost,
|
||||
filterText: k.label.replace(/\s+/g, ""),
|
||||
}));
|
||||
}
|
||||
|
||||
function objectItems(prefix: string, objects: SoqlCompletionObject[]): SoqlCompletionItem[] {
|
||||
const lower = prefix.toLowerCase();
|
||||
return objects
|
||||
.filter((o) => matchesPrefix(o.name, lower) || matchesPrefix(o.label ?? "", lower))
|
||||
.map((o) => ({
|
||||
label: o.name,
|
||||
type: "table" as const,
|
||||
detail: o.label && o.label !== o.name ? o.label : "sObject",
|
||||
filterText: o.name,
|
||||
// Rank standard objects (no __c/__kav suffix) slightly above custom for a tidy list.
|
||||
boost: o.name.endsWith("__c") ? 90 : 100,
|
||||
}));
|
||||
}
|
||||
|
||||
function fieldItems(prefix: string, context: SoqlCompletionContext, fields: SoqlCompletionField[]): SoqlCompletionItem[] {
|
||||
const lower = prefix.toLowerCase();
|
||||
const items: SoqlCompletionItem[] = [];
|
||||
|
||||
for (const field of fields) {
|
||||
if (!matchesPrefix(field.name, lower) && !matchesPrefix(field.label ?? "", lower)) continue;
|
||||
const isRelationship = !!field.relationshipName && (field.referenceTo?.length ?? 0) > 0;
|
||||
items.push({
|
||||
label: field.name,
|
||||
type: isRelationship ? "property" : "column",
|
||||
detail: describeField(field),
|
||||
info: field.label && field.label !== field.name ? field.label : undefined,
|
||||
filterText: field.name,
|
||||
// Offer `Relationship.` traversal by appending a dot when accepted.
|
||||
apply: isRelationship ? `${field.relationshipName}.` : field.name,
|
||||
boost: 100,
|
||||
});
|
||||
}
|
||||
|
||||
// Relationship-name traversal is already covered above (reference fields apply as
|
||||
// `Relationship.`). In aggregate-capable clauses, also offer functions and selectors.
|
||||
if (SOQL_AGGREGATE_CLAUSES.has(context.clause ?? null)) {
|
||||
for (const fn of SOQL_FUNCTIONS) {
|
||||
if (!matchesPrefix(fn.label, lower)) continue;
|
||||
items.push({ label: fn.label, type: "function", detail: fn.detail, apply: fn.apply, filterText: fn.label, boost: 78 });
|
||||
}
|
||||
}
|
||||
if ((context.clause ?? null) === "select" && !context.relationshipPath?.length) {
|
||||
for (const sel of SOQL_FIELDS_SELECTORS) {
|
||||
if (!matchesPrefix(sel.label, lower) && !matchesPrefix("fields", lower)) continue;
|
||||
items.push({ label: sel.label, type: "snippet", detail: sel.detail, apply: sel.label, filterText: sel.label.replace(/\W/g, ""), boost: 72 });
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function valueItems(prefix: string, field: SoqlCompletionField | null, insideString: boolean): SoqlCompletionItem[] {
|
||||
const lower = prefix.toLowerCase();
|
||||
const items: SoqlCompletionItem[] = [];
|
||||
const type = (field?.type ?? "").toLowerCase();
|
||||
|
||||
// `quoted` literals (picklist/string values) need surrounding single quotes. When the
|
||||
// cursor is already inside an open string we insert the bare value and consume the
|
||||
// closing quote; otherwise we insert a fully quoted literal. Unquoted literals
|
||||
// (NULL / TRUE / FALSE / date literals) are inserted bare either way.
|
||||
const addValues = (values: string[], detail: string, boost: number, quoted: boolean) => {
|
||||
for (const value of values) {
|
||||
if (!matchesPrefix(value, lower)) continue;
|
||||
if (quoted && !insideString) {
|
||||
items.push({ label: value, type: "text", detail, boost, apply: `'${value}'`, filterText: value });
|
||||
} else {
|
||||
items.push({
|
||||
label: value,
|
||||
type: "text",
|
||||
detail,
|
||||
boost,
|
||||
apply: value,
|
||||
filterText: value,
|
||||
...(quoted && insideString ? { replaceClosingQuote: "'" as const } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (field?.picklistValues?.length) {
|
||||
addValues(field.picklistValues, "Picklist value", 100, true);
|
||||
}
|
||||
if (type === "boolean") {
|
||||
addValues(["TRUE", "FALSE"], "Boolean", 96, false);
|
||||
}
|
||||
if (type === "date" || type === "datetime") {
|
||||
addValues(SOQL_DATE_LITERALS, "Date literal", 92, false);
|
||||
}
|
||||
// Universally valid literals.
|
||||
addValues(["NULL"], "Null literal", 70, false);
|
||||
if (!field || type === "string" || type === "textarea" || type === "email" || type === "phone" || type === "url") {
|
||||
addValues(["TRUE", "FALSE"], "Boolean", 40, false);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function describeField(field: SoqlCompletionField): string {
|
||||
const parts: string[] = [];
|
||||
if (field.type) parts.push(field.type);
|
||||
if (field.relationshipName && field.referenceTo?.length) parts.push(`→ ${field.referenceTo.join(", ")}`);
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function matchesPrefix(candidate: string, lowerPrefix: string): boolean {
|
||||
if (!lowerPrefix) return true;
|
||||
return candidate.toLowerCase().startsWith(lowerPrefix);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether typing at this cursor should auto-open the completion popup. Mirrors the
|
||||
* mongo/redis providers: open on relationship dots, after clause keywords + space,
|
||||
* inside a just-opened quote, and while typing an identifier.
|
||||
*/
|
||||
export function shouldAutoOpenSoqlCompletion(text: string, cursor: number): boolean {
|
||||
const safeCursor = Math.max(0, Math.min(cursor, text.length));
|
||||
const before = text.slice(0, safeCursor);
|
||||
const prevChar = before[before.length - 1];
|
||||
if (!prevChar) return false;
|
||||
if (prevChar === ".") return true;
|
||||
if (prevChar === "'") return true;
|
||||
if (/\s$/.test(before)) {
|
||||
const ctx = getSoqlCompletionContext(text, safeCursor);
|
||||
return ctx.mode !== "none";
|
||||
}
|
||||
if (WORD_CHAR.test(prevChar)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* validFor predicate source: completions stay valid while the user keeps typing the
|
||||
* identifier/value characters. Returned as a RegExp like the mongo/redis providers.
|
||||
*/
|
||||
export function getSoqlCompletionResultValidFor(context: SoqlCompletionContext): RegExp {
|
||||
// In value mode inside a string, valid while the typed slice has no closing quote.
|
||||
if (context.mode === "value" && context.insideString) return /^[^']*$/;
|
||||
return /^[A-Za-z0-9_.]*$/;
|
||||
}
|
||||
@@ -165,6 +165,21 @@ export const SQL_SEMANTIC_DIALECTS: Record<string, SqlSemanticDialectAdapter> =
|
||||
return parts.length >= 1 ? "schema" : "unknown";
|
||||
},
|
||||
},
|
||||
// SOQL: identifiers are never quoted, field names are case-insensitive, there are
|
||||
// no table aliases or projection aliases, and a qualifier is a relationship path
|
||||
// segment (Account.Owner) that always resolves to a related sObject ("table").
|
||||
soql: {
|
||||
id: "soql",
|
||||
identifierQuotes: [],
|
||||
supportsAsForTableAlias: false,
|
||||
projectionAliasVisibility: { where: false, groupBy: false, having: false, orderBy: false },
|
||||
normalizeIdentifier: (identifier) => identifier.toLowerCase(),
|
||||
quoteIdentifier: (identifier) => identifier,
|
||||
qualifierRole(_parts, context) {
|
||||
if (context === "column") return "table";
|
||||
return "unknown";
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function sqlReferenceAnalysisDialectFor(options: { databaseType?: DatabaseType; identifierQuote?: string; fallbackDialect: string }): string {
|
||||
@@ -174,7 +189,8 @@ export function sqlReferenceAnalysisDialectFor(options: { databaseType?: Databas
|
||||
return options.fallbackDialect;
|
||||
}
|
||||
|
||||
export function sqlSemanticDialectFor(options: { databaseType?: DatabaseType; dialect?: "mysql" | "postgres" | "sqlserver" | "clickhouse" | "doris" }): SqlSemanticDialectAdapter {
|
||||
export function sqlSemanticDialectFor(options: { databaseType?: DatabaseType; dialect?: "mysql" | "postgres" | "sqlserver" | "clickhouse" | "doris" | "soql" }): SqlSemanticDialectAdapter {
|
||||
if (options.databaseType === "salesforce") return SQL_SEMANTIC_DIALECTS.soql;
|
||||
if (options.databaseType === "clickhouse") return SQL_SEMANTIC_DIALECTS.clickhouse;
|
||||
// Doris/StarRocks connections ride the editor's MySQL fallback dialect (codeMirrorSqlDialect maps
|
||||
// them to "mysql"), so the explicit-dialect branch below would otherwise mask the doris adapter
|
||||
@@ -222,6 +238,6 @@ export function sqlSemanticDialectFor(options: { databaseType?: DatabaseType; di
|
||||
* "generic" default), matching tokenizeSqlSemantic's default and preserving the behavior callers
|
||||
* had before dialect-aware scanning existed.
|
||||
*/
|
||||
export function resolveSqlDialectId(options: { databaseType?: DatabaseType; dialect?: "mysql" | "postgres" | "sqlserver" | "clickhouse" | "doris" }): string {
|
||||
export function resolveSqlDialectId(options: { databaseType?: DatabaseType; dialect?: "mysql" | "postgres" | "sqlserver" | "clickhouse" | "doris" | "soql" }): string {
|
||||
return options.databaseType || options.dialect ? sqlSemanticDialectFor(options).id : "mysql";
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export function elasticsearchRestRequestRanges(sql: string, databaseType?: Datab
|
||||
return requests.length > 0 && requests.every((request) => ELASTICSEARCH_REST_REQUEST.test(request.sql)) ? requests : [];
|
||||
}
|
||||
|
||||
const NON_SQL_EXECUTION_TARGET_TYPES: ReadonlySet<DatabaseType> = new Set(["mongodb", "elasticsearch", "easysearch", "meilisearch", "solr", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "neo4j", "victoriametrics"]);
|
||||
const NON_SQL_EXECUTION_TARGET_TYPES: ReadonlySet<DatabaseType> = new Set(["mongodb", "elasticsearch", "easysearch", "meilisearch", "solr", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "neo4j", "victoriametrics", "salesforce"]);
|
||||
|
||||
export function supportsExecutionTargetPicker(databaseType?: DatabaseType): boolean {
|
||||
return !!databaseType && (databaseType === "redis" || isHttpJsonRestDatabaseType(databaseType) || !NON_SQL_EXECUTION_TARGET_TYPES.has(databaseType));
|
||||
|
||||
@@ -14,6 +14,21 @@ describe("tableMetadataCapabilities", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes only describe fields for Salesforce objects", () => {
|
||||
// An SObject has no index, foreign key, trigger or DDL metadata; only the
|
||||
// describe payload (fields) is real, so every other structure tab stays off
|
||||
// instead of rendering permanently empty.
|
||||
expect(getTableMetadataCapabilities("salesforce")).toEqual({
|
||||
columns: true,
|
||||
indexes: false,
|
||||
foreignKeys: false,
|
||||
constraints: false,
|
||||
triggers: false,
|
||||
partitions: false,
|
||||
ddl: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes structured constraints only for dialects that implement list_constraints", () => {
|
||||
expect(getTableMetadataCapabilities("oracle").constraints).toBe(true);
|
||||
expect(getTableMetadataCapabilities("postgres").constraints).toBe(true);
|
||||
|
||||
@@ -143,3 +143,49 @@ export function isClickHouseExistingRowReadonlyColumn(databaseType: DatabaseType
|
||||
const columnInfo = columns.find((info) => info.name.toLowerCase() === column.toLowerCase());
|
||||
return /\bpartition_key\b/i.test(columnInfo?.extra ?? "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe flags the Salesforce driver packs into `ColumnInfo.extra`
|
||||
* (see `parse_describe_columns` in crates/dbx-drivers/src/db/salesforce_driver.rs).
|
||||
* `relationshipName` / `referenceTo` also live there but are consumed by SOQL
|
||||
* completion instead (`soqlFieldFromColumnInfo`).
|
||||
*/
|
||||
export interface SalesforceColumnFlags {
|
||||
updateable?: boolean;
|
||||
createable?: boolean;
|
||||
custom?: boolean;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function parseSalesforceColumnExtra(extra?: string | null): SalesforceColumnFlags | null {
|
||||
if (!extra) return null;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(extra);
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as SalesforceColumnFlags) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function findColumnInfoByName(columns: ColumnInfo[], column: string): ColumnInfo | undefined {
|
||||
return columns.find((info) => info.name.toLowerCase() === column.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Existing Salesforce rows: the record `Id` is the identity and never a write
|
||||
* target, and describe reports `updateable: false` for formula, rollup summary,
|
||||
* auto-number and system fields (CreatedDate, CreatedById, LastModifiedDate…).
|
||||
* Missing/unparseable metadata fails open — Salesforce enforces the real rules
|
||||
* server-side and returns a per-record error we surface on the row.
|
||||
*/
|
||||
export function isSalesforceExistingRowReadonlyColumn(databaseType: DatabaseType | undefined, column: string, primaryKeys: readonly string[], columns: ColumnInfo[] = []): boolean {
|
||||
if (databaseType !== "salesforce") return false;
|
||||
if (primaryKeys.some((key) => key.toLowerCase() === column.toLowerCase())) return true;
|
||||
return parseSalesforceColumnExtra(findColumnInfoByName(columns, column)?.extra)?.updateable === false;
|
||||
}
|
||||
|
||||
/** New Salesforce rows: fields describe reports as `createable: false` (Id, CreatedDate, …) cannot be set on insert. */
|
||||
export function isSalesforceNewRowReadonlyColumn(databaseType: DatabaseType | undefined, column: string, columns: ColumnInfo[] = []): boolean {
|
||||
if (databaseType !== "salesforce") return false;
|
||||
return parseSalesforceColumnExtra(findColumnInfoByName(columns, column)?.extra)?.createable === false;
|
||||
}
|
||||
|
||||
@@ -90,6 +90,15 @@ const capabilityByType: Partial<Record<DatabaseType, Partial<TableMetadataCapabi
|
||||
triggers: false,
|
||||
ddl: false,
|
||||
},
|
||||
// A Salesforce object only has describe metadata: the driver lists fields, and
|
||||
// there is no index, foreign key, trigger or DDL surface behind an SObject, so
|
||||
// those structure tabs would render permanently empty.
|
||||
salesforce: {
|
||||
indexes: false,
|
||||
foreignKeys: false,
|
||||
triggers: false,
|
||||
ddl: false,
|
||||
},
|
||||
hbase: {
|
||||
indexes: false,
|
||||
foreignKeys: false,
|
||||
|
||||
@@ -89,6 +89,10 @@ function sqlStatementSpans(sql: string, dialectId: string): Array<{ start: numbe
|
||||
export function quoteTableIdentifier(databaseType: DatabaseType | undefined, name: string): string {
|
||||
if ((databaseType === "gaussdb" || databaseType === "opengauss") && isExplicitlyQuotedSqlIdentifier(name)) return name;
|
||||
if (databaseType === "iotdb") return name;
|
||||
// SOQL has no delimited identifiers — `"Account"` is a string literal there and
|
||||
// `SELECT * FROM "Account"` fails with MALFORMED_QUERY. Mirrors the Rust
|
||||
// `quote_table_identifier` so a grid ORDER BY / filter matches what the backend builds.
|
||||
if (databaseType === "salesforce") return name;
|
||||
// JDBC connections use the driver-reported identifier quote string
|
||||
// (DatabaseMetaData.getIdentifierQuoteString()) — pass through unquoted.
|
||||
if (databaseType === "jdbc") return name;
|
||||
@@ -427,6 +431,21 @@ export function normalizeWhereInput(whereInput?: string): string {
|
||||
return withoutSemicolon.replace(/^where\b/i, "").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Database types whose data-preview SQL cannot be built without a column list,
|
||||
* so the columns must be awaited before the statement is generated rather than
|
||||
* refreshed in the background.
|
||||
*
|
||||
* * MySQL / PostgreSQL: the large-value preview projection is derived from the
|
||||
* column types and primary keys.
|
||||
* * Salesforce: SOQL has no `SELECT *`. With no known fields the backend builder
|
||||
* falls back to the `FIELDS(ALL)` selector, which the org only accepts with
|
||||
* `LIMIT 200` or less — awaiting the describe keeps every page size working.
|
||||
*/
|
||||
export function requiresEagerTableMetadataForDataOpen(databaseType: DatabaseType | undefined): boolean {
|
||||
return databaseType === "mysql" || databaseType === "postgres" || databaseType === "salesforce";
|
||||
}
|
||||
|
||||
export async function buildTableSelectSql(options: BuildTableSelectSqlOptions): Promise<string> {
|
||||
if (options.databaseType === "victoriametrics") return metricRangeQuery(options.tableName);
|
||||
return api.buildTableSelectSql(options);
|
||||
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
import type { SalesforceCurrentUser } from "@/types/salesforce";
|
||||
|
||||
function installLocalStorage() {
|
||||
const data = new Map<string, string>();
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: vi.fn((key: string) => data.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => data.set(key, value)),
|
||||
removeItem: vi.fn((key: string) => data.delete(key)),
|
||||
});
|
||||
}
|
||||
|
||||
function salesforceConnection(): ConnectionConfig {
|
||||
return {
|
||||
id: "sf-1",
|
||||
name: "Salesforce",
|
||||
db_type: "salesforce",
|
||||
host: "acme--qas1.sandbox.my.salesforce.com",
|
||||
port: 443,
|
||||
username: "ada@example.com",
|
||||
password: "",
|
||||
database: "",
|
||||
read_only: false,
|
||||
} as ConnectionConfig;
|
||||
}
|
||||
|
||||
const identity: SalesforceCurrentUser = {
|
||||
userId: "005xx000001Swag",
|
||||
name: "Ada Lovelace",
|
||||
email: "ada@example.com",
|
||||
organizationId: "00Dxx0000001gEQ",
|
||||
username: "ada@example.com.qas1",
|
||||
profileName: "System Administrator",
|
||||
isAdmin: true,
|
||||
orgName: "Acme QA Sandbox",
|
||||
};
|
||||
|
||||
async function createStore(salesforceCurrentUser: ReturnType<typeof vi.fn>) {
|
||||
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
|
||||
vi.doMock("@/lib/backend/api", () => ({
|
||||
checkConnectionHealth: vi.fn().mockResolvedValue(undefined),
|
||||
connectionDatabaseInfo: vi.fn().mockResolvedValue(null),
|
||||
connectionIdentifierQuote: vi.fn().mockResolvedValue(undefined),
|
||||
salesforceCurrentUser,
|
||||
}));
|
||||
|
||||
const { useConnectionStore } = await import("@/stores/connectionStore");
|
||||
const store = useConnectionStore();
|
||||
store.connections = [salesforceConnection()];
|
||||
// Already connected: the identity lookup must never trigger a connect of its own.
|
||||
store.connectedIds.add("sf-1");
|
||||
return store;
|
||||
}
|
||||
|
||||
describe("connectionStore salesforce identity", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.unstubAllGlobals();
|
||||
installLocalStorage();
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
it("resolves the identity once per connection and serves it from cache afterwards", async () => {
|
||||
const api = vi.fn().mockResolvedValue(identity);
|
||||
const store = await createStore(api);
|
||||
|
||||
expect(store.salesforceCurrentUser("sf-1")).toBeNull();
|
||||
await expect(store.loadSalesforceCurrentUser("sf-1")).resolves.toEqual(identity);
|
||||
await expect(store.loadSalesforceCurrentUser("sf-1")).resolves.toEqual(identity);
|
||||
|
||||
expect(store.salesforceCurrentUser("sf-1")).toEqual(identity);
|
||||
expect(api).toHaveBeenCalledTimes(1);
|
||||
expect(api).toHaveBeenCalledWith("sf-1");
|
||||
});
|
||||
|
||||
it("deduplicates concurrent lookups into one request", async () => {
|
||||
const api = vi.fn().mockResolvedValue(identity);
|
||||
const store = await createStore(api);
|
||||
|
||||
const [first, second] = await Promise.all([store.loadSalesforceCurrentUser("sf-1"), store.loadSalesforceCurrentUser("sf-1")]);
|
||||
|
||||
expect(first).toEqual(identity);
|
||||
expect(second).toEqual(identity);
|
||||
expect(api).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("hides the badge on failure without caching it, so a later lookup can recover", async () => {
|
||||
const api = vi.fn().mockRejectedValueOnce(new Error("Connection not found")).mockResolvedValueOnce(identity);
|
||||
const store = await createStore(api);
|
||||
|
||||
// The badge and the save-confirmation warning are advisory: a failed probe
|
||||
// must resolve quietly instead of throwing into the caller.
|
||||
await expect(store.loadSalesforceCurrentUser("sf-1")).resolves.toBeNull();
|
||||
expect(store.salesforceCurrentUser("sf-1")).toBeNull();
|
||||
|
||||
await expect(store.loadSalesforceCurrentUser("sf-1")).resolves.toEqual(identity);
|
||||
expect(api).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not cache a response that carries no identity", async () => {
|
||||
const api = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ ...identity, userId: "", username: "", name: "" })
|
||||
.mockResolvedValueOnce(identity);
|
||||
const store = await createStore(api);
|
||||
|
||||
await store.loadSalesforceCurrentUser("sf-1");
|
||||
expect(store.salesforceCurrentUser("sf-1")).toBeNull();
|
||||
|
||||
await expect(store.loadSalesforceCurrentUser("sf-1")).resolves.toEqual(identity);
|
||||
expect(api).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps an identity whose admin probe did not resolve", async () => {
|
||||
const api = vi.fn().mockResolvedValue({ ...identity, isAdmin: null, profileName: undefined });
|
||||
const store = await createStore(api);
|
||||
|
||||
await expect(store.loadSalesforceCurrentUser("sf-1")).resolves.toMatchObject({ username: "ada@example.com.qas1", isAdmin: null });
|
||||
expect(store.salesforceCurrentUser("sf-1")?.profileName).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves quietly without a connection id", async () => {
|
||||
const api = vi.fn().mockResolvedValue(identity);
|
||||
const store = await createStore(api);
|
||||
|
||||
await expect(store.loadSalesforceCurrentUser("")).resolves.toBeNull();
|
||||
expect(api).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -697,6 +697,17 @@ describe("normalizeMcpGlobalPolicy", () => {
|
||||
expect(policy.connectionPolicies[0].executionModePolicyVersion).toBeNull();
|
||||
});
|
||||
|
||||
it("defaults the Salesforce DML opt-in to off and revokes it under read-only", () => {
|
||||
// Policies saved before the switch existed carry no field at all.
|
||||
expect(normalizeMcpGlobalPolicy({ connectionPolicies: [{ connectionId: "sfdc", databaseScope: "all" } as any] }).connectionPolicies[0].allowSalesforceDml).toBe(false);
|
||||
|
||||
expect(normalizeMcpGlobalPolicy({ connectionPolicies: [{ connectionId: "sfdc", allowSalesforceDml: true } as any] }).connectionPolicies[0].allowSalesforceDml).toBe(true);
|
||||
|
||||
expect(normalizeMcpGlobalPolicy({ connectionPolicies: [{ connectionId: "sfdc", readOnly: true, allowSalesforceDml: true } as any] }).connectionPolicies[0].allowSalesforceDml).toBe(false);
|
||||
|
||||
expect(normalizeMcpGlobalPolicy({ connectionPolicies: [{ connectionId: "sfdc", allowSalesforceDml: "yes" } as any] }).connectionPolicies[0].allowSalesforceDml).toBe(false);
|
||||
});
|
||||
|
||||
it("round-trips queryTimeoutSecs null, undefined and positive numbers", () => {
|
||||
expect(normalizeMcpGlobalPolicy({ queryTimeoutSecs: null }).queryTimeoutSecs).toBeNull();
|
||||
expect(normalizeMcpGlobalPolicy({ queryTimeoutSecs: undefined } as any).queryTimeoutSecs).toBeNull();
|
||||
|
||||
@@ -165,6 +165,8 @@ import { useSavedSqlStore } from "@/stores/savedSqlStore";
|
||||
import { decorateDatabaseSavedSqlTreeNodes, indexSavedSqlFilesByDatabase, stripDatabaseSavedSqlTreeNodes, withDatabaseSavedSqlRoot } from "@/lib/savedSql/savedSqlDatabaseTree";
|
||||
import { encodeSqlServerLinkedSchema, parseSqlServerLinkedSchema } from "@/lib/database/sqlServerLinkedServers";
|
||||
import { inferMongoCompletionFields, type MongoCompletionField } from "@/lib/mongo/mongoCompletion";
|
||||
import type { SoqlCompletionField, SoqlCompletionObject } from "@/lib/soql/soqlCompletion";
|
||||
import type { SalesforceCurrentUser } from "@/types/salesforce";
|
||||
import { flattenElasticsearchMappingFields, type ElasticsearchCompletionField } from "@/lib/elasticsearch/elasticsearchCompletion";
|
||||
import { isMongoLegacyDriverProfile } from "@/lib/mongo/mongoCapabilities";
|
||||
import { mongoCollectionKindFromNode, toMongoCollectionKind, visibleMongoCollections } from "@/lib/sidebar/mongoCollectionMutation";
|
||||
@@ -532,6 +534,11 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
const redisCommandDocsCacheGeneration = new Map<string, number>();
|
||||
const mongoCompletionCollectionsCache = ref<Record<string, string[]>>({});
|
||||
const mongoCompletionFieldsCache = ref<Record<string, MongoCompletionField[]>>({});
|
||||
const soqlCompletionObjectsCache = ref<Record<string, SoqlCompletionObject[]>>({});
|
||||
const soqlCompletionFieldsCache = ref<Record<string, SoqlCompletionField[]>>({});
|
||||
// One entry per connection: the authenticated Salesforce user never changes for
|
||||
// the life of a connection (token refreshes reuse the same identity).
|
||||
const salesforceCurrentUserCache = ref<Record<string, SalesforceCurrentUser>>({});
|
||||
const schemaListCache = ref<Record<string, string[]>>({});
|
||||
const sidebarSearchQuery = ref("");
|
||||
const sidebarTableSearchQueries = ref<Record<string, string>>({});
|
||||
@@ -1666,6 +1673,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
oscar: "神通 OSCAR",
|
||||
influxdb: "InfluxDB",
|
||||
victoriametrics: "VictoriaMetrics",
|
||||
salesforce: "Salesforce",
|
||||
};
|
||||
|
||||
const profile = config.driver_profile || config.db_type;
|
||||
@@ -3805,6 +3813,12 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
for (const key of Object.keys(mongoCompletionFieldsCache.value)) {
|
||||
if (key === exactCacheKey || key.startsWith(cachePrefix)) delete mongoCompletionFieldsCache.value[key];
|
||||
}
|
||||
for (const key of Object.keys(soqlCompletionObjectsCache.value)) {
|
||||
if (key === exactCacheKey || key.startsWith(cachePrefix)) delete soqlCompletionObjectsCache.value[key];
|
||||
}
|
||||
for (const key of Object.keys(soqlCompletionFieldsCache.value)) {
|
||||
if (key === exactCacheKey || key.startsWith(cachePrefix)) delete soqlCompletionFieldsCache.value[key];
|
||||
}
|
||||
for (const key of completionTableIndex.keys()) {
|
||||
if (key.startsWith(cachePrefix)) completionTableIndex.delete(key);
|
||||
}
|
||||
@@ -5182,7 +5196,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
}
|
||||
if (!connectedIds.value.has(connectionId)) return;
|
||||
const config = getConfig(connectionId);
|
||||
if (!config || ["redis", "etcd", "zookeeper", "consul", "mongodb", "dynamodb", "elasticsearch", "easysearch", "meilisearch", "solr", "milvus", "qdrant", "weaviate", "chromadb", "mq", "nacos"].includes(config.db_type)) return;
|
||||
if (!config || ["redis", "etcd", "zookeeper", "consul", "mongodb", "dynamodb", "elasticsearch", "easysearch", "meilisearch", "solr", "milvus", "qdrant", "weaviate", "chromadb", "mq", "nacos", "salesforce"].includes(config.db_type)) return;
|
||||
const node = findConnectionNode(connectionId);
|
||||
if (!node || node.type !== "connection" || hasConnectionMetadataChildren(node.children)) return;
|
||||
const scope = { kind: "connection-databases" as const, connectionId, driverProfile: metadataDriverProfile(config) };
|
||||
@@ -8511,6 +8525,105 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
});
|
||||
}
|
||||
|
||||
// Map a Salesforce describe column to a SOQL completion field. The backend packs
|
||||
// relationshipName / referenceTo / label into ColumnInfo.extra (JSON) and active
|
||||
// picklist values into enum_values; parse defensively since extra may be absent.
|
||||
function soqlFieldFromColumnInfo(column: ColumnInfo): SoqlCompletionField {
|
||||
let extra: { relationshipName?: string; referenceTo?: string[]; label?: string } | null = null;
|
||||
if (column.extra) {
|
||||
try {
|
||||
extra = JSON.parse(column.extra);
|
||||
} catch {
|
||||
extra = null;
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: column.name,
|
||||
type: column.data_type || undefined,
|
||||
label: extra?.label ?? column.comment ?? undefined,
|
||||
picklistValues: column.enum_values?.length ? column.enum_values : undefined,
|
||||
relationshipName: extra?.relationshipName ?? undefined,
|
||||
referenceTo: extra?.referenceTo?.length ? extra.referenceTo : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function listSoqlCompletionObjects(connectionId: string, database: string): Promise<SoqlCompletionObject[]> {
|
||||
// No `database` guard: the Salesforce backend ignores the database parameter
|
||||
// (the whole org is one synthesized database), and a restored query tab can
|
||||
// legitimately carry an empty database while the connection still works.
|
||||
const cacheKey = `${connectionId}:${database}`;
|
||||
const cached = soqlCompletionObjectsCache.value[cacheKey];
|
||||
if (cached) return cached;
|
||||
return withCompletionInFlight(`${cacheKey}:soql-objects`, async () => {
|
||||
await ensureConnected(connectionId);
|
||||
const tables = await api.listTables(connectionId, database, "");
|
||||
const labelByName = new Map(tables.map((t) => [t.name, t.comment ?? undefined]));
|
||||
const objects = sortSidebarNames(tables.map((t) => t.name)).map((name) => ({ name, label: labelByName.get(name) }));
|
||||
// Never cache an empty list: a transient failure would otherwise stick for the
|
||||
// whole session and silently degrade SOQL completion to keywords-only.
|
||||
if (objects.length > 0) {
|
||||
soqlCompletionObjectsCache.value[cacheKey] = objects;
|
||||
evictOldestCacheEntries(soqlCompletionObjectsCache.value, COMPLETION_CACHE_MAX);
|
||||
}
|
||||
return objects;
|
||||
});
|
||||
}
|
||||
|
||||
async function listSoqlCompletionFields(connectionId: string, database: string, objectName: string): Promise<SoqlCompletionField[]> {
|
||||
if (!objectName) return [];
|
||||
const cacheKey = `${connectionId}:${database}:${objectName}`;
|
||||
const cached = soqlCompletionFieldsCache.value[cacheKey];
|
||||
if (cached) return cached;
|
||||
return withCompletionInFlight(`${cacheKey}:soql-fields`, async () => {
|
||||
await ensureConnected(connectionId);
|
||||
// Backed by the driver's in-memory describe cache, so repeated loads (e.g. one
|
||||
// per keystroke while traversing a relationship) do not re-hit the Salesforce API.
|
||||
const columns = await api.getColumns(connectionId, database, "", objectName);
|
||||
const fields = columns.map(soqlFieldFromColumnInfo);
|
||||
// Never cache an empty list: a transient describe failure would otherwise stick
|
||||
// and make field completion silently vanish for this object all session.
|
||||
if (fields.length > 0) {
|
||||
soqlCompletionFieldsCache.value[cacheKey] = fields;
|
||||
evictOldestCacheEntries(soqlCompletionFieldsCache.value, COMPLETION_CACHE_MAX);
|
||||
}
|
||||
return fields;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity behind a Salesforce connection (`GET /services/oauth2/userinfo` plus an
|
||||
* admin probe), resolved at most once per connection and cached for the session.
|
||||
* Deliberately failure-tolerant: the toolbar badge and the non-admin hint on the
|
||||
* DML confirmation are advisory, so a failed lookup returns null instead of
|
||||
* surfacing an error or blocking an edit.
|
||||
*/
|
||||
async function loadSalesforceCurrentUser(connectionId: string): Promise<SalesforceCurrentUser | null> {
|
||||
if (!connectionId) return null;
|
||||
const cached = salesforceCurrentUserCache.value[connectionId];
|
||||
if (cached) return cached;
|
||||
try {
|
||||
return await withCompletionInFlight(`${connectionId}:salesforce-current-user`, async () => {
|
||||
await ensureConnected(connectionId);
|
||||
const user = await api.salesforceCurrentUser(connectionId);
|
||||
// Never cache an identity-less result: a partial failure would otherwise
|
||||
// stick for the session and keep the badge blank.
|
||||
if (user && (user.userId || user.username)) {
|
||||
salesforceCurrentUserCache.value[connectionId] = user;
|
||||
evictOldestCacheEntries(salesforceCurrentUserCache.value, COMPLETION_CACHE_MAX);
|
||||
}
|
||||
return user ?? null;
|
||||
});
|
||||
} catch (error) {
|
||||
console.debug("[salesforce] current user lookup failed", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Last known Salesforce identity for a connection, or null before it resolves. */
|
||||
function salesforceCurrentUser(connectionId: string): SalesforceCurrentUser | null {
|
||||
return salesforceCurrentUserCache.value[connectionId] ?? null;
|
||||
}
|
||||
|
||||
function listCompletionTableMetadata(connectionId: string, database: string, schema: string, filter?: string, limit?: number, catalog?: string): Promise<TableInfo[]> {
|
||||
if (catalog) return api.listTables(connectionId, database, schema, filter, limit, undefined, undefined, catalog);
|
||||
return api.listTables(connectionId, database, schema, filter, limit);
|
||||
@@ -9946,6 +10059,10 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
listRedisCompletionCommandDocs,
|
||||
listMongoCompletionCollections,
|
||||
listMongoCompletionFields,
|
||||
listSoqlCompletionObjects,
|
||||
listSoqlCompletionFields,
|
||||
loadSalesforceCurrentUser,
|
||||
salesforceCurrentUser,
|
||||
invalidateCompletionCache,
|
||||
invalidateCompletionTableCache,
|
||||
completionCacheRevision,
|
||||
|
||||
@@ -86,6 +86,13 @@ export interface McpConnectionPolicy {
|
||||
databaseScope: "all" | "selected" | "none";
|
||||
allowedDatabases: string[];
|
||||
databasePolicies: McpDatabasePolicy[];
|
||||
/**
|
||||
* Per-connection opt-in for AI-agent writes to a Salesforce org. Off by default and
|
||||
* forced off by `readOnly`: SOQL reads need nothing beyond the execution mode, but
|
||||
* Salesforce DML has no transaction and no rollback, so it also requires the
|
||||
* two-step prepare/apply confirmation on every single write.
|
||||
*/
|
||||
allowSalesforceDml: boolean;
|
||||
}
|
||||
|
||||
export interface McpDatabasePolicy {
|
||||
@@ -174,6 +181,9 @@ export function normalizeMcpGlobalPolicy(policy: Partial<McpGlobalPolicy> | null
|
||||
databaseScope,
|
||||
allowedDatabases,
|
||||
databasePolicies,
|
||||
// Same fail-closed shape as allowDangerousSql: read-only wins, and a policy
|
||||
// saved before the switch existed (field absent) normalizes to off.
|
||||
allowSalesforceDml: rule.readOnly !== true && rule.allowSalesforceDml === true,
|
||||
};
|
||||
return rules;
|
||||
}, {}),
|
||||
|
||||
@@ -122,6 +122,7 @@ export const CONNECTION_PROFILES = {
|
||||
influxdb: { type: "influxdb", port: 8086, user: "", label: "InfluxDB", icon: "InfluxDB" },
|
||||
influxdb3: { type: "influxdb3", port: 8181, user: "", label: "InfluxDB 3.x", icon: "InfluxDB" },
|
||||
victoriametrics: { type: "victoriametrics", port: 8428, user: "", label: "VictoriaMetrics", icon: "victoriametrics" },
|
||||
salesforce: { type: "salesforce", port: 443, user: "", label: "Salesforce", icon: "salesforce" },
|
||||
custom_mysql: { type: "mysql", port: 3306, user: "root", label: "Custom", icon: "mysql", urlParams: "" },
|
||||
dolt: { type: "mysql", port: 3306, user: "root", label: "Dolt", icon: "dolt", urlParams: "" },
|
||||
custom_postgres: { type: "postgres", port: 5432, user: "postgres", label: "Custom", icon: "postgres", urlParams: "" },
|
||||
@@ -230,6 +231,7 @@ export const CONNECTION_PROFILE_ICONS = {
|
||||
influxdb: "influxdb",
|
||||
influxdb3: "influxdb",
|
||||
victoriametrics: "victoriametrics",
|
||||
salesforce: "salesforce",
|
||||
custom_mysql: "mysql",
|
||||
dolt: "dolt",
|
||||
custom_postgres: "postgres",
|
||||
@@ -331,6 +333,7 @@ export const CONNECTION_PICKER_OPTIONS = [
|
||||
{ value: "influxdb", label: "InfluxDB", category: "timeseries" },
|
||||
{ value: "influxdb3", label: "InfluxDB 3.x", category: "timeseries" },
|
||||
{ value: "victoriametrics", label: "VictoriaMetrics", category: "timeseries" },
|
||||
{ value: "salesforce", label: "Salesforce", category: "analytics" },
|
||||
{ value: "custom_mysql", label: "Custom (MySQL)", category: "sql" },
|
||||
{ value: "dolt", label: "Dolt", category: "sql" },
|
||||
{ value: "custom_postgres", label: "Custom (PostgreSQL)", category: "sql" },
|
||||
|
||||
@@ -82,6 +82,7 @@ export const DATABASE_TYPES = [
|
||||
"jdbc",
|
||||
"spark",
|
||||
"plugin",
|
||||
"salesforce",
|
||||
] as const;
|
||||
|
||||
export type DatabaseType = (typeof DATABASE_TYPES)[number] | "plugin";
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// Salesforce OAuth metadata persisted on `ConnectionConfig.external_config.auth`.
|
||||
// The backend owns encryption/storage of refreshToken; the frontend only keeps
|
||||
// enough state to re-render the connection card and re-authorize when needed.
|
||||
|
||||
export type SalesforceAuthMode = "token" | "oauth" | "device" | "password";
|
||||
|
||||
export type SalesforceEnvironment = "production" | "sandbox" | "custom";
|
||||
|
||||
export interface SalesforceOAuthToken {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
instanceUrl: string;
|
||||
}
|
||||
|
||||
// Backend `SfRefreshedToken`: instanceUrl is absent when Salesforce did not
|
||||
// echo it on the refresh grant (callers keep their known instance URL).
|
||||
export interface SalesforceOAuthRefreshResult {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
instanceUrl?: string;
|
||||
}
|
||||
|
||||
export interface SalesforceOAuthAuthorizeParams {
|
||||
environment: SalesforceEnvironment;
|
||||
loginUrl?: string;
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
}
|
||||
|
||||
export interface SalesforceOAuthDeviceStartResult {
|
||||
deviceCode: string;
|
||||
userCode: string;
|
||||
verificationUri: string;
|
||||
intervalSecs: number;
|
||||
expiresInSecs: number;
|
||||
}
|
||||
|
||||
export type SalesforceOAuthDevicePollResult = { status: "pending"; intervalSecs: number } | { status: "success"; token: SalesforceOAuthToken } | { status: "expired" } | { status: "denied"; reason?: string };
|
||||
|
||||
export interface SalesforceAuthContext {
|
||||
mode: SalesforceAuthMode;
|
||||
environment?: SalesforceEnvironment;
|
||||
loginUrl?: string;
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
refreshToken?: string;
|
||||
// Username-password (ROPC) mode. The password is scrubbed into the backend
|
||||
// secret store (key salesforce.auth.password) and never rendered back.
|
||||
username?: string;
|
||||
password?: string;
|
||||
authorizedAt?: string;
|
||||
}
|
||||
|
||||
export interface SalesforceExternalConfig {
|
||||
auth?: SalesforceAuthContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity of the user a Salesforce connection is authenticated as, resolved from
|
||||
* `GET /services/oauth2/userinfo` plus a `Profile.PermissionsModifyAllData` probe.
|
||||
* Returned by the `salesforce_current_user` command / `GET /api/salesforce/current-user`.
|
||||
*
|
||||
* `isAdmin` is advisory only (a permission set can grant fine-grained rights), so it
|
||||
* drives a UI hint and never a security decision — Salesforce enforces the real rules.
|
||||
* The backend omits `isAdmin` / `profileName` when the probe did not resolve, so both
|
||||
* are optional; fields Salesforce did not return come back as empty strings.
|
||||
*/
|
||||
export interface SalesforceCurrentUser {
|
||||
userId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
organizationId: string;
|
||||
username: string;
|
||||
profileName?: string;
|
||||
isAdmin?: boolean | null;
|
||||
/** Org display name (`SELECT Name FROM Organization`), falling back to the instance host. */
|
||||
orgName: string;
|
||||
}
|
||||
|
||||
export const SALESFORCE_OAUTH_CALLBACK_URL = "http://localhost:27098/callback";
|
||||
@@ -68,6 +68,14 @@ pub fn dbx_mcp_enabled_tools(agent_mode: bool) -> Vec<&'static str> {
|
||||
if agent_mode {
|
||||
tools.push("dbx_execute_query");
|
||||
tools.push("dbx_execute_redis_command");
|
||||
// Read-only Salesforce identity, so an agent working against an org can tell the
|
||||
// user who a change would be attributed to. The `dbx_salesforce_prepare_write` /
|
||||
// `dbx_salesforce_apply_write` pair is deliberately absent: DBX spawns these CLI
|
||||
// agents with `--permission-mode dontAsk`, so a tool listed here runs with no
|
||||
// interactive gate, and a Salesforce write cannot be rolled back. The two-step
|
||||
// confirmation belongs to MCP clients that do put the summary in front of a
|
||||
// person; the connection-level DML switch alone is not a substitute.
|
||||
tools.push("dbx_salesforce_current_user");
|
||||
}
|
||||
tools
|
||||
}
|
||||
|
||||
@@ -2908,6 +2908,39 @@
|
||||
"userAdmin": false,
|
||||
"driverManagement": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"dbType": "salesforce",
|
||||
"label": "Salesforce",
|
||||
"dialect": "SOQL",
|
||||
"runtimeMode": "native",
|
||||
"mcpMode": "bridge",
|
||||
"singleConnectionPool": true,
|
||||
"metadataConnectionScoped": false,
|
||||
"skipTcpProbe": true,
|
||||
"defaultPort": 443,
|
||||
"traits": {
|
||||
"singleDatabase": true
|
||||
},
|
||||
"supportLevel": "operate",
|
||||
"capabilities": {
|
||||
"queryExecution": true,
|
||||
"metadataBrowse": true,
|
||||
"objectBrowser": true,
|
||||
"objectSource": false,
|
||||
"schemaSearch": true,
|
||||
"diagram": false,
|
||||
"tableDataEdit": true,
|
||||
"tableStructureEdit": false,
|
||||
"tableImport": false,
|
||||
"dataTransfer": false,
|
||||
"sqlFileExecution": false,
|
||||
"databaseCreate": false,
|
||||
"fieldLineage": false,
|
||||
"sqlExplain": false,
|
||||
"userAdmin": false,
|
||||
"driverManagement": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -166,6 +166,21 @@ fn apply_ceiling(current: (bool, bool), ceiling: (bool, bool)) -> (bool, bool) {
|
||||
(read_only, !read_only && current.1 && ceiling.1)
|
||||
}
|
||||
|
||||
/// Whether an AI agent may write to this Salesforce connection through MCP.
|
||||
///
|
||||
/// A pure per-connection opt-in: no global or group setting can turn it on, and
|
||||
/// a connection without a saved rule stays off. Callers still run every write
|
||||
/// through the ordinary execution policy, so the global/connection read-only
|
||||
/// modes, connection read-only protection and production protection all remain
|
||||
/// upper bounds on top of this switch.
|
||||
pub fn connection_allows_salesforce_dml(policy: &McpGlobalPolicy, connection_id: &str) -> bool {
|
||||
policy
|
||||
.connection_policies
|
||||
.iter()
|
||||
.find(|rule| rule.connection_id == connection_id)
|
||||
.is_some_and(|rule| rule.allow_salesforce_dml && !rule.read_only)
|
||||
}
|
||||
|
||||
/// Reject qualified SQL references when database-specific execution rules are
|
||||
/// present and individual referenced databases have not been evaluated.
|
||||
pub fn ensure_sql_database_execution_scope(
|
||||
@@ -251,7 +266,10 @@ pub fn ensure_mongo_database_execution_scope(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{effective_database_execution_policy, resolve_database, MCP_EXECUTION_POLICY_VERSION};
|
||||
use super::{
|
||||
connection_allows_salesforce_dml, effective_database_execution_policy, resolve_database,
|
||||
MCP_EXECUTION_POLICY_VERSION,
|
||||
};
|
||||
use crate::storage::{McpConnectionPolicy, McpDatabasePolicy, McpGlobalPolicy};
|
||||
|
||||
fn policy(version: Option<u8>) -> McpGlobalPolicy {
|
||||
@@ -270,6 +288,7 @@ mod tests {
|
||||
read_only: false,
|
||||
allow_dangerous_sql: true,
|
||||
}],
|
||||
allow_salesforce_dml: false,
|
||||
}],
|
||||
..Default::default()
|
||||
}
|
||||
@@ -316,4 +335,26 @@ mod tests {
|
||||
assert_eq!(resolve_database(" ", Some("sample")), "sample");
|
||||
assert_eq!(resolve_database("analytics", Some("sample")), "analytics");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn salesforce_dml_needs_an_explicit_connection_opt_in() {
|
||||
// No rule for the connection at all → off, whatever the global mode says.
|
||||
let mut open = policy(Some(MCP_EXECUTION_POLICY_VERSION));
|
||||
open.read_only = false;
|
||||
assert!(!connection_allows_salesforce_dml(&open, "other"));
|
||||
assert!(!connection_allows_salesforce_dml(&open, "conn"));
|
||||
|
||||
open.connection_policies[0].allow_salesforce_dml = true;
|
||||
assert!(connection_allows_salesforce_dml(&open, "conn"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn salesforce_dml_opt_in_is_void_on_a_read_only_connection_rule() {
|
||||
let mut policy = policy(Some(MCP_EXECUTION_POLICY_VERSION));
|
||||
policy.connection_policies[0].allow_salesforce_dml = true;
|
||||
policy.connection_policies[0].read_only = true;
|
||||
// `normalized()` already clears the flag for read-only rules; this keeps a
|
||||
// hand-built or legacy policy from sneaking a write through.
|
||||
assert!(!connection_allows_salesforce_dml(&policy, "conn"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,10 @@ pub const MQTT_AUTH_PASSWORD_KEY: &str = "mqtt.auth.password";
|
||||
pub const CASSANDRA_TLS_SECRET_PREFIX: &str = "cassandra.tls.";
|
||||
pub const CASSANDRA_TRUSTSTORE_PASSWORD_KEY: &str = "cassandra.tls.truststore_password";
|
||||
pub const CASSANDRA_KEYSTORE_PASSWORD_KEY: &str = "cassandra.tls.keystore_password";
|
||||
pub const SALESFORCE_AUTH_SECRET_PREFIX: &str = "salesforce.auth.";
|
||||
pub const SALESFORCE_AUTH_CLIENT_SECRET_KEY: &str = "salesforce.auth.client_secret";
|
||||
pub const SALESFORCE_AUTH_REFRESH_TOKEN_KEY: &str = "salesforce.auth.refresh_token";
|
||||
pub const SALESFORCE_AUTH_PASSWORD_KEY: &str = "salesforce.auth.password";
|
||||
pub const PLUGIN_CONNECTION_SECRET_PREFIX: &str = "plugin_connection.";
|
||||
|
||||
/// Storage-level secret key for one plugin connection field: namespaced under
|
||||
@@ -127,6 +131,7 @@ pub fn save_connections_to_file(
|
||||
persist_mq_token_signing_secret(store, config)?;
|
||||
persist_mqtt_auth_secrets(store, config)?;
|
||||
persist_cassandra_tls_secrets(store, config)?;
|
||||
persist_salesforce_auth_secrets(store, config)?;
|
||||
delete_secret_prefix(store, &config.id, PLUGIN_CONNECTION_SECRET_PREFIX)?;
|
||||
for (key, secret) in &config.connection_secrets {
|
||||
persist_secret(store, &config.id, &plugin_connection_secret_key(key)?, secret)?;
|
||||
@@ -201,6 +206,7 @@ pub fn load_connections_from_file(
|
||||
hydrate_mq_token_signing_secret(store, config, &mut needs_rewrite)?;
|
||||
hydrate_mqtt_auth_secrets(store, config, &mut needs_rewrite)?;
|
||||
hydrate_cassandra_tls_secrets(store, config, &mut needs_rewrite)?;
|
||||
hydrate_salesforce_auth_secrets(store, config, &mut needs_rewrite)?;
|
||||
let plugin_secret_keys = config.connection_secrets.keys().cloned().collect::<Vec<_>>();
|
||||
for key in plugin_secret_keys {
|
||||
let storage_key = plugin_connection_secret_key(&key)?;
|
||||
@@ -394,6 +400,7 @@ fn delete_removed_connection_secrets(
|
||||
delete_secret_prefix(store, &config.id, MQ_TOKEN_SIGNING_SECRET_PREFIX)?;
|
||||
delete_secret_prefix(store, &config.id, MQTT_AUTH_SECRET_PREFIX)?;
|
||||
delete_secret_prefix(store, &config.id, CASSANDRA_TLS_SECRET_PREFIX)?;
|
||||
delete_secret_prefix(store, &config.id, SALESFORCE_AUTH_SECRET_PREFIX)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -653,6 +660,96 @@ fn cassandra_tls_object_mut(
|
||||
value?.get_mut("tls")?.as_object_mut()
|
||||
}
|
||||
|
||||
// ── Salesforce OAuth 密钥持久化 ─────────────────────────────────
|
||||
//
|
||||
// Persist semantics (mirrors MQ's replace_mq_auth_secret):
|
||||
// 1. Incoming empty/missing + secret already stored → preserve existing secret.
|
||||
// 2. Incoming non-empty → overwrite.
|
||||
// 3. external_config.auth block absent (user switched off OAuth) → delete both secrets.
|
||||
// This is required because the frontend edits an existing connection with the
|
||||
// sensitive fields blanked out — an empty value must not clobber the stored secret.
|
||||
|
||||
fn persist_salesforce_auth_secrets(store: &dyn ConnectionSecretStore, config: &ConnectionConfig) -> Result<(), String> {
|
||||
if config.db_type != DatabaseType::Salesforce {
|
||||
delete_secret_prefix(store, &config.id, SALESFORCE_AUTH_SECRET_PREFIX)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(auth) = salesforce_auth_object(config.external_config.as_ref()) else {
|
||||
delete_secret_prefix(store, &config.id, SALESFORCE_AUTH_SECRET_PREFIX)?;
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
replace_salesforce_auth_secret(store, &config.id, SALESFORCE_AUTH_CLIENT_SECRET_KEY, auth, "clientSecret")?;
|
||||
replace_salesforce_auth_secret(store, &config.id, SALESFORCE_AUTH_REFRESH_TOKEN_KEY, auth, "refreshToken")?;
|
||||
replace_salesforce_auth_secret(store, &config.id, SALESFORCE_AUTH_PASSWORD_KEY, auth, "password")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_salesforce_auth_secret(
|
||||
store: &dyn ConnectionSecretStore,
|
||||
connection_id: &str,
|
||||
key: &str,
|
||||
auth: &serde_json::Map<String, serde_json::Value>,
|
||||
field: &str,
|
||||
) -> Result<(), String> {
|
||||
let current = auth.get(field).and_then(serde_json::Value::as_str).filter(|secret| !secret.is_empty());
|
||||
let existing = if current.is_none() { store.get_secret(connection_id, key)? } else { None };
|
||||
// Per-key delete: we only manage the exact key we own, so two parallel
|
||||
// replace calls don't wipe each other out.
|
||||
store.delete_secret(connection_id, key)?;
|
||||
match current {
|
||||
Some(secret) => store.set_secret(connection_id, key, secret),
|
||||
None => match existing {
|
||||
Some(secret) => store.set_secret(connection_id, key, &secret),
|
||||
None => Ok(()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn hydrate_salesforce_auth_secrets(
|
||||
store: &dyn ConnectionSecretStore,
|
||||
config: &mut ConnectionConfig,
|
||||
needs_rewrite: &mut bool,
|
||||
) -> Result<(), String> {
|
||||
if config.db_type != DatabaseType::Salesforce {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(auth) = salesforce_auth_object_mut(config.external_config.as_mut()) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
hydrate_json_secret(store, &config.id, SALESFORCE_AUTH_CLIENT_SECRET_KEY, auth, "clientSecret", needs_rewrite)?;
|
||||
hydrate_json_secret(store, &config.id, SALESFORCE_AUTH_REFRESH_TOKEN_KEY, auth, "refreshToken", needs_rewrite)?;
|
||||
hydrate_json_secret(store, &config.id, SALESFORCE_AUTH_PASSWORD_KEY, auth, "password", needs_rewrite)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn scrub_salesforce_auth_secrets(config: &mut ConnectionConfig) {
|
||||
if config.db_type != DatabaseType::Salesforce {
|
||||
return;
|
||||
}
|
||||
let Some(auth) = salesforce_auth_object_mut(config.external_config.as_mut()) else {
|
||||
return;
|
||||
};
|
||||
scrub_json_secret(auth, "clientSecret");
|
||||
scrub_json_secret(auth, "refreshToken");
|
||||
scrub_json_secret(auth, "password");
|
||||
}
|
||||
|
||||
fn salesforce_auth_object(value: Option<&serde_json::Value>) -> Option<&serde_json::Map<String, serde_json::Value>> {
|
||||
value?.get("auth")?.as_object()
|
||||
}
|
||||
|
||||
fn salesforce_auth_object_mut(
|
||||
value: Option<&mut serde_json::Value>,
|
||||
) -> Option<&mut serde_json::Map<String, serde_json::Value>> {
|
||||
value?.get_mut("auth")?.as_object_mut()
|
||||
}
|
||||
|
||||
// ── MQTT 密钥持久化 ──────────────────────────────────────────────
|
||||
|
||||
fn persist_mqtt_auth_secrets(store: &dyn ConnectionSecretStore, config: &ConnectionConfig) -> Result<(), String> {
|
||||
@@ -845,6 +942,7 @@ fn sanitize_connections(configs: &[ConnectionConfig]) -> Vec<ConnectionConfig> {
|
||||
scrub_mq_token_signing_secret(&mut config);
|
||||
scrub_mqtt_auth_secrets(&mut config);
|
||||
scrub_cassandra_tls_secrets(&mut config);
|
||||
scrub_salesforce_auth_secrets(&mut config);
|
||||
scrub_plugin_connection_secrets(&mut config);
|
||||
config
|
||||
})
|
||||
|
||||
@@ -43,6 +43,7 @@ use crate::plugins::{
|
||||
PluginRuntimeEnv, PluginRuntimeProxy,
|
||||
};
|
||||
use crate::query_cancel::RunningQueries;
|
||||
use crate::salesforce_oauth::SfBrowserOpener;
|
||||
use crate::session_credentials::SessionCredentialStore;
|
||||
use crate::storage::{normalize_duckdb_worker_max_processes, Storage, DUCKDB_WORKER_MAX_PROCESSES_DEFAULT};
|
||||
use crate::task_supervisor::TaskSupervisor;
|
||||
@@ -118,6 +119,7 @@ pub enum PoolKind {
|
||||
Easysearch(db::easysearch_driver::EasysearchClient),
|
||||
Solr(db::solr_driver::SolrClient),
|
||||
Meilisearch(db::meilisearch_driver::MeilisearchClient),
|
||||
Salesforce(db::salesforce_driver::SfClient),
|
||||
HBase(db::hbase_driver::HBaseClient),
|
||||
VectorDb(db::vector_driver::VectorClient),
|
||||
InfluxDb(db::influxdb_driver::InfluxdbClient),
|
||||
@@ -367,6 +369,24 @@ struct SharedResourceBudget {
|
||||
semaphore: Arc<Semaphore>,
|
||||
}
|
||||
|
||||
/// Cached Salesforce connected-user identity + org display name, serialized
|
||||
/// with camelCase field names for the frontend. `Deserialize` is derived too so
|
||||
/// the Web-mode MCP backend can decode the same JSON the desktop route emits.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SalesforceCurrentUser {
|
||||
pub user_id: String,
|
||||
pub name: String,
|
||||
pub email: String,
|
||||
pub organization_id: String,
|
||||
pub username: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub profile_name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub is_admin: Option<bool>,
|
||||
pub org_name: String,
|
||||
}
|
||||
|
||||
pub struct AppState {
|
||||
connections: Arc<RwLock<ConnectionPoolRegistry>>,
|
||||
task_supervisor: TaskSupervisor,
|
||||
@@ -407,6 +427,7 @@ pub struct AppState {
|
||||
pub write_unlock_windows: crate::write_unlock::WriteUnlockWindows,
|
||||
metadata_gates: Arc<Mutex<HashMap<String, Arc<Semaphore>>>>,
|
||||
mongo_oidc_browser_opener: std::sync::RwLock<Option<MongoOidcBrowserOpener>>,
|
||||
salesforce_browser_opener: std::sync::RwLock<Option<SfBrowserOpener>>,
|
||||
#[cfg(feature = "mq-admin")]
|
||||
pub mq_registry: crate::mq::MqAdminRegistry,
|
||||
}
|
||||
@@ -1601,6 +1622,7 @@ impl AppState {
|
||||
write_unlock_windows: crate::write_unlock::WriteUnlockWindows::default(),
|
||||
metadata_gates: Arc::new(Mutex::new(HashMap::new())),
|
||||
mongo_oidc_browser_opener: std::sync::RwLock::new(None),
|
||||
salesforce_browser_opener: std::sync::RwLock::new(None),
|
||||
#[cfg(feature = "mq-admin")]
|
||||
mq_registry: crate::mq::MqAdminRegistry::new(),
|
||||
}
|
||||
@@ -1614,6 +1636,14 @@ impl AppState {
|
||||
self.mongo_oidc_browser_opener.read().expect("MongoDB OIDC browser opener lock poisoned").clone()
|
||||
}
|
||||
|
||||
pub fn set_salesforce_browser_opener(&self, opener: SfBrowserOpener) {
|
||||
*self.salesforce_browser_opener.write().expect("Salesforce browser opener lock poisoned") = Some(opener);
|
||||
}
|
||||
|
||||
pub fn salesforce_browser_opener(&self) -> Option<SfBrowserOpener> {
|
||||
self.salesforce_browser_opener.read().expect("Salesforce browser opener lock poisoned").clone()
|
||||
}
|
||||
|
||||
pub(crate) async fn acquire_metadata_permit(
|
||||
&self,
|
||||
connection_id: &str,
|
||||
@@ -2844,6 +2874,16 @@ impl AppState {
|
||||
db::meilisearch_driver::test_connection(&client, connect_timeout).await?;
|
||||
PoolKind::Meilisearch(client)
|
||||
}
|
||||
DatabaseType::Salesforce => {
|
||||
let client = db::salesforce_driver::SfClient::from_config(
|
||||
&url,
|
||||
Some(&db_config.password),
|
||||
db_config.external_config.as_ref(),
|
||||
connect_timeout,
|
||||
)?;
|
||||
db::salesforce_driver::SfClient::test_connection(&client, connect_timeout).await?;
|
||||
PoolKind::Salesforce(client)
|
||||
}
|
||||
DatabaseType::Hbase => {
|
||||
let client = db::hbase_driver::HBaseClient::new(
|
||||
&url,
|
||||
@@ -4214,6 +4254,17 @@ impl AppState {
|
||||
}
|
||||
}
|
||||
}
|
||||
PoolKind::Salesforce(client) => {
|
||||
let client = client.clone();
|
||||
let timeout = crate::db::connection_timeout();
|
||||
match db::salesforce_driver::SfClient::test_connection(&client, timeout).await {
|
||||
Ok(()) => false,
|
||||
Err(err) => {
|
||||
log::warn!("Salesforce connection pool '{pool_key}' is stale: {err}");
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
PoolKind::HBase(client) => {
|
||||
let client = client.clone();
|
||||
let timeout = crate::db::connection_timeout();
|
||||
@@ -5249,6 +5300,38 @@ impl AppState {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Cached connected-user identity for a Salesforce connection. Returns a
|
||||
/// camelCase-serializable struct for the frontend (identity badge, admin
|
||||
/// warning). Errors when the connection is not Salesforce or has no pool.
|
||||
pub async fn salesforce_current_user(&self, connection_id: &str) -> Result<SalesforceCurrentUser, String> {
|
||||
let db_type = {
|
||||
let configs = self.configs.read().await;
|
||||
configs.get(connection_id).map(|c| c.db_type)
|
||||
};
|
||||
if db_type != Some(DatabaseType::Salesforce) {
|
||||
return Err("Not a Salesforce connection".to_string());
|
||||
}
|
||||
let pool_key = base_pool_key_for(db_type, connection_id, None, false);
|
||||
let pool = self.pool_handle(&pool_key).await.ok_or_else(|| "Connection not found".to_string())?;
|
||||
match pool {
|
||||
PoolKind::Salesforce(client) => {
|
||||
let user = client.cached_current_user().await?;
|
||||
let org_name = client.org_display_name().await;
|
||||
Ok(SalesforceCurrentUser {
|
||||
user_id: user.user_id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
organization_id: user.organization_id,
|
||||
username: user.username,
|
||||
profile_name: user.profile_name,
|
||||
is_admin: user.is_admin,
|
||||
org_name,
|
||||
})
|
||||
}
|
||||
_ => Err("Not a Salesforce connection".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Warm the driver/pool a tab is about to use, off the user's critical path.
|
||||
///
|
||||
/// The first statement of a session pays costs that the user perceives as
|
||||
@@ -5405,6 +5488,16 @@ impl AppState {
|
||||
}
|
||||
}
|
||||
}
|
||||
PoolKind::Salesforce(client) => {
|
||||
let client = client.clone();
|
||||
match db::salesforce_driver::SfClient::test_connection(&client, timeout).await {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
log::warn!("Salesforce connection pool '{key}' is unhealthy: {e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
PoolKind::HBase(client) => match db::hbase_driver::test_connection(client, timeout).await {
|
||||
Ok(_) => true,
|
||||
Err(e) => {
|
||||
@@ -5796,6 +5889,7 @@ enum KeepaliveTarget {
|
||||
SqlServer(Arc<tokio::sync::Mutex<db::sqlserver::SqlServerClient>>),
|
||||
Elasticsearch(db::elasticsearch_driver::EsClient),
|
||||
Easysearch(db::easysearch_driver::EasysearchClient),
|
||||
Salesforce(db::salesforce_driver::SfClient),
|
||||
Solr(db::solr_driver::SolrClient),
|
||||
HBase(db::hbase_driver::HBaseClient),
|
||||
VectorDb(db::vector_driver::VectorClient),
|
||||
@@ -5898,6 +5992,7 @@ fn keepalive_target_from_pool(pool: &PoolKind, config: &ConnectionConfig) -> Opt
|
||||
PoolKind::SqlServer(client) => Some(KeepaliveTarget::SqlServer(client.clone())),
|
||||
PoolKind::Elasticsearch(client) => Some(KeepaliveTarget::Elasticsearch(client.clone())),
|
||||
PoolKind::Easysearch(client) => Some(KeepaliveTarget::Easysearch(client.clone())),
|
||||
PoolKind::Salesforce(client) => Some(KeepaliveTarget::Salesforce(client.clone())),
|
||||
PoolKind::Solr(client) => Some(KeepaliveTarget::Solr(client.clone())),
|
||||
PoolKind::HBase(client) => Some(KeepaliveTarget::HBase(client.clone())),
|
||||
PoolKind::VectorDb(client) => Some(KeepaliveTarget::VectorDb(client.clone())),
|
||||
@@ -5947,6 +6042,9 @@ async fn ping_keepalive_target(target: &mut KeepaliveTarget, timeout: Duration)
|
||||
KeepaliveTarget::Easysearch(client) => {
|
||||
db::easysearch_driver::test_connection(client, timeout).await.map_err(Into::into)
|
||||
}
|
||||
KeepaliveTarget::Salesforce(client) => {
|
||||
db::salesforce_driver::SfClient::test_connection(client, timeout).await.map_err(Into::into)
|
||||
}
|
||||
KeepaliveTarget::Solr(client) => db::solr_driver::test_connection(client, timeout).await.map_err(Into::into),
|
||||
KeepaliveTarget::HBase(client) => {
|
||||
db::hbase_driver::test_connection(client, timeout).await.map(|_| ()).map_err(Into::into)
|
||||
@@ -6370,6 +6468,7 @@ fn clone_pool_kind(pool: &PoolKind) -> PoolKind {
|
||||
PoolKind::Easysearch(client) => PoolKind::Easysearch(client.clone()),
|
||||
PoolKind::Solr(client) => PoolKind::Solr(client.clone()),
|
||||
PoolKind::Meilisearch(client) => PoolKind::Meilisearch(client.clone()),
|
||||
PoolKind::Salesforce(client) => PoolKind::Salesforce(client.clone()),
|
||||
PoolKind::HBase(client) => PoolKind::HBase(client.clone()),
|
||||
PoolKind::VectorDb(client) => PoolKind::VectorDb(client.clone()),
|
||||
PoolKind::InfluxDb(client) => PoolKind::InfluxDb(client.clone()),
|
||||
@@ -6434,6 +6533,9 @@ async fn close_pool_kind(pool: PoolKind) -> Result<(), String> {
|
||||
PoolKind::Meilisearch(client) => {
|
||||
drop(client);
|
||||
}
|
||||
PoolKind::Salesforce(client) => {
|
||||
drop(client);
|
||||
}
|
||||
PoolKind::HBase(client) => {
|
||||
drop(client);
|
||||
}
|
||||
@@ -6534,6 +6636,7 @@ fn base_pool_key_for_with_catalog(
|
||||
| DatabaseType::Milvus
|
||||
| DatabaseType::Weaviate
|
||||
| DatabaseType::ChromaDb
|
||||
| DatabaseType::Salesforce
|
||||
));
|
||||
is_single && (!database_capabilities::is_agent_type(db_type) || shares_database_pool_with_connection(db_type))
|
||||
});
|
||||
@@ -8997,6 +9100,20 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn salesforce_connections_never_use_a_tcp_probe() {
|
||||
// Salesforce reaches a cloud HTTPS endpoint through one pooled client, so the
|
||||
// manifest sets skipTcpProbe: a raw TCP pre-flight is meaningless even for a
|
||||
// forwarded local endpoint.
|
||||
let mut config = mysql_config(Some("app"));
|
||||
config.db_type = DatabaseType::Salesforce;
|
||||
config.host = "acme.my.salesforce.com".to_string();
|
||||
config.port = 443;
|
||||
|
||||
assert!(!uses_tcp_probe(&config, "acme.my.salesforce.com", 443), "instance url");
|
||||
assert!(!uses_tcp_probe(&config, "127.0.0.1", 54000), "forwarded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn h2_agent_connections_skip_tcp_probe_for_file_and_tcp_modes() {
|
||||
let mut file_config = mysql_config(None);
|
||||
|
||||
@@ -42,6 +42,16 @@ async fn connection_database_type(state: &AppState, connection_id: &str) -> Resu
|
||||
|
||||
pub async fn export_table_data_csv_core(state: &AppState, options: TableCsvExportOptions) -> Result<u64, String> {
|
||||
let database_type = connection_database_type(state, &options.connection_id).await?;
|
||||
// This loop pages with `LIMIT <page_size> OFFSET <n>` and stops at the first
|
||||
// short page. Neither half holds for SOQL: a `/query` response carries at most
|
||||
// 2000 rows and hands the rest back as a QueryLocator (`has_more` +
|
||||
// `session_id`), and OFFSET is capped at 2000, so the first page always looks
|
||||
// short and the export would stop there — a CSV silently missing the rest of
|
||||
// the object. Refuse instead of truncating; a Salesforce export has to follow
|
||||
// the QueryLocator (`fetch_more`) the way the grid's "load more" does.
|
||||
if database_type == DatabaseType::Salesforce {
|
||||
return Err("Exporting a Salesforce object to CSV is not supported yet: SOQL pages through a QueryLocator, not LIMIT/OFFSET. Run a SOQL query and export its result instead.".to_string());
|
||||
}
|
||||
let page_size = options.page_size.unwrap_or(TABLE_DATA_EXPORT_PAGE_SIZE).max(1);
|
||||
let mut writer =
|
||||
BufWriter::new(File::create(&options.file_path).map_err(|err| format!("Failed to write CSV file: {err}"))?);
|
||||
@@ -109,3 +119,59 @@ pub async fn export_table_data_csv_core(state: &AppState, options: TableCsvExpor
|
||||
writer.flush().map_err(|err| err.to_string())?;
|
||||
Ok(rows_exported)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{export_table_data_csv_core, CsvQuoteMode, TableCsvExportOptions};
|
||||
use crate::connection::AppState;
|
||||
use crate::models::connection::{ConnectionConfig, DatabaseType};
|
||||
use crate::storage::Storage;
|
||||
|
||||
fn salesforce_config(id: &str) -> ConnectionConfig {
|
||||
let mut config = serde_json::from_value::<ConnectionConfig>(serde_json::json!({
|
||||
"id": id,
|
||||
"name": "SFDC QA",
|
||||
"db_type": "postgres",
|
||||
"host": "example.my.salesforce.com",
|
||||
"port": 443,
|
||||
"username": "user@example.com",
|
||||
"password": "",
|
||||
"database": ""
|
||||
}))
|
||||
.unwrap();
|
||||
config.db_type = DatabaseType::Salesforce;
|
||||
config
|
||||
}
|
||||
|
||||
/// The refusal has to land before `File::create`, otherwise the user is left
|
||||
/// holding a header-only CSV that looks like an object with no records.
|
||||
#[tokio::test]
|
||||
async fn salesforce_table_csv_export_is_refused_before_a_file_is_created() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let storage = Storage::open(&directory.path().join("storage.db")).await.unwrap();
|
||||
let state = AppState::new(storage);
|
||||
let config = salesforce_config("sfdc-csv");
|
||||
state.configs.write().await.insert(config.id.clone(), config);
|
||||
|
||||
let csv_path = directory.path().join("Account.csv");
|
||||
let error = export_table_data_csv_core(
|
||||
&state,
|
||||
TableCsvExportOptions {
|
||||
file_path: csv_path.to_string_lossy().into_owned(),
|
||||
connection_id: "sfdc-csv".to_string(),
|
||||
database: String::new(),
|
||||
schema: None,
|
||||
table_name: "Account".to_string(),
|
||||
columns: vec!["Id".to_string(), "Name".to_string()],
|
||||
page_size: None,
|
||||
timeout_secs: None,
|
||||
csv_quote_mode: CsvQuoteMode::default(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("SOQL cannot page an export with LIMIT/OFFSET");
|
||||
|
||||
assert!(error.contains("QueryLocator"), "unexpected error: {error}");
|
||||
assert!(!csv_path.exists(), "a refused export must not leave a truncated CSV behind");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ pub use data::mongodb_dump;
|
||||
pub use data::mongodb_import_export;
|
||||
pub use dbx_drivers::mongo_oidc;
|
||||
pub use dbx_drivers::mongo_shell;
|
||||
pub use dbx_drivers::salesforce_oauth;
|
||||
pub use dbx_formats::export_split_zip;
|
||||
pub use dbx_plugin_runtime::plugins;
|
||||
pub use dbx_sql::db_admin_sql;
|
||||
|
||||
@@ -93,12 +93,30 @@ impl SecretCodec {
|
||||
data_dir: &std::path::Path,
|
||||
allow_create: bool,
|
||||
) -> Result<SecretKeyResolution, String> {
|
||||
if let Some(path) = std::env::var_os("DBX_SECRET_KEY_FILE") {
|
||||
Self::resolve_with_env(policy, data_dir, allow_create, |name| std::env::var_os(name))
|
||||
}
|
||||
|
||||
/// [`Self::resolve`] with the environment lookup injected. `DBX_SECRET_KEY`
|
||||
/// and `DBX_SECRET_KEY_FILE` are process global, so tests drive the explicit
|
||||
/// key branches through here instead of mutating the environment: a mutation
|
||||
/// in flight would silently change the key every other test in the same
|
||||
/// binary resolves, and the tests that encrypt with one key would fail to
|
||||
/// decrypt once it is restored.
|
||||
fn resolve_with_env<F>(
|
||||
policy: SecretKeyPolicy,
|
||||
data_dir: &std::path::Path,
|
||||
allow_create: bool,
|
||||
env_lookup: F,
|
||||
) -> Result<SecretKeyResolution, String>
|
||||
where
|
||||
F: Fn(&str) -> Option<std::ffi::OsString>,
|
||||
{
|
||||
if let Some(path) = env_lookup("DBX_SECRET_KEY_FILE") {
|
||||
let path = std::path::PathBuf::from(path);
|
||||
let codec = Self::read_key_file(&path, false)?;
|
||||
return Ok(SecretKeyResolution { codec, source: SecretKeySource::ExplicitFile });
|
||||
}
|
||||
if let Some(value) = std::env::var_os("DBX_SECRET_KEY") {
|
||||
if let Some(value) = env_lookup("DBX_SECRET_KEY") {
|
||||
let value = value.to_str().ok_or_else(|| "SECRET_KEY_INVALID".to_string())?;
|
||||
let codec = Self::from_key_material(value.trim()).map_err(|_| "SECRET_KEY_INVALID".to_string())?;
|
||||
return Ok(SecretKeyResolution { codec, source: SecretKeySource::ExplicitEnv });
|
||||
@@ -142,7 +160,9 @@ impl SecretCodec {
|
||||
F: FnOnce(bool) -> Result<Option<SecretCodec>, String>,
|
||||
{
|
||||
let compatibility_error = if let Some(path) = compatibility_path.as_ref().filter(|path| path.exists()) {
|
||||
match Self::read_key_file(path, false) {
|
||||
// Another process may have just created this file and not finished
|
||||
// writing it yet; retry briefly before treating it as corrupt.
|
||||
match read_key_file_with_retry(path, false) {
|
||||
Ok(codec) => return Ok(SecretKeyResolution { codec, source: SecretKeySource::ManagedDataDir }),
|
||||
Err(error) => Some(error),
|
||||
}
|
||||
@@ -417,13 +437,9 @@ fn create_key_file(path: &std::path::Path, managed: bool) -> Result<SecretCodec,
|
||||
}
|
||||
match path.symlink_metadata() {
|
||||
Ok(metadata) if managed && metadata.file_type().is_symlink() => Err("KEY_FILE_UNAVAILABLE".to_string()),
|
||||
Ok(_) => {
|
||||
if managed {
|
||||
read_key_file_with_retry(path, managed)
|
||||
} else {
|
||||
SecretCodec::read_key_file(path, managed)
|
||||
}
|
||||
}
|
||||
// The winner of the `create_new` race may still be mid-write, so read
|
||||
// through the same retry loop the managed key path uses.
|
||||
Ok(_) => read_key_file_with_retry(path, managed),
|
||||
Err(_) => Err("KEY_FILE_UNAVAILABLE".to_string()),
|
||||
}
|
||||
}
|
||||
@@ -433,16 +449,24 @@ pub fn managed_key_path(data_dir: &std::path::Path) -> std::path::PathBuf {
|
||||
}
|
||||
|
||||
fn read_key_file_with_retry(path: &std::path::Path, reject_symlink: bool) -> Result<SecretCodec, String> {
|
||||
let mut last_error = "KEY_FILE_UNAVAILABLE".to_string();
|
||||
for attempt in 0..20 {
|
||||
match SecretCodec::read_key_file(path, reject_symlink) {
|
||||
Ok(codec) => return Ok(codec),
|
||||
Err(error) if attempt < 19 && error == "SECRET_KEY_INVALID" && path.metadata().is_ok() => {
|
||||
Err(error) => {
|
||||
let retryable = attempt < 19 && error == "SECRET_KEY_INVALID" && path.metadata().is_ok();
|
||||
last_error = error;
|
||||
if !retryable {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
Err("KEY_FILE_UNAVAILABLE".to_string())
|
||||
// Surface what the file actually said: callers tell a corrupt key
|
||||
// (SECRET_KEY_INVALID) apart from an unreadable one, and retrying must not
|
||||
// blur that distinction once the retries are exhausted.
|
||||
Err(last_error)
|
||||
}
|
||||
|
||||
fn default_key_path() -> Option<std::path::PathBuf> {
|
||||
@@ -470,7 +494,7 @@ fn aad(namespace: &str, key: &str) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{managed_key_path, SecretCodec, SecretKeyPolicy, SecretKeySource};
|
||||
use super::{managed_key_path, read_key_file_with_retry, SecretCodec, SecretKeyPolicy, SecretKeySource};
|
||||
use base64::Engine as _;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
@@ -555,6 +579,46 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatibility_key_still_being_written_by_a_concurrent_starter_is_retried() {
|
||||
// The shared compatibility path (`~/.config/dbx/secret.key`) is global, so
|
||||
// two starters can race: the `create_new` winner leaves an empty file on
|
||||
// disk for a moment. A reader that gives up immediately reports
|
||||
// SECRET_KEY_INVALID for a key that is fine a millisecond later.
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("secret.key");
|
||||
std::fs::write(&path, "").unwrap();
|
||||
let writer_path = path.clone();
|
||||
let writer = std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
let material = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([7u8; 32]);
|
||||
std::fs::write(&writer_path, material).unwrap();
|
||||
});
|
||||
|
||||
// allow_create=false covers the read-only probe every write goes through;
|
||||
// allow_create=true covers the losing side of the create_new race.
|
||||
for allow_create in [false, true] {
|
||||
let resolved =
|
||||
SecretCodec::resolve_platform_default(Some(path.clone()), allow_create, |_| Ok(None)).unwrap();
|
||||
assert_eq!(resolved.source, SecretKeySource::ManagedDataDir);
|
||||
let expected = SecretCodec::new([7u8; 32]);
|
||||
let envelope = expected.encrypt("n", "k", "value").unwrap();
|
||||
assert_eq!(resolved.codec.decrypt("n", "k", &envelope).unwrap(), "value");
|
||||
}
|
||||
writer.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exhausted_key_file_retries_keep_the_original_error_code() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("secret.key");
|
||||
std::fs::write(&path, "\n").unwrap();
|
||||
// A key file that stays corrupt must not be laundered into the generic
|
||||
// "unavailable" code: the migration preflight reports Invalid vs
|
||||
// Unavailable differently.
|
||||
assert!(matches!(read_key_file_with_retry(&path, false), Err(error) if error == "SECRET_KEY_INVALID"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn managed_key_symlink_is_rejected() {
|
||||
@@ -576,39 +640,37 @@ mod tests {
|
||||
#[test]
|
||||
fn explicit_key_file_symlink_is_allowed() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let _guard = env_lock().lock().unwrap();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let real = dir.path().join("real.key");
|
||||
let link = dir.path().join("external.key");
|
||||
std::fs::write(&real, base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([5u8; 32])).unwrap();
|
||||
symlink(&real, &link).unwrap();
|
||||
|
||||
let previous_file = std::env::var_os("DBX_SECRET_KEY_FILE");
|
||||
let previous_env = std::env::var_os("DBX_SECRET_KEY");
|
||||
std::env::set_var("DBX_SECRET_KEY_FILE", &link);
|
||||
std::env::remove_var("DBX_SECRET_KEY");
|
||||
let resolved = SecretCodec::resolve(SecretKeyPolicy::ManagedDataDir, dir.path(), false).unwrap();
|
||||
let resolved = SecretCodec::resolve_with_env(
|
||||
SecretKeyPolicy::ManagedDataDir,
|
||||
dir.path(),
|
||||
false,
|
||||
explicit_env(&[("DBX_SECRET_KEY_FILE", std::ffi::OsString::from(&link))]),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(resolved.source, SecretKeySource::ExplicitFile);
|
||||
restore_env("DBX_SECRET_KEY_FILE", previous_file);
|
||||
restore_env("DBX_SECRET_KEY", previous_env);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn invalid_non_utf8_explicit_environment_key_does_not_fallback() {
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
let _guard = env_lock().lock().unwrap();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let previous_file = std::env::var_os("DBX_SECRET_KEY_FILE");
|
||||
let previous_env = std::env::var_os("DBX_SECRET_KEY");
|
||||
std::env::remove_var("DBX_SECRET_KEY_FILE");
|
||||
std::env::set_var("DBX_SECRET_KEY", std::ffi::OsString::from_vec(vec![0xff, 0xfe]));
|
||||
let invalid = std::ffi::OsString::from_vec(vec![0xff, 0xfe]);
|
||||
assert!(matches!(
|
||||
SecretCodec::resolve(SecretKeyPolicy::ManagedDataDir, dir.path(), true),
|
||||
SecretCodec::resolve_with_env(
|
||||
SecretKeyPolicy::ManagedDataDir,
|
||||
dir.path(),
|
||||
true,
|
||||
explicit_env(&[("DBX_SECRET_KEY", invalid)])
|
||||
),
|
||||
Err(error) if error == "SECRET_KEY_INVALID"
|
||||
));
|
||||
restore_env("DBX_SECRET_KEY_FILE", previous_file);
|
||||
restore_env("DBX_SECRET_KEY", previous_env);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -673,40 +735,38 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn explicit_sources_follow_precedence_without_fallback() {
|
||||
let _guard = env_lock().lock().unwrap();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("external.key");
|
||||
let previous_file = std::env::var_os("DBX_SECRET_KEY_FILE");
|
||||
let previous_env = std::env::var("DBX_SECRET_KEY").ok();
|
||||
std::env::remove_var("DBX_SECRET_KEY_FILE");
|
||||
std::env::remove_var("DBX_SECRET_KEY");
|
||||
SecretCodec::resolve(SecretKeyPolicy::ManagedDataDir, dir.path(), true).unwrap();
|
||||
let env_key = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([2u8; 32]);
|
||||
let resolve = |allow_create: bool, entries: &[(&str, std::ffi::OsString)]| {
|
||||
let env = explicit_env(entries);
|
||||
SecretCodec::resolve_with_env(SecretKeyPolicy::ManagedDataDir, dir.path(), allow_create, env)
|
||||
};
|
||||
|
||||
// Nothing explicit is configured, so the managed data-dir key is provisioned.
|
||||
resolve(true, &[]).unwrap();
|
||||
|
||||
// A corrupt explicit file outranks a valid explicit key and must not fall back.
|
||||
std::fs::write(&file, "\n").unwrap();
|
||||
std::env::set_var("DBX_SECRET_KEY_FILE", &file);
|
||||
std::env::set_var("DBX_SECRET_KEY", base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([2u8; 32]));
|
||||
assert!(matches!(
|
||||
SecretCodec::resolve(SecretKeyPolicy::ManagedDataDir, dir.path(), false),
|
||||
Err(error) if error == "SECRET_KEY_INVALID"
|
||||
));
|
||||
let both = [
|
||||
("DBX_SECRET_KEY_FILE", std::ffi::OsString::from(&file)),
|
||||
("DBX_SECRET_KEY", std::ffi::OsString::from(env_key.as_str())),
|
||||
];
|
||||
assert!(matches!(resolve(false, &both), Err(error) if error == "SECRET_KEY_INVALID"));
|
||||
|
||||
std::fs::write(&file, base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([1u8; 32])).unwrap();
|
||||
let resolved = SecretCodec::resolve(SecretKeyPolicy::ManagedDataDir, dir.path(), false).unwrap();
|
||||
assert_eq!(resolved.source, SecretKeySource::ExplicitFile);
|
||||
assert_eq!(resolve(false, &both).unwrap().source, SecretKeySource::ExplicitFile);
|
||||
|
||||
std::env::remove_var("DBX_SECRET_KEY_FILE");
|
||||
let resolved = SecretCodec::resolve(SecretKeyPolicy::ManagedDataDir, dir.path(), false).unwrap();
|
||||
assert_eq!(resolved.source, SecretKeySource::ExplicitEnv);
|
||||
|
||||
restore_env("DBX_SECRET_KEY_FILE", previous_file);
|
||||
restore_env("DBX_SECRET_KEY", previous_env.map(std::ffi::OsString::from));
|
||||
let key_only = [("DBX_SECRET_KEY", std::ffi::OsString::from(env_key.as_str()))];
|
||||
assert_eq!(resolve(false, &key_only).unwrap().source, SecretKeySource::ExplicitEnv);
|
||||
}
|
||||
|
||||
fn restore_env(name: &str, value: Option<std::ffi::OsString>) {
|
||||
if let Some(value) = value {
|
||||
std::env::set_var(name, value);
|
||||
} else {
|
||||
std::env::remove_var(name);
|
||||
}
|
||||
/// Environment lookup for [`SecretCodec::resolve_with_env`] that never
|
||||
/// touches the process environment, so these tests cannot change the key
|
||||
/// another test in the same binary resolves while they run.
|
||||
fn explicit_env<'a>(
|
||||
entries: &'a [(&'a str, std::ffi::OsString)],
|
||||
) -> impl Fn(&str) -> Option<std::ffi::OsString> + 'a {
|
||||
move |name| entries.iter().find(|(key, _)| *key == name).map(|(_, value)| value.clone())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ use crate::connection_secrets::{
|
||||
CASSANDRA_TRUSTSTORE_PASSWORD_KEY, MQTT_AUTH_PASSWORD_KEY, MQTT_AUTH_SECRET_PREFIX, MQ_AUTH_API_KEY_VALUE_KEY,
|
||||
MQ_AUTH_CLIENT_SECRET_KEY, MQ_AUTH_PASSWORD_KEY, MQ_AUTH_SECRET_PREFIX, MQ_AUTH_TOKEN_KEY, MQ_TOKEN_SIGNING_KEY,
|
||||
MQ_TOKEN_SIGNING_SECRET_PREFIX, NACOS_AUTH_PASSWORD_KEY, NACOS_AUTH_SECRET_PREFIX,
|
||||
NACOS_RNACOS_CONSOLE_PASSWORD_KEY, PLUGIN_CONNECTION_SECRET_PREFIX,
|
||||
NACOS_RNACOS_CONSOLE_PASSWORD_KEY, PLUGIN_CONNECTION_SECRET_PREFIX, SALESFORCE_AUTH_CLIENT_SECRET_KEY,
|
||||
SALESFORCE_AUTH_PASSWORD_KEY, SALESFORCE_AUTH_REFRESH_TOKEN_KEY, SALESFORCE_AUTH_SECRET_PREFIX,
|
||||
};
|
||||
use crate::db::sqlite::{connect_path_create_if_missing, SqliteHandle};
|
||||
use crate::history::{
|
||||
@@ -471,6 +472,15 @@ pub struct McpConnectionPolicy {
|
||||
/// connection default, while a present entry takes priority over it.
|
||||
#[serde(default)]
|
||||
pub database_policies: Vec<McpDatabasePolicy>,
|
||||
/// Opt-in switch letting an AI agent write to this Salesforce org through
|
||||
/// MCP. Absent on every policy saved before the switch existed, and `false`
|
||||
/// by default: SOQL reads need no permission beyond the execution mode, but
|
||||
/// Salesforce DML has no transaction and no rollback, so an agent may only
|
||||
/// reach it after a person turns this on for the connection and confirms
|
||||
/// each write (`dbx_salesforce_prepare_write` → `dbx_salesforce_apply_write`).
|
||||
/// Forced back to false whenever `read_only` is set.
|
||||
#[serde(default)]
|
||||
pub allow_salesforce_dml: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -646,6 +656,9 @@ impl McpGlobalPolicy {
|
||||
current.allowed_databases = databases;
|
||||
current.database_policies =
|
||||
merge_mcp_database_policies(¤t.database_policies, &rule.database_policies);
|
||||
// Same conjunction as high-risk SQL: every duplicate rule has
|
||||
// to opt in before an agent may write to the org.
|
||||
current.allow_salesforce_dml &= rule.allow_salesforce_dml;
|
||||
})
|
||||
.or_insert_with(|| McpConnectionPolicy {
|
||||
connection_id: connection_id.to_string(),
|
||||
@@ -656,6 +669,7 @@ impl McpGlobalPolicy {
|
||||
database_scope: rule.database_scope,
|
||||
allowed_databases: normalize_mcp_database_names(&rule.allowed_databases),
|
||||
database_policies: normalize_mcp_database_policies(&rule.database_policies),
|
||||
allow_salesforce_dml: rule.allow_salesforce_dml,
|
||||
});
|
||||
}
|
||||
let mut connection_policies = policies.into_values().collect::<Vec<_>>();
|
||||
@@ -663,6 +677,9 @@ impl McpGlobalPolicy {
|
||||
for rule in &mut connection_policies {
|
||||
if rule.read_only {
|
||||
rule.allow_dangerous_sql = false;
|
||||
// A read-only connection cannot carry a Salesforce write opt-in,
|
||||
// however the saved rules were merged.
|
||||
rule.allow_salesforce_dml = false;
|
||||
}
|
||||
rule.allowed_databases = normalize_mcp_database_names(&rule.allowed_databases);
|
||||
if rule.database_scope != McpDatabaseScope::Selected {
|
||||
@@ -2947,6 +2964,18 @@ fn scrub_cassandra_tls_secrets(config: &mut ConnectionConfig) {
|
||||
scrub_json_secret(tls, "keystore_password");
|
||||
}
|
||||
|
||||
fn scrub_salesforce_auth_secrets(config: &mut ConnectionConfig) {
|
||||
if config.db_type != DatabaseType::Salesforce {
|
||||
return;
|
||||
}
|
||||
let Some(auth) = salesforce_auth_object_mut(config.external_config.as_mut()) else {
|
||||
return;
|
||||
};
|
||||
scrub_json_secret(auth, "clientSecret");
|
||||
scrub_json_secret(auth, "refreshToken");
|
||||
scrub_json_secret(auth, "password");
|
||||
}
|
||||
|
||||
fn delete_secret_prefix_in_tx(
|
||||
tx: &rusqlite::Transaction<'_>,
|
||||
connection_id: &str,
|
||||
@@ -5209,6 +5238,7 @@ fn sanitized_connection_config(config: &ConnectionConfig) -> ConnectionConfig {
|
||||
scrub_mq_token_signing_secret(&mut sanitized);
|
||||
scrub_nacos_auth_secrets(&mut sanitized);
|
||||
scrub_cassandra_tls_secrets(&mut sanitized);
|
||||
scrub_salesforce_auth_secrets(&mut sanitized);
|
||||
sanitized.connection_secrets.clear();
|
||||
sanitized
|
||||
}
|
||||
@@ -5306,6 +5336,7 @@ fn persist_connection_in_tx(
|
||||
persist_mq_token_signing_secret_in_tx(tx, codec, &config)?;
|
||||
persist_nacos_auth_secrets_in_tx(tx, codec, &config)?;
|
||||
persist_cassandra_tls_secrets_in_tx(tx, codec, &config)?;
|
||||
persist_salesforce_auth_secrets_in_tx(tx, codec, &config)?;
|
||||
delete_secret_prefix_in_tx(tx, &config.id, PLUGIN_CONNECTION_SECRET_PREFIX)?;
|
||||
for (key, secret) in &config.connection_secrets {
|
||||
if !key.is_empty() {
|
||||
@@ -5576,6 +5607,7 @@ impl Storage {
|
||||
scrub_mq_token_signing_secret(&mut sanitized);
|
||||
scrub_nacos_auth_secrets(&mut sanitized);
|
||||
scrub_cassandra_tls_secrets(&mut sanitized);
|
||||
scrub_salesforce_auth_secrets(&mut sanitized);
|
||||
sanitized.connection_secrets.clear();
|
||||
let json = serde_json::to_string(&sanitized).map_err(|e| e.to_string())?;
|
||||
|
||||
@@ -5964,6 +5996,7 @@ impl Storage {
|
||||
let needs_mq_token_signing_rewrite = self.hydrate_mq_token_signing_secret(&id, &mut config).await?;
|
||||
let needs_nacos_auth_rewrite = self.hydrate_nacos_auth_secret(&id, &mut config).await?;
|
||||
let needs_cassandra_tls_rewrite = self.hydrate_cassandra_tls_secrets(&id, &mut config).await?;
|
||||
let needs_salesforce_auth_rewrite = self.hydrate_salesforce_auth_secrets(&id, &mut config).await?;
|
||||
let mut needs_plugin_secret_rewrite = false;
|
||||
let plugin_secret_keys = config.connection_secrets.keys().cloned().collect::<Vec<_>>();
|
||||
for key in plugin_secret_keys {
|
||||
@@ -5983,6 +6016,7 @@ impl Storage {
|
||||
|| needs_mq_token_signing_rewrite
|
||||
|| needs_nacos_auth_rewrite
|
||||
|| needs_cassandra_tls_rewrite
|
||||
|| needs_salesforce_auth_rewrite
|
||||
|| needs_plugin_secret_rewrite;
|
||||
if needs_external_secret_rewrite {
|
||||
// `config` is hydrated above, so always use the canonical
|
||||
@@ -6140,6 +6174,28 @@ impl Storage {
|
||||
.await?;
|
||||
Ok(truststore_rewrite || keystore_rewrite)
|
||||
}
|
||||
|
||||
async fn hydrate_salesforce_auth_secrets(
|
||||
&self,
|
||||
connection_id: &str,
|
||||
config: &mut ConnectionConfig,
|
||||
) -> Result<bool, String> {
|
||||
if config.db_type != DatabaseType::Salesforce {
|
||||
return Ok(false);
|
||||
}
|
||||
let Some(auth) = salesforce_auth_object_mut(config.external_config.as_mut()) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let client_secret_rewrite =
|
||||
hydrate_mq_json_secret(self, connection_id, SALESFORCE_AUTH_CLIENT_SECRET_KEY, auth, "clientSecret")
|
||||
.await?;
|
||||
let refresh_token_rewrite =
|
||||
hydrate_mq_json_secret(self, connection_id, SALESFORCE_AUTH_REFRESH_TOKEN_KEY, auth, "refreshToken")
|
||||
.await?;
|
||||
let password_rewrite =
|
||||
hydrate_mq_json_secret(self, connection_id, SALESFORCE_AUTH_PASSWORD_KEY, auth, "password").await?;
|
||||
Ok(client_secret_rewrite || refresh_token_rewrite || password_rewrite)
|
||||
}
|
||||
}
|
||||
|
||||
// Saved SQL
|
||||
@@ -7862,6 +7918,60 @@ fn persist_cassandra_tls_secrets_in_tx(
|
||||
)
|
||||
}
|
||||
|
||||
fn persist_salesforce_auth_secrets_in_tx(
|
||||
tx: &rusqlite::Transaction<'_>,
|
||||
codec: &SecretCodec,
|
||||
config: &ConnectionConfig,
|
||||
) -> Result<(), String> {
|
||||
if config.db_type != DatabaseType::Salesforce {
|
||||
delete_secret_prefix_in_tx(tx, &config.id, SALESFORCE_AUTH_SECRET_PREFIX)?;
|
||||
return Ok(());
|
||||
}
|
||||
let Some(auth) = salesforce_auth_object(config.external_config.as_ref()) else {
|
||||
delete_secret_prefix_in_tx(tx, &config.id, SALESFORCE_AUTH_SECRET_PREFIX)?;
|
||||
return Ok(());
|
||||
};
|
||||
replace_salesforce_auth_secret_in_tx(
|
||||
tx,
|
||||
codec,
|
||||
&config.id,
|
||||
SALESFORCE_AUTH_CLIENT_SECRET_KEY,
|
||||
auth,
|
||||
"clientSecret",
|
||||
)?;
|
||||
replace_salesforce_auth_secret_in_tx(
|
||||
tx,
|
||||
codec,
|
||||
&config.id,
|
||||
SALESFORCE_AUTH_REFRESH_TOKEN_KEY,
|
||||
auth,
|
||||
"refreshToken",
|
||||
)?;
|
||||
replace_salesforce_auth_secret_in_tx(tx, codec, &config.id, SALESFORCE_AUTH_PASSWORD_KEY, auth, "password")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_salesforce_auth_secret_in_tx(
|
||||
tx: &rusqlite::Transaction<'_>,
|
||||
codec: &SecretCodec,
|
||||
connection_id: &str,
|
||||
key: &str,
|
||||
auth: &serde_json::Map<String, serde_json::Value>,
|
||||
field: &str,
|
||||
) -> Result<(), String> {
|
||||
let current = auth.get(field).and_then(serde_json::Value::as_str).filter(|secret| !secret.is_empty());
|
||||
let existing = if current.is_none() { get_secret_in_tx(tx, codec, connection_id, key)? } else { None };
|
||||
tx.execute("DELETE FROM connection_secrets WHERE connection_id = ?1 AND key = ?2", params![connection_id, key])
|
||||
.map_err(|e| e.to_string())?;
|
||||
match current {
|
||||
Some(secret) => persist_secret_in_tx(tx, codec, connection_id, key, secret),
|
||||
None => match existing {
|
||||
Some(secret) => persist_secret_in_tx(tx, codec, connection_id, key, &secret),
|
||||
None => Ok(()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn persist_json_secret_if_present_in_tx(
|
||||
tx: &rusqlite::Transaction<'_>,
|
||||
codec: &SecretCodec,
|
||||
@@ -7934,6 +8044,16 @@ fn cassandra_tls_object_mut(
|
||||
value?.get_mut("tls")?.as_object_mut()
|
||||
}
|
||||
|
||||
fn salesforce_auth_object(value: Option<&serde_json::Value>) -> Option<&serde_json::Map<String, serde_json::Value>> {
|
||||
value?.get("auth")?.as_object()
|
||||
}
|
||||
|
||||
fn salesforce_auth_object_mut(
|
||||
value: Option<&mut serde_json::Value>,
|
||||
) -> Option<&mut serde_json::Map<String, serde_json::Value>> {
|
||||
value?.get_mut("auth")?.as_object_mut()
|
||||
}
|
||||
|
||||
fn nacos_auth_object(value: Option<&serde_json::Value>) -> Option<&serde_json::Map<String, serde_json::Value>> {
|
||||
value?.get("auth")?.as_object()
|
||||
}
|
||||
|
||||
@@ -2078,6 +2078,23 @@ async fn do_execute_typed(
|
||||
}
|
||||
result
|
||||
}
|
||||
PoolKind::Salesforce(client) => {
|
||||
let client = client.clone();
|
||||
let sql = sql.to_string();
|
||||
let max_rows = options.max_rows;
|
||||
let result = wait_for_query_opt(cancel_token, query_timeout, async move {
|
||||
if let Some(cursor) = options.result_session_id.as_deref() {
|
||||
client.fetch_more(cursor).await
|
||||
} else {
|
||||
client.execute_query(&sql, max_rows).await
|
||||
}
|
||||
})
|
||||
.await;
|
||||
if matches!(result.as_ref(), Err(err) if should_discard_pool_after_error(pool_db_type, err)) {
|
||||
state.remove_pool_by_key(pool_key).await;
|
||||
}
|
||||
result
|
||||
}
|
||||
PoolKind::VectorDb(client) => {
|
||||
let client = client.clone();
|
||||
let sql = sql.to_string();
|
||||
@@ -4704,6 +4721,7 @@ fn pool_kind_has_transactional_path(pool: &PoolKind) -> bool {
|
||||
| PoolKind::Easysearch(_)
|
||||
| PoolKind::Solr(_)
|
||||
| PoolKind::Meilisearch(_)
|
||||
| PoolKind::Salesforce(_)
|
||||
| PoolKind::VectorDb(_)
|
||||
| PoolKind::InfluxDb(_)
|
||||
| PoolKind::InfluxDb3(_)
|
||||
@@ -5108,6 +5126,7 @@ fn batch_transaction_path(pool: &PoolKind) -> BatchTransactionPath {
|
||||
| PoolKind::Easysearch(_)
|
||||
| PoolKind::Solr(_)
|
||||
| PoolKind::Meilisearch(_)
|
||||
| PoolKind::Salesforce(_)
|
||||
| PoolKind::VectorDb(_)
|
||||
| PoolKind::InfluxDb(_)
|
||||
| PoolKind::InfluxDb3(_)
|
||||
|
||||
@@ -676,6 +676,12 @@ async fn list_databases_once(state: &AppState, connection_id: &str) -> Result<Ve
|
||||
if let Some(client) = extract_pool!(pool_handle.as_ref(), VictoriaMetrics) {
|
||||
return db::victoriametrics_driver::list_databases(&client).await;
|
||||
}
|
||||
if let Some(client) = extract_pool!(pool_handle.as_ref(), Salesforce) {
|
||||
// singleDatabase trait: the whole org is one synthesized database
|
||||
// node; sObjects are listed as its tables.
|
||||
let name = client.org_display_name().await;
|
||||
return Ok(vec![db::DatabaseInfo { name, ..Default::default() }]);
|
||||
}
|
||||
try_sqlserver!(pool_handle, list_databases);
|
||||
if let Some(client) = extract_pool!(pool_handle.as_ref(), Agent) {
|
||||
let is_mongo = db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::MongoDb);
|
||||
@@ -2683,6 +2689,10 @@ async fn list_tables_once(
|
||||
.await
|
||||
.map(|names| collection_names_to_tables(names, "INDEX"))
|
||||
.map(|tables| filter_table_infos(tables, filter, limit, offset, object_types, table_name_filter)),
|
||||
PoolKind::Salesforce(client) => db::salesforce_driver::SfClient::list_tables(client)
|
||||
.await
|
||||
.map(|names| collection_names_to_tables(names, "SOBJECT"))
|
||||
.map(|tables| filter_table_infos(tables, filter, limit, offset, object_types, table_name_filter)),
|
||||
PoolKind::HBase(client) => db::hbase_driver::list_tables(client, database)
|
||||
.await
|
||||
.map(|tables| filter_table_infos(tables, filter, limit, offset, object_types, table_name_filter)),
|
||||
@@ -7456,6 +7466,9 @@ async fn get_columns_core_for_session_inner_with_pool(
|
||||
PoolKind::Meilisearch(client) => {
|
||||
db::meilisearch_driver::get_columns(client, table).await.map(deduplicate_column_infos)
|
||||
}
|
||||
PoolKind::Salesforce(client) => {
|
||||
db::salesforce_driver::SfClient::get_columns(client, table).await.map(deduplicate_column_infos)
|
||||
}
|
||||
PoolKind::HBase(client) => {
|
||||
db::hbase_driver::get_columns(client, database, table).await.map(deduplicate_column_infos)
|
||||
}
|
||||
|
||||
@@ -364,6 +364,18 @@ fn driver_manifest_declares_expected_product_capabilities() {
|
||||
assert!(!uxdb.capabilities.data_transfer);
|
||||
assert!(!uxdb.capabilities.user_admin);
|
||||
|
||||
let salesforce = find_driver(DatabaseType::Salesforce);
|
||||
assert_eq!(salesforce.label, "Salesforce");
|
||||
assert_eq!(salesforce.support_level, "operate");
|
||||
assert!(salesforce.capabilities.query_execution);
|
||||
assert!(salesforce.capabilities.metadata_browse);
|
||||
assert!(salesforce.capabilities.object_browser);
|
||||
assert!(salesforce.capabilities.schema_search);
|
||||
assert!(salesforce.capabilities.table_data_edit);
|
||||
assert!(!salesforce.capabilities.table_structure_edit);
|
||||
assert!(!salesforce.capabilities.object_source);
|
||||
assert!(!salesforce.capabilities.diagram);
|
||||
|
||||
let spanner = find_driver(DatabaseType::Spanner);
|
||||
assert_eq!(spanner.label, "Google Cloud Spanner");
|
||||
assert_eq!(spanner.runtime_mode, "agent");
|
||||
|
||||
@@ -39,6 +39,7 @@ pub mod proxy_tunnel;
|
||||
pub mod questdb;
|
||||
pub mod redis_driver;
|
||||
pub mod rqlite_driver;
|
||||
pub mod salesforce_driver;
|
||||
pub mod solr_driver;
|
||||
pub mod sqlite;
|
||||
pub mod sqlite_worker;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,4 +22,5 @@ pub mod metadata;
|
||||
pub mod mongo_oidc;
|
||||
pub mod mongo_shell;
|
||||
pub mod runtime_config;
|
||||
pub mod salesforce_oauth;
|
||||
pub mod ssh_config;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ use async_trait::async_trait;
|
||||
use dbx_core::{
|
||||
agent_events::{ToolCall, ToolResult},
|
||||
agent_tools::{self, format_query_result_as_text, AgentSqlPermissions, QueryCellWindow},
|
||||
connection::{connection_configs_pool_equivalent, AppState, ConnectionLifecycleSnapshot},
|
||||
connection::{connection_configs_pool_equivalent, AppState, ConnectionLifecycleSnapshot, SalesforceCurrentUser},
|
||||
db::{mongo_driver::MongoIndexSpec, redis_driver::RedisCommandResult, ColumnInfo, TableInfo},
|
||||
history::HistoryEntry,
|
||||
mcp_policy::{connection_group_paths, McpConnectionGroupPath},
|
||||
@@ -87,6 +87,9 @@ fn effective_mcp_policy_with_legacy_allow_writes(
|
||||
for rule in &mut policy.connection_policies {
|
||||
rule.read_only = true;
|
||||
rule.allow_dangerous_sql = false;
|
||||
// An unconfirmed CLI run must not reach Salesforce DML either: the
|
||||
// connection opt-in is a write permission like any other here.
|
||||
rule.allow_salesforce_dml = false;
|
||||
rule.execution_mode_configured = true;
|
||||
rule.execution_mode_policy_version = Some(dbx_core::mcp_policy::MCP_EXECUTION_POLICY_VERSION);
|
||||
for database_policy in &mut rule.database_policies {
|
||||
@@ -390,6 +393,14 @@ pub trait DbxBackend: Send + Sync {
|
||||
let _ = (connection, database, command);
|
||||
Err("MongoDB shell commands are not supported by this backend.".to_string())
|
||||
}
|
||||
/// Connected-user identity for a Salesforce connection: who a write would be
|
||||
/// attributed to, plus the profile's "Modify All Data" flag. Salesforce has
|
||||
/// no session-scoped identity, so this reads the pool's cached user info and
|
||||
/// creates the pool when the MCP process is still cold.
|
||||
async fn salesforce_current_user(&self, connection: &ConnectionConfig) -> Result<SalesforceCurrentUser, String> {
|
||||
let _ = connection;
|
||||
Err("Salesforce identity is not supported by this backend.".to_string())
|
||||
}
|
||||
/// Release the connection pool pinned by an MCP session (`client_session_id`).
|
||||
async fn close_client_session(
|
||||
&self,
|
||||
@@ -1253,6 +1264,19 @@ impl DbxBackend for LocalBackend {
|
||||
dbx_core::mongo_ops::execute_mongo_command_core(&self.state, &connection.id, database, command, 100).await
|
||||
}
|
||||
|
||||
async fn salesforce_current_user(&self, connection: &ConnectionConfig) -> Result<SalesforceCurrentUser, String> {
|
||||
if connection.db_type != DatabaseType::Salesforce {
|
||||
return Err("Not a Salesforce connection".to_string());
|
||||
}
|
||||
// The identity is the pool's cached connected-user record, so a cold MCP
|
||||
// process has to establish the connection first — the same thing the
|
||||
// metadata paths above do before reading pool state.
|
||||
if self.state.pool_handle(&connection.id).await.is_none() {
|
||||
self.state.get_or_create_pool(&connection.id, None).await?;
|
||||
}
|
||||
self.state.salesforce_current_user(&connection.id).await
|
||||
}
|
||||
|
||||
async fn close_client_session(
|
||||
&self,
|
||||
connection_id: &str,
|
||||
@@ -1838,6 +1862,19 @@ impl DbxBackend for WebBackend {
|
||||
.map_err(|error| format!("Invalid Redis command response: {error}"))
|
||||
}
|
||||
|
||||
async fn salesforce_current_user(&self, connection: &ConnectionConfig) -> Result<SalesforceCurrentUser, String> {
|
||||
self.ensure_connected(connection).await?;
|
||||
self.request(
|
||||
reqwest::Method::GET,
|
||||
&format!("/api/salesforce/current-user?connection_id={}", url_encode(&connection.id)),
|
||||
None,
|
||||
)
|
||||
.await?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|error| format!("Invalid Salesforce identity response: {error}"))
|
||||
}
|
||||
|
||||
async fn execute_mongo_command(
|
||||
&self,
|
||||
connection: &ConnectionConfig,
|
||||
@@ -2684,6 +2721,35 @@ mod tests {
|
||||
assert!(effective_mcp_policy_with_legacy_allow_writes(policy_state(true, true), None).read_only);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_read_only_also_revokes_the_salesforce_dml_opt_in() {
|
||||
// DBX_MCP_ALLOW_WRITES=0 marks an unconfirmed CLI run. The Salesforce DML
|
||||
// opt-in is a write permission like any other, so it must be withdrawn
|
||||
// with the rest — an opted-in connection must not become writable just
|
||||
// because the org speaks REST instead of SQL.
|
||||
let mut state = policy_state(true, false);
|
||||
state.connection_policies = vec![dbx_core::storage::McpConnectionPolicy {
|
||||
connection_id: "sfdc".to_string(),
|
||||
read_only: false,
|
||||
allow_dangerous_sql: true,
|
||||
execution_mode_configured: false,
|
||||
execution_mode_policy_version: None,
|
||||
database_scope: dbx_core::storage::McpDatabaseScope::All,
|
||||
allowed_databases: Vec::new(),
|
||||
database_policies: Vec::new(),
|
||||
allow_salesforce_dml: true,
|
||||
}];
|
||||
|
||||
let forced = effective_mcp_policy_with_legacy_allow_writes(state.clone(), Some(false));
|
||||
assert!(forced.read_only);
|
||||
assert!(!forced.connection_policies[0].allow_salesforce_dml);
|
||||
assert!(!forced.connection_policies[0].allow_dangerous_sql);
|
||||
|
||||
// Without the env override the stored opt-in survives untouched.
|
||||
let untouched = effective_mcp_policy_with_legacy_allow_writes(state, None);
|
||||
assert!(untouched.connection_policies[0].allow_salesforce_dml);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_database_type_using_dbx_protocol_names() {
|
||||
assert_eq!(parse_database_type("Postgres").unwrap(), DatabaseType::Postgres);
|
||||
|
||||
+1128
-7
File diff suppressed because it is too large
Load Diff
@@ -57,6 +57,7 @@ async fn list_databases(
|
||||
database_scope,
|
||||
allowed_databases,
|
||||
database_policies: Vec::new(),
|
||||
allow_salesforce_dml: false,
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
|
||||
@@ -328,9 +328,9 @@ async fn initializes_lists_tools_and_calls_a_tool() {
|
||||
let tools = client.peer().list_tools(None).await.expect("list tools");
|
||||
let names = tools.tools.iter().map(|tool| tool.name.as_ref()).collect::<Vec<_>>();
|
||||
#[cfg(feature = "mq-admin")]
|
||||
assert_eq!(names.len(), 22);
|
||||
assert_eq!(names.len(), 25);
|
||||
#[cfg(not(feature = "mq-admin"))]
|
||||
assert_eq!(names.len(), 20);
|
||||
assert_eq!(names.len(), 23);
|
||||
#[cfg(feature = "mq-admin")]
|
||||
assert!(names.contains(&"dbx_peek_messages"));
|
||||
#[cfg(not(feature = "mq-admin"))]
|
||||
@@ -339,6 +339,9 @@ async fn initializes_lists_tools_and_calls_a_tool() {
|
||||
assert!(names.contains(&"dbx_list_databases"));
|
||||
assert!(names.contains(&"dbx_duplicate_connection"));
|
||||
assert!(names.contains(&"dbx_execute_redis_command"));
|
||||
assert!(names.contains(&"dbx_salesforce_current_user"));
|
||||
assert!(names.contains(&"dbx_salesforce_prepare_write"));
|
||||
assert!(names.contains(&"dbx_salesforce_apply_write"));
|
||||
assert!(names.contains(&"dbx_execute_and_show"));
|
||||
assert!(names.contains(&"dbx_execute_batch"));
|
||||
assert!(names.contains(&"dbx_list_routines"));
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use super::*;
|
||||
use crate::models::connection::DatabaseType;
|
||||
|
||||
/// Header line emitted before every JSON body. The driver's
|
||||
/// `parse_salesforce_statement` matches this case-insensitively.
|
||||
const SALESFORCE_DML_HEADER: &str = "DBX SALESFORCE DML";
|
||||
|
||||
/// Build the save statements for a Salesforce grid save. Order matches the
|
||||
/// generic SQL builder: updates, deletes, inserts (see `build_data_grid_save_statements`).
|
||||
///
|
||||
/// Each statement is a `DBX SALESFORCE DML` pseudo-command carrying a JSON
|
||||
/// body. The driver routes them to the appropriate REST endpoint.
|
||||
pub(super) fn build_salesforce_data_grid_save_statements(options: &DataGridSaveStatementOptions) -> Vec<String> {
|
||||
let object = &options.table_meta.table_name;
|
||||
let columns = resolve_salesforce_columns(options);
|
||||
let column_info = options.table_meta.columns.as_deref().unwrap_or(&[]);
|
||||
let id_column_index = find_salesforce_id_column_index(&columns, &options.table_meta.primary_keys);
|
||||
let mut statements = Vec::new();
|
||||
|
||||
// 1. Updates (dirty_rows): one statement per dirty row, fields = only the changed columns.
|
||||
for (row_index, changes) in &options.dirty_rows {
|
||||
let Some(row) = options.rows.get(*row_index) else { continue };
|
||||
let Some(id_index) = id_column_index else { continue };
|
||||
let Some(id_value) = row.get(id_index).and_then(Value::as_str).filter(|s| !s.is_empty()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let mut fields = Map::new();
|
||||
for (column_index, value) in changes {
|
||||
let Some(field_name) = columns.get(*column_index).and_then(|c| c.as_deref()) else { continue };
|
||||
// Id is the identity, never a write target.
|
||||
if field_name.eq_ignore_ascii_case("Id") {
|
||||
continue;
|
||||
}
|
||||
// Skip columns marked non-updateable in the describe metadata.
|
||||
if !salesforce_column_is_updateable(column_info_for(column_info, field_name)) {
|
||||
continue;
|
||||
}
|
||||
fields.insert(field_name.to_string(), value.clone());
|
||||
}
|
||||
if fields.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let body = serde_json::json!({
|
||||
"op": "update",
|
||||
"object": object,
|
||||
"id": id_value,
|
||||
"fields": fields
|
||||
});
|
||||
statements.push(format!("{SALESFORCE_DML_HEADER}\n{body}"));
|
||||
}
|
||||
|
||||
// 2. Deletes (deleted_rows): one statement per deleted row index.
|
||||
for row_index in &options.deleted_rows {
|
||||
let Some(row) = options.rows.get(*row_index) else { continue };
|
||||
let Some(id_index) = id_column_index else { continue };
|
||||
let Some(id_value) = row.get(id_index).and_then(Value::as_str).filter(|s| !s.is_empty()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let body = serde_json::json!({
|
||||
"op": "delete",
|
||||
"object": object,
|
||||
"id": id_value
|
||||
});
|
||||
statements.push(format!("{SALESFORCE_DML_HEADER}\n{body}"));
|
||||
}
|
||||
|
||||
// 3. Inserts (new_rows): one statement per new row, skipping null and empty-string cells.
|
||||
for row in &options.new_rows {
|
||||
let mut fields = Map::new();
|
||||
for (index, field_name) in columns.iter().enumerate() {
|
||||
let Some(field_name) = field_name.as_deref() else { continue };
|
||||
// Id is server-generated for inserts; never send it.
|
||||
if field_name.eq_ignore_ascii_case("Id") {
|
||||
continue;
|
||||
}
|
||||
// Skip columns marked non-createable in the describe metadata.
|
||||
if !salesforce_column_is_createable(column_info_for(column_info, field_name)) {
|
||||
continue;
|
||||
}
|
||||
let value = row.get(index).unwrap_or(&Value::Null);
|
||||
// Untouched cells must not be sent.
|
||||
if value.is_null() {
|
||||
continue;
|
||||
}
|
||||
if value.is_string() && value.as_str().unwrap_or("").is_empty() {
|
||||
continue;
|
||||
}
|
||||
fields.insert(field_name.to_string(), value.clone());
|
||||
}
|
||||
if fields.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let body = serde_json::json!({
|
||||
"op": "insert",
|
||||
"object": object,
|
||||
"fields": fields
|
||||
});
|
||||
statements.push(format!("{SALESFORCE_DML_HEADER}\n{body}"));
|
||||
}
|
||||
|
||||
statements
|
||||
}
|
||||
|
||||
/// Salesforce REST has no transactions — rollback statements are always empty.
|
||||
pub(super) fn build_salesforce_data_grid_rollback_statements(_options: &DataGridSaveStatementOptions) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Validate that the Salesforce sObject has a resolvable Id column. Called
|
||||
/// from `validate_data_grid_save` before the builder runs.
|
||||
pub(super) fn validate_salesforce_id_column(options: &DataGridSaveStatementOptions) -> Option<String> {
|
||||
if options.database_type != Some(DatabaseType::Salesforce) {
|
||||
return None;
|
||||
}
|
||||
// Only validate when there are updates, deletes, or inserts that would
|
||||
// need an Id (updates/deletes need it from existing rows; inserts don't
|
||||
// strictly need it, but the grid always has an Id column from the query).
|
||||
if options.dirty_rows.is_empty() && options.deleted_rows.is_empty() && options.new_rows.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let columns = resolve_salesforce_columns(options);
|
||||
if find_salesforce_id_column_index(&columns, &options.table_meta.primary_keys).is_none() {
|
||||
return Some(format!(
|
||||
"Cannot save Salesforce {}: no Id column found. The grid must include the record Id to enable editing.",
|
||||
options.table_meta.table_name
|
||||
));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Resolve the field name for each display column: `source_columns[i]` when
|
||||
/// present, else `columns[i]`.
|
||||
fn resolve_salesforce_columns(options: &DataGridSaveStatementOptions) -> Vec<Option<String>> {
|
||||
match &options.source_columns {
|
||||
Some(source_columns) if source_columns.len() == options.columns.len() => source_columns.clone(),
|
||||
_ => options.columns.iter().map(|column| Some(column.clone())).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Locate the Id column by matching `table_meta.primary_keys` (case-insensitive)
|
||||
/// against the resolved field names.
|
||||
fn find_salesforce_id_column_index(columns: &[Option<String>], primary_keys: &[String]) -> Option<usize> {
|
||||
// Try primary keys first (case-insensitive).
|
||||
for pk in primary_keys {
|
||||
let pk_upper = pk.to_ascii_uppercase();
|
||||
if let Some(index) =
|
||||
columns.iter().position(|col| col.as_deref().is_some_and(|name| name.to_ascii_uppercase() == pk_upper))
|
||||
{
|
||||
return Some(index);
|
||||
}
|
||||
}
|
||||
// Fallback: look for a column literally named "Id" (case-insensitive).
|
||||
columns.iter().position(|col| col.as_deref().is_some_and(|name| name.eq_ignore_ascii_case("Id")))
|
||||
}
|
||||
|
||||
/// Whether a column should be included in update payloads. Returns `true`
|
||||
/// unless the column's `extra` metadata explicitly marks `updateable: false`.
|
||||
/// Missing or unparseable `extra` → treat as writable (fail-open; Salesforce
|
||||
/// enforces the real rules server-side).
|
||||
fn salesforce_column_is_updateable(info: Option<&DataGridColumnInfo>) -> bool {
|
||||
salesforce_extra_flag(info, "updateable").unwrap_or(true)
|
||||
}
|
||||
|
||||
/// Whether a column should be included in insert payloads. Returns `true`
|
||||
/// unless the column's `extra` metadata explicitly marks `createable: false`.
|
||||
fn salesforce_column_is_createable(info: Option<&DataGridColumnInfo>) -> bool {
|
||||
salesforce_extra_flag(info, "createable").unwrap_or(true)
|
||||
}
|
||||
|
||||
/// Read a boolean flag from the `extra` JSON string on a `DataGridColumnInfo`.
|
||||
/// Returns `None` when `extra` is absent, unparseable, or lacks the key.
|
||||
fn salesforce_extra_flag(info: Option<&DataGridColumnInfo>, key: &str) -> Option<bool> {
|
||||
let extra_str = info?.extra.as_deref()?;
|
||||
let extra: Value = serde_json::from_str(extra_str).ok()?;
|
||||
extra.get(key).and_then(Value::as_bool)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_options(
|
||||
columns: Vec<&str>,
|
||||
primary_keys: Vec<&str>,
|
||||
dirty_rows: Vec<(usize, Vec<(usize, Value)>)>,
|
||||
deleted_rows: Vec<usize>,
|
||||
new_rows: Vec<Vec<Value>>,
|
||||
rows: Vec<Vec<Value>>,
|
||||
) -> DataGridSaveStatementOptions {
|
||||
DataGridSaveStatementOptions {
|
||||
database_type: Some(DatabaseType::Salesforce),
|
||||
identifier_quote: None,
|
||||
table_meta: DataGridTableMeta {
|
||||
database: None,
|
||||
catalog: None,
|
||||
schema: None,
|
||||
table_name: "Account".to_string(),
|
||||
primary_keys: primary_keys.into_iter().map(str::to_string).collect(),
|
||||
columns: None,
|
||||
},
|
||||
columns: columns.into_iter().map(str::to_string).collect(),
|
||||
source_columns: None,
|
||||
rows,
|
||||
dirty_rows,
|
||||
deleted_rows,
|
||||
new_rows,
|
||||
include_database_name: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_options_with_extra(
|
||||
columns: Vec<&str>,
|
||||
primary_keys: Vec<&str>,
|
||||
extras: Vec<Option<&str>>,
|
||||
) -> DataGridSaveStatementOptions {
|
||||
let cols: Vec<DataGridColumnInfo> = columns
|
||||
.iter()
|
||||
.zip(extras.iter())
|
||||
.map(|(name, extra)| DataGridColumnInfo {
|
||||
name: name.to_string(),
|
||||
data_type: String::new(),
|
||||
is_nullable: true,
|
||||
is_primary_key: false,
|
||||
column_default: None,
|
||||
extra: extra.map(|s| s.to_string()),
|
||||
})
|
||||
.collect();
|
||||
DataGridSaveStatementOptions {
|
||||
database_type: Some(DatabaseType::Salesforce),
|
||||
identifier_quote: None,
|
||||
table_meta: DataGridTableMeta {
|
||||
database: None,
|
||||
catalog: None,
|
||||
schema: None,
|
||||
table_name: "Account".to_string(),
|
||||
primary_keys: primary_keys.into_iter().map(str::to_string).collect(),
|
||||
columns: Some(cols),
|
||||
},
|
||||
columns: columns.into_iter().map(str::to_string).collect(),
|
||||
source_columns: None,
|
||||
rows: Vec::new(),
|
||||
dirty_rows: Vec::new(),
|
||||
deleted_rows: Vec::new(),
|
||||
new_rows: Vec::new(),
|
||||
include_database_name: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_generates_dml_statement() {
|
||||
let opts = make_options(
|
||||
vec!["Id", "Name", "Industry"],
|
||||
vec!["Id"],
|
||||
vec![(0, vec![(1, Value::String("Acme".to_string()))])],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![vec![
|
||||
Value::String("001xx000003DGbY".to_string()),
|
||||
Value::String("Old Name".to_string()),
|
||||
Value::String("Tech".to_string()),
|
||||
]],
|
||||
);
|
||||
let stmts = build_salesforce_data_grid_save_statements(&opts);
|
||||
assert_eq!(stmts.len(), 1);
|
||||
assert!(stmts[0].starts_with("DBX SALESFORCE DML\n"));
|
||||
let body: Value = serde_json::from_str(stmts[0].split_once('\n').unwrap().1).unwrap();
|
||||
assert_eq!(body["op"], "update");
|
||||
assert_eq!(body["object"], "Account");
|
||||
assert_eq!(body["id"], "001xx000003DGbY");
|
||||
assert_eq!(body["fields"]["Name"], "Acme");
|
||||
// Industry was not changed → not in fields.
|
||||
assert!(body["fields"].get("Industry").is_none());
|
||||
// Id is never in fields.
|
||||
assert!(body["fields"].get("Id").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_generates_dml_statement() {
|
||||
let opts = make_options(
|
||||
vec!["Id", "Name"],
|
||||
vec!["Id"],
|
||||
vec![],
|
||||
vec![0],
|
||||
vec![],
|
||||
vec![vec![Value::String("001xx000003DGbY".to_string()), Value::String("Acme".to_string())]],
|
||||
);
|
||||
let stmts = build_salesforce_data_grid_save_statements(&opts);
|
||||
assert_eq!(stmts.len(), 1);
|
||||
let body: Value = serde_json::from_str(stmts[0].split_once('\n').unwrap().1).unwrap();
|
||||
assert_eq!(body["op"], "delete");
|
||||
assert_eq!(body["id"], "001xx000003DGbY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_generates_dml_statement_skipping_nulls_and_empties() {
|
||||
let opts = make_options(
|
||||
vec!["Id", "Name", "Phone", "Website"],
|
||||
vec!["Id"],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![vec![
|
||||
Value::Null,
|
||||
Value::String("Acme".to_string()),
|
||||
Value::String("".to_string()), // empty → skip
|
||||
Value::Null, // null → skip
|
||||
]],
|
||||
vec![],
|
||||
);
|
||||
let stmts = build_salesforce_data_grid_save_statements(&opts);
|
||||
assert_eq!(stmts.len(), 1);
|
||||
let body: Value = serde_json::from_str(stmts[0].split_once('\n').unwrap().1).unwrap();
|
||||
assert_eq!(body["op"], "insert");
|
||||
assert_eq!(body["object"], "Account");
|
||||
assert_eq!(body["fields"]["Name"], "Acme");
|
||||
// Id, Phone, Website excluded.
|
||||
assert!(body["fields"].get("Id").is_none());
|
||||
assert!(body["fields"].get("Phone").is_none());
|
||||
assert!(body["fields"].get("Website").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn id_excluded_from_update_fields() {
|
||||
// Even if Id appears in the changes, it must not be in the fields payload.
|
||||
let opts = make_options(
|
||||
vec!["Id", "Name"],
|
||||
vec!["Id"],
|
||||
vec![(0, vec![(0, Value::String("new-id".to_string())), (1, Value::String("Acme".to_string()))])],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![vec![Value::String("001xx000003DGbY".to_string()), Value::String("Old".to_string())]],
|
||||
);
|
||||
let stmts = build_salesforce_data_grid_save_statements(&opts);
|
||||
assert_eq!(stmts.len(), 1);
|
||||
let body: Value = serde_json::from_str(stmts[0].split_once('\n').unwrap().1).unwrap();
|
||||
assert!(body["fields"].get("Id").is_none());
|
||||
assert_eq!(body["fields"]["Name"], "Acme");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn updateable_false_columns_skipped_on_update() {
|
||||
let opts = make_options_with_extra(
|
||||
vec!["Id", "Name", "SystemField"],
|
||||
vec!["Id"],
|
||||
vec![
|
||||
None,
|
||||
Some(r#"{"updateable":true,"createable":true}"#),
|
||||
Some(r#"{"updateable":false,"createable":true}"#),
|
||||
],
|
||||
);
|
||||
let mut opts = opts;
|
||||
opts.rows = vec![vec![
|
||||
Value::String("001xx000003DGbY".to_string()),
|
||||
Value::String("Old".to_string()),
|
||||
Value::String("system".to_string()),
|
||||
]];
|
||||
opts.dirty_rows =
|
||||
vec![(0, vec![(1, Value::String("New".to_string())), (2, Value::String("changed".to_string()))])];
|
||||
let stmts = build_salesforce_data_grid_save_statements(&opts);
|
||||
assert_eq!(stmts.len(), 1);
|
||||
let body: Value = serde_json::from_str(stmts[0].split_once('\n').unwrap().1).unwrap();
|
||||
assert_eq!(body["fields"]["Name"], "New");
|
||||
assert!(body["fields"].get("SystemField").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn createable_false_columns_skipped_on_insert() {
|
||||
let opts = make_options_with_extra(
|
||||
vec!["Id", "Name", "AutoField"],
|
||||
vec!["Id"],
|
||||
vec![
|
||||
None,
|
||||
Some(r#"{"updateable":true,"createable":true}"#),
|
||||
Some(r#"{"updateable":true,"createable":false}"#),
|
||||
],
|
||||
);
|
||||
let mut opts = opts;
|
||||
opts.new_rows = vec![vec![Value::Null, Value::String("Acme".to_string()), Value::String("auto".to_string())]];
|
||||
let stmts = build_salesforce_data_grid_save_statements(&opts);
|
||||
assert_eq!(stmts.len(), 1);
|
||||
let body: Value = serde_json::from_str(stmts[0].split_once('\n').unwrap().1).unwrap();
|
||||
assert_eq!(body["fields"]["Name"], "Acme");
|
||||
assert!(body["fields"].get("AutoField").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_id_column_produces_validation_error() {
|
||||
let opts = make_options(
|
||||
vec!["Name", "Industry"],
|
||||
vec![], // no primary keys
|
||||
vec![(0, vec![(0, Value::String("Acme".to_string()))])],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![vec![Value::String("Old".to_string()), Value::String("Tech".to_string())]],
|
||||
);
|
||||
let error = validate_salesforce_id_column(&opts);
|
||||
assert!(error.is_some());
|
||||
assert!(error.unwrap().contains("no Id column found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rollback_statements_are_empty() {
|
||||
let opts = make_options(vec!["Id", "Name"], vec!["Id"], vec![], vec![], vec![], vec![]);
|
||||
assert!(build_salesforce_data_grid_rollback_statements(&opts).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn statement_order_is_updates_deletes_inserts() {
|
||||
let opts = make_options(
|
||||
vec!["Id", "Name"],
|
||||
vec!["Id"],
|
||||
vec![(0, vec![(1, Value::String("Updated".to_string()))])],
|
||||
vec![1],
|
||||
vec![vec![Value::Null, Value::String("New".to_string())]],
|
||||
vec![
|
||||
vec![Value::String("id1".to_string()), Value::String("Row1".to_string())],
|
||||
vec![Value::String("id2".to_string()), Value::String("Row2".to_string())],
|
||||
],
|
||||
);
|
||||
let stmts = build_salesforce_data_grid_save_statements(&opts);
|
||||
assert_eq!(stmts.len(), 3);
|
||||
let ops: Vec<&str> = stmts
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let body: Value = serde_json::from_str(s.split_once('\n').unwrap().1).unwrap();
|
||||
// Leak to get a &'static str — fine in tests.
|
||||
Box::leak(body["op"].as_str().unwrap().to_string().into_boxed_str()) as &str
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(ops, vec!["update", "delete", "insert"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_change_set_after_filtering_skips_row() {
|
||||
let opts = make_options(
|
||||
vec!["Id", "Name"],
|
||||
vec!["Id"],
|
||||
// Only change is to Id itself → filtered out → empty fields → skip.
|
||||
vec![(0, vec![(0, Value::String("new-id".to_string()))])],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![vec![Value::String("001xx000003DGbY".to_string()), Value::String("Old".to_string())]],
|
||||
);
|
||||
let stmts = build_salesforce_data_grid_save_statements(&opts);
|
||||
assert!(stmts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_salesforce_database_type_returns_no_validation_error() {
|
||||
let mut opts = make_options(vec!["Id"], vec!["Id"], vec![], vec![], vec![], vec![]);
|
||||
opts.database_type = Some(DatabaseType::Postgres);
|
||||
assert!(validate_salesforce_id_column(&opts).is_none());
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,13 @@ use data_grid_tdengine_sql::{
|
||||
validate_tdengine_existing_rows, validate_tdengine_inserted_rows,
|
||||
};
|
||||
|
||||
#[path = "data_grid_salesforce_sql.rs"]
|
||||
mod data_grid_salesforce_sql;
|
||||
use data_grid_salesforce_sql::{
|
||||
build_salesforce_data_grid_rollback_statements, build_salesforce_data_grid_save_statements,
|
||||
validate_salesforce_id_column,
|
||||
};
|
||||
|
||||
use crate::models::connection::DatabaseType;
|
||||
use crate::sql_dialect::{
|
||||
firebird_rows_clause, quote_table_identifier, table_pagination_strategy, uses_oracle_row_id,
|
||||
@@ -1195,6 +1202,9 @@ fn build_neo4j_data_grid_column_distinct_values_sql(options: &DataGridColumnDist
|
||||
}
|
||||
|
||||
fn validate_data_grid_save(options: &DataGridSaveStatementOptions) -> Option<String> {
|
||||
if let Some(error) = validate_salesforce_id_column(options) {
|
||||
return Some(error);
|
||||
}
|
||||
if let Some(error) = validate_iotdb_existing_rows(options) {
|
||||
return Some(error);
|
||||
}
|
||||
@@ -1461,6 +1471,9 @@ fn build_data_grid_save_statements(
|
||||
if options.database_type == Some(DatabaseType::Tdengine) {
|
||||
return build_tdengine_data_grid_save_statements(options);
|
||||
}
|
||||
if options.database_type == Some(DatabaseType::Salesforce) {
|
||||
return build_salesforce_data_grid_save_statements(options);
|
||||
}
|
||||
if uses_iotdb_table_model_save(options) {
|
||||
return build_iotdb_data_grid_save_statements(options);
|
||||
}
|
||||
@@ -1656,6 +1669,9 @@ fn build_data_grid_rollback_statements(
|
||||
if options.database_type == Some(DatabaseType::Tdengine) {
|
||||
return build_tdengine_data_grid_rollback_statements(options);
|
||||
}
|
||||
if options.database_type == Some(DatabaseType::Salesforce) {
|
||||
return build_salesforce_data_grid_rollback_statements(options);
|
||||
}
|
||||
if uses_iotdb_table_model_save(options) {
|
||||
return build_iotdb_data_grid_rollback_statements(options);
|
||||
}
|
||||
|
||||
@@ -424,6 +424,13 @@ pub fn is_write_sql_for_database(sql: &str, database_type: DatabaseType) -> bool
|
||||
return is_write;
|
||||
}
|
||||
}
|
||||
if database_type == DatabaseType::Salesforce {
|
||||
// SOQL has no write verbs — every write arrives as a DBX pseudo-command —
|
||||
// so the keyword scan below would misread a string literal
|
||||
// (`Subject = 'Delete request'`) or a custom field (`Update__c`) as a
|
||||
// write and lock a read-only session out of an ordinary SELECT.
|
||||
return classify_salesforce_statement_risk(sql) != SalesforceStatementRisk::Read;
|
||||
}
|
||||
if let Some(risk) = classify_search_engine_query_risk(sql, database_type) {
|
||||
return risk != SearchEngineQueryRisk::ReadOnly;
|
||||
}
|
||||
@@ -440,6 +447,68 @@ fn classify_dynamodb_statement_write(source: &str) -> Option<bool> {
|
||||
}
|
||||
}
|
||||
|
||||
/// How a Salesforce statement must be treated by write and risk classification.
|
||||
///
|
||||
/// `SfClient::execute_query` accepts exactly two shapes: a SOQL `SELECT`, which
|
||||
/// the REST query endpoint runs read-only, and a `DBX SALESFORCE <…>`
|
||||
/// pseudo-command carrying a JSON body. Anything else is rejected by Salesforce
|
||||
/// anyway, so it is classified as an opaque write and fails closed rather than
|
||||
/// being guessed at by the SQL keyword scan.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SalesforceStatementRisk {
|
||||
/// SOQL `SELECT` — always a read, whatever its literals and field names say.
|
||||
Read,
|
||||
/// Single-record DML naming its object and, for update/delete, its record Id.
|
||||
ScopedWrite,
|
||||
/// Unscoped or unknown DML, unrecognized pseudo-commands, non-SOQL text.
|
||||
OpaqueWrite,
|
||||
}
|
||||
|
||||
pub fn classify_salesforce_statement_risk(source: &str) -> SalesforceStatementRisk {
|
||||
use SalesforceStatementRisk::{OpaqueWrite, Read, ScopedWrite};
|
||||
|
||||
let header = source.lines().find(|line| !line.trim().is_empty()).unwrap_or("").trim();
|
||||
if header.to_ascii_uppercase().starts_with("DBX SALESFORCE") {
|
||||
return if salesforce_dml_is_scoped(source) { ScopedWrite } else { OpaqueWrite };
|
||||
}
|
||||
// Comments are stripped before the verb check, but the pseudo-command branch
|
||||
// above deliberately reads the raw text: a `--` inside a JSON field value
|
||||
// must not be mistaken for a line comment and truncate the payload.
|
||||
let first_word = strip_sql_comments(source)
|
||||
.split(|character: char| !character.is_alphanumeric() && character != '_')
|
||||
.find(|token| !token.is_empty())
|
||||
.unwrap_or("")
|
||||
.to_ascii_uppercase();
|
||||
if first_word == "SELECT" {
|
||||
Read
|
||||
} else {
|
||||
OpaqueWrite
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a `DBX SALESFORCE DML` body targets exactly one identified record:
|
||||
/// an insert needs only its object, while an update or delete without an Id has
|
||||
/// nothing bounding it and stays opaque.
|
||||
fn salesforce_dml_is_scoped(source: &str) -> bool {
|
||||
// The header line never contains a brace, so the JSON body starts at the first `{`.
|
||||
let Some(start) = source.find('{') else { return false };
|
||||
let Ok(body) = serde_json::from_str::<serde_json::Value>(&source[start..]) else {
|
||||
return false;
|
||||
};
|
||||
let names_object =
|
||||
body.get("object").and_then(|value| value.as_str()).is_some_and(|object| !object.trim().is_empty());
|
||||
if !names_object {
|
||||
return false;
|
||||
}
|
||||
match body.get("op").and_then(|value| value.as_str()).map(str::to_ascii_lowercase).as_deref() {
|
||||
Some("insert") => true,
|
||||
Some("update") | Some("delete") => {
|
||||
body.get("id").and_then(|value| value.as_str()).is_some_and(|id| !id.trim().is_empty())
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SearchEngineQueryRisk {
|
||||
ReadOnly,
|
||||
@@ -2460,6 +2529,107 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_salesforce_statement_risk_scoped_dml() {
|
||||
let update = "DBX SALESFORCE DML\n{\"op\":\"update\",\"object\":\"Account\",\"id\":\"001x\",\"fields\":{\"Name\":\"Acme\"}}";
|
||||
assert_eq!(classify_salesforce_statement_risk(update), SalesforceStatementRisk::ScopedWrite);
|
||||
let delete = "DBX SALESFORCE DML\n{\"op\":\"delete\",\"object\":\"Account\",\"id\":\"001x\"}";
|
||||
assert_eq!(classify_salesforce_statement_risk(delete), SalesforceStatementRisk::ScopedWrite);
|
||||
let insert = "DBX SALESFORCE DML\n{\"op\":\"insert\",\"object\":\"Lead\",\"fields\":{\"Company\":\"Acme\"}}";
|
||||
assert_eq!(classify_salesforce_statement_risk(insert), SalesforceStatementRisk::ScopedWrite);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_salesforce_statement_risk_unscoped_dml_fails_closed() {
|
||||
// No Id: nothing bounds the write, so it is not a safe write.
|
||||
assert_eq!(
|
||||
classify_salesforce_statement_risk(
|
||||
"DBX SALESFORCE DML\n{\"op\":\"update\",\"object\":\"Account\",\"fields\":{}}"
|
||||
),
|
||||
SalesforceStatementRisk::OpaqueWrite
|
||||
);
|
||||
// Unknown op / unknown header / unparseable body all fail closed.
|
||||
assert_eq!(
|
||||
classify_salesforce_statement_risk(
|
||||
"DBX SALESFORCE DML\n{\"op\":\"upsert\",\"object\":\"Account\",\"id\":\"001x\"}"
|
||||
),
|
||||
SalesforceStatementRisk::OpaqueWrite
|
||||
);
|
||||
assert_eq!(
|
||||
classify_salesforce_statement_risk("DBX SALESFORCE FUTURE\n{}"),
|
||||
SalesforceStatementRisk::OpaqueWrite
|
||||
);
|
||||
assert_eq!(
|
||||
classify_salesforce_statement_risk("DBX SALESFORCE DML\n{not json"),
|
||||
SalesforceStatementRisk::OpaqueWrite
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_salesforce_statement_risk_dml_body_survives_comment_lookalikes() {
|
||||
// A `--` inside a field value must not be stripped as a line comment:
|
||||
// the pseudo-command branch reads the raw text, so the body still parses
|
||||
// and the write stays scoped.
|
||||
let dml = "DBX SALESFORCE DML\n{\"op\":\"update\",\"object\":\"Account\",\"id\":\"001x\",\"fields\":{\"Description\":\"a--b\"}}";
|
||||
assert_eq!(classify_salesforce_statement_risk(dml), SalesforceStatementRisk::ScopedWrite);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_salesforce_statement_risk_soql_is_read() {
|
||||
assert_eq!(classify_salesforce_statement_risk("SELECT Id FROM Account"), SalesforceStatementRisk::Read);
|
||||
assert_eq!(classify_salesforce_statement_risk(" select Name from Lead "), SalesforceStatementRisk::Read);
|
||||
// SOQL forms sqlparser cannot parse must still read as reads.
|
||||
assert_eq!(
|
||||
classify_salesforce_statement_risk("SELECT FIELDS(ALL) FROM Account LIMIT 200"),
|
||||
SalesforceStatementRisk::Read
|
||||
);
|
||||
assert_eq!(
|
||||
classify_salesforce_statement_risk("SELECT Id FROM Opportunity WHERE CloseDate = LAST_N_DAYS:7"),
|
||||
SalesforceStatementRisk::Read
|
||||
);
|
||||
assert_eq!(
|
||||
classify_salesforce_statement_risk("SELECT Id, (SELECT Id FROM Contacts) FROM Account"),
|
||||
SalesforceStatementRisk::Read
|
||||
);
|
||||
assert_eq!(
|
||||
classify_salesforce_statement_risk("/* report */ SELECT Id FROM Account"),
|
||||
SalesforceStatementRisk::Read
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_salesforce_statement_risk_non_soql_is_opaque_write() {
|
||||
// The driver only accepts SELECT and DBX pseudo-commands; anything else
|
||||
// is a client error, not something to classify as a harmless read.
|
||||
assert_eq!(classify_salesforce_statement_risk("DELETE FROM Account"), SalesforceStatementRisk::OpaqueWrite);
|
||||
assert_eq!(
|
||||
classify_salesforce_statement_risk("FIND {Acme} IN ALL FIELDS"),
|
||||
SalesforceStatementRisk::OpaqueWrite
|
||||
);
|
||||
assert_eq!(classify_salesforce_statement_risk(""), SalesforceStatementRisk::OpaqueWrite);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_write_sql_for_database_salesforce_soql_is_read() {
|
||||
assert!(!is_write_sql_for_database("SELECT Id FROM Account", DatabaseType::Salesforce));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_write_sql_for_database_salesforce_literals_are_not_writes() {
|
||||
// The generic keyword scan would see DELETE/UPDATE here; SOQL cannot write.
|
||||
assert!(!is_write_sql_for_database(
|
||||
"SELECT Id FROM Case WHERE Subject = 'Delete request'",
|
||||
DatabaseType::Salesforce
|
||||
));
|
||||
assert!(!is_write_sql_for_database("SELECT Update__c FROM Lead", DatabaseType::Salesforce));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_write_sql_for_database_salesforce_dml_is_write() {
|
||||
let dml = "DBX SALESFORCE DML\n{\"op\":\"delete\",\"object\":\"Account\",\"id\":\"x\"}";
|
||||
assert!(is_write_sql_for_database(dml, DatabaseType::Salesforce));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mysql_explicit_transaction_openers_are_recognized() {
|
||||
for sql in [
|
||||
|
||||
@@ -550,9 +550,17 @@ fn err(reason: &str) -> QuerySqlBuildResult {
|
||||
}
|
||||
|
||||
fn unsupported_pagination_type(database_type: Option<DatabaseType>) -> bool {
|
||||
// SOQL has no derived tables and no OFFSET wrapping; the Salesforce driver
|
||||
// pages through QueryLocator cursors (session_id) instead.
|
||||
matches!(
|
||||
database_type,
|
||||
Some(DatabaseType::Neo4j | DatabaseType::MongoDb | DatabaseType::Redis | DatabaseType::Solr)
|
||||
Some(
|
||||
DatabaseType::Neo4j
|
||||
| DatabaseType::MongoDb
|
||||
| DatabaseType::Redis
|
||||
| DatabaseType::Salesforce
|
||||
| DatabaseType::Solr
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user