feat(structure): geometry subtype and srid editor for postgres

This commit is contained in:
Tara authored and GitHub committed 2026-09-11 17:39:31 +08:00
1 parent 71cc734cdd
commit 27f21d5cc4
15 files changed
+329 -3

No files matched your search

@@ -76,6 +76,11 @@ import {
generateUniqueIndexName,
getColumnEditorControls,
getDataTypeOptions,
POSTGRES_GEOMETRY_TYPES,
combinePostgresGeometryType,
isPostgresGeometryDataType,
postgresGeometrySridValue,
postgresGeometryTypeValue,
getDataTypeLengthUnitOptions,
getDefaultLengthForType,
hasExistingColumnTypeChange,
@@ -2796,6 +2801,11 @@ function updateColumnDataTypeLength(column: EditableStructureColumn, value: stri
syncDamengIdentityForDataType(column);
}
function updatePostgresGeometryColumn(column: EditableStructureColumn, geomType: string, srid: string) {
const baseType = dataTypeBaseInputValue(databaseType.value, column.dataType);
column.dataType = combinePostgresGeometryType(baseType, geomType, srid);
}
function updateColumnDataTypeLengthUnit(column: EditableStructureColumn, value: unknown) {
const baseType = dataTypeBaseInputValue(databaseType.value, column.dataType);
const unit = value === "__default" ? "" : String(value ?? "");
@@ -4453,6 +4463,35 @@ watch(
</div>
</PopoverContent>
</Popover>
<div v-else-if="isPostgresGeometryDataType(databaseType, column.dataType)" class="flex min-w-0 flex-col gap-0.5">
<div class="flex min-w-0 items-center gap-1" :title="t('structureEditor.geometrySridHint')">
<SearchableSelect
:model-value="postgresGeometryTypeValue(column.dataType)"
:options="[...POSTGRES_GEOMETRY_TYPES]"
:allow-custom="true"
:clearable="true"
:placeholder="t('structureEditor.geometryTypePlaceholder')"
:search-placeholder="t('structureEditor.geometryTypePlaceholder')"
:empty-text="t('structureEditor.noMatchingType')"
:trigger-class="[structureMonoControlClass, 'min-w-0 flex-1']"
:disabled="isColumnTypeDisabled(column)"
@update:model-value="(v: string) => updatePostgresGeometryColumn(column, v, postgresGeometrySridValue(column.dataType))"
/>
<Input
:model-value="postgresGeometrySridValue(column.dataType)"
:class="[structureMonoControlClass, 'w-20 shrink-0']"
type="number"
min="0"
max="999999"
:placeholder="t('structureEditor.sridPlaceholder')"
:disabled="isColumnTypeDisabled(column)"
@update:model-value="(v: string | number) => updatePostgresGeometryColumn(column, postgresGeometryTypeValue(column.dataType), String(v))"
/>
</div>
<p v-if="!postgresGeometryTypeValue(column.dataType) && postgresGeometrySridValue(column.dataType)" class="text-[length:var(--structure-font-size)] text-muted-foreground leading-tight">
{{ t("structureEditor.geometryEmptyTypeHint") }}
</p>
</div>
<div v-else class="flex min-w-0 items-center gap-1">
<Input :model-value="dataTypeLengthInputValue(databaseType, column.dataType)" :class="[structureMonoControlClass, 'min-w-0 flex-1']" :disabled="isColumnLengthDisabled(column)" @update:model-value="updateColumnDataTypeLength(column, $event)" />
<Select v-if="columnLengthUnitOptions(column).length" :model-value="dataTypeLengthUnitValue(databaseType, column.dataType) || '__default'" :disabled="isColumnLengthUnitDisabled(column)" @update:model-value="updateColumnDataTypeLengthUnit(column, $event)">
@@ -2,6 +2,7 @@
import { computed, nextTick, ref, watch } from "vue";
import type { HTMLAttributes } from "vue";
import { Check, ChevronDown, Search, X } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import type { ButtonVariants } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -76,6 +77,7 @@ const helpPanel = ref<{ element?: HTMLElement }>();
const highlightIndex = ref(-1);
const activeHelpOption = ref<string>();
const helpPanelOffsetTop = ref(0);
const { t } = useI18n();
const selectedLabel = computed(() => {
if (!props.modelValue && !props.options.includes("")) return props.placeholder;
@@ -241,12 +243,29 @@ function handleKeydown(event: KeyboardEvent) {
<template>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button type="button" :variant="triggerVariant" :disabled="disabled" :title="selectedLabel" :class="cn(triggerBaseClass, triggerClass)">
<!--
Keep the trigger Button as the outermost element: triggerClass carries the layout
utilities consumers depend on (flex-1 / min-w-0 / max-w-* / w-full / h-*), and any
wrapper element would become the flex item and swallow them. The clearable overlay
therefore anchors to this button (relative) instead of a wrapper. The overlay is a
span (not the svg itself) because the Button base sets [&_svg]:pointer-events-none,
which is also why the old in-trigger @pointerdown.stop never fired; @click.stop
keeps Reka's bubble-phase trigger onClick (onOpenToggle) from opening the dropdown.
-->
<Button type="button" :variant="triggerVariant" :disabled="disabled" :title="selectedLabel" :class="cn('relative', triggerBaseClass, triggerClass)">
<slot name="trigger-label" :value="modelValue" :label="selectedLabel" :loading="loading">
<span class="truncate">{{ loading ? loadingText : selectedLabel }}</span>
</slot>
<X v-if="clearable && !disabled && modelValue" :class="cn('shrink-0 opacity-60 hover:opacity-100', triggerIconClass)" @pointerdown.stop.prevent="emit('update:modelValue', '')" />
<ChevronDown v-else :class="cn('shrink-0 opacity-60', triggerIconClass)" />
<ChevronDown :class="cn('shrink-0 opacity-60', clearable && !disabled && modelValue && 'invisible', triggerIconClass)" />
<span
v-if="clearable && !disabled && modelValue"
class="absolute right-2 top-1/2 z-10 -translate-y-1/2 flex cursor-pointer items-center justify-center opacity-60 hover:opacity-100"
:aria-label="t('common.clear')"
:title="t('common.clear')"
@click.stop.prevent="emit('update:modelValue', '')"
>
<X :class="triggerIconClass" />
</span>
</Button>
</PopoverTrigger>
<PopoverContent :align="SEARCHABLE_SELECT_HELP_PANEL_ALIGN" :class="cn('w-auto max-w-[calc(100vw-1rem)] border-0 bg-transparent p-0 shadow-none ring-0', contentClass)" :style="contentStyle">
@@ -0,0 +1,70 @@
// @vitest-environment happy-dom
import { createApp, nextTick, type App } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
import { SearchableSelect } from "..";
const mountedApps: App[] = [];
function mountSelect(props: Record<string, unknown> = {}) {
const root = document.createElement("div");
document.body.append(root);
const emitted = { modelValue: [] as unknown[], open: [] as unknown[] };
const app = createApp(SearchableSelect, {
modelValue: "Point",
options: ["Point", "LineString"],
placeholder: "type",
searchPlaceholder: "search",
emptyText: "empty",
...props,
"onUpdate:modelValue": (value: unknown) => emitted.modelValue.push(value),
"onUpdate:open": (value: unknown) => emitted.open.push(value),
});
mountedApps.push(app);
app.mount(root);
return { root, emitted };
}
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.innerHTML = "";
});
describe("SearchableSelect trigger", () => {
it("renders the trigger button itself as the layout participant", () => {
const { root } = mountSelect({ triggerClass: "min-w-0 flex-1" });
const trigger = root.querySelector("button");
// No wrapper element may sit between the parent layout context and the
// trigger: triggerClass utilities (flex-1 / min-w-0 / max-w-*) must stay
// effective on the element the consumer lays out.
expect(trigger).not.toBeNull();
expect(root.firstElementChild).toBe(trigger);
expect(trigger?.className).toContain("flex-1");
expect(trigger?.className).toContain("min-w-0");
});
it("clears through the overlay without toggling the popover open", async () => {
const { root, emitted } = mountSelect({ clearable: true });
const clear = root.querySelector<HTMLElement>('[title="common.clear"]');
expect(clear).not.toBeNull();
expect(clear?.getAttribute("aria-label")).toBe("common.clear");
clear?.click();
await nextTick();
expect(emitted.modelValue).toEqual([""]);
expect(emitted.open).toEqual([]);
});
it("keeps the chevron space reserved while the clear overlay is shown", () => {
const { root } = mountSelect({ clearable: true });
expect(root.querySelector("button .invisible")).not.toBeNull();
});
});
+4
View File
@@ -4339,6 +4339,10 @@ export default withEnglishFallback({
length: "Uzunluq",
lengthUnit: "Uzunluq vahidi",
unitPlaceholder: "Vahid",
geometryTypePlaceholder: "Həndəsi tipi seçin və ya daxil edin",
sridPlaceholder: "məs. 4326",
geometrySridHint: "Həndəsi tip və SRID təyin etmək spatial indeksləşdirmə və düzgün spatial sorğular üçün vacibdir.",
geometryEmptyTypeHint: "Həndəsi tipi boş buraxmaq GEOMETRY (istənilən alt tip) ilə bərabərdir; geometry(GEOMETRY, SRID) yaranır.",
enumValues: "Sadalanan dəyərlər",
enumValueCount: "{count} dəyər",
addEnumValue: "Dəyər əlavə et",
+4
View File
@@ -4335,6 +4335,10 @@ export default {
length: "Length",
lengthUnit: "Length unit",
unitPlaceholder: "Unit",
geometryTypePlaceholder: "Select or type a geometry type",
sridPlaceholder: "e.g. 4326",
geometrySridHint: "Specify geometry type and SRID to enable spatial indexing and correct spatial queries.",
geometryEmptyTypeHint: "Empty geometry type equals GEOMETRY (any sub-type); dbx emits geometry(GEOMETRY, SRID).",
enumValues: "Enum values",
enumValueCount: "{count} values",
addEnumValue: "Add value",
+4
View File
@@ -4045,6 +4045,10 @@ export default withEnglishFallback({
length: "Longitud",
lengthUnit: "Unidad de longitud",
unitPlaceholder: "Unidad",
geometryTypePlaceholder: "Selecciona o escribe un tipo de geometría",
sridPlaceholder: "ej. 4326",
geometrySridHint: "Especifica el tipo de geometría y el SRID para habilitar la indexación espacial y las consultas espaciales correctas.",
geometryEmptyTypeHint: "Dejar el tipo de geometría vacío equivale a GEOMETRY (cualquier subtipo); se genera geometry(GEOMETRY, SRID).",
nullable: "Admite nulos",
primaryKey: "Clave primaria",
defaultValue: "Valor por defecto",
+4
View File
@@ -4043,6 +4043,10 @@ export default withEnglishFallback({
length: "Lunghezza",
lengthUnit: "Unità di lunghezza",
unitPlaceholder: "Unità",
geometryTypePlaceholder: "Seleziona o digita un tipo di geometria",
sridPlaceholder: "es. 4326",
geometrySridHint: "Specifica il tipo di geometria e l'SRID per abilitare l'indicizzazione spaziale e le query spaziali corrette.",
geometryEmptyTypeHint: "Lasciare vuoto il tipo di geometria equivale a GEOMETRY (qualsiasi sottotipo); viene generato geometry(GEOMETRY, SRID).",
nullable: "Nullabile",
primaryKey: "Chiave Primaria",
defaultValue: "Valore Predefinito",
+4
View File
@@ -4105,6 +4105,10 @@ export default withEnglishFallback({
length: "長さ",
lengthUnit: "長さの単位",
unitPlaceholder: "単位",
geometryTypePlaceholder: "ジオメトリタイプを選択または入力",
sridPlaceholder: "例: 4326",
geometrySridHint: "空間インデックスの作成と正確な空間クエリには、ジオメトリタイプと SRID の指定が必要です。",
geometryEmptyTypeHint: "ジオメトリタイプを空にすると GEOMETRY(任意のサブタイプ)と同等になり、geometry(GEOMETRY, SRID) が生成されます。",
nullable: "NULL許容",
primaryKey: "主キー",
defaultValue: "デフォルト",
+4
View File
@@ -4012,6 +4012,10 @@ export default withEnglishFallback({
length: "길이",
lengthUnit: "길이 단위",
unitPlaceholder: "단위",
geometryTypePlaceholder: "지오메트리 유형을 선택하거나 입력",
sridPlaceholder: "예: 4326",
geometrySridHint: "공간 인덱싱과 정확한 공간 쿼리를 사용하려면 지오메트리 유형과 SRID를 지정하세요.",
geometryEmptyTypeHint: "지오메트리 유형을 비우면 GEOMETRY(임의 하위 유형)와 같으며, geometry(GEOMETRY, SRID)가 생성됩니다.",
enumValues: "Enum 값",
enumValueCount: "{count}개 값",
addEnumValue: "값 추가",
+4
View File
@@ -4045,6 +4045,10 @@ export default withEnglishFallback({
length: "Comprimento",
lengthUnit: "Unidade de comprimento",
unitPlaceholder: "Unidade",
geometryTypePlaceholder: "Selecione ou digite um tipo de geometria",
sridPlaceholder: "ex.: 4326",
geometrySridHint: "Especifique o tipo de geometria e o SRID para habilitar a indexação espacial e consultas espaciais corretas.",
geometryEmptyTypeHint: "Deixar o tipo de geometria vazio equivale a GEOMETRY (qualquer subtipo); gera geometry(GEOMETRY, SRID).",
nullable: "Permite nulo",
primaryKey: "Chave primária",
defaultValue: "Padrão",
+4
View File
@@ -4243,6 +4243,10 @@ export default withEnglishFallback({
length: "Uzunluk",
lengthUnit: "Uzunluk birimi",
unitPlaceholder: "Birim",
geometryTypePlaceholder: "Bir geometri türü seçin veya yazın",
sridPlaceholder: "örn. 4326",
geometrySridHint: "Uzamsal dizinleme ve doğru uzamsal sorgular için geometri türü ve SRID belirtin.",
geometryEmptyTypeHint: "Geometri türünü boş bırakmak GEOMETRY (herhangi bir alt tür) ile eşdeğerdir; geometry(GEOMETRY, SRID) üretilir.",
enumValues: "Enum değerleri",
enumValueCount: "{count} değer",
addEnumValue: "Değer ekle",
+4
View File
@@ -4217,6 +4217,10 @@ export default withEnglishFallback({
length: "长度",
lengthUnit: "长度单位",
unitPlaceholder: "单位",
geometryTypePlaceholder: "选择或输入几何类型",
sridPlaceholder: "如 4326",
geometrySridHint: "指定几何类型与 SRID 后方可创建空间索引并正确执行空间查询。",
geometryEmptyTypeHint: "几何类型留空时等价于 GEOMETRY(任意子类型),将生成 geometry(GEOMETRY, SRID)。",
enumValues: "枚举值",
enumValueCount: "{count} 个值",
addEnumValue: "添加值",
+4
View File
@@ -3787,6 +3787,10 @@ export default withEnglishFallback({
length: "長度",
lengthUnit: "長度單位",
unitPlaceholder: "單位",
geometryTypePlaceholder: "選擇或輸入幾何類型",
sridPlaceholder: "如 4326",
geometrySridHint: "指定幾何類型與 SRID 後方可建立空間索引並正確執行空間查詢。",
geometryEmptyTypeHint: "幾何類型留空時等價於 GEOMETRY(任意子類型),將生成 geometry(GEOMETRY, SRID)。",
typePlaceholder: "選擇或輸入……",
noMatchingType: "沒有相符類型",
nullable: "可為空",
@@ -0,0 +1,98 @@
import { describe, expect, it } from "vitest";
import { combinePostgresGeometryType, isPostgresGeometryDataType, POSTGRES_GEOMETRY_TYPES, postgresGeometrySridValue, postgresGeometryTypeValue } from "@/lib/table/tableStructureEditorState";
describe("postgres geometry column typmod helpers", () => {
describe("combinePostgresGeometryType", () => {
it("returns bare type when both sub-type and SRID are empty", () => {
expect(combinePostgresGeometryType("geometry", "", "")).toBe("geometry");
});
it("emits geometry(Point) for sub-type only", () => {
expect(combinePostgresGeometryType("geometry", "Point", "")).toBe("geometry(Point)");
});
it("emits geometry(GEOMETRY,4326) for SRID only (geometry(,4326) and geometry(4326) are rejected by PostGIS)", () => {
expect(combinePostgresGeometryType("geometry", "", "4326")).toBe("geometry(GEOMETRY,4326)");
});
it("emits geometry(Point,4326) for both", () => {
expect(combinePostgresGeometryType("geometry", "Point", "4326")).toBe("geometry(Point,4326)");
});
it("trims whitespace in inputs", () => {
expect(combinePostgresGeometryType(" geography ", " Polygon ", " 3857 ")).toBe("geography(Polygon,3857)");
});
it("emits geography(GEOMETRY,4326) for geography SRID only (verified against PostGIS)", () => {
expect(combinePostgresGeometryType("geography", "", "4326")).toBe("geography(GEOMETRY,4326)");
});
it("returns empty string when base type is empty", () => {
expect(combinePostgresGeometryType("", "Point", "4326")).toBe("");
});
});
describe("postgresGeometryTypeValue / postgresGeometrySridValue", () => {
it("parses geometry(Point,4326)", () => {
expect(postgresGeometryTypeValue("geometry(Point,4326)")).toBe("Point");
expect(postgresGeometrySridValue("geometry(Point,4326)")).toBe("4326");
});
it("parses geography(Polygon,3857)", () => {
expect(postgresGeometryTypeValue("geography(Polygon,3857)")).toBe("Polygon");
expect(postgresGeometrySridValue("geography(Polygon,3857)")).toBe("3857");
});
it("parses bare geometry with no typmod", () => {
expect(postgresGeometryTypeValue("geometry")).toBe("");
expect(postgresGeometrySridValue("geometry")).toBe("");
});
it("parses whitespace variant geometry( Point , 4326 )", () => {
expect(postgresGeometryTypeValue("geometry( Point , 4326 )")).toBe("Point");
expect(postgresGeometrySridValue("geometry( Point , 4326 )")).toBe("4326");
});
it("parses sub-type-only geometry(Point)", () => {
expect(postgresGeometryTypeValue("geometry(Point)")).toBe("Point");
expect(postgresGeometrySridValue("geometry(Point)")).toBe("");
});
it("parses geometry(GEOMETRY,4326) as blank (GEOMETRY = any sub-type, normalized to empty)", () => {
expect(postgresGeometryTypeValue("geometry(GEOMETRY,4326)")).toBe("");
expect(postgresGeometrySridValue("geometry(GEOMETRY,4326)")).toBe("4326");
});
});
describe("isPostgresGeometryDataType", () => {
it("returns true for postgres + geometry", () => {
expect(isPostgresGeometryDataType("postgres", "geometry(Point,4326)")).toBe(true);
});
it("returns true for postgres + bare geography", () => {
expect(isPostgresGeometryDataType("postgres", "geography")).toBe(true);
});
it("returns true for gaussdb (Postgres family)", () => {
expect(isPostgresGeometryDataType("gaussdb", "geometry(Point,4326)")).toBe(true);
});
it("returns false for mysql even with a geometry type string", () => {
expect(isPostgresGeometryDataType("mysql", "geometry")).toBe(false);
});
it("returns false for postgres non-spatial types", () => {
expect(isPostgresGeometryDataType("postgres", "varchar(255)")).toBe(false);
});
});
it("POSTGRES_GEOMETRY_TYPES includes the OGC simple-feature sub-types", () => {
expect(POSTGRES_GEOMETRY_TYPES).toContain("Point");
expect(POSTGRES_GEOMETRY_TYPES).toContain("LineString");
expect(POSTGRES_GEOMETRY_TYPES).toContain("Polygon");
expect(POSTGRES_GEOMETRY_TYPES).toContain("MultiPoint");
expect(POSTGRES_GEOMETRY_TYPES).toContain("MultiPolygon");
expect(POSTGRES_GEOMETRY_TYPES).toContain("GeometryCollection");
});
});
@@ -1470,3 +1470,63 @@ export function buildStructureTargetLabel(connectionName: string | undefined, da
if (tableName) parts.push(tableName);
return parts.filter(Boolean).join(" / ");
}
/** PostGIS `geometry(...)`/`geography(...)` typmod accepts these geometry sub-type
* names (case-insensitive). An empty value means "no sub-type constraint". */
export const POSTGRES_GEOMETRY_TYPES: readonly string[] = ["Point", "LineString", "Polygon", "MultiPoint", "MultiLineString", "MultiPolygon", "GeometryCollection", "CircularString", "CompoundCurve", "CurvePolygon", "MultiCurve", "MultiSurface", "PolyhedralSurface", "TIN", "Triangle"];
const POSTGRES_SPATIAL_TYPES = new Set(["geometry", "geography"]);
const POSTGRES_LIKE_DATABASES = new Set<DatabaseType | undefined>(["postgres", "gaussdb", "kwdb", "opengauss", "highgo", "uxdb", "vastbase", "kingbase"]);
/** Whether a column is a PostGIS `geometry`/`geography` on a PostgreSQL-family
* database — the case where the structure editor shows dedicated geometry-type
* and SRID controls instead of the generic length input. */
export function isPostgresGeometryDataType(dbType: DatabaseType | undefined, rawDataType: string): boolean {
if (!POSTGRES_LIKE_DATABASES.has(dbType)) return false;
const { baseType } = splitDataType(rawDataType);
return POSTGRES_SPATIAL_TYPES.has(baseType.trim().toLowerCase());
}
/** Geometry sub-type parsed from `geometry(Point,4326)` → `"Point"`. Returns empty
* for bare `geometry` (no typmod) and for `geometry(GEOMETRY,srid)` — the latter is
* PostGIS's storage form of "any sub-type" (what a blank sub-type + SRID compiles to),
* normalized back to empty to match the user-facing "leave blank" intent. This keeps
* the clear (X) button visually emptying the field instead of showing "GEOMETRY".
* `geometry(,4326)` is invalid and never produced. */
export function postgresGeometryTypeValue(rawDataType: string): string {
const { params } = splitDataType(rawDataType);
const geomType = splitPostgresGeometryParams(params).geomType;
return geomType.toLowerCase() === "geometry" ? "" : geomType;
}
/** SRID parsed from `geometry(Point,4326)` → `"4326"`. Empty when unspecified. */
export function postgresGeometrySridValue(rawDataType: string): string {
const { params } = splitDataType(rawDataType);
return splitPostgresGeometryParams(params).srid;
}
function splitPostgresGeometryParams(params: string): { geomType: string; srid: string } {
const trimmed = params.trim();
if (!trimmed) return { geomType: "", srid: "" };
const commaIndex = trimmed.indexOf(",");
if (commaIndex === -1) return { geomType: trimmed, srid: "" };
return { geomType: trimmed.slice(0, commaIndex).trim(), srid: trimmed.slice(commaIndex + 1).trim() };
}
/** Reassemble a PostGIS spatial type string from its parts.
*
* - both empty → bare `geometry` (no typmod)
* - sub-type only → `geometry(Point)`
* - SRID only → `geometry(GEOMETRY,4326)` (PostGIS rejects `geometry(,4326)` (syntax error at `,`) and `geometry(4326)` ("Invalid geometry type modifier"); `GEOMETRY` is a valid sub-type token meaning any geometry, so it is the canonical way to constrain SRID only)
* - both → `geometry(Point,4326)`
*/
export function combinePostgresGeometryType(baseType: string, geomType: string, srid: string): string {
const type = baseType.trim();
const geom = geomType.trim();
const sridValue = srid.trim();
if (!type) return "";
if (!geom && !sridValue) return type;
if (!geom) return `${type}(GEOMETRY,${sridValue})`; // PostGIS rejects geometry(,4326) and geometry(4326); GEOMETRY = any sub-type, constrains SRID only
if (!sridValue) return `${type}(${geom})`;
return `${type}(${geom},${sridValue})`;
}