feat(pgsql): support vector and PostGIS indexes with extensions
* Add handling for pgvector and PostGIS extensions in migration scripts * Implement operator class and storage parameters for vector indexes * Update tests to validate new index behaviors and extension creation
This commit is contained in:
@@ -0,0 +1,459 @@
|
||||
package pgsql
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Extension describes a PostgreSQL extension RelSpec recognizes, along with the schema
|
||||
// artefacts that imply it: the types it provides (declared on TypeSpec.Extension), the
|
||||
// index access methods and operator classes it installs, and the functions whose use in a
|
||||
// default, check constraint, index predicate, or view body requires it.
|
||||
type Extension struct {
|
||||
Name string
|
||||
Category string
|
||||
Description string
|
||||
|
||||
// Requires lists extensions that must be created before this one.
|
||||
Requires []string
|
||||
|
||||
// IndexMethods are access methods usable as Index.Type.
|
||||
IndexMethods []string
|
||||
|
||||
// OperatorClasses are operator classes the extension installs.
|
||||
OperatorClasses []string
|
||||
|
||||
// Functions are function names whose use implies the extension.
|
||||
Functions []string
|
||||
|
||||
// FunctionPrefixes match whole families of functions (e.g. "st_" for PostGIS).
|
||||
FunctionPrefixes []string
|
||||
}
|
||||
|
||||
// postgresExtensions is the set of extensions RelSpec knows how to detect and emit.
|
||||
var postgresExtensions = map[string]Extension{
|
||||
"amcheck": {
|
||||
Name: "amcheck", Category: "integrity",
|
||||
Description: "Verifies B-tree and related structure consistency to help detect corruption.",
|
||||
Functions: []string{"bt_index_check", "bt_index_parent_check", "verify_heapam"},
|
||||
},
|
||||
"btree_gin": {
|
||||
Name: "btree_gin", Category: "indexing",
|
||||
Description: "Adds GIN operator classes for common scalar data types.",
|
||||
},
|
||||
"btree_gist": {
|
||||
Name: "btree_gist", Category: "indexing",
|
||||
Description: "Adds GiST operator classes for common scalar data types and exclusion constraints.",
|
||||
},
|
||||
"citext": {
|
||||
Name: "citext", Category: "text",
|
||||
Description: "Provides case-insensitive text columns and operators.",
|
||||
Functions: []string{"citext"},
|
||||
},
|
||||
"fuzzystrmatch": {
|
||||
Name: "fuzzystrmatch", Category: "text",
|
||||
Description: "Adds phonetic and fuzzy matching helpers like Soundex and Levenshtein.",
|
||||
Functions: []string{
|
||||
"soundex", "difference", "levenshtein", "levenshtein_less_equal",
|
||||
"metaphone", "dmetaphone", "dmetaphone_alt",
|
||||
},
|
||||
},
|
||||
"hstore": {
|
||||
Name: "hstore", Category: "document",
|
||||
Description: "Adds a lightweight key/value data type for semi-structured attributes.",
|
||||
OperatorClasses: []string{"gin_hstore_ops", "gist_hstore_ops", "hash_hstore_ops", "btree_hstore_ops"},
|
||||
Functions: []string{
|
||||
"hstore", "akeys", "avals", "skeys", "svals",
|
||||
"hstore_to_json", "hstore_to_jsonb", "hstore_to_array", "hstore_to_matrix",
|
||||
},
|
||||
},
|
||||
"http": {
|
||||
Name: "http", Category: "integration",
|
||||
Description: "Lets SQL functions make outbound HTTP requests.",
|
||||
Functions: []string{
|
||||
"http", "http_get", "http_post", "http_put", "http_patch", "http_delete",
|
||||
"http_head", "urlencode",
|
||||
},
|
||||
},
|
||||
"pg_background": {
|
||||
Name: "pg_background", Category: "jobs",
|
||||
Description: "Runs SQL asynchronously in PostgreSQL background workers.",
|
||||
Functions: []string{"pg_background_launch", "pg_background_result", "pg_background_detach"},
|
||||
},
|
||||
"pg_cron": {
|
||||
Name: "pg_cron", Category: "scheduling",
|
||||
Description: "Schedules recurring SQL jobs inside PostgreSQL.",
|
||||
FunctionPrefixes: []string{"cron."},
|
||||
},
|
||||
"pg_jsonschema": {
|
||||
Name: "pg_jsonschema", Category: "validation",
|
||||
Description: "Validates json and jsonb values against JSON Schema.",
|
||||
Functions: []string{"json_matches_schema", "jsonb_matches_schema", "jsonschema_is_valid"},
|
||||
},
|
||||
"pg_partman": {
|
||||
Name: "pg_partman", Category: "partitioning",
|
||||
Description: "Automates time-based and serial-based partition management.",
|
||||
FunctionPrefixes: []string{"partman."},
|
||||
},
|
||||
"pg_qualstats": {
|
||||
Name: "pg_qualstats", Category: "observability",
|
||||
Description: "Tracks predicate usage in WHERE and JOIN clauses for tuning and index advice.",
|
||||
},
|
||||
"pg_repack": {
|
||||
Name: "pg_repack", Category: "maintenance",
|
||||
Description: "Rebuilds bloated tables and indexes online with minimal locking.",
|
||||
},
|
||||
"pg_search": {
|
||||
Name: "pg_search", Category: "search",
|
||||
Description: "Provides ParadeDB full-text and relevance search features.",
|
||||
// bm25 is also the access method name used by pg_textsearch; pg_search is the
|
||||
// canonical provider, so a bm25 index resolves to it.
|
||||
IndexMethods: []string{"bm25"},
|
||||
FunctionPrefixes: []string{"paradedb."},
|
||||
},
|
||||
"pg_stat_statements": {
|
||||
Name: "pg_stat_statements", Category: "observability",
|
||||
Description: "Tracks normalized query execution statistics.",
|
||||
},
|
||||
"pg_textsearch": {
|
||||
Name: "pg_textsearch", Category: "search",
|
||||
Description: "Adds BM25-style text search support.",
|
||||
},
|
||||
"pg_trgm": {
|
||||
Name: "pg_trgm", Category: "text",
|
||||
Description: "Adds trigram similarity search and fast fuzzy matching indexes.",
|
||||
OperatorClasses: []string{"gin_trgm_ops", "gist_trgm_ops"},
|
||||
Functions: []string{
|
||||
"similarity", "word_similarity", "strict_word_similarity",
|
||||
"show_trgm", "show_limit", "set_limit",
|
||||
},
|
||||
},
|
||||
"pgcrypto": {
|
||||
Name: "pgcrypto", Category: "security",
|
||||
Description: "Adds hashing, encryption, random bytes, and UUID helpers.",
|
||||
// gen_random_uuid is deliberately absent: it is built in since PostgreSQL 13.
|
||||
Functions: []string{
|
||||
"crypt", "gen_salt", "gen_random_bytes", "digest", "hmac",
|
||||
"pgp_sym_encrypt", "pgp_sym_decrypt", "pgp_pub_encrypt", "pgp_pub_decrypt",
|
||||
"armor", "dearmor",
|
||||
},
|
||||
},
|
||||
"pgrouting": {
|
||||
Name: "pgrouting", Category: "geospatial",
|
||||
Description: "Adds routing and graph algorithms on top of PostGIS data.",
|
||||
Requires: []string{"postgis"},
|
||||
FunctionPrefixes: []string{"pgr_"},
|
||||
},
|
||||
"pgstattuple": {
|
||||
Name: "pgstattuple", Category: "maintenance",
|
||||
Description: "Reports table and index tuple density and bloat information.",
|
||||
Functions: []string{"pgstattuple", "pgstatindex", "pgstatginindex", "pg_relpages"},
|
||||
},
|
||||
"plpython3u": {
|
||||
Name: "plpython3u", Category: "procedural",
|
||||
Description: "Lets you write PostgreSQL functions in Python 3.",
|
||||
},
|
||||
"postgis": {
|
||||
Name: "postgis", Category: "geospatial",
|
||||
Description: "Adds spatial data types, functions, and indexes.",
|
||||
IndexMethods: nil, // uses the built-in gist/spgist/brin access methods
|
||||
OperatorClasses: []string{
|
||||
"gist_geometry_ops_2d", "gist_geometry_ops_nd", "gist_geography_ops",
|
||||
"spgist_geometry_ops_2d", "spgist_geometry_ops_3d", "spgist_geometry_ops_nd",
|
||||
"brin_geometry_inclusion_ops_2d", "brin_geometry_inclusion_ops_3d",
|
||||
"brin_geometry_inclusion_ops_4d", "brin_geography_inclusion_ops_2d",
|
||||
"btree_geometry_ops", "btree_geography_ops",
|
||||
},
|
||||
FunctionPrefixes: []string{"st_"},
|
||||
Functions: []string{
|
||||
"geometrytype", "addgeometrycolumn", "dropgeometrycolumn", "updategeometrysrid",
|
||||
"find_srid", "postgis_version", "postgis_full_version",
|
||||
},
|
||||
},
|
||||
"postgis_raster": {
|
||||
Name: "postgis_raster", Category: "geospatial",
|
||||
Description: "Adds the raster type and raster analysis functions.",
|
||||
Requires: []string{"postgis"},
|
||||
},
|
||||
"postgis_topology": {
|
||||
Name: "postgis_topology", Category: "geospatial",
|
||||
Description: "Adds topology-aware spatial models and validation tools.",
|
||||
Requires: []string{"postgis"},
|
||||
FunctionPrefixes: []string{"topology."},
|
||||
},
|
||||
"postgres_fdw": {
|
||||
Name: "postgres_fdw", Category: "federation",
|
||||
Description: "Connects PostgreSQL tables to other PostgreSQL servers.",
|
||||
},
|
||||
"timescaledb": {
|
||||
Name: "timescaledb", Category: "time-series",
|
||||
Description: "Adds hypertables, compression, retention, and time-series optimizations.",
|
||||
Functions: []string{
|
||||
"create_hypertable", "add_dimension", "time_bucket", "time_bucket_gapfill",
|
||||
"add_retention_policy", "add_compression_policy", "locf", "interpolate",
|
||||
},
|
||||
},
|
||||
"unaccent": {
|
||||
Name: "unaccent", Category: "text",
|
||||
Description: "Removes accents and diacritics for normalized text search.",
|
||||
Functions: []string{"unaccent"},
|
||||
},
|
||||
"uuid-ossp": {
|
||||
Name: "uuid-ossp", Category: "utility",
|
||||
Description: "Generates UUIDs using several algorithms.",
|
||||
Functions: []string{
|
||||
"uuid_generate_v1", "uuid_generate_v1mc", "uuid_generate_v3",
|
||||
"uuid_generate_v4", "uuid_generate_v5",
|
||||
"uuid_nil", "uuid_ns_dns", "uuid_ns_url", "uuid_ns_oid", "uuid_ns_x500",
|
||||
},
|
||||
},
|
||||
"vector": {
|
||||
Name: "vector", Category: "ai/search",
|
||||
Description: "Adds vector data types and similarity search for embeddings.",
|
||||
IndexMethods: []string{"hnsw", "ivfflat"},
|
||||
OperatorClasses: []string{
|
||||
"vector_l2_ops", "vector_ip_ops", "vector_cosine_ops", "vector_l1_ops",
|
||||
"halfvec_l2_ops", "halfvec_ip_ops", "halfvec_cosine_ops", "halfvec_l1_ops",
|
||||
"sparsevec_l2_ops", "sparsevec_ip_ops", "sparsevec_cosine_ops", "sparsevec_l1_ops",
|
||||
"bit_hamming_ops", "bit_jaccard_ops",
|
||||
},
|
||||
Functions: []string{"l2_distance", "inner_product", "cosine_distance", "l1_distance", "vector_dims", "vector_norm"},
|
||||
},
|
||||
"vchord": {
|
||||
Name: "vchord", Category: "ai/search",
|
||||
Description: "Adds VectorChord scalable disk-friendly vector indexes compatible with pgvector data types.",
|
||||
Requires: []string{"vector"},
|
||||
IndexMethods: []string{"vchordrq", "vchordg"},
|
||||
},
|
||||
"ltree": {
|
||||
Name: "ltree", Category: "document",
|
||||
Description: "Adds a hierarchical label tree type.",
|
||||
OperatorClasses: []string{"gist_ltree_ops", "gin_ltree_ops", "gist__ltree_ops"},
|
||||
Functions: []string{"subltree", "subpath", "nlevel", "lca", "ltree2text", "text2ltree"},
|
||||
},
|
||||
}
|
||||
|
||||
// extensionIndexMethods maps an index access method to the extension providing it.
|
||||
var extensionIndexMethods = buildExtensionIndex(func(ext Extension) []string { return ext.IndexMethods })
|
||||
|
||||
// extensionOperatorClasses maps an operator class to the extension providing it.
|
||||
var extensionOperatorClasses = buildExtensionIndex(func(ext Extension) []string { return ext.OperatorClasses })
|
||||
|
||||
// extensionFunctions maps a function name to the extension providing it.
|
||||
var extensionFunctions = buildExtensionIndex(func(ext Extension) []string { return ext.Functions })
|
||||
|
||||
// extensionFunctionPrefixes maps a function name prefix to the extension providing it.
|
||||
var extensionFunctionPrefixes = buildExtensionIndex(func(ext Extension) []string { return ext.FunctionPrefixes })
|
||||
|
||||
func buildExtensionIndex(keys func(Extension) []string) map[string]string {
|
||||
index := make(map[string]string)
|
||||
for _, ext := range postgresExtensions {
|
||||
for _, key := range keys(ext) {
|
||||
// Deterministic on collision: the alphabetically first extension wins.
|
||||
if existing, ok := index[key]; ok && existing < ext.Name {
|
||||
continue
|
||||
}
|
||||
index[key] = ext.Name
|
||||
}
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
// LookupExtension returns the registered extension by name.
|
||||
func LookupExtension(name string) (Extension, bool) {
|
||||
ext, ok := postgresExtensions[strings.ToLower(strings.TrimSpace(name))]
|
||||
return ext, ok
|
||||
}
|
||||
|
||||
// IsKnownExtension reports whether the named extension is registered.
|
||||
func IsKnownExtension(name string) bool {
|
||||
_, ok := LookupExtension(name)
|
||||
return ok
|
||||
}
|
||||
|
||||
// GetExtensions returns every registered extension name, sorted.
|
||||
func GetExtensions() []string {
|
||||
names := make([]string, 0, len(postgresExtensions))
|
||||
for name := range postgresExtensions {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// IndexMethodExtension returns the extension providing an index access method
|
||||
// ("hnsw" -> "vector", "vchordrq" -> "vchord"). Built-in methods return "".
|
||||
func IndexMethodExtension(method string) string {
|
||||
return extensionIndexMethods[strings.ToLower(strings.TrimSpace(method))]
|
||||
}
|
||||
|
||||
// OperatorClassExtension returns the extension providing an operator class
|
||||
// ("gin_trgm_ops" -> "pg_trgm"). Built-in operator classes return "".
|
||||
func OperatorClassExtension(opClass string) string {
|
||||
return extensionOperatorClasses[strings.ToLower(strings.TrimSpace(opClass))]
|
||||
}
|
||||
|
||||
// ExtensionsForExpression returns the extensions whose functions appear in a SQL
|
||||
// expression such as a column default, check constraint, index predicate, or view body.
|
||||
// The result is sorted and deduplicated.
|
||||
func ExtensionsForExpression(expression string) []string {
|
||||
if strings.TrimSpace(expression) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
lower := strings.ToLower(expression)
|
||||
found := make(map[string]bool)
|
||||
|
||||
for _, call := range sqlFunctionCalls(lower) {
|
||||
if ext, ok := extensionFunctions[call]; ok {
|
||||
found[ext] = true
|
||||
continue
|
||||
}
|
||||
for prefix, ext := range extensionFunctionPrefixes {
|
||||
if strings.HasPrefix(call, prefix) {
|
||||
found[ext] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(found) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(found))
|
||||
for name := range found {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// sqlFunctionCalls returns the lowercase names of every function call in an expression.
|
||||
// A call is an identifier (optionally schema-qualified) immediately followed by "(".
|
||||
func sqlFunctionCalls(lowerExpression string) []string {
|
||||
calls := make([]string, 0, 4)
|
||||
end := 0
|
||||
|
||||
for i := 0; i < len(lowerExpression); i++ {
|
||||
if lowerExpression[i] != '(' {
|
||||
continue
|
||||
}
|
||||
|
||||
end = i
|
||||
// Allow whitespace between the identifier and its opening parenthesis.
|
||||
for end > 0 && isSQLSpace(lowerExpression[end-1]) {
|
||||
end--
|
||||
}
|
||||
|
||||
start := end
|
||||
for start > 0 && isSQLIdentifierByte(lowerExpression[start-1]) {
|
||||
start--
|
||||
}
|
||||
if start == end {
|
||||
continue
|
||||
}
|
||||
// A leading digit means this is not an identifier (e.g. "2(").
|
||||
if lowerExpression[start] >= '0' && lowerExpression[start] <= '9' {
|
||||
continue
|
||||
}
|
||||
calls = append(calls, lowerExpression[start:end])
|
||||
}
|
||||
|
||||
return calls
|
||||
}
|
||||
|
||||
func isSQLIdentifierByte(b byte) bool {
|
||||
switch {
|
||||
case b >= 'a' && b <= 'z', b >= 'A' && b <= 'Z', b >= '0' && b <= '9':
|
||||
return true
|
||||
case b == '_', b == '.':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isSQLSpace(b byte) bool {
|
||||
return b == ' ' || b == '\t' || b == '\n' || b == '\r'
|
||||
}
|
||||
|
||||
// SortExtensions orders extension names so that dependencies come first (postgis before
|
||||
// postgis_topology, vector before vchord), with alphabetical order breaking ties.
|
||||
// Duplicates are removed; unknown names are kept and sorted alphabetically.
|
||||
func SortExtensions(names []string) []string {
|
||||
unique := make(map[string]bool, len(names))
|
||||
for _, name := range names {
|
||||
name = strings.ToLower(strings.TrimSpace(name))
|
||||
if name != "" {
|
||||
unique[name] = true
|
||||
}
|
||||
}
|
||||
if len(unique) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
pending := make([]string, 0, len(unique))
|
||||
for name := range unique {
|
||||
pending = append(pending, name)
|
||||
}
|
||||
sort.Strings(pending)
|
||||
|
||||
sorted := make([]string, 0, len(pending))
|
||||
emitted := make(map[string]bool, len(pending))
|
||||
|
||||
var emit func(name string, seen map[string]bool)
|
||||
emit = func(name string, seen map[string]bool) {
|
||||
if emitted[name] || seen[name] {
|
||||
return
|
||||
}
|
||||
seen[name] = true
|
||||
|
||||
if ext, ok := LookupExtension(name); ok {
|
||||
for _, dependency := range ext.Requires {
|
||||
// Only order dependencies that are actually being created.
|
||||
if unique[dependency] {
|
||||
emit(dependency, seen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emitted[name] = true
|
||||
sorted = append(sorted, name)
|
||||
}
|
||||
|
||||
for _, name := range pending {
|
||||
emit(name, make(map[string]bool))
|
||||
}
|
||||
return sorted
|
||||
}
|
||||
|
||||
// ExtensionDependencies returns the extensions a given extension requires, sorted.
|
||||
func ExtensionDependencies(name string) []string {
|
||||
ext, ok := LookupExtension(name)
|
||||
if !ok || len(ext.Requires) == 0 {
|
||||
return nil
|
||||
}
|
||||
requires := append([]string(nil), ext.Requires...)
|
||||
sort.Strings(requires)
|
||||
return requires
|
||||
}
|
||||
|
||||
// QuoteExtensionName quotes an extension name when it is not a bare SQL identifier,
|
||||
// e.g. uuid-ossp -> "uuid-ossp".
|
||||
func QuoteExtensionName(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
for i := 0; i < len(name); i++ {
|
||||
b := name[i]
|
||||
switch {
|
||||
case b >= 'a' && b <= 'z', b == '_':
|
||||
case b >= '0' && b <= '9' && i > 0:
|
||||
default:
|
||||
return `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package pgsql
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtensionRegistryConsistency(t *testing.T) {
|
||||
for name, ext := range postgresExtensions {
|
||||
if name != ext.Name {
|
||||
t.Errorf("extension registered as %q has Name %q", name, ext.Name)
|
||||
}
|
||||
if name != strings.ToLower(name) {
|
||||
t.Errorf("extension %q must be registered lowercase", name)
|
||||
}
|
||||
if ext.Description == "" || ext.Category == "" {
|
||||
t.Errorf("extension %q is missing a category or description", name)
|
||||
}
|
||||
for _, dependency := range ext.Requires {
|
||||
if !IsKnownExtension(dependency) {
|
||||
t.Errorf("extension %q requires unregistered extension %q", name, dependency)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every extension named by a type in the type registry must itself be registered,
|
||||
// otherwise a column type would ask for a CREATE EXTENSION nothing knows how to order.
|
||||
func TestTypeExtensionsAreRegistered(t *testing.T) {
|
||||
for typeName, spec := range postgresBaseTypes {
|
||||
if spec.Extension == "" {
|
||||
continue
|
||||
}
|
||||
if !IsKnownExtension(spec.Extension) {
|
||||
t.Errorf("type %q declares unregistered extension %q", typeName, spec.Extension)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexMethodExtension(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"hnsw": "vector",
|
||||
"ivfflat": "vector",
|
||||
"HNSW": "vector",
|
||||
"vchordrq": "vchord",
|
||||
"vchordg": "vchord",
|
||||
"bm25": "pg_search",
|
||||
"btree": "",
|
||||
"gin": "",
|
||||
"": "",
|
||||
}
|
||||
|
||||
for method, want := range tests {
|
||||
if got := IndexMethodExtension(method); got != want {
|
||||
t.Errorf("IndexMethodExtension(%q) = %q, want %q", method, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperatorClassExtension(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"gin_trgm_ops": "pg_trgm",
|
||||
"gist_trgm_ops": "pg_trgm",
|
||||
"vector_cosine_ops": "vector",
|
||||
"halfvec_l2_ops": "vector",
|
||||
"gist_ltree_ops": "ltree",
|
||||
"gist_geometry_ops_2d": "postgis",
|
||||
"jsonb_path_ops": "",
|
||||
"array_ops": "",
|
||||
"": "",
|
||||
}
|
||||
|
||||
for opClass, want := range tests {
|
||||
if got := OperatorClassExtension(opClass); got != want {
|
||||
t.Errorf("OperatorClassExtension(%q) = %q, want %q", opClass, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionsForExpression(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expression string
|
||||
want []string
|
||||
}{
|
||||
{"empty", "", nil},
|
||||
{"no functions", "status = 'active'", nil},
|
||||
{"builtin only", "now()", nil},
|
||||
{"uuid-ossp default", "uuid_generate_v4()", []string{"uuid-ossp"}},
|
||||
{"gen_random_uuid is builtin", "gen_random_uuid()", nil},
|
||||
{"pgcrypto", "crypt(password, gen_salt('bf'))", []string{"pgcrypto"}},
|
||||
{"postgis prefix", "ST_Area(geom) > 0", []string{"postgis"}},
|
||||
{"paradedb prefix", "paradedb.snippet(body)", []string{"pg_search"}},
|
||||
{"jsonschema", "json_matches_schema('{}', payload)", []string{"pg_jsonschema"}},
|
||||
{"whitespace before paren", "unaccent ('crème')", []string{"unaccent"}},
|
||||
{"multiple sorted", "ST_X(geom) = levenshtein(a, b)::float", []string{"fuzzystrmatch", "postgis"}},
|
||||
{"column named like function", "similarity_score > 0.5", nil},
|
||||
{"numeric prefix ignored", "2(3)", nil},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := ExtensionsForExpression(tt.expression); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("ExtensionsForExpression(%q) = %v, want %v", tt.expression, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortExtensions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input []string
|
||||
want []string
|
||||
}{
|
||||
{"empty", nil, nil},
|
||||
{"alphabetical", []string{"pg_trgm", "citext"}, []string{"citext", "pg_trgm"}},
|
||||
{"deduplicated", []string{"vector", "vector", " VECTOR "}, []string{"vector"}},
|
||||
{"dependency first", []string{"vchord", "vector"}, []string{"vector", "vchord"}},
|
||||
{
|
||||
"postgis dependants",
|
||||
[]string{"postgis_topology", "pgrouting", "postgis"},
|
||||
[]string{"postgis", "pgrouting", "postgis_topology"},
|
||||
},
|
||||
{"dependency not requested", []string{"vchord"}, []string{"vchord"}},
|
||||
{"unknown names kept", []string{"zzz_custom", "citext"}, []string{"citext", "zzz_custom"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := SortExtensions(tt.input); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("SortExtensions(%v) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuoteExtensionName(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"vector": "vector",
|
||||
"pg_trgm": "pg_trgm",
|
||||
"uuid-ossp": `"uuid-ossp"`,
|
||||
"PostGIS": `"PostGIS"`,
|
||||
"": "",
|
||||
}
|
||||
|
||||
for name, want := range tests {
|
||||
if got := QuoteExtensionName(name); got != want {
|
||||
t.Errorf("QuoteExtensionName(%q) = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionDependencies(t *testing.T) {
|
||||
if got := ExtensionDependencies("vchord"); !reflect.DeepEqual(got, []string{"vector"}) {
|
||||
t.Errorf("ExtensionDependencies(vchord) = %v, want [vector]", got)
|
||||
}
|
||||
if got := ExtensionDependencies("citext"); got != nil {
|
||||
t.Errorf("ExtensionDependencies(citext) = %v, want nil", got)
|
||||
}
|
||||
if got := ExtensionDependencies("not_an_extension"); got != nil {
|
||||
t.Errorf("ExtensionDependencies(not_an_extension) = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package pgsql
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Index access-method storage parameters, the WITH (...) clause of CREATE INDEX. RelSpec
|
||||
// carries them through the model in Index.Comment, so the parsing here is deliberately
|
||||
// strict: only well-formed "key = value" pairs survive, and comment prose is discarded.
|
||||
//
|
||||
// Value forms accepted:
|
||||
// - bare tokens: lists=100, m=16, deduplicate_items=true
|
||||
// - quoted strings: key_field='id' (pg_search bm25)
|
||||
// - dollar-quoted blocks: options=$$ [build.internal] lists=[4096] $$ (vchord)
|
||||
|
||||
// ExtractWithClause returns the contents of the first WITH (...) clause in s, without the
|
||||
// surrounding parentheses. Parentheses inside quoted and dollar-quoted values are ignored,
|
||||
// so a vchord TOML block survives intact. Returns "" when there is no WITH clause.
|
||||
func ExtractWithClause(s string) string {
|
||||
lower := strings.ToLower(s)
|
||||
|
||||
for offset := 0; ; {
|
||||
idx := strings.Index(lower[offset:], "with")
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
start := offset + idx
|
||||
offset = start + 4
|
||||
|
||||
// "with" must stand as its own word.
|
||||
if start > 0 && isSQLIdentifierByte(s[start-1]) {
|
||||
continue
|
||||
}
|
||||
|
||||
pos := offset
|
||||
for pos < len(s) && isSQLSpace(s[pos]) {
|
||||
pos++
|
||||
}
|
||||
if pos >= len(s) || s[pos] != '(' {
|
||||
continue
|
||||
}
|
||||
|
||||
if end, ok := matchClosingParen(s, pos); ok {
|
||||
return s[pos+1 : end]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// matchClosingParen returns the index of the ')' matching the '(' at open, skipping over
|
||||
// quoted and dollar-quoted spans.
|
||||
func matchClosingParen(s string, open int) (int, bool) {
|
||||
depth := 0
|
||||
for i := open; i < len(s); i++ {
|
||||
switch s[i] {
|
||||
case '\'':
|
||||
end, ok := skipQuoted(s, i)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
i = end
|
||||
case '$':
|
||||
if end, ok := skipDollarQuoted(s, i); ok {
|
||||
i = end
|
||||
}
|
||||
case '(':
|
||||
depth++
|
||||
case ')':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return i, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// skipQuoted returns the index of the closing quote of the single-quoted string starting
|
||||
// at start, treating ” as an escaped quote.
|
||||
func skipQuoted(s string, start int) (int, bool) {
|
||||
for i := start + 1; i < len(s); i++ {
|
||||
if s[i] != '\'' {
|
||||
continue
|
||||
}
|
||||
if i+1 < len(s) && s[i+1] == '\'' {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
return i, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// skipDollarQuoted returns the index of the last byte of the dollar-quoted block starting
|
||||
// at start ($tag$ … $tag$). Reports false when start does not open one.
|
||||
func skipDollarQuoted(s string, start int) (int, bool) {
|
||||
tagEnd := strings.IndexByte(s[start+1:], '$')
|
||||
if tagEnd < 0 {
|
||||
return 0, false
|
||||
}
|
||||
tag := s[start : start+1+tagEnd+1]
|
||||
for i := start + 1; i < len(tag); i++ {
|
||||
if !isSQLIdentifierByte(tag[i]) && tag[i] != '$' {
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
closing := strings.Index(s[start+len(tag):], tag)
|
||||
if closing < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return start + len(tag) + closing + len(tag) - 1, true
|
||||
}
|
||||
|
||||
// SplitStorageParameters splits a WITH clause body on top-level commas, leaving quoted and
|
||||
// dollar-quoted values untouched.
|
||||
func SplitStorageParameters(clause string) []string {
|
||||
parts := make([]string, 0, 4)
|
||||
depth := 0
|
||||
start := 0
|
||||
|
||||
for i := 0; i < len(clause); i++ {
|
||||
switch clause[i] {
|
||||
case '\'':
|
||||
if end, ok := skipQuoted(clause, i); ok {
|
||||
i = end
|
||||
}
|
||||
case '$':
|
||||
if end, ok := skipDollarQuoted(clause, i); ok {
|
||||
i = end
|
||||
}
|
||||
case '(', '[':
|
||||
depth++
|
||||
case ')', ']':
|
||||
depth--
|
||||
case ',':
|
||||
if depth == 0 {
|
||||
parts = append(parts, clause[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
parts = append(parts, clause[start:])
|
||||
|
||||
trimmed := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if part = strings.TrimSpace(part); part != "" {
|
||||
trimmed = append(trimmed, part)
|
||||
}
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
// ParseStorageParameter splits one "key = value" storage parameter. It reports false for
|
||||
// anything that is not a well-formed parameter, which is how comment prose is filtered out.
|
||||
func ParseStorageParameter(part string) (key, value string, ok bool) {
|
||||
key, value, found := strings.Cut(part, "=")
|
||||
if !found {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
key = strings.ToLower(strings.TrimSpace(key))
|
||||
value = strings.TrimSpace(value)
|
||||
if key == "" || value == "" || !isBareIdentifier(key) {
|
||||
return "", "", false
|
||||
}
|
||||
if !isStorageParameterValue(value) {
|
||||
return "", "", false
|
||||
}
|
||||
return key, value, true
|
||||
}
|
||||
|
||||
// FormatStorageParameters renders a WITH clause body as a canonical "key = value" list,
|
||||
// dropping anything malformed. Returns "" when nothing survives.
|
||||
func FormatStorageParameters(clause string) string {
|
||||
params := make([]string, 0, 4)
|
||||
for _, part := range SplitStorageParameters(clause) {
|
||||
key, value, ok := ParseStorageParameter(part)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
params = append(params, key+" = "+value)
|
||||
}
|
||||
return strings.Join(params, ", ")
|
||||
}
|
||||
|
||||
// NormalizeStorageParameterValue unquotes a value that PostgreSQL rendered as a string but
|
||||
// that is really a number, so that pg_indexes output (lists='100') and hand-written models
|
||||
// (lists=100) normalize identically. Non-numeric quoted values keep their quotes because
|
||||
// some access methods require a string (pg_search's key_field='id').
|
||||
func NormalizeStorageParameterValue(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) < 2 || value[0] != '\'' || value[len(value)-1] != '\'' {
|
||||
return value
|
||||
}
|
||||
|
||||
inner := strings.ReplaceAll(value[1:len(value)-1], "''", "'")
|
||||
if _, err := strconv.ParseFloat(inner, 64); err == nil {
|
||||
return inner
|
||||
}
|
||||
if strings.EqualFold(inner, "true") || strings.EqualFold(inner, "false") {
|
||||
return strings.ToLower(inner)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func isBareIdentifier(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(s); i++ {
|
||||
b := s[i]
|
||||
switch {
|
||||
case b >= 'a' && b <= 'z', b >= 'A' && b <= 'Z', b == '_':
|
||||
case b >= '0' && b <= '9' && i > 0:
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// isStorageParameterValue reports whether value is a bare token, a complete quoted string,
|
||||
// or a complete dollar-quoted block.
|
||||
func isStorageParameterValue(value string) bool {
|
||||
switch {
|
||||
case value == "":
|
||||
return false
|
||||
case value[0] == '\'':
|
||||
end, ok := skipQuoted(value, 0)
|
||||
return ok && end == len(value)-1
|
||||
case value[0] == '$':
|
||||
end, ok := skipDollarQuoted(value, 0)
|
||||
return ok && end == len(value)-1
|
||||
}
|
||||
|
||||
for i := 0; i < len(value); i++ {
|
||||
b := value[i]
|
||||
switch {
|
||||
case b >= 'a' && b <= 'z', b >= 'A' && b <= 'Z', b >= '0' && b <= '9':
|
||||
case b == '_', b == '.', b == '-', b == '+':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package pgsql
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractWithClause(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"empty", "", ""},
|
||||
{"no clause", "opclass=vector_cosine_ops", ""},
|
||||
{"simple", "WITH (lists=100)", "lists=100"},
|
||||
{"lowercase", "with (m = 16, ef_construction = 64)", "m = 16, ef_construction = 64"},
|
||||
{
|
||||
"index definition",
|
||||
"CREATE INDEX i ON t USING ivfflat (embedding vector_cosine_ops) WITH (lists='100')",
|
||||
"lists='100'",
|
||||
},
|
||||
{"paren inside quotes", "with (key_field='id(x)')", "key_field='id(x)'"},
|
||||
{"dollar quoted", "with (options = $$f(x)$$)", "options = $$f(x)$$"},
|
||||
{"word boundary", "swith (lists=100)", ""},
|
||||
{"not followed by paren", "with lists=100", ""},
|
||||
{"unterminated", "with (lists=100", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := ExtractWithClause(tt.input); got != tt.want {
|
||||
t.Errorf("ExtractWithClause(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitStorageParameters(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want []string
|
||||
}{
|
||||
{"empty", "", []string{}},
|
||||
{"single", "lists=100", []string{"lists=100"}},
|
||||
{"multiple", "m = 16, ef_construction = 64", []string{"m = 16", "ef_construction = 64"}},
|
||||
{"comma in quotes", "key_field='a,b', m=16", []string{"key_field='a,b'", "m=16"}},
|
||||
{"comma in dollar quotes", "options=$$a,b$$, m=16", []string{"options=$$a,b$$", "m=16"}},
|
||||
{"comma in brackets", "options=[1,2], m=16", []string{"options=[1,2]", "m=16"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := SplitStorageParameters(tt.input); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("SplitStorageParameters(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStorageParameter(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantKey string
|
||||
wantValue string
|
||||
wantOK bool
|
||||
}{
|
||||
{"bare", "lists=100", "lists", "100", true},
|
||||
{"spaced and uppercased key", " Lists = 100 ", "lists", "100", true},
|
||||
{"quoted", "key_field='id'", "key_field", "'id'", true},
|
||||
{"dollar quoted", "options=$$a$$", "options", "$$a$$", true},
|
||||
{"boolean", "deduplicate_items=true", "deduplicate_items", "true", true},
|
||||
{"float", "fillfactor=90.5", "fillfactor", "90.5", true},
|
||||
{"no equals", "please drop everything", "", "", false},
|
||||
{"empty value", "lists=", "", "", false},
|
||||
{"quoted key rejected", "'lists'=100", "", "", false},
|
||||
{"injection rejected", "lists=100); drop table t", "", "", false},
|
||||
{"unterminated quote rejected", "key_field='id", "", "", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
key, value, ok := ParseStorageParameter(tt.input)
|
||||
if key != tt.wantKey || value != tt.wantValue || ok != tt.wantOK {
|
||||
t.Errorf("ParseStorageParameter(%q) = (%q, %q, %v), want (%q, %q, %v)",
|
||||
tt.input, key, value, ok, tt.wantKey, tt.wantValue, tt.wantOK)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeStorageParameterValue(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"'100'": "100",
|
||||
"'90.5'": "90.5",
|
||||
"'true'": "true",
|
||||
"'id'": "'id'",
|
||||
"100": "100",
|
||||
"$$a,b$$": "$$a,b$$",
|
||||
"'": "'",
|
||||
"''": "''",
|
||||
}
|
||||
|
||||
for input, want := range tests {
|
||||
if got := NormalizeStorageParameterValue(input); got != want {
|
||||
t.Errorf("NormalizeStorageParameterValue(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+100
-8
@@ -2,6 +2,7 @@ package pgsql
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -9,6 +10,14 @@ import (
|
||||
type TypeSpec struct {
|
||||
SupportsLength bool
|
||||
SupportsPrecision bool
|
||||
|
||||
// SupportsTypeModifier marks types whose "(...)" modifier is opaque and must be
|
||||
// preserved verbatim (e.g. vector(1536), geometry(Point,4326)) instead of being
|
||||
// decomposed into Length/Precision/Scale.
|
||||
SupportsTypeModifier bool
|
||||
|
||||
// Extension is the PostgreSQL extension providing the type; empty for built-ins.
|
||||
Extension string
|
||||
}
|
||||
|
||||
var postgresBaseTypes = map[string]TypeSpec{
|
||||
@@ -104,14 +113,28 @@ var postgresBaseTypes = map[string]TypeSpec{
|
||||
"void": {},
|
||||
|
||||
// Common extensions
|
||||
"citext": {},
|
||||
"hstore": {},
|
||||
"ltree": {},
|
||||
"lquery": {},
|
||||
"ltxtquery": {},
|
||||
"vector": {}, // pgvector: keep explicit modifier form (vector(dim))
|
||||
"halfvec": {}, // pgvector: keep explicit modifier form (halfvec(dim))
|
||||
"sparsevec": {}, // pgvector: keep explicit modifier form (sparsevec(dim))
|
||||
"citext": {Extension: "citext"},
|
||||
"hstore": {Extension: "hstore"},
|
||||
"ltree": {Extension: "ltree"},
|
||||
"lquery": {Extension: "ltree"},
|
||||
"ltxtquery": {Extension: "ltree"},
|
||||
|
||||
// pgvector: modifier form is opaque (vector(dim), sparsevec(dim))
|
||||
"vector": {SupportsTypeModifier: true, Extension: "vector"},
|
||||
"halfvec": {SupportsTypeModifier: true, Extension: "vector"},
|
||||
"sparsevec": {SupportsTypeModifier: true, Extension: "vector"},
|
||||
|
||||
// PostGIS: geometry/geography carry an opaque modifier (geometry(PointZ,4326))
|
||||
"geometry": {SupportsTypeModifier: true, Extension: "postgis"},
|
||||
"geography": {SupportsTypeModifier: true, Extension: "postgis"},
|
||||
"box2d": {Extension: "postgis"},
|
||||
"box3d": {Extension: "postgis"},
|
||||
"geometry_dump": {Extension: "postgis"},
|
||||
"geomval": {Extension: "postgis"},
|
||||
"spheroid": {Extension: "postgis"},
|
||||
"valid_detail": {Extension: "postgis"},
|
||||
"raster": {SupportsTypeModifier: true, Extension: "postgis_raster"},
|
||||
"topogeometry": {Extension: "postgis_topology"},
|
||||
}
|
||||
|
||||
var postgresTypeAliases = map[string]string{
|
||||
@@ -346,3 +369,72 @@ func stripArraySuffixes(t string) string {
|
||||
func normalizeTypeToken(t string) string {
|
||||
return strings.Join(strings.Fields(strings.TrimSpace(t)), " ")
|
||||
}
|
||||
|
||||
// SupportsTypeModifier reports if this SQL type carries an opaque "(...)" modifier
|
||||
// that must be preserved verbatim (e.g. vector(1536), geometry(Point,4326)).
|
||||
func SupportsTypeModifier(sqlType string) bool {
|
||||
base := CanonicalizeBaseType(ExtractBaseTypeLower(sqlType))
|
||||
spec, ok := postgresBaseTypes[base]
|
||||
return ok && spec.SupportsTypeModifier
|
||||
}
|
||||
|
||||
// TypeExtension returns the PostgreSQL extension providing the given type
|
||||
// ("postgis", "vector", "citext", …). Built-in types return "".
|
||||
func TypeExtension(sqlType string) string {
|
||||
base := CanonicalizeBaseType(ExtractBaseTypeLower(sqlType))
|
||||
return postgresBaseTypes[base].Extension
|
||||
}
|
||||
|
||||
// IsSpatialType reports whether the type comes from PostGIS (geometry, geography,
|
||||
// raster, topogeometry, …).
|
||||
func IsSpatialType(sqlType string) bool {
|
||||
return strings.HasPrefix(TypeExtension(sqlType), "postgis")
|
||||
}
|
||||
|
||||
// IsVectorType reports whether the type comes from pgvector (vector, halfvec, sparsevec).
|
||||
func IsVectorType(sqlType string) bool {
|
||||
return TypeExtension(sqlType) == "vector"
|
||||
}
|
||||
|
||||
// TypeModifier returns the raw "(...)" modifier of a SQL type without the parentheses,
|
||||
// or "" when the type has none. Array suffixes are ignored.
|
||||
// Example: geometry(PointZ,4326)[] -> "PointZ,4326".
|
||||
func TypeModifier(sqlType string) string {
|
||||
t := stripArraySuffixes(normalizeTypeToken(sqlType))
|
||||
start := strings.Index(t, "(")
|
||||
end := strings.LastIndex(t, ")")
|
||||
if start < 0 || end < start {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(t[start+1 : end])
|
||||
}
|
||||
|
||||
// SpatialSRID returns the SRID declared in a PostGIS type modifier, or 0 when absent.
|
||||
// Example: geometry(Point,4326) -> 4326.
|
||||
func SpatialSRID(sqlType string) int {
|
||||
if !IsSpatialType(sqlType) {
|
||||
return 0
|
||||
}
|
||||
parts := strings.Split(TypeModifier(sqlType), ",")
|
||||
if len(parts) < 2 {
|
||||
return 0
|
||||
}
|
||||
srid, err := strconv.Atoi(strings.TrimSpace(parts[len(parts)-1]))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return srid
|
||||
}
|
||||
|
||||
// SpatialGeometryType returns the geometry subtype declared in a PostGIS type modifier
|
||||
// ("Point", "MultiPolygonZ", …), or "" when absent.
|
||||
func SpatialGeometryType(sqlType string) string {
|
||||
if !IsSpatialType(sqlType) {
|
||||
return ""
|
||||
}
|
||||
modifier := TypeModifier(sqlType)
|
||||
if modifier == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(strings.Split(modifier, ",")[0])
|
||||
}
|
||||
|
||||
@@ -145,3 +145,104 @@ func TestEquivalentSQLTypeVariants(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionTypes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
sqlType string
|
||||
wantKnown bool
|
||||
wantExtension string
|
||||
wantSpatial bool
|
||||
wantVector bool
|
||||
wantModifier bool
|
||||
}{
|
||||
{"geometry with modifier", "geometry(Point,4326)", true, "postgis", true, false, true},
|
||||
{"geography", "geography", true, "postgis", true, false, true},
|
||||
{"geometry array", "geometry[]", true, "postgis", true, false, true},
|
||||
{"box2d", "box2d", true, "postgis", true, false, false},
|
||||
{"raster", "raster", true, "postgis_raster", true, false, true},
|
||||
{"topogeometry", "topogeometry", true, "postgis_topology", true, false, false},
|
||||
{"vector", "vector(1536)", true, "vector", false, true, true},
|
||||
{"halfvec", "halfvec(768)", true, "vector", false, true, true},
|
||||
{"sparsevec", "sparsevec(1000)", true, "vector", false, true, true},
|
||||
{"citext", "citext", true, "citext", false, false, false},
|
||||
{"builtin text", "text", true, "", false, false, false},
|
||||
{"builtin point is not postgis", "point", true, "", false, false, false},
|
||||
{"unknown type", "mytype", false, "", false, false, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := IsKnownPostgresType(tt.sqlType); got != tt.wantKnown {
|
||||
t.Errorf("IsKnownPostgresType(%q) = %v, want %v", tt.sqlType, got, tt.wantKnown)
|
||||
}
|
||||
if got := TypeExtension(tt.sqlType); got != tt.wantExtension {
|
||||
t.Errorf("TypeExtension(%q) = %q, want %q", tt.sqlType, got, tt.wantExtension)
|
||||
}
|
||||
if got := IsSpatialType(tt.sqlType); got != tt.wantSpatial {
|
||||
t.Errorf("IsSpatialType(%q) = %v, want %v", tt.sqlType, got, tt.wantSpatial)
|
||||
}
|
||||
if got := IsVectorType(tt.sqlType); got != tt.wantVector {
|
||||
t.Errorf("IsVectorType(%q) = %v, want %v", tt.sqlType, got, tt.wantVector)
|
||||
}
|
||||
if got := SupportsTypeModifier(tt.sqlType); got != tt.wantModifier {
|
||||
t.Errorf("SupportsTypeModifier(%q) = %v, want %v", tt.sqlType, got, tt.wantModifier)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionTypesDoNotSupportLengthOrPrecision(t *testing.T) {
|
||||
for _, sqlType := range []string{"geometry(Point,4326)", "geography", "vector(1536)", "halfvec(768)"} {
|
||||
if SupportsLength(sqlType) {
|
||||
t.Errorf("SupportsLength(%q) = true, want false", sqlType)
|
||||
}
|
||||
if SupportsPrecision(sqlType) {
|
||||
t.Errorf("SupportsPrecision(%q) = true, want false", sqlType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpatialTypeModifier(t *testing.T) {
|
||||
tests := []struct {
|
||||
sqlType string
|
||||
wantModifier string
|
||||
wantGeomType string
|
||||
wantSRID int
|
||||
}{
|
||||
{"geometry(Point,4326)", "Point,4326", "Point", 4326},
|
||||
{"geometry(MultiPolygonZ, 3857)", "MultiPolygonZ, 3857", "MultiPolygonZ", 3857},
|
||||
{"geography(Point)", "Point", "Point", 0},
|
||||
{"geometry", "", "", 0},
|
||||
{"geometry(Point,4326)[]", "Point,4326", "Point", 4326},
|
||||
{"vector(1536)", "1536", "", 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.sqlType, func(t *testing.T) {
|
||||
if got := TypeModifier(tt.sqlType); got != tt.wantModifier {
|
||||
t.Errorf("TypeModifier() = %q, want %q", got, tt.wantModifier)
|
||||
}
|
||||
if got := SpatialGeometryType(tt.sqlType); got != tt.wantGeomType {
|
||||
t.Errorf("SpatialGeometryType() = %q, want %q", got, tt.wantGeomType)
|
||||
}
|
||||
if got := SpatialSRID(tt.sqlType); got != tt.wantSRID {
|
||||
t.Errorf("SpatialSRID() = %d, want %d", got, tt.wantSRID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeEquivalentSQLTypePreservesExtensionModifiers(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"geometry(Point,4326)": "geometry(Point,4326)",
|
||||
"vector(1536)": "vector(1536)",
|
||||
"geography(Point)[]": "geography(Point)[]",
|
||||
}
|
||||
|
||||
for input, want := range tests {
|
||||
if got := NormalizeEquivalentSQLType(input); got != want {
|
||||
t.Errorf("NormalizeEquivalentSQLType(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user