feat(manticore): add Manticore Search support

Add Manticore Search table data editing, structure editing, metadata handling, and SQL completion support.
This commit is contained in:
Vergil Lai 2026-06-14 16:01:02 +08:00 committed by GitHub
parent 93d2e7dc18
commit f385386284
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 1250 additions and 130 deletions

View File

@ -23,7 +23,7 @@ import { copyToClipboard } from "@/lib/clipboard";
import { queryTimeoutSecsForConnection } from "@/lib/queryTimeout";
import { type EditableStructureColumn, type EditableStructureForeignKey, type EditableStructureIndex, type EditableStructureTrigger } from "@/lib/tableStructureEditorSql";
import { getTableMetadataCapabilities } from "@/lib/tableMetadataCapabilities";
import { getTableStructureCapabilities } from "@/lib/tableStructureCapabilities";
import { canAddTableStructureColumn, getTableStructureCapabilities } from "@/lib/tableStructureCapabilities";
import { connectionObjectTreeQuerySchema, tableStructureDatabaseTypeForConnection } from "@/lib/jdbcDialect";
import {
buildStructureTargetLabel,
@ -34,10 +34,14 @@ import {
createTriggerDrafts,
generateIndexName,
generateUniqueIndexName,
getColumnEditorControls,
getDataTypeOptions,
getDefaultLengthForType,
isProtectedManticoreIdColumn,
splitDataType,
toColumnNames,
applyManticoreDdlColumnExtras,
canEditManticoreColumnProperties,
} from "@/lib/tableStructureEditorState";
import * as api from "@/lib/api";
@ -250,7 +254,7 @@ function onColResize(e: MouseEvent, col: number) {
const onMove = (ev: MouseEvent) => {
if (!colResizing.value) return;
const delta = ev.clientX - colResizing.value.startX;
colWidths.value[col] = Math.max(structureDensityMetric.value.minColumnWidth, colResizing.value.startW + delta);
colWidths.value[widthIndex] = Math.max(structureDensityMetric.value.minColumnWidth, colResizing.value.startW + delta);
};
const onUp = () => {
colResizing.value = null;
@ -285,6 +289,7 @@ const tableMetadataCapabilities = computed(() => getTableMetadataCapabilities(da
const structureDialect = computed(() => structureCapabilities.value.dialect);
const isTableCommentDisabled = computed(() => !structureCapabilities.value.comment);
const dataTypeOptions = computed(() => getDataTypeOptions(databaseType.value));
const columnEditorControls = computed(() => getColumnEditorControls(databaseType.value));
const indexTypesByDb: Record<string, string[]> = {
postgres: ["BTREE", "HASH", "GIST", "SPGIST", "GIN", "BRIN"],
@ -301,21 +306,31 @@ function isPostgresIdentityType(dbType: string | undefined): boolean {
const showExtendedProperties = computed(() => {
const dt = databaseType.value;
return dt === "mysql" || isPostgresIdentityType(dt) || dt === "sqlserver";
return dt === "mysql" || dt === "manticoresearch" || isPostgresIdentityType(dt) || dt === "sqlserver";
});
const extendedPropertiesColumnIndex = 8;
const visibleColWidths = computed(() => (showExtendedProperties.value ? colWidths.value : colWidths.value.filter((_, index) => index !== extendedPropertiesColumnIndex)));
const visibleColumnIndexes = computed(() => colLabels.value.map((column) => column.widthIndex));
const visibleColWidths = computed(() => visibleColumnIndexes.value.map((index) => colWidths.value[index] ?? structureDensityMetric.value.minColumnWidth));
function columnWidthIndex(visibleIndex: number) {
return !showExtendedProperties.value && visibleIndex >= extendedPropertiesColumnIndex ? visibleIndex + 1 : visibleIndex;
return visibleColumnIndexes.value[visibleIndex] ?? visibleIndex;
}
const colLabels = computed(() => {
const labels = ["#", t("structureEditor.columnName"), t("structureEditor.dataType"), t("structureEditor.length"), t("structureEditor.nullable"), t("structureEditor.primaryKey"), t("structureEditor.defaultValue"), t("structureEditor.comment")];
const labels = [
{ key: "ordinal", label: "#", widthIndex: 0 },
{ key: "name", label: t("structureEditor.columnName"), widthIndex: 1 },
{ key: "type", label: t("structureEditor.dataType"), widthIndex: 2 },
];
if (columnEditorControls.value.length) labels.push({ key: "length", label: t("structureEditor.length"), widthIndex: 3 });
if (columnEditorControls.value.nullable) labels.push({ key: "nullable", label: t("structureEditor.nullable"), widthIndex: 4 });
if (columnEditorControls.value.primaryKey) labels.push({ key: "primaryKey", label: t("structureEditor.primaryKey"), widthIndex: 5 });
if (columnEditorControls.value.defaultValue) labels.push({ key: "defaultValue", label: t("structureEditor.defaultValue"), widthIndex: 6 });
if (columnEditorControls.value.comment) labels.push({ key: "comment", label: t("structureEditor.comment"), widthIndex: 7 });
if (showExtendedProperties.value) {
labels.push(t("structureEditor.extendedProperties"));
labels.push({ key: "extendedProperties", label: t("structureEditor.extendedProperties"), widthIndex: extendedPropertiesColumnIndex });
}
labels.push(t("structureEditor.actions"));
labels.push({ key: "actions", label: t("structureEditor.actions"), widthIndex: 9 });
return labels;
});
const indexColLabels = computed(() => [t("structureEditor.indexName"), t("structureEditor.indexColumns"), t("structureEditor.unique"), t("structureEditor.indexType"), t("structureEditor.includedColumns"), t("structureEditor.filter"), t("structureEditor.comment"), t("structureEditor.actions")]);
@ -325,11 +340,23 @@ const triggerEventOptions = ["INSERT", "UPDATE", "DELETE"];
const metadataSchema = computed(() => connectionObjectTreeQuerySchema(connection.value, props.database, props.schema));
const refreshVersion = computed(() => (props.connectionId && props.tableName ? queryStore.tableStructureRefreshVersion(props.connectionId, props.database, props.schema, props.tableName) : 0));
const isCreateMode = computed(() => !props.tableName);
const canAddColumn = computed(() => canAddTableStructureColumn(databaseType.value, isCreateMode.value));
const newTableName = ref("");
const tableComment = ref("");
const originalTableComment = ref("");
const targetLabel = computed(() => buildStructureTargetLabel(connection.value?.name, props.database, props.schema, isCreateMode.value ? undefined : props.tableName));
function isManticoreTextColumn(column: EditableStructureColumn): boolean {
if (databaseType.value !== "manticoresearch") return false;
const baseType = splitDataType(column.dataType).baseType.trim().toLowerCase();
return baseType === "text" || baseType === "string";
}
function isManticoreJsonColumn(column: EditableStructureColumn): boolean {
if (databaseType.value !== "manticoresearch") return false;
return splitDataType(column.dataType).baseType.trim().toLowerCase() === "json";
}
let sqlPreviewRequestId = 0;
let keydownListenerRegistered = false;
@ -388,7 +415,17 @@ async function loadStructure(silent = false) {
errorMessage.value = "";
try {
await store.ensureConnected(props.connectionId);
const nextColumns = await api.getColumns(props.connectionId, props.database, metadataSchema.value, props.tableName);
let nextColumns = await api.getColumns(props.connectionId, props.database, metadataSchema.value, props.tableName);
if (databaseType.value === "manticoresearch" && tableMetadataCapabilities.value.ddl) {
try {
const ddl = await api.getTableDdl(props.connectionId, props.database, metadataSchema.value, props.tableName);
ddlContent.value = ddl;
ddlFetched.value = true;
nextColumns = applyManticoreDdlColumnExtras(nextColumns, ddl);
} catch {
/* ignore — Manticore column properties can still come from SHOW COLUMNS when available */
}
}
const [nextIndexes, nextForeignKeys, nextTriggers] = await Promise.all([
tableMetadataCapabilities.value.indexes ? api.listIndexes(props.connectionId, props.database, metadataSchema.value, props.tableName).catch(() => []) : Promise.resolve([]),
tableMetadataCapabilities.value.foreignKeys ? api.listForeignKeys(props.connectionId, props.database, metadataSchema.value, props.tableName).catch(() => []) : Promise.resolve([]),
@ -414,12 +451,13 @@ async function loadStructure(silent = false) {
}
async function addColumn() {
if (!structureCapabilities.value.addColumn) return;
if (!canAddColumn.value) return;
activeTab.value = "columns";
const dataType = databaseType.value === "manticoresearch" ? combineDataTypeForDatabase(databaseType.value, dataTypeOptions.value[0] ?? "text", getDefaultLengthForType(databaseType.value, dataTypeOptions.value[0] ?? "text")) : "varchar(255)";
columns.value.push({
id: `new:${uuid()}`,
name: "",
dataType: "varchar(255)",
dataType,
isNullable: true,
defaultValue: "",
comment: "",
@ -470,6 +508,13 @@ function isColumnTypeDisabled(column: EditableStructureColumn): boolean {
return column.markedForDrop || (!!column.original && !structureCapabilities.value.alterType);
}
function isColumnLengthDisabled(column: EditableStructureColumn): boolean {
if (isColumnTypeDisabled(column)) return true;
if (databaseType.value !== "manticoresearch") return false;
const baseType = splitDataType(column.dataType).baseType.trim().toLowerCase();
return baseType !== "bit" && baseType !== "float_vector";
}
function isColumnNullableDisabled(column: EditableStructureColumn): boolean {
return column.markedForDrop || column.isPrimaryKey || (!!column.original && !structureCapabilities.value.alterNullability);
}
@ -489,7 +534,11 @@ function isPrimaryKeyDisabled(column: EditableStructureColumn): boolean {
}
function canDropColumn(column: EditableStructureColumn): boolean {
return !!column.original && !column.isPrimaryKey && structureCapabilities.value.dropColumn;
return !!column.original && !column.isPrimaryKey && !isProtectedManticoreIdColumn(databaseType.value, column.original.name) && structureCapabilities.value.dropColumn;
}
function isManticoreColumnPropertyDisabled(column: EditableStructureColumn): boolean {
return !canEditManticoreColumnProperties(databaseType.value, !!column.original) || column.markedForDrop;
}
function addIndex() {
@ -740,7 +789,7 @@ async function applyChanges() {
}
function addItemForActiveTab(): boolean {
if (activeTab.value === "columns" && structureCapabilities.value.addColumn) {
if (activeTab.value === "columns" && canAddColumn.value) {
void addColumn();
return true;
}
@ -903,7 +952,7 @@ watch(activeTab, (tab) => {
</SelectContent>
</Select>
</div>
<Button v-if="activeTab === 'columns'" size="sm" :class="structureToolbarButtonClass" :disabled="!structureCapabilities.addColumn" @click="addColumn">
<Button v-if="activeTab === 'columns'" size="sm" :class="structureToolbarButtonClass" :disabled="!canAddColumn" @click="addColumn">
<Plus :class="structureIconClass" />
{{ t("structureEditor.addColumn") }}
</Button>
@ -926,8 +975,8 @@ watch(activeTab, (tab) => {
<table class="border-separate border-spacing-0 text-[length:var(--structure-font-size)] leading-[var(--structure-line-height)]" :style="{ minWidth: visibleColWidths.reduce((a, w) => a + w, 0) + 'px' }">
<thead class="sticky top-0 z-10 bg-background">
<tr>
<th v-for="(label, i) in colLabels" :key="i" :class="[structureHeaderCellClass, { 'text-center': i === 5 }]" :style="{ width: visibleColWidths[i] + 'px', minWidth: visibleColWidths[i] + 'px' }">
{{ label }}
<th v-for="(columnLabel, i) in colLabels" :key="columnLabel.key" :class="[structureHeaderCellClass, { 'text-center': columnLabel.key === 'primaryKey' }]" :style="{ width: visibleColWidths[i] + 'px', minWidth: visibleColWidths[i] + 'px' }">
{{ columnLabel.label }}
<div v-if="i < colLabels.length - 1" class="absolute right-0 top-0 z-20 h-full w-1 cursor-col-resize hover:bg-primary/30" :class="colResizing?.col === columnWidthIndex(i) ? 'bg-primary/30' : ''" @mousedown="onColResize($event, i)" />
</th>
</tr>
@ -958,16 +1007,16 @@ watch(activeTab, (tab) => {
/>
<Input v-else :model-value="splitDataType(column.dataType).baseType" :class="[structureMonoControlClass, 'w-full']" disabled />
</td>
<td :class="structureCellClass">
<Input :model-value="splitDataType(column.dataType).params" :class="structureMonoControlClass" :disabled="isColumnTypeDisabled(column)" @update:model-value="column.dataType = combineDataTypeForDatabase(databaseType, splitDataType(column.dataType).baseType, String($event))" />
<td v-if="columnEditorControls.length" :class="structureCellClass">
<Input :model-value="splitDataType(column.dataType).params" :class="structureMonoControlClass" :disabled="isColumnLengthDisabled(column)" @update:model-value="column.dataType = combineDataTypeForDatabase(databaseType, splitDataType(column.dataType).baseType, String($event))" />
</td>
<td :class="structureCellClass">
<td v-if="columnEditorControls.nullable" :class="structureCellClass">
<label class="flex items-center gap-1.5">
<input v-model="column.isNullable" type="checkbox" :class="structureCheckboxClass" :disabled="isColumnNullableDisabled(column)" />
<span>{{ column.isNullable ? t("structureEditor.yes") : t("structureEditor.no") }}</span>
</label>
</td>
<td :class="[structureCellClass, 'text-center']">
<td v-if="columnEditorControls.primaryKey" :class="[structureCellClass, 'text-center']">
<input
v-model="column.isPrimaryKey"
type="checkbox"
@ -980,10 +1029,10 @@ watch(activeTab, (tab) => {
"
/>
</td>
<td :class="structureCellClass">
<td v-if="columnEditorControls.defaultValue" :class="structureCellClass">
<Input v-model="column.defaultValue" :class="structureMonoControlClass" :disabled="isColumnDefaultDisabled(column)" />
</td>
<td :class="structureCellClass">
<td v-if="columnEditorControls.comment" :class="structureCellClass">
<div class="flex min-w-0 items-center gap-1">
<Input v-model="column.comment" :class="[structureControlClass, 'flex-1']" :disabled="isColumnCommentDisabled(column)" />
<Popover>
@ -1013,8 +1062,31 @@ watch(activeTab, (tab) => {
</td>
<td v-if="showExtendedProperties" :class="structureCellClass">
<div class="flex items-center gap-2">
<!-- Manticore Search: character data type properties -->
<template v-if="databaseType === 'manticoresearch'">
<template v-if="isManticoreTextColumn(column)">
<label class="flex items-center gap-1 whitespace-nowrap">
<input :checked="!!column.extra.manticoreIndexed" type="checkbox" :class="structureCheckboxClass" :disabled="isManticoreColumnPropertyDisabled(column)" @change="column.extra.manticoreIndexed = ($event.target as HTMLInputElement).checked" />
indexed
</label>
<label class="flex items-center gap-1 whitespace-nowrap">
<input :checked="!!column.extra.manticoreStored" type="checkbox" :class="structureCheckboxClass" :disabled="isManticoreColumnPropertyDisabled(column)" @change="column.extra.manticoreStored = ($event.target as HTMLInputElement).checked" />
stored
</label>
<label class="flex items-center gap-1 whitespace-nowrap">
<input :checked="!!column.extra.manticoreAttribute" type="checkbox" :class="structureCheckboxClass" :disabled="isManticoreColumnPropertyDisabled(column)" @change="column.extra.manticoreAttribute = ($event.target as HTMLInputElement).checked" />
attribute
</label>
</template>
<template v-else-if="isManticoreJsonColumn(column)">
<label class="flex items-center gap-1 whitespace-nowrap">
<input :checked="!!column.extra.manticoreSecondaryIndex" type="checkbox" :class="structureCheckboxClass" :disabled="isManticoreColumnPropertyDisabled(column)" @change="column.extra.manticoreSecondaryIndex = ($event.target as HTMLInputElement).checked" />
secondary_index
</label>
</template>
</template>
<!-- MySQL: AUTO_INCREMENT + ON UPDATE CURRENT_TIMESTAMP -->
<template v-if="structureDialect === 'mysql'">
<template v-else-if="structureDialect === 'mysql'">
<label class="flex items-center gap-1 whitespace-nowrap">
<input v-model="column.extra.autoIncrement" type="checkbox" :class="structureCheckboxClass" />
{{ t("structureEditor.autoIncrement") }}

View File

@ -163,4 +163,4 @@ export function connectionOptionSubtitle(connection?: ConnectionPresentationConf
export function connectionRedactedOptionSubtitle(connection?: ConnectionPresentationConfig): string {
return [connectionDriverLabel(connection), connectionRedactedEndpointLabel(connection)].filter(Boolean).join(" · ");
}
}

View File

@ -87,4 +87,4 @@ export function supportsTableTruncate(dbType?: DatabaseType): boolean {
export function usesPostgresLikeStructureCopy(dbType?: DatabaseType): boolean {
return !!dbType && PG_LIKE_STRUCTURE_TYPES.has(dbType);
}
}

View File

@ -9,10 +9,12 @@ export interface DatabaseObjectCapabilities {
}
const TABLE_VIEW_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW"];
const TABLE_FUNCTION_OBJECTS: SidebarObjectKind[] = ["TABLE", "FUNCTION"];
const ROUTINE_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "PROCEDURE", "FUNCTION"];
const POSTGRES_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "PROCEDURE", "FUNCTION", "SEQUENCE"];
const ORACLE_OBJECTS: SidebarObjectKind[] = ["TABLE", "VIEW", "PROCEDURE", "FUNCTION", "PACKAGE", "PACKAGE_BODY"];
const TABLE_FUNCTION_TYPES = new Set<DatabaseType>(["manticoresearch"]);
const TABLE_VIEW_ONLY_TYPES = new Set<DatabaseType>(["sqlite", "rqlite", "turso", "duckdb", "clickhouse", "doris", "starrocks", "databend", "hive", "trino", "cassandra", "bigquery", "kylin", "tdengine", "iotdb", "neo4j"]);
const ORACLE_PACKAGE_TYPES = new Set<DatabaseType>(["oracle", "oceanbase-oracle"]);
@ -30,6 +32,7 @@ export function databaseObjectCapabilities(dbType?: DatabaseType): DatabaseObjec
export function sidebarObjectKindsForDatabase(dbType?: DatabaseType): SidebarObjectKind[] {
if (!dbType) return [...TABLE_VIEW_OBJECTS];
if (ORACLE_PACKAGE_TYPES.has(dbType)) return [...ORACLE_OBJECTS];
if (TABLE_FUNCTION_TYPES.has(dbType)) return [...TABLE_FUNCTION_OBJECTS];
if (TABLE_VIEW_ONLY_TYPES.has(dbType)) return [...TABLE_VIEW_OBJECTS];
if (POSTGRES_SEQUENCE_TYPES.has(dbType)) return [...POSTGRES_OBJECTS];
return [...ROUTINE_OBJECTS];
@ -44,4 +47,4 @@ export function normalizeSidebarObjectKind(type: string): SidebarObjectKind {
if (value.includes("PROC")) return "PROCEDURE";
if (value.includes("FUNC")) return "FUNCTION";
return "TABLE";
}
}

View File

@ -47,6 +47,7 @@ const DEFAULT_CAPABILITY: DatabaseCapability = {
const NAVICAT_STYLE_TABLE_DATA_TYPES = new Set<DatabaseType>([
"mysql",
"manticoresearch",
"postgres",
"sqlite",
"rqlite",
@ -103,6 +104,16 @@ const DATABASE_CAPABILITY_OVERRIDES: Partial<Record<DatabaseType, Partial<Databa
transaction: false,
},
},
manticoresearch: {
tableData: {
insert: true,
updateRequiresPrimaryKey: false,
deleteRequiresPrimaryKey: false,
keylessRowPredicate: true,
requiresTransactionalTableForExistingRows: false,
transaction: false,
},
},
neo4j: {
syntheticKey: "neo4j-element-id",
},

View File

@ -416,6 +416,8 @@ const MYSQL_SQL_KEYWORDS = [
"DATE_FORMAT",
];
const MANTICORESEARCH_SQL_KEYWORDS = ["FACET", "MATCH", "SHOW", "SHOW META", "SHOW TABLES", "CALL", "CALL PQ", "PQ", "META", "TABLES", "OPTION", "WITHIN GROUP ORDER BY"];
const SQLITE_SQL_KEYWORDS = ["AUTOINCREMENT", "INTEGER", "BLOB", "BOOLEAN", "WITHOUT ROWID", "VACUUM", "PRAGMA", "JSON_EXTRACT", "JSON_SET", "STRFTIME"];
const SQLSERVER_SQL_KEYWORDS = ["TOP", "IDENTITY", "UNIQUEIDENTIFIER", "NVARCHAR", "DATETIME2", "DATETIMEOFFSET", "BIT", "GO", "MERGE", "OUTPUT", "TRY_CAST", "TRY_CONVERT", "OPENJSON", "JSON_VALUE", "JSON_QUERY"];
@ -427,6 +429,7 @@ const DATABASE_SQL_KEYWORDS: Partial<Record<DatabaseType, string[]>> = {
rqlite: SQLITE_SQL_KEYWORDS,
turso: SQLITE_SQL_KEYWORDS,
sqlserver: SQLSERVER_SQL_KEYWORDS,
manticoresearch: MANTICORESEARCH_SQL_KEYWORDS,
};
// Keywords that appear in nearly every SQL query — boosted so frequency beats length tie-breaking.
@ -680,6 +683,39 @@ export const DEFAULT_SQL_SNIPPETS: SqlSnippet[] = [
},
];
const MANTICORESEARCH_SQL_SNIPPETS: SqlSnippet[] = [
{
id: "builtin-manticore-match",
label: "match query",
prefix: "match",
body: "MATCH('query')",
},
{
id: "builtin-manticore-facet",
label: "facet",
prefix: "facet",
body: "FACET column",
},
{
id: "builtin-manticore-show-meta",
label: "show meta",
prefix: "m",
body: "SHOW META;",
},
{
id: "builtin-manticore-show-tables",
label: "show tables",
prefix: "tab",
body: "SHOW TABLES;",
},
{
id: "builtin-manticore-call-pq",
label: "call pq",
prefix: "p",
body: "CALL PQ ('pq', ('{\"title\":\"query\"}'));",
},
];
const SQL_FUNCTION_SIGNATURES = new Map<string, string[]>([
// Aggregate
["COUNT", ["expression"]],
@ -810,6 +846,40 @@ const SQLSERVER_FUNCTION_SIGNATURES = new Map<string, string[]>([
["NEWID", []],
]);
const MANTICORESEARCH_FUNCTION_SIGNATURES = new Map<string, string[]>([
["MATCH", ["query"]],
["BM25F", ["field=weight", "...fields"]],
["EXIST", ["attribute", "default"]],
["IDF", ["keyword"]],
["PACKEDFACTORS", []],
["QUERY", []],
["REMAP", ["expression", "from_values", "to_values"]],
["SNIPPET", ["field", "query"]],
["WEIGHT", []],
["ZONESPANLIST", []],
["BIGINT", ["expression"]],
["DOUBLE", ["expression"]],
["INTEGER", ["expression"]],
["SINT", ["expression"]],
["TO_STRING", ["expression"]],
["UINT", ["expression"]],
["UINT64", ["expression"]],
["GEODIST", ["lat1", "lon1", "lat2", "lon2"]],
["CONTAINS", ["polygon", "point"]],
["POLY2D", ["...points"]],
["CRC32", ["expression"]],
["FIBONACCI", ["number"]],
["KNN_DIST", []],
["NOW", []],
["DATE_FORMAT", ["timestamp", "format"]],
["DAY", ["timestamp"]],
["MONTH", ["timestamp"]],
["YEAR", ["timestamp"]],
["HOUR", ["timestamp"]],
["MINUTE", ["timestamp"]],
["SECOND", ["timestamp"]],
]);
const DATABASE_FUNCTION_SIGNATURES: Partial<Record<DatabaseType, Map<string, string[]>>> = {
mysql: MYSQL_FUNCTION_SIGNATURES,
postgres: POSTGRES_FUNCTION_SIGNATURES,
@ -817,6 +887,7 @@ const DATABASE_FUNCTION_SIGNATURES: Partial<Record<DatabaseType, Map<string, str
rqlite: SQLITE_FUNCTION_SIGNATURES,
turso: SQLITE_FUNCTION_SIGNATURES,
sqlserver: SQLSERVER_FUNCTION_SIGNATURES,
manticoresearch: MANTICORESEARCH_FUNCTION_SIGNATURES,
};
const COMMON_SQL_FUNCTION_NAMES = new Set([
@ -1076,10 +1147,20 @@ class SqlCompletionProvider {
}
if (!context.exclusiveTableSuggestions && !context.exclusiveColumnSuggestions && !context.exclusiveRoutineSuggestions) {
this.items.push(...buildSnippetItems(context.prefix, this.input.snippets ?? DEFAULT_SQL_SNIPPETS));
const snippets = this.databaseType === "manticoresearch" ? [...(this.input.snippets ?? DEFAULT_SQL_SNIPPETS), ...MANTICORESEARCH_SQL_SNIPPETS] : (this.input.snippets ?? DEFAULT_SQL_SNIPPETS);
this.items.push(...buildSnippetItems(context.prefix, snippets));
this.items.push(...buildFunctionSnippetItems(context.prefix, getFunctionDescriptions(this.t), this.databaseType));
}
if (this.databaseType === "manticoresearch" && context.exclusiveRoutineSuggestions) {
this.items.push(
...buildSnippetItems(
context.prefix,
MANTICORESEARCH_SQL_SNIPPETS.filter((snippet) => snippet.id === "builtin-manticore-call-pq"),
),
);
}
if (context.preferredKeywords.length > 0) {
this.items.push(...buildPreferredKeywordItems(context.prefix, context.preferredKeywords));
}
@ -3265,4 +3346,4 @@ function countTopLevelCommas(text: string): number {
}
return count;
}
}

View File

@ -21,6 +21,10 @@ const capabilityByType: Partial<Record<DatabaseType, Partial<TableMetadataCapabi
foreignKeys: false,
triggers: false,
},
manticoresearch: {
foreignKeys: false,
triggers: false,
},
elasticsearch: {
indexes: false,
foreignKeys: false,
@ -36,4 +40,4 @@ const capabilityByType: Partial<Record<DatabaseType, Partial<TableMetadataCapabi
export function getTableMetadataCapabilities(dbType?: DatabaseType): TableMetadataCapabilities {
return { ...defaultCapabilities, ...(dbType ? capabilityByType[dbType] : undefined) };
}
}

View File

@ -237,6 +237,13 @@ const influxdbCapabilities = capabilities({
comment: false,
});
const manticoreSearchCapabilities = capabilities({
dialect: "mysql",
createTable: true,
addColumn: true,
dropColumn: true,
});
const capabilityByType: Partial<Record<DatabaseType, TableStructureCapabilities>> = {
mysql: mysqlCapabilities,
doris: mysqlCapabilities,
@ -271,6 +278,7 @@ const capabilityByType: Partial<Record<DatabaseType, TableStructureCapabilities>
clickhouse: clickhouseCapabilities,
informix: informixCapabilities,
influxdb: influxdbCapabilities,
manticoresearch: manticoreSearchCapabilities,
};
export function getTableStructureCapabilities(dbType?: DatabaseType): TableStructureCapabilities {
@ -281,3 +289,8 @@ export function canEditTableStructure(dbType?: DatabaseType): boolean {
const caps = getTableStructureCapabilities(dbType);
return caps.createTable || caps.addColumn || caps.alterExistingColumn || caps.createIndex || caps.dropIndex;
}
export function canAddTableStructureColumn(dbType: DatabaseType | undefined, isCreateMode: boolean): boolean {
const caps = getTableStructureCapabilities(dbType);
return isCreateMode ? caps.createTable : caps.addColumn;
}

View File

@ -10,6 +10,10 @@ export interface ColumnExtra {
autoIncrement?: boolean;
onUpdateCurrentTimestamp?: boolean;
identity?: ColumnIdentity;
manticoreIndexed?: boolean;
manticoreStored?: boolean;
manticoreAttribute?: boolean;
manticoreSecondaryIndex?: boolean;
}
export interface EditableStructureColumn {
@ -86,4 +90,4 @@ export interface BuildSingleColumnAlterSqlOptions {
schema?: string;
tableName: string;
column: EditableStructureColumn;
}
}

View File

@ -236,6 +236,7 @@ export const DATA_TYPE_OPTIONS: Record<string, string[]> = {
"MultiPolygon",
"JSON",
],
manticoresearch: ["text", "string", "int", "bit", "bigint", "bool", "timestamp", "float", "json", "float_vector", "multi", "mva"],
informix: [
"smallint",
"integer",
@ -291,6 +292,43 @@ export function getDataTypeOptions(dbType: DatabaseType | undefined): string[] {
return DATA_TYPE_OPTIONS[key] ?? [];
}
export interface ColumnEditorControls {
length: boolean;
nullable: boolean;
primaryKey: boolean;
defaultValue: boolean;
comment: boolean;
}
const DEFAULT_COLUMN_EDITOR_CONTROLS: ColumnEditorControls = {
length: true,
nullable: true,
primaryKey: true,
defaultValue: true,
comment: true,
};
export function getColumnEditorControls(dbType: DatabaseType | undefined): ColumnEditorControls {
if (dbType === "manticoresearch") {
return {
length: true,
nullable: false,
primaryKey: false,
defaultValue: false,
comment: false,
};
}
return DEFAULT_COLUMN_EDITOR_CONTROLS;
}
export function isProtectedManticoreIdColumn(dbType: DatabaseType | undefined, columnName: string): boolean {
return dbType === "manticoresearch" && columnName.trim().toLowerCase() === "id";
}
export function canEditManticoreColumnProperties(dbType: DatabaseType | undefined, hasOriginalColumn: boolean): boolean {
return dbType === "manticoresearch" && !hasOriginalColumn;
}
export const DEFAULT_TYPE_LENGTHS: Record<string, string> = {
tinyint: "4",
"tinyint unsigned": "4",
@ -363,11 +401,68 @@ export function parseExtraToColumnExtra(extra: string | null | undefined, databa
};
}
}
} else if (databaseType === "manticoresearch") {
const tokens = new Set(lower.split(/\s+/).filter(Boolean));
if (tokens.has("indexed")) result.manticoreIndexed = true;
if (tokens.has("stored")) result.manticoreStored = true;
if (tokens.has("attribute")) result.manticoreAttribute = true;
if (/secondary_index\s*=\s*['"]?1['"]?/.test(lower)) result.manticoreSecondaryIndex = true;
}
return result;
}
const MANTICORE_COLUMN_PROPERTY_TOKENS = new Set(["indexed", "stored", "attribute"]);
function splitManticoreDdlColumnLine(line: string): { name: string; dataType: string; extra: string } | null {
const trimmed = line.trim().replace(/,$/, "").trim();
if (!trimmed || trimmed.startsWith(")") || trimmed.startsWith("(")) return null;
let name = "";
let rest = "";
const quoted = trimmed.match(/^`((?:``|[^`])+)`\s+(.+)$/);
if (quoted) {
name = quoted[1]!.replace(/``/g, "`");
rest = quoted[2]!.trim();
} else {
const plain = trimmed.match(/^([A-Za-z_][\w$]*)\s+(.+)$/);
if (!plain) return null;
name = plain[1]!;
rest = plain[2]!.trim();
}
const parts = rest.split(/\s+/).filter(Boolean);
const dataType = parts.shift() ?? "";
const properties = parts.filter((part) => {
const normalized = part.toLowerCase();
return MANTICORE_COLUMN_PROPERTY_TOKENS.has(normalized) || /^secondary_index\s*=/.test(normalized);
});
if (!name || !dataType || properties.length === 0) return null;
return { name, dataType, extra: properties.join(" ") };
}
export function applyManticoreDdlColumnExtras(columns: ColumnInfo[], ddl: string): ColumnInfo[] {
if (!ddl.trim()) return columns;
const extrasByColumn = new Map<string, { dataType: string; extra: string }>();
for (const line of ddl.split(/\r?\n/)) {
const parsed = splitManticoreDdlColumnLine(line);
if (parsed) extrasByColumn.set(parsed.name.toLowerCase(), { dataType: parsed.dataType, extra: parsed.extra });
}
if (extrasByColumn.size === 0) return columns;
return columns.map((column) => {
const ddlColumn = extrasByColumn.get(column.name.toLowerCase());
if (!ddlColumn) return column;
const existingExtra = column.extra?.trim();
return {
...column,
data_type: ddlColumn.dataType || column.data_type,
extra: existingExtra ? `${existingExtra} ${ddlColumn.extra}` : ddlColumn.extra,
};
});
}
export function createColumnDrafts(columns: ColumnInfo[], databaseType?: DatabaseType): EditableStructureColumn[] {
return columns.map((column, index) => ({
id: `existing:${column.name}`,

View File

@ -95,4 +95,4 @@ export function filterDatabaseNamesForConnection(databaseNames: string[], connec
return databaseNames;
}
return databaseNames.filter((name) => !isSystemDatabaseName(connection?.db_type, name));
}
}

View File

@ -387,7 +387,7 @@
"metadataConnectionScoped": false,
"skipTcpProbe": false,
"defaultPort": 9306,
"supportLevel": "browse",
"supportLevel": "operate",
"capabilities": {
"queryExecution": true,
"metadataBrowse": true,
@ -395,8 +395,8 @@
"objectSource": false,
"schemaSearch": false,
"diagram": false,
"tableDataEdit": false,
"tableStructureEdit": false,
"tableDataEdit": true,
"tableStructureEdit": true,
"tableImport": false,
"dataTransfer": false,
"sqlFileExecution": true,

View File

@ -252,7 +252,10 @@ pub fn build_data_grid_copy_update_statements(options: DataGridCopyUpdateStateme
})
.collect::<Vec<_>>()
.join(" AND ");
statements.push(format!("UPDATE {table} SET {sets} WHERE {where_clause};"));
statements.push(data_grid_statement(
options.database_type,
format!("UPDATE {table} SET {sets} WHERE {where_clause}"),
));
}
statements
}
@ -528,7 +531,10 @@ fn build_data_grid_save_statements(options: &DataGridSaveStatementOptions) -> Ve
row,
column_info,
);
statements.push(format!("UPDATE {table} SET {sets} WHERE {where_clause};"));
statements.push(data_grid_statement(
options.database_type,
format!("UPDATE {table} SET {sets} WHERE {where_clause}"),
));
}
for row_index in &options.deleted_rows {
@ -542,7 +548,8 @@ fn build_data_grid_save_statements(options: &DataGridSaveStatementOptions) -> Ve
row,
column_info,
);
statements.push(format!("DELETE FROM {table} WHERE {where_clause};"));
statements
.push(data_grid_statement(options.database_type, format!("DELETE FROM {table} WHERE {where_clause}")));
}
for row in &options.new_rows {
@ -568,7 +575,10 @@ fn build_data_grid_save_statements(options: &DataGridSaveStatementOptions) -> Ve
})
.collect::<Vec<_>>()
.join(", ");
statements.push(format!("INSERT INTO {table} ({columns}) VALUES ({values});"));
statements.push(data_grid_statement(
options.database_type,
format!("INSERT INTO {table} ({columns}) VALUES ({values})"),
));
}
statements
@ -591,7 +601,8 @@ fn build_data_grid_rollback_statements(options: &DataGridSaveStatementOptions) -
for row in &options.new_rows {
let where_clause = build_row_where(options.database_type, &save_columns, row, column_info);
if !where_clause.is_empty() {
statements.push(format!("DELETE FROM {table} WHERE {where_clause};"));
statements
.push(data_grid_statement(options.database_type, format!("DELETE FROM {table} WHERE {where_clause}")));
}
}
@ -617,7 +628,10 @@ fn build_data_grid_rollback_statements(options: &DataGridSaveStatementOptions) -
})
.collect::<Vec<_>>()
.join(", ");
statements.push(format!("INSERT INTO {table} ({columns}) VALUES ({values});"));
statements.push(data_grid_statement(
options.database_type,
format!("INSERT INTO {table} ({columns}) VALUES ({values})"),
));
}
for (row_index, changes) in &options.dirty_rows {
@ -668,9 +682,12 @@ fn build_data_grid_rollback_statements(options: &DataGridSaveStatementOptions) -
predicates.extend(writable_changes.iter().map(|((_, value), column)| {
build_column_predicate(options.database_type, column, value, column_info_for(column_info, column))
}));
statements.push(format!(
"UPDATE {table} SET {sets} WHERE {};",
predicates.into_iter().filter(|part| !part.is_empty()).collect::<Vec<_>>().join(" AND ")
statements.push(data_grid_statement(
options.database_type,
format!(
"UPDATE {table} SET {sets} WHERE {}",
predicates.into_iter().filter(|part| !part.is_empty()).collect::<Vec<_>>().join(" AND ")
),
));
}
@ -749,6 +766,11 @@ pub fn format_grid_sql_literal(
return format_pg_array_sql_literal(arr);
}
let text = value.as_str().map_or_else(|| value.to_string(), ToString::to_string);
if database_type == Some(DatabaseType::ManticoreSearch) {
if let Some(typed_value) = manticore_typed_attribute_value(&text, column_info) {
return format_grid_sql_literal(&typed_value, database_type, column_info);
}
}
if text.is_empty() {
return if database_type == Some(DatabaseType::SqlServer) { "N''" } else { "''" }.to_string();
}
@ -779,6 +801,20 @@ fn is_bit_column_type(data_type: &str) -> bool {
lower.split(|ch: char| !ch.is_ascii_alphanumeric()).any(|token| token == "bit")
}
fn manticore_typed_attribute_value(text: &str, column_info: Option<&DataGridColumnInfo>) -> Option<Value> {
let data_type = column_info?.data_type.to_ascii_lowercase();
if is_boolean_type(&data_type) && text.eq_ignore_ascii_case("true") {
return Some(Value::Bool(true));
}
if is_boolean_type(&data_type) && text.eq_ignore_ascii_case("false") {
return Some(Value::Bool(false));
}
if is_numeric_type(&data_type) && is_numeric_literal(text) {
return text.parse::<serde_json::Number>().ok().map(Value::Number);
}
None
}
fn format_mysql_bit_literal_text(text: &str) -> Option<String> {
let trimmed = text.trim();
if trimmed.eq_ignore_ascii_case("true") {
@ -1032,6 +1068,14 @@ fn build_column_predicate(
}
}
fn data_grid_statement(database_type: Option<DatabaseType>, sql: String) -> String {
if database_type == Some(DatabaseType::ManticoreSearch) {
sql
} else {
format!("{sql};")
}
}
fn uses_mysql_binary_text_predicate(
database_type: Option<DatabaseType>,
value: &Value,
@ -1250,6 +1294,7 @@ fn uses_keyless_row_predicate(database_type: Option<DatabaseType>) -> bool {
database_type,
Some(
DatabaseType::Mysql
| DatabaseType::ManticoreSearch
| DatabaseType::Postgres
| DatabaseType::Sqlite
| DatabaseType::DuckDb
@ -1568,6 +1613,35 @@ mod tests {
);
}
#[test]
fn prepares_manticore_save_statements_without_trailing_semicolons() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {
database_type: Some(DatabaseType::ManticoreSearch),
table_meta: DataGridTableMeta {
schema: None,
table_name: "rt_products".to_string(),
primary_keys: vec![],
columns: Some(vec![column("id", "bigint", false, None), column("title", "text", true, None)]),
},
columns: vec!["id".to_string(), "title".to_string()],
source_columns: None,
rows: vec![vec![json!("1"), json!("old")], vec![json!("2"), json!("deleted")]],
dirty_rows: vec![(0, vec![(1, json!("new"))])],
deleted_rows: vec![1],
new_rows: vec![vec![json!("3"), json!("inserted")]],
});
assert_eq!(
result.statements,
vec![
"UPDATE `rt_products` SET `title` = 'new' WHERE `id` = 1 AND `title` = 'old'",
"DELETE FROM `rt_products` WHERE `id` = 2 AND `title` = 'deleted'",
"INSERT INTO `rt_products` (`id`, `title`) VALUES (3, 'inserted')",
]
);
assert!(result.rollback_statements.iter().all(|statement| !statement.ends_with(';')));
}
#[test]
fn validates_duplicate_inserted_primary_keys() {
let result = prepare_data_grid_save(DataGridSaveStatementOptions {

View File

@ -0,0 +1,235 @@
use mysql_async::prelude::*;
use std::collections::HashMap;
use crate::types::{ColumnInfo, IndexInfo, ObjectInfo};
use super::mysql::{self, MySqlPool};
fn quote_identifier(value: &str) -> String {
format!("`{}`", value.replace('`', "``"))
}
fn row_get<T, I>(row: &mysql_async::Row, index: I) -> Option<T>
where
T: mysql_async::prelude::FromValue,
I: mysql_async::prelude::ColumnIndex,
{
row.get_opt::<T, I>(index).and_then(|result| result.ok())
}
fn get_str_by_name(row: &mysql_async::Row, name: &str) -> String {
row_get::<String, _>(row, name)
.or_else(|| row_get::<Vec<u8>, _>(row, name).map(|bytes| String::from_utf8_lossy(&bytes).to_string()))
.unwrap_or_default()
}
fn get_opt_str(row: &mysql_async::Row, name: &str) -> Option<String> {
row_get::<String, _>(row, name)
.or_else(|| row_get::<Vec<u8>, _>(row, name).map(|bytes| String::from_utf8_lossy(&bytes).to_string()))
}
fn preferred_metadata(primary: Option<String>, fallback: Option<String>) -> Option<String> {
primary.filter(|value| !value.trim().is_empty()).or_else(|| fallback.filter(|value| !value.trim().is_empty()))
}
pub async fn list_objects(pool: &MySqlPool, database: &str) -> Result<Vec<ObjectInfo>, String> {
let (tables, functions) = tokio::join!(mysql::list_tables_show(pool, database), list_udf_objects(pool, database));
let mut objects: Vec<ObjectInfo> = tables?
.into_iter()
.map(|table| ObjectInfo {
name: table.name,
object_type: "TABLE".to_string(),
schema: Some(database.to_string()),
comment: table.comment,
created_at: None,
updated_at: None,
parent_schema: table.parent_schema,
parent_name: table.parent_name,
})
.collect();
match functions {
Ok(functions) => objects.extend(functions),
Err(err) => log::warn!("Skipping UDFs for Manticore Search database `{}` in object browser: {}", database, err),
}
Ok(objects)
}
async fn list_udf_objects(pool: &MySqlPool, database: &str) -> Result<Vec<ObjectInfo>, String> {
let mut conn = pool.get_conn().await.map_err(|err| err.to_string())?;
let result = conn.query_iter("SHOW PLUGINS").await.map_err(|err| err.to_string())?;
let rows: Vec<mysql_async::Row> = result.collect_and_drop().await.map_err(|err| err.to_string())?;
Ok(rows
.iter()
.filter_map(|row| {
plugin_object(
&get_str_by_name(row, "Type"),
&get_str_by_name(row, "Name"),
get_opt_str(row, "Library").as_deref(),
get_opt_str(row, "Extra").as_deref(),
database,
)
})
.collect())
}
fn plugin_object(
plugin_type: &str,
name: &str,
library: Option<&str>,
extra: Option<&str>,
database: &str,
) -> Option<ObjectInfo> {
if !plugin_type.eq_ignore_ascii_case("udf") {
return None;
}
let name = name.trim();
if name.is_empty() {
return None;
}
let mut comment_parts = Vec::new();
if let Some(library) = library.map(str::trim).filter(|value| !value.is_empty()) {
comment_parts.push(format!("SONAME {library}"));
}
if let Some(return_type) = extra.map(str::trim).filter(|value| !value.is_empty()) {
comment_parts.push(format!("RETURNS {return_type}"));
}
Some(ObjectInfo {
name: name.to_string(),
object_type: "FUNCTION".to_string(),
schema: Some(database.to_string()),
comment: if comment_parts.is_empty() { None } else { Some(comment_parts.join(", ")) },
created_at: None,
updated_at: None,
parent_schema: None,
parent_name: None,
})
}
pub async fn get_columns(pool: &MySqlPool, database: &str, table: &str) -> Result<Vec<ColumnInfo>, String> {
let mut columns = mysql::get_columns_show(pool, database, table).await?;
let properties = column_properties(pool, database, table).await.unwrap_or_default();
for column in &mut columns {
if let Some(property) = properties.get(&column.name.to_lowercase()) {
column.extra = preferred_metadata(column.extra.take(), Some(property.clone()));
}
}
Ok(columns)
}
async fn column_properties(pool: &MySqlPool, database: &str, table: &str) -> Result<HashMap<String, String>, String> {
let sql = show_columns_sql(database, table, true);
let mut conn = pool.get_conn().await.map_err(|err| err.to_string())?;
let rows: Vec<mysql_async::Row> = match conn.query_iter(&sql).await {
Ok(result) => result.collect_and_drop().await.map_err(|err| err.to_string())?,
Err(_) => {
let sql = show_columns_sql(database, table, false);
let result = conn.query_iter(&sql).await.map_err(|err| err.to_string())?;
result.collect_and_drop().await.map_err(|err| err.to_string())?
}
};
Ok(rows
.iter()
.filter_map(|row| {
let name = get_str_by_name(row, "Field").trim().to_string();
if name.is_empty() {
return None;
}
get_opt_str(row, "Properties")
.filter(|value| !value.trim().is_empty())
.map(|properties| (name.to_lowercase(), properties))
})
.collect())
}
fn show_columns_sql(database: &str, table: &str, full: bool) -> String {
let prefix = if full { "SHOW FULL COLUMNS FROM" } else { "SHOW COLUMNS FROM" };
if database.trim().is_empty() {
format!("{prefix} {}", quote_identifier(table))
} else {
format!("{prefix} {}.{}", quote_identifier(database), quote_identifier(table))
}
}
pub async fn list_indexes(pool: &MySqlPool, table: &str) -> Result<Vec<IndexInfo>, String> {
let sql = list_indexes_sql(table);
let mut conn = pool.get_conn().await.map_err(|err| err.to_string())?;
let result = conn.query_iter(&sql).await.map_err(|err| err.to_string())?;
let rows: Vec<mysql_async::Row> = result.collect_and_drop().await.map_err(|err| err.to_string())?;
Ok(rows.iter().map(index_info_from_row).collect())
}
fn list_indexes_sql(table: &str) -> String {
format!("SHOW TABLE INDEXES FROM {}", quote_identifier(table))
}
fn index_info_from_row(row: &mysql_async::Row) -> IndexInfo {
let name = get_str_by_name(row, "Name");
let enabled = get_str_by_name(row, "Enabled");
let percent = get_str_by_name(row, "Percent");
let comment_parts: Vec<String> = [
(!enabled.is_empty()).then(|| format!("Enabled: {enabled}")),
(!percent.is_empty()).then(|| format!("Percent: {percent}")),
]
.into_iter()
.flatten()
.collect();
IndexInfo {
name: name.clone(),
columns: vec![name],
is_unique: false,
is_primary: false,
filter: None,
index_type: Some(get_str_by_name(row, "Type")),
included_columns: None,
comment: (!comment_parts.is_empty()).then(|| comment_parts.join(", ")),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn show_metadata_uses_unqualified_table_names_when_database_is_empty() {
assert_eq!(show_columns_sql("", "idx", true), "SHOW FULL COLUMNS FROM `idx`");
}
#[test]
fn column_extra_falls_back_to_properties() {
assert_eq!(
preferred_metadata(Some("auto_increment".to_string()), Some("indexed attribute".to_string())),
Some("auto_increment".to_string())
);
assert_eq!(
preferred_metadata(Some(" ".to_string()), Some("indexed attribute".to_string())),
Some("indexed attribute".to_string())
);
assert_eq!(preferred_metadata(None, Some(" ".to_string())), None);
}
#[test]
fn indexes_sql_uses_show_table_indexes() {
assert_eq!(list_indexes_sql("materials"), "SHOW TABLE INDEXES FROM `materials`");
assert_eq!(list_indexes_sql("odd`name"), "SHOW TABLE INDEXES FROM `odd``name`");
}
#[test]
fn udf_plugin_maps_to_function_object() {
let object = plugin_object("udf", "sequence", Some("udfexample.dll"), Some("INT"), "app").expect("udf plugin");
assert_eq!(object.name, "sequence");
assert_eq!(object.object_type, "FUNCTION");
assert_eq!(object.schema.as_deref(), Some("app"));
assert_eq!(object.comment.as_deref(), Some("SONAME udfexample.dll, RETURNS INT"));
}
#[test]
fn non_udf_plugins_are_not_sidebar_functions() {
assert!(plugin_object("ranker", "bm25", Some("ranker.so"), None, "app").is_none());
assert!(plugin_object("udf", " ", Some("udf.so"), Some("INT"), "app").is_none());
}
}

View File

@ -5,6 +5,7 @@ pub mod elasticsearch_driver;
pub mod elasticsearch_sql;
pub mod file_validator;
pub mod influxdb_driver;
pub mod manticoresearch;
pub mod mongo_driver;
pub mod mysql;
pub mod ob_oracle;

View File

@ -698,6 +698,20 @@ mod tests {
assert_eq!(filtered.into_iter().map(|database| database.name).collect::<Vec<_>>(), vec!["Manticore"]);
}
#[test]
fn manticoresearch_show_metadata_uses_unqualified_table_names() {
let config = test_connection_config(DatabaseType::ManticoreSearch);
assert_eq!(super::mysql_show_metadata_database_for_config(Some(&config), "Manticore"), "");
}
#[test]
fn doris_show_metadata_keeps_database_qualifier() {
let config = test_connection_config(DatabaseType::Doris);
assert_eq!(super::mysql_show_metadata_database_for_config(Some(&config), "analytics"), "analytics");
}
#[test]
fn doris_database_list_keeps_system_databases() {
let databases = vec![test_database_info("information_schema"), test_database_info("analytics")];
@ -922,6 +936,8 @@ async fn list_objects_once(
// Note: mysql and ob_oracle take different second args (database vs schema)
if *mode == MysqlMode::OceanBaseOracle {
db::ob_oracle::list_objects(p, schema).await
} else if db_config.as_ref().is_some_and(is_manticoresearch_config) {
db::manticoresearch::list_objects(p, database).await
} else if db_config.as_ref().is_some_and(is_doris_family_config) {
db::mysql::list_table_objects_show(p, database).await
} else {
@ -1215,8 +1231,13 @@ pub async fn get_columns_core(
let pool = connections.get(&pool_key).ok_or("Pool not found")?;
match pool {
PoolKind::Mysql(p, _) if db_config.as_ref().is_some_and(is_manticoresearch_config) => {
let metadata_database = mysql_show_metadata_database_for_config(db_config.as_ref(), database);
db::manticoresearch::get_columns(p, metadata_database, table).await.map(deduplicate_column_infos)
}
PoolKind::Mysql(p, _) if db_config.as_ref().is_some_and(is_doris_family_config) => {
db::mysql::get_columns_show(p, database, table).await.map(deduplicate_column_infos)
let metadata_database = mysql_show_metadata_database_for_config(db_config.as_ref(), database);
db::mysql::get_columns_show(p, metadata_database, table).await.map(deduplicate_column_infos)
}
PoolKind::Mysql(p, mode) => {
dispatch_mysql!(p, mode, db::mysql::get_columns, db::ob_oracle::get_columns, database, table)
@ -1282,6 +1303,7 @@ pub async fn list_indexes_core(
table: &str,
) -> Result<Vec<db::IndexInfo>, String> {
let pool_key = state.get_or_create_pool(connection_id, Some(database)).await?;
let db_config = connection_config(state, connection_id).await;
{
let connections = state.connections.read().await;
@ -1294,6 +1316,9 @@ pub async fn list_indexes_core(
match pool {
PoolKind::Mysql(p, mode) => {
if db_config.as_ref().is_some_and(is_manticoresearch_config) {
return db::manticoresearch::list_indexes(p, table).await;
}
dispatch_mysql!(p, mode, db::mysql::list_indexes, db::ob_oracle::list_indexes, schema, table)
}
PoolKind::Postgres(p) => db::postgres::list_indexes(p, schema, table).await,
@ -1514,6 +1539,14 @@ fn is_manticoresearch_config(config: &ConnectionConfig) -> bool {
|| matches!(config.driver_profile.as_deref(), Some("manticoresearch"))
}
fn mysql_show_metadata_database_for_config<'a>(config: Option<&ConnectionConfig>, database: &'a str) -> &'a str {
if config.is_some_and(is_manticoresearch_config) {
""
} else {
database
}
}
fn filter_mysql_system_databases_for_config(
databases: Vec<db::DatabaseInfo>,
config: Option<&ConnectionConfig>,

View File

@ -69,6 +69,9 @@ pub(in crate::schema) async fn list_objects(
PoolKind::Mysql(p, mode) if *mode == MysqlMode::OceanBaseOracle => {
db::ob_oracle::list_objects(p, schema).await.map(Some)
}
PoolKind::Mysql(p, _) if config.is_some_and(is_manticoresearch_config) => {
db::manticoresearch::list_objects(p, database).await.map(Some)
}
PoolKind::Mysql(p, _) if config.is_some_and(is_doris_family_config) => {
db::mysql::list_table_objects_show(p, database).await.map(Some)
}
@ -103,8 +106,13 @@ pub(in crate::schema) async fn get_columns(
table: &str,
) -> Result<Vec<db::ColumnInfo>, String> {
match pool {
PoolKind::Mysql(p, _) if config.is_some_and(is_manticoresearch_config) => {
let metadata_database = mysql_show_metadata_database_for_config(config, database);
db::manticoresearch::get_columns(p, metadata_database, table).await
}
PoolKind::Mysql(p, _) if config.is_some_and(is_doris_family_config) => {
db::mysql::get_columns_show(p, database, table).await
let metadata_database = mysql_show_metadata_database_for_config(config, database);
db::mysql::get_columns_show(p, metadata_database, table).await
}
PoolKind::Mysql(p, mode) if *mode == MysqlMode::OceanBaseOracle => {
db::ob_oracle::get_columns(p, database, table).await
@ -273,6 +281,17 @@ fn is_manticoresearch_config(config: &ConnectionConfig) -> bool {
|| matches!(config.driver_profile.as_deref(), Some("manticoresearch"))
}
fn mysql_show_metadata_database_for_config<'a>(
config: Option<&ConnectionConfig>,
database: &'a str,
) -> &'a str {
if config.is_some_and(is_manticoresearch_config) {
""
} else {
database
}
}
fn filter_mysql_system_databases_for_config(
databases: Vec<db::DatabaseInfo>,
config: Option<&ConnectionConfig>,

View File

@ -4,8 +4,8 @@ use super::comments::build_sqlserver_column_comment_sql;
use super::dialect::{capabilities_for, database_label, StructureDialect};
use super::types::{EditableStructureColumn, SingleColumnAlterSqlOptions, TableStructureSqlResult};
use super::util::{
clean, format_default_for_sql, normalize_default, original_comment, original_default, qualified_table, quote_ident,
quote_string,
clean, format_default_for_sql, is_protected_manticore_id_column, normalize_default, original_comment,
original_default, qualified_table, quote_ident, quote_string,
};
use crate::table_structure_sql::ColumnExtra;
@ -30,6 +30,10 @@ pub fn build_single_column_alter_sql(options: SingleColumnAlterSqlOptions) -> Ta
warnings.push(format!("Primary key column \"{}\" cannot be dropped from this editor.", original.name));
return TableStructureSqlResult { statements, warnings };
}
if is_protected_manticore_id_column(dialect, &original.name) {
warnings.push("Manticore Search id column cannot be dropped from this editor.".to_string());
return TableStructureSqlResult { statements, warnings };
}
statements.push(build_drop_column_sql(dialect, &table, &original.name));
return TableStructureSqlResult { statements, warnings };
}
@ -95,6 +99,22 @@ fn is_column_extra_empty(extra: &ColumnExtra) -> bool {
!extra.auto_increment.unwrap_or(false)
&& !extra.on_update_current_timestamp.unwrap_or(false)
&& extra.identity.is_none()
&& !extra.manticore_indexed.unwrap_or(false)
&& !extra.manticore_stored.unwrap_or(false)
&& !extra.manticore_attribute.unwrap_or(false)
&& !extra.manticore_secondary_index.unwrap_or(false)
}
fn original_manticore_extra_flags(extra: &str) -> (bool, bool, bool, bool) {
let lower = extra.to_lowercase();
(
lower.split_whitespace().any(|token| token == "indexed"),
lower.split_whitespace().any(|token| token == "stored"),
lower.split_whitespace().any(|token| token == "attribute"),
lower.contains("secondary_index='1'")
|| lower.contains("secondary_index=\"1\"")
|| lower.contains("secondary_index=1"),
)
}
pub(super) fn has_column_extra_change(column: &EditableStructureColumn) -> bool {
@ -103,8 +123,18 @@ pub(super) fn has_column_extra_change(column: &EditableStructureColumn) -> bool
match (current_extra, original.extra.as_deref()) {
// Neither has extra → no change
(None, None | Some("")) => false,
// Current extra is empty (all None) → no effective extra
(Some(curr), _) if is_column_extra_empty(curr) => false,
// Current extra is empty (all None) → changed only if the original had effective extra
(Some(curr), None | Some("")) if is_column_extra_empty(curr) => false,
(Some(curr), Some(orig)) if is_column_extra_empty(curr) => {
let (indexed, stored, attribute, secondary_index) = original_manticore_extra_flags(orig);
let orig_lower = orig.to_lowercase();
orig_lower.contains("auto_increment")
|| orig_lower.contains("on update")
|| indexed
|| stored
|| attribute
|| secondary_index
}
// Extra added or removed
(Some(_), None | Some("")) => true,
(None, Some(_)) => true,
@ -116,8 +146,18 @@ pub(super) fn has_column_extra_change(column: &EditableStructureColumn) -> bool
let curr_has_on_update = curr.on_update_current_timestamp.unwrap_or(false);
let orig_has_on_update = orig_lower.contains("on update");
let curr_has_identity = curr.identity.is_some();
let curr_manticore = (
curr.manticore_indexed.unwrap_or(false),
curr.manticore_stored.unwrap_or(false),
curr.manticore_attribute.unwrap_or(false),
curr.manticore_secondary_index.unwrap_or(false),
);
let orig_manticore = original_manticore_extra_flags(orig);
// identity is harder to detect in free-form original.extra, so treat it as changed if present
curr_has_ai != orig_has_ai || curr_has_on_update != orig_has_on_update || curr_has_identity
curr_has_ai != orig_has_ai
|| curr_has_on_update != orig_has_on_update
|| curr_has_identity
|| curr_manticore != orig_manticore
}
}
}

View File

@ -71,9 +71,44 @@ pub(super) fn column_data_type(dialect: StructureDialect, column: &EditableStruc
if dialect == StructureDialect::ClickHouse {
return clickhouse_column_type(column);
}
if dialect == StructureDialect::ManticoreSearch {
return manticore_column_type(column);
}
normalize_column_data_type(dialect, &column.data_type)
}
fn manticore_column_type(column: &EditableStructureColumn) -> String {
let data_type = normalize_column_data_type(StructureDialect::ManticoreSearch, &column.data_type);
let normalized = data_type.trim().to_ascii_lowercase();
if normalized == "json" {
let Some(extra) = column.extra.as_ref() else {
return data_type;
};
if extra.manticore_secondary_index.unwrap_or(false) {
return format!("{data_type} secondary_index='1'");
}
return data_type;
}
if !matches!(normalized.as_str(), "text" | "string") {
return data_type;
}
let Some(extra) = column.extra.as_ref() else {
return data_type;
};
let mut parts = vec![data_type];
if extra.manticore_stored.unwrap_or(false) {
parts.push("stored".to_string());
}
if extra.manticore_attribute.unwrap_or(false) {
parts.push("attribute".to_string());
}
if extra.manticore_indexed.unwrap_or(false) {
parts.push("indexed".to_string());
}
parts.join(" ")
}
pub(super) fn normalize_column_data_type(dialect: StructureDialect, data_type: &str) -> String {
let trimmed = data_type.trim();
let Some(open_index) = trimmed.find('(') else {

View File

@ -9,7 +9,8 @@ use super::comments::build_sqlserver_column_comment_sql;
use super::dialect::{capabilities_for, database_label, StructureDialect};
use super::types::{EditableStructureColumn, TableStructureSqlOptions};
use super::util::{
clean, normalize_default, original_comment, original_default, qualified_table, quote_ident, quote_string,
clean, is_protected_manticore_id_column, normalize_default, original_comment, original_default, qualified_table,
quote_ident, quote_string,
};
pub(super) fn build_column_sql(options: &TableStructureSqlOptions, warnings: &mut Vec<String>) -> Vec<String> {
@ -34,6 +35,10 @@ pub(super) fn build_column_sql(options: &TableStructureSqlOptions, warnings: &mu
warnings.push(format!("Primary key column \"{}\" cannot be dropped from this editor.", original.name));
continue;
}
if is_protected_manticore_id_column(dialect, &original.name) {
warnings.push("Manticore Search id column cannot be dropped from this editor.".to_string());
continue;
}
statements.push(build_drop_column_sql(dialect, &table, &original.name));
continue;
}

View File

@ -31,14 +31,17 @@ pub fn build_create_table_sql(options: TableStructureSqlOptions) -> TableStructu
for column in &active_columns {
let data_type = column_data_type(dialect, column);
let mut parts = vec![quote_ident(dialect, &column.name), data_type];
if !column.is_nullable && !column.is_primary_key && dialect != StructureDialect::ClickHouse {
if !column.is_nullable
&& !column.is_primary_key
&& !matches!(dialect, StructureDialect::ClickHouse | StructureDialect::ManticoreSearch)
{
parts.push("NOT NULL".to_string());
}
if let Some(extra_clause) = column_extra_clause(dialect, column) {
parts.push(extra_clause);
}
let default_value = normalize_default(Some(&column.default_value));
if !default_value.is_empty() {
if !default_value.is_empty() && dialect != StructureDialect::ManticoreSearch {
parts.push(format!("DEFAULT {}", format_default_for_sql(dialect, &column.data_type, &default_value)));
}
if let Some(on_update) = column.extra.as_ref().and_then(|e| e.on_update_current_timestamp).filter(|v| *v) {
@ -52,7 +55,10 @@ pub fn build_create_table_sql(options: TableStructureSqlOptions) -> TableStructu
column_definitions.push(parts.join(" "));
}
let pk_columns: Vec<_> = active_columns.iter().filter(|column| column.is_primary_key).collect();
let pk_columns: Vec<_> = active_columns
.iter()
.filter(|column| column.is_primary_key && dialect != StructureDialect::ManticoreSearch)
.collect();
if !pk_columns.is_empty() {
let pk_list = pk_columns.iter().map(|column| quote_ident(dialect, &column.name)).collect::<Vec<_>>().join(", ");
column_definitions.push(format!("PRIMARY KEY ({pk_list})"));

View File

@ -11,6 +11,7 @@ pub(super) enum StructureDialect {
Oracle,
H2,
ClickHouse,
ManticoreSearch,
Informix,
Unsupported,
}
@ -204,6 +205,12 @@ pub(super) fn capabilities_for(database_type: Option<DatabaseType>) -> TableStru
comment: true,
..base
},
Some(DatabaseType::ManticoreSearch) => TableStructureCapabilities {
dialect: StructureDialect::ManticoreSearch,
add_column: true,
drop_column: true,
..base
},
Some(DatabaseType::Informix) => TableStructureCapabilities {
dialect: StructureDialect::Informix,
add_column: true,
@ -245,6 +252,7 @@ pub(super) fn dialect_label(dialect: StructureDialect) -> String {
StructureDialect::Oracle => "oracle",
StructureDialect::H2 => "h2",
StructureDialect::ClickHouse => "clickhouse",
StructureDialect::ManticoreSearch => "manticoresearch",
StructureDialect::Informix => "informix",
StructureDialect::Unsupported => "this database",
}
@ -262,6 +270,7 @@ pub(super) fn database_type_for_dialect(dialect: StructureDialect) -> Option<Dat
StructureDialect::Oracle => Some(DatabaseType::Oracle),
StructureDialect::H2 => Some(DatabaseType::H2),
StructureDialect::ClickHouse => Some(DatabaseType::ClickHouse),
StructureDialect::ManticoreSearch => Some(DatabaseType::ManticoreSearch),
StructureDialect::Informix => Some(DatabaseType::Informix),
StructureDialect::Unsupported => None,
}

View File

@ -215,6 +215,76 @@ fn mysql_create_index_with_comment() {
);
}
#[test]
fn manticoresearch_builds_create_table_sql_only() {
let mut title = column("title");
title.data_type = "text".to_string();
title.is_nullable = false;
let mut views = column("views");
views.data_type = "int".to_string();
let result = build_create_table_sql(TableStructureSqlOptions {
database_type: Some(DatabaseType::ManticoreSearch),
schema: None,
table_name: "materials".to_string(),
columns: vec![title, views],
indexes: Vec::new(),
foreign_keys: Vec::new(),
triggers: Vec::new(),
table_comment: None,
original_table_comment: None,
});
assert_eq!(result.warnings, Vec::<String>::new());
assert_eq!(result.statements, vec!["CREATE TABLE `materials` (\n `title` text,\n `views` int\n);"]);
}
#[test]
fn manticoresearch_builds_add_and_drop_column_sql() {
let mut old_code = column("code");
old_code.data_type = "string".to_string();
old_code.marked_for_drop = true;
old_code.original = Some(ColumnInfo {
name: "code".to_string(),
data_type: "string".to_string(),
is_nullable: true,
column_default: None,
is_primary_key: false,
extra: None,
comment: None,
});
let mut name = column("name");
name.data_type = "string".to_string();
name.extra =
Some(ColumnExtra { manticore_attribute: Some(true), manticore_indexed: Some(true), ..Default::default() });
let mut resource = column("resource");
resource.data_type = "json".to_string();
resource.extra = Some(ColumnExtra { manticore_secondary_index: Some(true), ..Default::default() });
let result = build_table_structure_change_sql(TableStructureSqlOptions {
database_type: Some(DatabaseType::ManticoreSearch),
schema: None,
table_name: "materials".to_string(),
columns: vec![old_code, name, resource],
indexes: Vec::new(),
foreign_keys: Vec::new(),
triggers: Vec::new(),
table_comment: None,
original_table_comment: None,
});
assert_eq!(result.warnings, Vec::<String>::new());
assert_eq!(
result.statements,
vec![
"ALTER TABLE `materials` DROP COLUMN `code`;",
"ALTER TABLE `materials` ADD COLUMN `name` string attribute indexed;",
"ALTER TABLE `materials` ADD COLUMN `resource` json secondary_index='1';",
]
);
}
#[test]
fn gbase8a_uses_limited_mysql_ddl() {
let mut renamed = column("display_email");
@ -278,6 +348,193 @@ fn gbase8a_uses_limited_mysql_ddl() {
);
}
#[test]
fn manticoresearch_does_not_drop_id_column() {
let mut id = column("id");
id.data_type = "bigint".to_string();
id.marked_for_drop = true;
id.original = Some(ColumnInfo {
name: "id".to_string(),
data_type: "bigint".to_string(),
is_nullable: false,
column_default: None,
is_primary_key: false,
extra: None,
comment: None,
});
let result = build_table_structure_change_sql(TableStructureSqlOptions {
database_type: Some(DatabaseType::ManticoreSearch),
schema: None,
table_name: "materials".to_string(),
columns: vec![id],
indexes: Vec::new(),
foreign_keys: Vec::new(),
triggers: Vec::new(),
table_comment: None,
original_table_comment: None,
});
assert_eq!(result.statements, Vec::<String>::new());
assert_eq!(result.warnings, vec!["Manticore Search id column cannot be dropped from this editor."]);
}
#[test]
fn manticoresearch_warns_when_existing_column_properties_change() {
let mut name = column("name");
name.data_type = "string".to_string();
name.extra = Some(ColumnExtra {
manticore_indexed: Some(true),
manticore_stored: Some(true),
manticore_attribute: Some(true),
..Default::default()
});
name.original = Some(ColumnInfo {
name: "name".to_string(),
data_type: "string".to_string(),
is_nullable: true,
column_default: None,
is_primary_key: false,
extra: None,
comment: None,
});
let mut resource = column("resource");
resource.data_type = "json".to_string();
resource.extra = Some(ColumnExtra { manticore_secondary_index: Some(true), ..Default::default() });
resource.original = Some(ColumnInfo {
name: "resource".to_string(),
data_type: "json".to_string(),
is_nullable: true,
column_default: None,
is_primary_key: false,
extra: None,
comment: None,
});
let mut old_resource = column("old_resource");
old_resource.data_type = "json".to_string();
old_resource.extra = Some(ColumnExtra::default());
old_resource.original = Some(ColumnInfo {
name: "old_resource".to_string(),
data_type: "json".to_string(),
is_nullable: true,
column_default: None,
is_primary_key: false,
extra: Some("secondary_index='1'".to_string()),
comment: None,
});
let result = build_table_structure_change_sql(TableStructureSqlOptions {
database_type: Some(DatabaseType::ManticoreSearch),
schema: None,
table_name: "materials".to_string(),
columns: vec![name, resource, old_resource],
indexes: Vec::new(),
foreign_keys: Vec::new(),
triggers: Vec::new(),
table_comment: None,
original_table_comment: None,
});
assert_eq!(result.statements, Vec::<String>::new());
assert_eq!(
result.warnings,
vec![
"Editing existing columns is not supported for manticoresearch yet.",
"Editing existing columns is not supported for manticoresearch yet.",
"Editing existing columns is not supported for manticoresearch yet.",
]
);
}
#[test]
fn manticoresearch_ignores_mysql_column_options() {
let mut title = column("title");
title.data_type = "text".to_string();
title.is_nullable = false;
title.is_primary_key = true;
title.default_value = "'untitled'".to_string();
title.comment = "Title text".to_string();
let result = build_create_table_sql(TableStructureSqlOptions {
database_type: Some(DatabaseType::ManticoreSearch),
schema: None,
table_name: "materials".to_string(),
columns: vec![title],
indexes: Vec::new(),
foreign_keys: Vec::new(),
triggers: Vec::new(),
table_comment: None,
original_table_comment: None,
});
assert_eq!(result.warnings, Vec::<String>::new());
assert_eq!(result.statements, vec!["CREATE TABLE `materials` (\n `title` text\n);"]);
}
#[test]
fn manticoresearch_builds_text_column_properties() {
let mut title = column("title");
title.data_type = "text".to_string();
title.extra =
Some(ColumnExtra { manticore_indexed: Some(true), manticore_stored: Some(true), ..Default::default() });
let mut sku = column("sku");
sku.data_type = "string".to_string();
sku.extra =
Some(ColumnExtra { manticore_indexed: Some(true), manticore_attribute: Some(true), ..Default::default() });
let mut name = column("name");
name.data_type = "string".to_string();
name.extra = Some(ColumnExtra {
manticore_indexed: Some(true),
manticore_stored: Some(true),
manticore_attribute: Some(true),
..Default::default()
});
let result = build_create_table_sql(TableStructureSqlOptions {
database_type: Some(DatabaseType::ManticoreSearch),
schema: None,
table_name: "materials".to_string(),
columns: vec![title, sku, name],
indexes: Vec::new(),
foreign_keys: Vec::new(),
triggers: Vec::new(),
table_comment: None,
original_table_comment: None,
});
assert_eq!(result.warnings, Vec::<String>::new());
assert_eq!(
result.statements,
vec![
"CREATE TABLE `materials` (\n `title` text stored indexed,\n `sku` string attribute indexed,\n `name` string stored attribute indexed\n);"
]
);
}
#[test]
fn manticoresearch_builds_json_secondary_index_property() {
let mut metadata = column("metadata");
metadata.data_type = "json".to_string();
metadata.extra = Some(ColumnExtra { manticore_secondary_index: Some(true), ..Default::default() });
let result = build_create_table_sql(TableStructureSqlOptions {
database_type: Some(DatabaseType::ManticoreSearch),
schema: None,
table_name: "materials".to_string(),
columns: vec![metadata],
indexes: Vec::new(),
foreign_keys: Vec::new(),
triggers: Vec::new(),
table_comment: None,
original_table_comment: None,
});
assert_eq!(result.warnings, Vec::<String>::new());
assert_eq!(result.statements, vec!["CREATE TABLE `materials` (\n `metadata` json secondary_index='1'\n);"]);
}
#[test]
fn mysql_create_unique_index_with_comment_and_btree() {
let mut idx = index("uniq_users_email", &["email"]);
@ -854,7 +1111,7 @@ fn mysql_create_table_with_auto_increment() {
col.data_type = "int".to_string();
col.is_nullable = false;
col.is_primary_key = true;
col.extra = Some(ColumnExtra { auto_increment: Some(true), on_update_current_timestamp: None, identity: None });
col.extra = Some(ColumnExtra { auto_increment: Some(true), ..Default::default() });
let result = build_create_table_sql(TableStructureSqlOptions {
database_type: Some(DatabaseType::Mysql),
@ -879,7 +1136,7 @@ fn mysql_create_table_with_on_update_current_timestamp() {
col.data_type = "timestamp".to_string();
col.is_nullable = false;
col.default_value = "CURRENT_TIMESTAMP".to_string();
col.extra = Some(ColumnExtra { auto_increment: None, on_update_current_timestamp: Some(true), identity: None });
col.extra = Some(ColumnExtra { on_update_current_timestamp: Some(true), ..Default::default() });
let result = build_create_table_sql(TableStructureSqlOptions {
database_type: Some(DatabaseType::Mysql),
@ -903,9 +1160,8 @@ fn postgres_create_table_with_identity() {
col.data_type = "integer".to_string();
col.is_nullable = false;
col.extra = Some(ColumnExtra {
auto_increment: None,
on_update_current_timestamp: None,
identity: Some(ColumnIdentity { generation: Some("BY DEFAULT".to_string()), seed: None, increment: None }),
..Default::default()
});
let result = build_create_table_sql(TableStructureSqlOptions {
@ -931,8 +1187,8 @@ fn sqlserver_create_table_with_identity() {
col.is_nullable = false;
col.extra = Some(ColumnExtra {
auto_increment: Some(true),
on_update_current_timestamp: None,
identity: Some(ColumnIdentity { generation: None, seed: Some(100), increment: Some(5) }),
..Default::default()
});
let result = build_create_table_sql(TableStructureSqlOptions {

View File

@ -25,7 +25,7 @@ pub struct EditableStructureColumn {
pub marked_for_drop: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ColumnExtra {
#[serde(default, skip_serializing_if = "Option::is_none")]
@ -34,6 +34,14 @@ pub struct ColumnExtra {
pub on_update_current_timestamp: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identity: Option<ColumnIdentity>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub manticore_indexed: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub manticore_stored: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub manticore_attribute: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub manticore_secondary_index: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]

View File

@ -18,7 +18,7 @@ pub(super) fn qualified_table(dialect: StructureDialect, schema: Option<&str>, t
pub(super) fn quote_ident(dialect: StructureDialect, name: &str) -> String {
match dialect {
StructureDialect::Mysql => format!("`{}`", name.replace('`', "``")),
StructureDialect::Mysql | StructureDialect::ManticoreSearch => format!("`{}`", name.replace('`', "``")),
StructureDialect::SqlServer => format!("[{}]", name.replace(']', "]]")),
StructureDialect::Informix if is_simple_informix_identifier(name) => name.to_string(),
_ => format!("\"{}\"", name.replace('"', "\"\"")),
@ -42,6 +42,10 @@ pub(super) fn clean(value: &str) -> String {
value.trim().to_string()
}
pub(super) fn is_protected_manticore_id_column(dialect: StructureDialect, column_name: &str) -> bool {
dialect == StructureDialect::ManticoreSearch && column_name.trim().eq_ignore_ascii_case("id")
}
pub(super) fn is_temporal_type_for_default(dialect: StructureDialect, base_type: &str) -> bool {
let normalized = base_type.split_whitespace().collect::<Vec<_>>().join(" ").to_ascii_lowercase();
match dialect {

View File

@ -107,7 +107,7 @@ fn maps_agent_database_types_to_driver_keys() {
assert_eq!(agent_key(&DatabaseType::Firebird, None), Some("firebird"));
assert_eq!(agent_key(&DatabaseType::Exasol, None), Some("exasol"));
assert_eq!(agent_key(&DatabaseType::OceanbaseOracle, None), Some("oceanbase-oracle"));
assert_eq!(agent_key(&DatabaseType::Gbase, None), Some("gbase"));
assert_eq!(agent_key(&DatabaseType::Gbase, None), Some("gbase8a"));
assert_eq!(agent_key(&DatabaseType::Access, None), Some("access"));
assert_eq!(agent_key(&DatabaseType::Oracle, None), Some("oracle"));
assert_eq!(agent_key(&DatabaseType::Databend, None), Some("databend"));
@ -279,6 +279,14 @@ fn driver_manifest_declares_expected_product_capabilities() {
assert!(!jdbc.capabilities.table_structure_edit);
assert!(!jdbc.capabilities.user_admin);
let manticore = find_driver(DatabaseType::ManticoreSearch);
assert_eq!(manticore.support_level, "operate");
assert!(manticore.capabilities.metadata_browse);
assert!(manticore.capabilities.sql_file_execution);
assert!(manticore.capabilities.table_structure_edit);
assert!(!manticore.capabilities.object_browser);
assert!(manticore.capabilities.table_data_edit);
let redis = find_driver(DatabaseType::Redis);
assert_eq!(redis.support_level, "connect");
assert!(!redis.capabilities.object_browser);

View File

@ -1,58 +0,0 @@
# Query Result Archive Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add `.dbxresults` export/import so a query tab's saved execution-result runs can be restored later.
**Architecture:** Add a focused archive codec around the existing tab-result-cache snapshot codec. The query store exposes bytes-in/bytes-out methods, while Vue components handle file dialogs, browser download/upload, and toasts.
**Tech Stack:** Vue 3, Pinia, TypeScript, MessagePack via `@msgpack/msgpack`, existing DBX tab-result-cache snapshot codec, Vitest.
---
### Task 1: Archive Codec
**Files:**
- Create: `apps/desktop/src/lib/queryResultArchive.ts`
- Test: `packages/app-tests/queryResultArchive.test.ts`
- [ ] Write failing tests for encoding/decoding a query result archive with multiple runs, rejecting invalid bytes, and producing a binary payload smaller than equivalent JSON for repeated rows.
- [ ] Run `pnpm vitest run packages/app-tests/queryResultArchive.test.ts` and confirm it fails because the module does not exist.
- [ ] Implement `encodeQueryResultArchive`, `decodeQueryResultArchive`, `defaultQueryResultArchiveFileName`, and archive metadata types.
- [ ] Run `pnpm vitest run packages/app-tests/queryResultArchive.test.ts` and confirm it passes.
- [ ] Commit `feat(query): add result archive codec`.
### Task 2: Query Store Import/Export
**Files:**
- Modify: `apps/desktop/src/stores/queryStore.ts`
- Test: `packages/app-tests/queryStore.test.ts`
- [ ] Write a failing store test that exports a query tab with two result runs and imports the archive into a new tab with the active run restored.
- [ ] Run the focused test and confirm it fails because store archive methods do not exist.
- [ ] Add `exportResultArchive(tabId)` and `importResultArchive(bytes)` to the query store. Export should read evicted cache payloads when needed. Import should create a new query tab and project the active archived run into the result grid.
- [ ] Run the focused store test and confirm it passes.
- [ ] Commit `feat(query): restore result archives`.
### Task 3: UI Actions
**Files:**
- Modify: `apps/desktop/src/components/layout/ContentArea.vue`
- Modify: `apps/desktop/src/components/layout/EditorToolbar.vue`
- Modify: `apps/desktop/src/App.vue`
- Modify: `apps/desktop/src/i18n/locales/en.ts`
- Modify: `apps/desktop/src/i18n/locales/zh-CN.ts`
- [ ] Add an import icon button to the query editor toolbar and wire it to App.
- [ ] Add an export button to the result pane when query output exists.
- [ ] Implement Tauri save/open using `@tauri-apps/plugin-dialog` and `@tauri-apps/plugin-fs`; implement browser fallback using Blob download and an `<input type="file">`.
- [ ] Add English and Simplified Chinese UI strings.
- [ ] Run `pnpm typecheck` and confirm it passes.
- [ ] Commit `feat(query): add result archive actions`.
### Task 4: Final Verification
- [ ] Run `pnpm typecheck`.
- [ ] Run `pnpm vitest run packages/app-tests/queryResultArchive.test.ts packages/app-tests/queryStore.test.ts packages/app-tests/openTabsPersistence.test.ts packages/app-tests/tabResultCache.test.ts packages/app-tests/tabPresentation.test.ts`.
- [ ] Run `pnpm build`.
- [ ] Confirm `git status --short` is clean after commits.

View File

@ -129,6 +129,15 @@ test("describes table editing capabilities for special database engines", () =>
transaction: false,
});
assert.deepEqual(getDatabaseCapability("manticoresearch").tableData, {
insert: true,
updateRequiresPrimaryKey: false,
deleteRequiresPrimaryKey: false,
keylessRowPredicate: true,
requiresTransactionalTableForExistingRows: false,
transaction: false,
});
assert.deepEqual(getDatabaseCapability("jdbc").tableData, {
insert: false,
updateRequiresPrimaryKey: true,
@ -227,7 +236,7 @@ test("describes feature support through capability helpers", () => {
assert.equal(supportsTableStructureEditing("informix"), true);
assert.equal(supportsTableStructureEditing("rqlite"), true);
assert.equal(supportsTableStructureEditing("mongodb"), false);
assert.equal(supportsTableStructureEditing("manticoresearch"), false);
assert.equal(supportsTableStructureEditing("manticoresearch"), true);
assert.equal(supportsDatabaseCreation("clickhouse"), true);
assert.equal(supportsDatabaseCreation("manticoresearch"), false);
assert.equal(supportsDatabaseCreation("sqlite"), false);
@ -257,7 +266,7 @@ test("describes feature support through capability helpers", () => {
test("loads product support levels and capabilities from the driver manifest", () => {
assert.equal(manifestDatabaseTypes().includes("mysql"), true);
assert.equal(databaseSupportLevel("mysql"), "operate");
assert.equal(databaseSupportLevel("manticoresearch"), "browse");
assert.equal(databaseSupportLevel("manticoresearch"), "operate");
assert.equal(databaseSupportLevel("jdbc"), "browse");
assert.equal(databaseSupportLevel("redis"), "connect");
@ -290,8 +299,8 @@ test("loads product support levels and capabilities from the driver manifest", (
queryExecution: true,
metadataBrowse: true,
objectBrowser: false,
tableDataEdit: false,
tableStructureEdit: false,
tableDataEdit: true,
tableStructureEdit: true,
sqlFileExecution: true,
userAdmin: false,
},
@ -310,6 +319,7 @@ test("object browser entry follows database tree shape", () => {
test("sidebar object capability registry describes object groups by database type", () => {
assert.deepEqual(sidebarObjectKindsForDatabase("databend"), ["TABLE", "VIEW"]);
assert.deepEqual(sidebarObjectKindsForDatabase("manticoresearch"), ["TABLE", "FUNCTION"]);
assert.deepEqual(sidebarObjectKindsForDatabase("postgres"), ["TABLE", "VIEW", "PROCEDURE", "FUNCTION", "SEQUENCE"]);
assert.deepEqual(sidebarObjectKindsForDatabase("oracle"), ["TABLE", "VIEW", "PROCEDURE", "FUNCTION", "PACKAGE", "PACKAGE_BODY"]);
});

View File

@ -115,6 +115,50 @@ test("suggests PostgreSQL-specific data types and functions", () => {
);
});
test("suggests Manticore Search SQL functions and command snippets", () => {
const matchItems = buildSqlCompletionItems("select * from products where mat", "select * from products where mat".length, {
tables,
columnsByTable,
databaseType: "manticoresearch",
});
const facetItems = buildSqlCompletionItems("select * from products fac", "select * from products fac".length, {
tables,
columnsByTable,
databaseType: "manticoresearch",
});
const showItems = buildSqlCompletionItems("show m", "show m".length, {
tables,
columnsByTable,
databaseType: "manticoresearch",
});
const showTablesItems = buildSqlCompletionItems("show tab", "show tab".length, {
tables,
columnsByTable,
databaseType: "manticoresearch",
});
const callPqItems = buildSqlCompletionItems("call p", "call p".length, {
tables,
columnsByTable,
databaseType: "manticoresearch",
});
const rankingItems = buildSqlCompletionItems("select bm", "select bm".length, {
tables,
columnsByTable,
databaseType: "manticoresearch",
});
assert.ok(
matchItems.some((item) => item.type === "function" && item.label === "MATCH" && item.apply === "MATCH(${query})"),
);
assert.ok(facetItems.some((item) => item.type === "keyword" && item.label === "FACET"));
assert.ok(showItems.some((item) => item.type === "snippet" && item.label === "show meta" && item.apply === "SHOW META;"));
assert.ok(showTablesItems.some((item) => item.type === "snippet" && item.label === "show tables" && item.apply === "SHOW TABLES;"));
assert.ok(
callPqItems.some((item) => item.type === "snippet" && item.label === "call pq" && item.apply === "CALL PQ ('pq', ('{\"title\":\"query\"}'));"),
);
assert.ok(rankingItems.some((item) => item.type === "function" && item.label === "BM25F"));
});
test("MongoDB completion avoids SQL keywords", () => {
const items = buildSqlCompletionItems("fi", 2, {
tables: [],

View File

@ -52,12 +52,14 @@ test("allows updateable SQL table data editing even without declared primary key
assert.equal(isTableDataEditable("informix", []), true);
assert.equal(isTableDataEditable("tdengine", []), true);
assert.equal(isTableDataEditable("mysql", []), true);
assert.equal(isTableDataEditable("manticoresearch", []), true);
assert.equal(isTableDataEditable("postgres", []), true);
assert.equal(isTableDataEditable("postgres", ["id"]), true);
});
test("does not use transactional grid saves for non-transactional engines", () => {
assert.equal(supportsDataGridTransaction("hive"), false);
assert.equal(supportsDataGridTransaction("manticoresearch"), false);
assert.equal(supportsDataGridTransaction("trino"), false);
assert.equal(supportsDataGridTransaction("jdbc"), false);
assert.equal(supportsDataGridTransaction("yashandb"), true);
@ -75,6 +77,7 @@ test("allows existing row edits according to database-specific key requirements"
assert.equal(canEditExistingTableRows("trino", undefined, []), false);
assert.equal(canEditExistingTableRows("trino", undefined, ["id"]), true);
assert.equal(canEditExistingTableRows("mysql", undefined, []), true);
assert.equal(canEditExistingTableRows("manticoresearch", undefined, []), true);
assert.equal(canEditExistingTableRows("postgres", undefined, []), true);
assert.equal(canEditExistingTableRows("sqlite", undefined, []), true);
assert.equal(canEditExistingTableRows("sqlite", undefined, ["id"]), true);

View File

@ -0,0 +1,13 @@
import assert from "node:assert/strict";
import { test } from "vitest";
import { getTableMetadataCapabilities } from "../../apps/desktop/src/lib/tableMetadataCapabilities.ts";
test("manticore search exposes secondary indexes but hides relational constraints", () => {
assert.deepEqual(getTableMetadataCapabilities("manticoresearch"), {
columns: true,
indexes: true,
foreignKeys: false,
triggers: false,
ddl: true,
});
});

View File

@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { test } from "vitest";
import { canEditTableStructure, getTableStructureCapabilities } from "../../apps/desktop/src/lib/tableStructureCapabilities.ts";
import { canAddTableStructureColumn, canEditTableStructure, getTableStructureCapabilities } from "../../apps/desktop/src/lib/tableStructureCapabilities.ts";
test("sqlite-family and duckdb do not support table comments", () => {
for (const dbType of ["sqlite", "rqlite", "duckdb"] as const) {
@ -108,6 +108,32 @@ test("limited analytic engines can open the editor for supported operations only
assert.equal(canEditTableStructure("clickhouse"), true);
});
test("manticore search can open the editor for limited table structure changes", () => {
const caps = getTableStructureCapabilities("manticoresearch");
assert.equal(caps.dialect, "mysql");
assert.equal(caps.createTable, true);
assert.equal(caps.addColumn, true);
assert.equal(caps.dropColumn, true);
assert.equal(caps.renameColumn, false);
assert.equal(caps.alterExistingColumn, false);
assert.equal(caps.createIndex, false);
assert.equal(caps.dropIndex, false);
assert.equal(canEditTableStructure("manticoresearch"), true);
assert.equal(canAddTableStructureColumn("manticoresearch", true), true);
assert.equal(canAddTableStructureColumn("manticoresearch", false), true);
});
test("manticore search keeps generic index DDL disabled", () => {
const caps = getTableStructureCapabilities("manticoresearch");
assert.equal(caps.createIndex, false);
assert.equal(caps.dropIndex, false);
assert.equal(caps.rebuildIndex, false);
assert.equal(caps.indexType, false);
assert.equal(caps.indexInclude, false);
assert.equal(caps.indexFilter, false);
assert.equal(caps.indexComment, false);
});
test("informix exposes conservative structure editing capabilities", () => {
const caps = getTableStructureCapabilities("informix");
assert.equal(caps.dialect, "informix");
@ -126,7 +152,7 @@ test("informix exposes conservative structure editing capabilities", () => {
});
test("unsupported non-relational databases do not open the structure editor", () => {
for (const dbType of ["redis", "mongodb", "elasticsearch", "manticoresearch", "neo4j", undefined] as const) {
for (const dbType of ["redis", "mongodb", "elasticsearch", "neo4j", undefined] as const) {
const caps = getTableStructureCapabilities(dbType);
assert.equal(caps.dialect, "unsupported");
assert.equal(canEditTableStructure(dbType), false);

View File

@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { test } from "vitest";
import { buildStructureTargetLabel, combineDataTypeForDatabase, createColumnDrafts, createIndexDrafts, generateIndexName, generateUniqueIndexName, getDataTypeOptions, normalizeDataTypeParams, parseExtraToColumnExtra, toColumnNames } from "../../apps/desktop/src/lib/tableStructureEditorState.ts";
import { applyManticoreDdlColumnExtras, buildStructureTargetLabel, canEditManticoreColumnProperties, combineDataTypeForDatabase, createColumnDrafts, createIndexDrafts, generateIndexName, generateUniqueIndexName, getColumnEditorControls, getDataTypeOptions, isProtectedManticoreIdColumn, normalizeDataTypeParams, parseExtraToColumnExtra, toColumnNames } from "../../apps/desktop/src/lib/tableStructureEditorState.ts";
import type { ColumnInfo, IndexInfo } from "../../apps/desktop/src/types/database.ts";
const columns: ColumnInfo[] = [
@ -77,6 +77,30 @@ test("creates editable column drafts from column metadata", () => {
);
});
test("applies manticore column properties from ddl", () => {
const manticoreColumns: ColumnInfo[] = [
{ name: "name", data_type: "string", is_nullable: true, column_default: null, is_primary_key: false, extra: null, comment: null },
{ name: "code", data_type: "string", is_nullable: true, column_default: null, is_primary_key: false, extra: null, comment: null },
{ name: "resource", data_type: "json", is_nullable: true, column_default: null, is_primary_key: false, extra: null, comment: null },
];
const ddl = `CREATE TABLE materials (
name string indexed attribute,
code string attribute,
resource json secondary_index='1'
)`;
const drafts = createColumnDrafts(applyManticoreDdlColumnExtras(manticoreColumns, ddl), "manticoresearch");
assert.deepEqual(
drafts.map((draft) => ({ name: draft.name, dataType: draft.dataType, extra: draft.extra })),
[
{ name: "name", dataType: "string", extra: { manticoreIndexed: true, manticoreAttribute: true } },
{ name: "code", dataType: "string", extra: { manticoreAttribute: true } },
{ name: "resource", dataType: "json", extra: { manticoreSecondaryIndex: true } },
],
);
});
test("parses MySQL extra string to ColumnExtra", () => {
assert.deepEqual(parseExtraToColumnExtra("auto_increment", "mysql"), { autoIncrement: true });
assert.deepEqual(parseExtraToColumnExtra("on update CURRENT_TIMESTAMP", "mysql"), {
@ -119,6 +143,20 @@ test("parses SQL Server identity extra string to ColumnExtra", () => {
});
});
test("parses Manticore Search text properties to ColumnExtra", () => {
assert.deepEqual(parseExtraToColumnExtra("stored indexed", "manticoresearch"), {
manticoreStored: true,
manticoreIndexed: true,
});
assert.deepEqual(parseExtraToColumnExtra("attribute indexed", "manticoresearch"), {
manticoreAttribute: true,
manticoreIndexed: true,
});
assert.deepEqual(parseExtraToColumnExtra("secondary_index='1'", "manticoresearch"), {
manticoreSecondaryIndex: true,
});
});
test("creates editable index drafts and splits pasted column lists", () => {
const drafts = createIndexDrafts(indexes);
@ -184,3 +222,31 @@ test("returns data type options for compatible table structure editors", () => {
assert.equal(getDataTypeOptions("dameng").includes("varchar2"), true);
assert.equal(getDataTypeOptions("sqlserver").includes("nvarchar"), true);
});
test("returns Manticore Search data type options", () => {
assert.deepEqual(getDataTypeOptions("manticoresearch"), ["text", "string", "int", "bit", "bigint", "bool", "timestamp", "float", "json", "float_vector", "multi", "mva"]);
});
test("returns Manticore Search column editor controls", () => {
assert.deepEqual(getColumnEditorControls("manticoresearch"), {
length: true,
nullable: false,
primaryKey: false,
defaultValue: false,
comment: false,
});
assert.equal(getColumnEditorControls("mysql").nullable, true);
});
test("protects Manticore Search id column from destructive structure edits", () => {
assert.equal(isProtectedManticoreIdColumn("manticoresearch", "id"), true);
assert.equal(isProtectedManticoreIdColumn("manticoresearch", "ID"), true);
assert.equal(isProtectedManticoreIdColumn("manticoresearch", "name"), false);
assert.equal(isProtectedManticoreIdColumn("mysql", "id"), false);
});
test("allows Manticore Search column properties only before the column exists", () => {
assert.equal(canEditManticoreColumnProperties("manticoresearch", false), true);
assert.equal(canEditManticoreColumnProperties("manticoresearch", true), false);
assert.equal(canEditManticoreColumnProperties("mysql", false), false);
});

View File

@ -79,12 +79,12 @@ test("driver manifest declares support levels and product capabilities", () => {
assert.equal(jdbc?.capabilities.tableStructureEdit, false);
const manticore = manifest.drivers.find((driver) => driver.dbType === "manticoresearch");
assert.equal(manticore?.supportLevel, "browse");
assert.equal(manticore?.supportLevel, "operate");
assert.equal(manticore?.capabilities.queryExecution, true);
assert.equal(manticore?.capabilities.metadataBrowse, true);
assert.equal(manticore?.capabilities.objectBrowser, false);
assert.equal(manticore?.capabilities.tableDataEdit, false);
assert.equal(manticore?.capabilities.tableStructureEdit, false);
assert.equal(manticore?.capabilities.tableDataEdit, true);
assert.equal(manticore?.capabilities.tableStructureEdit, true);
assert.equal(manticore?.capabilities.databaseCreate, false);
assert.equal(manticore?.capabilities.userAdmin, false);
});