Merge branch 'dev' into feature/20260602_connection_driver_i18n

This commit is contained in:
tianqijiuyun-latiao
2026-06-23 23:34:16 +08:00
39 changed files with 3045 additions and 1840 deletions

View File

@@ -19,6 +19,7 @@ const sectionKeyEntries = [
['uri', 'uri'],
['target', 'target'],
['fileTarget', 'fileTarget'],
['readOnly', 'readOnly'],
['connectionMode', 'connectionMode'],
['oceanBaseProtocol', 'oceanBaseProtocol'],
['mongoDiscovery', 'mongoDiscovery'],

View File

@@ -22,6 +22,7 @@ export type ConnectionConfigSectionKey =
| 'uri'
| 'target'
| 'fileTarget'
| 'readOnly'
| 'connectionMode'
| 'oceanBaseProtocol'
| 'mongoDiscovery'
@@ -86,6 +87,7 @@ const connectionConfigSectionKeyMap: Record<
uri: 'uri',
target: 'target',
fileTarget: 'fileTarget',
readOnly: 'readOnly',
connectionMode: 'connectionMode',
oceanBaseProtocol: 'oceanBaseProtocol',
mongoDiscovery: 'mongoDiscovery',

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import {
findConnectionMutatingStatements,
isConnectionDataEditRestricted,
isConnectionDataImportRestricted,
isConnectionScriptExecutionRestricted,
isConnectionStructureEditRestricted,
resolveConnectionProtectionConfig,
} from './connectionReadOnly';
describe('connectionReadOnly', () => {
it('maps legacy readOnly connections to the full production protection set', () => {
expect(resolveConnectionProtectionConfig({
type: 'postgres',
readOnly: true,
})).toEqual({
restrictDataEdit: true,
restrictStructureEdit: true,
restrictScriptExecution: true,
restrictDataImport: true,
});
});
it('keeps partial protection flags isolated from each other', () => {
const config = {
type: 'postgres',
protection: {
restrictDataEdit: true,
restrictDataImport: true,
},
};
expect(isConnectionDataEditRestricted(config)).toBe(true);
expect(isConnectionDataImportRestricted(config)).toBe(true);
expect(isConnectionStructureEditRestricted(config)).toBe(false);
expect(isConnectionScriptExecutionRestricted(config)).toBe(false);
});
it('only blocks mutating SQL when script execution protection is enabled', () => {
expect(findConnectionMutatingStatements({
type: 'postgres',
protection: {
restrictScriptExecution: true,
},
}, "SELECT * FROM users; UPDATE users SET name = 'next';")).toEqual([
"UPDATE users SET name = 'next'",
]);
expect(findConnectionMutatingStatements({
type: 'postgres',
protection: {
restrictDataEdit: true,
},
}, "UPDATE users SET name = 'next';")).toEqual([]);
});
});

View File

@@ -3,11 +3,42 @@ import { convertMongoShellToJsonCommand } from "./mongodb";
import { resolveSqlDialect } from "./sqlDialect";
import { findSqlStatementRanges } from "./sqlStatementSelection";
export type ConnectionProtectionKey =
| "restrictDataEdit"
| "restrictStructureEdit"
| "restrictScriptExecution"
| "restrictDataImport";
export type ConnectionProtectionConfig = NonNullable<
ConnectionConfig["protection"]
>;
type ConnectionReadOnlyLike = Pick<
ConnectionConfig,
"type" | "driver" | "oceanBaseProtocol" | "readOnly"
"type" | "driver" | "oceanBaseProtocol" | "readOnly" | "protection"
> | null | undefined;
export const CONNECTION_PROTECTION_KEYS: ConnectionProtectionKey[] = [
"restrictDataEdit",
"restrictStructureEdit",
"restrictScriptExecution",
"restrictDataImport",
];
const EMPTY_CONNECTION_PROTECTION: ConnectionProtectionConfig = {
restrictDataEdit: false,
restrictStructureEdit: false,
restrictScriptExecution: false,
restrictDataImport: false,
};
const FULL_CONNECTION_PROTECTION: ConnectionProtectionConfig = {
restrictDataEdit: true,
restrictStructureEdit: true,
restrictScriptExecution: true,
restrictDataImport: true,
};
const CONNECTION_READ_ONLY_TYPES = new Set([
"mysql",
"goldendb",
@@ -230,17 +261,109 @@ export const supportsConnectionReadOnlyMode = (
return CONNECTION_READ_ONLY_TYPES.has(resolveConnectionReadOnlyType(config));
};
export const normalizeConnectionProtectionConfig = (
value: unknown,
): ConnectionProtectionConfig => {
const raw =
value && typeof value === "object"
? (value as Record<string, unknown>)
: {};
return {
restrictDataEdit: raw.restrictDataEdit === true,
restrictStructureEdit: raw.restrictStructureEdit === true,
restrictScriptExecution: raw.restrictScriptExecution === true,
restrictDataImport: raw.restrictDataImport === true,
};
};
export const createEmptyConnectionProtectionConfig =
(): ConnectionProtectionConfig => ({ ...EMPTY_CONNECTION_PROTECTION });
export const deriveLegacyConnectionReadOnlyFlag = (
protection: unknown,
): boolean => {
const normalized = normalizeConnectionProtectionConfig(protection);
return CONNECTION_PROTECTION_KEYS.every((key) => normalized[key] === true);
};
export const resolveConnectionProtectionConfig = (
config: ConnectionReadOnlyLike,
): ConnectionProtectionConfig => {
if (!supportsConnectionReadOnlyMode(config)) {
return createEmptyConnectionProtectionConfig();
}
const normalized = normalizeConnectionProtectionConfig(config?.protection);
const hasExplicitRestriction = CONNECTION_PROTECTION_KEYS.some(
(key) => normalized[key] === true,
);
if (hasExplicitRestriction) {
return normalized;
}
if (config?.readOnly === true) {
return { ...FULL_CONNECTION_PROTECTION };
}
return createEmptyConnectionProtectionConfig();
};
export const isConnectionProtectionEnabled = (
config: ConnectionReadOnlyLike,
key: ConnectionProtectionKey,
): boolean => {
return resolveConnectionProtectionConfig(config)[key] === true;
};
export const getConnectionProtectionEnabledCount = (
config: ConnectionReadOnlyLike,
): number => {
const protection = resolveConnectionProtectionConfig(config);
return CONNECTION_PROTECTION_KEYS.filter(
(key) => protection[key] === true,
).length;
};
export const hasAnyConnectionProtection = (
config: ConnectionReadOnlyLike,
): boolean => {
return getConnectionProtectionEnabledCount(config) > 0;
};
export const isConnectionDataEditRestricted = (
config: ConnectionReadOnlyLike,
): boolean => {
return isConnectionProtectionEnabled(config, "restrictDataEdit");
};
export const isConnectionStructureEditRestricted = (
config: ConnectionReadOnlyLike,
): boolean => {
return isConnectionProtectionEnabled(config, "restrictStructureEdit");
};
export const isConnectionScriptExecutionRestricted = (
config: ConnectionReadOnlyLike,
): boolean => {
return isConnectionProtectionEnabled(config, "restrictScriptExecution");
};
export const isConnectionDataImportRestricted = (
config: ConnectionReadOnlyLike,
): boolean => {
return isConnectionProtectionEnabled(config, "restrictDataImport");
};
export const isConnectionForcedReadOnly = (
config: ConnectionReadOnlyLike,
): boolean => {
return supportsConnectionReadOnlyMode(config) && config?.readOnly === true;
return deriveLegacyConnectionReadOnlyFlag(
resolveConnectionProtectionConfig(config),
);
};
export const findConnectionMutatingStatements = (
config: ConnectionReadOnlyLike,
sql: string,
): string[] => {
if (!isConnectionForcedReadOnly(config)) {
if (!isConnectionScriptExecutionRestricted(config)) {
return [];
}
return findSqlStatementRanges(String(sql || ""))

View File

@@ -1,4 +1,8 @@
import { connection } from '../../wailsjs/go/models';
import {
deriveLegacyConnectionReadOnlyFlag,
normalizeConnectionProtectionConfig,
} from './connectionReadOnly';
import {
OCEANBASE_PROTOCOL_PARAM_KEYS,
resolveOceanBaseProtocolFromConfig,
@@ -120,6 +124,7 @@ export function buildRpcConnectionConfig(
const baseId = toStringValue(config.id).trim() || toStringValue(overrides.id).trim() || undefined;
const timeout = toOptionalInteger(rpcMerged.timeout, toOptionalInteger(config.timeout));
const redisDB = toOptionalInteger(rpcMerged.redisDB, toOptionalInteger(config.redisDB));
const protection = normalizeConnectionProtectionConfig(rpcMerged.protection);
const rpcConfig = new connection.ConnectionConfig({
...rpcPayload,
@@ -129,7 +134,8 @@ export function buildRpcConnectionConfig(
user: toStringValue(rpcMerged.user),
password: toStringValue(rpcMerged.password),
database: toStringValue(rpcMerged.database),
readOnly: rpcMerged.readOnly === true,
readOnly: deriveLegacyConnectionReadOnlyFlag(protection),
protection: new connection.ConnectionProtectionConfig(protection),
useSSH: rpcMerged.useSSH === true,
ssh: normalizeSSHConfig(rpcMerged.ssh),
useProxy: rpcMerged.useProxy === true,

View File

@@ -248,6 +248,39 @@ describe('dataSourceCapabilities', () => {
supportsDropDatabase: false,
supportsMessagePublish: false,
forceReadOnlyQueryResult: true,
forceReadOnlyStructureDesigner: true,
});
});
it('allows script execution while still disabling result edits when only data-edit protection is enabled', () => {
expect(getDataSourceCapabilities({
type: 'postgres',
protection: {
restrictDataEdit: true,
},
})).toMatchObject({
type: 'postgres',
supportsCreateDatabase: true,
supportsRenameDatabase: true,
supportsDropDatabase: true,
forceReadOnlyQueryResult: true,
forceReadOnlyStructureDesigner: false,
});
});
it('keeps query results editable while disabling DDL shortcuts when only structure protection is enabled', () => {
expect(getDataSourceCapabilities({
type: 'postgres',
protection: {
restrictStructureEdit: true,
},
})).toMatchObject({
type: 'postgres',
supportsCreateDatabase: false,
supportsRenameDatabase: false,
supportsDropDatabase: false,
forceReadOnlyQueryResult: false,
forceReadOnlyStructureDesigner: true,
});
});
@@ -258,6 +291,7 @@ describe('dataSourceCapabilities', () => {
supportsCreateDatabase: false,
supportsDropDatabase: false,
forceReadOnlyQueryResult: false,
forceReadOnlyStructureDesigner: true,
});
});

View File

@@ -1,8 +1,14 @@
import type { ConnectionConfig } from '../types';
import { isConnectionForcedReadOnly } from './connectionReadOnly';
import {
isConnectionDataEditRestricted,
isConnectionStructureEditRestricted,
} from './connectionReadOnly';
import { normalizeOceanBaseProtocol } from './oceanBaseProtocol';
type ConnectionLike = Pick<ConnectionConfig, 'type' | 'driver' | 'oceanBaseProtocol' | 'readOnly'> | null | undefined;
type ConnectionLike = Pick<
ConnectionConfig,
'type' | 'driver' | 'oceanBaseProtocol' | 'readOnly' | 'protection'
> | null | undefined;
const normalizeDataSourceToken = (raw: string): string => {
const normalized = String(raw || '').trim().toLowerCase();
@@ -142,6 +148,7 @@ const COPY_INSERT_TYPES = new Set([
const QUERY_EDITOR_DISABLED_TYPES = new Set(['redis']);
const FORCE_READ_ONLY_QUERY_TYPES = new Set(['tdengine', 'iotdb', 'clickhouse', 'rocketmq', 'mqtt', 'kafka', 'rabbitmq']);
const FORCE_READ_ONLY_STRUCTURE_DESIGNER_TYPES = new Set(['elasticsearch', 'mongodb', 'redis', 'iotdb']);
const MESSAGE_PUBLISH_TYPES = new Set(['rocketmq', 'mqtt', 'kafka', 'rabbitmq']);
const MANUAL_TOTAL_COUNT_TYPES = new Set(['duckdb', 'oracle', 'rocketmq', 'mqtt']);
const APPROXIMATE_TABLE_COUNT_TYPES = new Set(['duckdb', 'oracle']);
@@ -157,6 +164,7 @@ export type DataSourceCapabilities = {
supportsDropDatabase: boolean;
supportsMessagePublish: boolean;
forceReadOnlyQueryResult: boolean;
forceReadOnlyStructureDesigner: boolean;
preferManualTotalCount: boolean;
supportsApproximateTableCount: boolean;
supportsApproximateTotalPages: boolean;
@@ -209,17 +217,20 @@ const DROP_DATABASE_TYPES = new Set([
export const getDataSourceCapabilities = (config: ConnectionLike): DataSourceCapabilities => {
const type = resolveDataSourceType(config);
const forcedReadOnly = isConnectionForcedReadOnly(config);
const dataEditRestricted = isConnectionDataEditRestricted(config);
const structureEditRestricted = isConnectionStructureEditRestricted(config);
return {
type,
supportsQueryEditor: !QUERY_EDITOR_DISABLED_TYPES.has(type),
supportsSqlQueryExport: SQL_QUERY_EXPORT_TYPES.has(type),
supportsCopyInsert: COPY_INSERT_TYPES.has(type),
supportsCreateDatabase: !forcedReadOnly && CREATE_DATABASE_TYPES.has(type),
supportsRenameDatabase: !forcedReadOnly && RENAME_DATABASE_TYPES.has(type),
supportsDropDatabase: !forcedReadOnly && DROP_DATABASE_TYPES.has(type),
supportsMessagePublish: !forcedReadOnly && MESSAGE_PUBLISH_TYPES.has(type),
forceReadOnlyQueryResult: forcedReadOnly || FORCE_READ_ONLY_QUERY_TYPES.has(type),
supportsCreateDatabase: !structureEditRestricted && CREATE_DATABASE_TYPES.has(type),
supportsRenameDatabase: !structureEditRestricted && RENAME_DATABASE_TYPES.has(type),
supportsDropDatabase: !structureEditRestricted && DROP_DATABASE_TYPES.has(type),
supportsMessagePublish: !dataEditRestricted && MESSAGE_PUBLISH_TYPES.has(type),
forceReadOnlyQueryResult: dataEditRestricted || FORCE_READ_ONLY_QUERY_TYPES.has(type),
forceReadOnlyStructureDesigner:
structureEditRestricted || FORCE_READ_ONLY_STRUCTURE_DESIGNER_TYPES.has(type),
preferManualTotalCount: MANUAL_TOTAL_COUNT_TYPES.has(type),
supportsApproximateTableCount: APPROXIMATE_TABLE_COUNT_TYPES.has(type),
supportsApproximateTotalPages: APPROXIMATE_TOTAL_PAGE_TYPES.has(type),

View File

@@ -5,6 +5,7 @@ import {
buildMongoFindCommand,
convertMongoShellToJsonCommand,
formatMongoEditableValue,
normalizeMongoDocumentForEditing,
parseMongoEditedValue,
} from './mongodb';
@@ -157,6 +158,12 @@ describe('Mongo edit value helpers', () => {
})).toBe('UUID("12345678-1234-4678-9234-567812345678")');
});
it('infers editable Mongo typed literals from common string field names', () => {
expect(formatMongoEditableValue('5a7fb5b93560e06a6e1e4950', 'merchantId')).toBe('ObjectId("5a7fb5b93560e06a6e1e4950")');
expect(formatMongoEditableValue('5ba279393560e029bb0b6359', 'pMid')).toBe('5ba279393560e029bb0b6359');
expect(formatMongoEditableValue('2018-06-24 07:42:51.8', 'updateTime')).toBe('ISODate("2018-06-24T07:42:51.800Z")');
});
it('parses typed Mongo edit text back to extended JSON wrappers', () => {
expect(parseMongoEditedValue('_id', '507f1f77bcf86cd799439011')).toEqual({ $oid: '507f1f77bcf86cd799439011' });
expect(parseMongoEditedValue('createdAt', '2024-06-23T00:00:00.000Z', { $date: { $numberLong: '1719100800000' } })).toEqual({
@@ -173,4 +180,28 @@ describe('Mongo edit value helpers', () => {
},
});
});
it('infers typed Mongo values from string edits when the field name is sufficient', () => {
expect(parseMongoEditedValue('merchantId', '5a7fb5b93560e06a6e1e4950')).toEqual({ $oid: '5a7fb5b93560e06a6e1e4950' });
expect(parseMongoEditedValue('updateTime', '2018-06-24 07:42:51.8')).toEqual({
$date: '2018-06-24T07:42:51.800Z',
});
expect(parseMongoEditedValue('pMid', '5ba279393560e029bb0b6359')).toBe('5ba279393560e029bb0b6359');
});
it('normalizes Mongo documents for JSON editing without promoting plain string ids blindly', () => {
expect(normalizeMongoDocumentForEditing({
_id: '5a8262f93560e05dd3465288',
merchantId: '5a7fb5b93560e06a6e1e4950',
pMid: '5ba279393560e029bb0b6359',
updateTime: '2018-06-24 07:42:51.8',
userId: '5a65611fadfce63b96bb2001',
})).toEqual({
_id: { $oid: '5a8262f93560e05dd3465288' },
merchantId: { $oid: '5a7fb5b93560e06a6e1e4950' },
pMid: '5ba279393560e029bb0b6359',
updateTime: { $date: '2018-06-24T07:42:51.800Z' },
userId: { $oid: '5a65611fadfce63b96bb2001' },
});
});
});

View File

@@ -85,6 +85,37 @@ const buildMongoBinaryUUID = (uuidText: string): { $binary: { base64: string; su
},
});
const isMongoObjectIdFieldName = (fieldName: string): boolean => {
const text = String(fieldName || '').trim();
if (!text) return false;
return text === '_id'
|| text === 'id'
|| text.endsWith('Id')
|| text.endsWith('ID')
|| text.endsWith('_id')
|| text.endsWith('-id');
};
const isMongoDateFieldName = (fieldName: string): boolean => {
const text = String(fieldName || '').trim();
if (!text) return false;
return text === 'date'
|| text === 'time'
|| text === 'timestamp'
|| text.endsWith('Date')
|| text.endsWith('Time')
|| text.endsWith('At')
|| text.endsWith('Timestamp')
|| text.endsWith('_date')
|| text.endsWith('_time')
|| text.endsWith('_at')
|| text.endsWith('_timestamp')
|| text.endsWith('-date')
|| text.endsWith('-time')
|| text.endsWith('-at')
|| text.endsWith('-timestamp');
};
const buildMongoDateLiteralText = (raw?: unknown): string => {
const millis = typeof raw === 'object' && raw && !Array.isArray(raw)
? parseMongoDateToMillis((raw as Record<string, unknown>)?.$numberLong ?? raw)
@@ -201,6 +232,17 @@ const parseMongoDateToMillis = (raw: unknown): number | null => {
if (Number.isFinite(n)) return Math.trunc(n);
}
const naiveMatch = text.match(
/^(\d{4}-\d{2}-\d{2})(?:[ T](\d{2}:\d{2}:\d{2})(\.\d{1,9})?)?$/
);
if (naiveMatch) {
const [, datePart, timePart = '00:00:00', fractionPart = ''] = naiveMatch;
const fractionDigits = fractionPart ? `${fractionPart.slice(1)}000`.slice(0, 3) : '000';
const utcText = `${datePart}T${timePart}.${fractionDigits}Z`;
const utcMillis = Date.parse(utcText);
if (!Number.isNaN(utcMillis)) return utcMillis;
}
const direct = new Date(text);
if (!Number.isNaN(direct.getTime())) return direct.getTime();
@@ -379,6 +421,56 @@ const parseMongoJSONValue = (raw: string): unknown => {
}
};
const relaxMongoDateValue = (raw: unknown): { $date: string } => ({
$date: buildMongoDateLiteralText(raw),
});
const normalizeMongoFieldValueForEditing = (fieldName: string, value: unknown): unknown => {
if (value === null || typeof value === 'undefined') return value;
const singleEntry = getSingleMongoOperatorEntry(value);
if (singleEntry) {
if (singleEntry[0] === '$date') {
return relaxMongoDateValue(singleEntry[1]);
}
return value;
}
if (Array.isArray(value)) {
return value.map((item) => normalizeMongoFieldValueForEditing(fieldName, item));
}
if (isPlainMongoObject(value)) {
const next: Record<string, unknown> = {};
Object.entries(value).forEach(([key, nestedValue]) => {
next[key] = normalizeMongoFieldValueForEditing(key, nestedValue);
});
return next;
}
if (typeof value !== 'string') return value;
const text = value.trim();
if (!text) return value;
if (isMongoObjectIdFieldName(fieldName) && HEX24_RE.test(text)) {
return { $oid: text.toLowerCase() };
}
if (isMongoDateFieldName(fieldName)) {
const millis = parseMongoDateToMillis(text);
if (millis !== null) {
return relaxMongoDateValue(millis);
}
}
return value;
};
export const normalizeMongoDocumentForEditing = <T>(value: T): T => (
normalizeMongoFieldValueForEditing('', value) as T
);
export const formatMongoValueForDisplay = (value: unknown): string => {
if (value === null) return 'NULL';
if (typeof value === 'undefined') return '';
@@ -420,20 +512,21 @@ export const formatMongoValueForDisplay = (value: unknown): string => {
return String(value);
};
export const formatMongoEditableValue = (value: unknown): string => {
if (value === null || typeof value === 'undefined') return '';
const singleEntry = getSingleMongoOperatorEntry(value);
export const formatMongoEditableValue = (value: unknown, columnName = ''): string => {
const normalizedValue = normalizeMongoFieldValueForEditing(columnName, value);
if (normalizedValue === null || typeof normalizedValue === 'undefined') return '';
const singleEntry = getSingleMongoOperatorEntry(normalizedValue);
if (singleEntry) {
return formatMongoValueForDisplay(value);
return formatMongoValueForDisplay(normalizedValue);
}
if (Array.isArray(value) || isPlainMongoObject(value)) {
if (Array.isArray(normalizedValue) || isPlainMongoObject(normalizedValue)) {
try {
return JSON.stringify(value, null, 2);
return JSON.stringify(normalizedValue, null, 2);
} catch {
return String(value);
return String(normalizedValue);
}
}
return String(value);
return String(normalizedValue);
};
export const parseMongoEditedValue = (
@@ -443,7 +536,9 @@ export const parseMongoEditedValue = (
): unknown => {
if (typeof rawValue !== 'string') return rawValue;
const currentKind = resolveMongoValueKind(currentValue);
const normalizedCurrentValue = normalizeMongoFieldValueForEditing(columnName, currentValue);
const inferredRawValue = normalizeMongoFieldValueForEditing(columnName, rawValue);
const currentKind = resolveMongoValueKind(normalizedCurrentValue);
const text = rawValue.trim();
const structuredLiteral = looksLikeMongoStructuredLiteral(rawValue);
const explicitLiteral = looksLikeExplicitMongoTypedLiteral(rawValue);
@@ -501,9 +596,7 @@ export const parseMongoEditedValue = (
case 'string':
case 'nullish':
default:
if (String(columnName || '').trim() === '_id' && HEX24_RE.test(text)) {
return { $oid: text.toLowerCase() };
}
if (inferredRawValue !== rawValue) return inferredRawValue;
return rawValue;
}
};