feat(structure): geometry subtype and srid editor for postgres
This commit is contained in:
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">
|
||||
|
||||
Vendored
+70
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -4105,6 +4105,10 @@ export default withEnglishFallback({
|
||||
length: "長さ",
|
||||
lengthUnit: "長さの単位",
|
||||
unitPlaceholder: "単位",
|
||||
geometryTypePlaceholder: "ジオメトリタイプを選択または入力",
|
||||
sridPlaceholder: "例: 4326",
|
||||
geometrySridHint: "空間インデックスの作成と正確な空間クエリには、ジオメトリタイプと SRID の指定が必要です。",
|
||||
geometryEmptyTypeHint: "ジオメトリタイプを空にすると GEOMETRY(任意のサブタイプ)と同等になり、geometry(GEOMETRY, SRID) が生成されます。",
|
||||
nullable: "NULL許容",
|
||||
primaryKey: "主キー",
|
||||
defaultValue: "デフォルト",
|
||||
|
||||
@@ -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: "값 추가",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -4217,6 +4217,10 @@ export default withEnglishFallback({
|
||||
length: "长度",
|
||||
lengthUnit: "长度单位",
|
||||
unitPlaceholder: "单位",
|
||||
geometryTypePlaceholder: "选择或输入几何类型",
|
||||
sridPlaceholder: "如 4326",
|
||||
geometrySridHint: "指定几何类型与 SRID 后方可创建空间索引并正确执行空间查询。",
|
||||
geometryEmptyTypeHint: "几何类型留空时等价于 GEOMETRY(任意子类型),将生成 geometry(GEOMETRY, SRID)。",
|
||||
enumValues: "枚举值",
|
||||
enumValueCount: "{count} 个值",
|
||||
addEnumValue: "添加值",
|
||||
|
||||
@@ -3787,6 +3787,10 @@ export default withEnglishFallback({
|
||||
length: "長度",
|
||||
lengthUnit: "長度單位",
|
||||
unitPlaceholder: "單位",
|
||||
geometryTypePlaceholder: "選擇或輸入幾何類型",
|
||||
sridPlaceholder: "如 4326",
|
||||
geometrySridHint: "指定幾何類型與 SRID 後方可建立空間索引並正確執行空間查詢。",
|
||||
geometryEmptyTypeHint: "幾何類型留空時等價於 GEOMETRY(任意子類型),將生成 geometry(GEOMETRY, SRID)。",
|
||||
typePlaceholder: "選擇或輸入……",
|
||||
noMatchingType: "沒有相符類型",
|
||||
nullable: "可為空",
|
||||
|
||||
+98
@@ -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})`;
|
||||
}
|
||||
Reference in new issue
Block a user