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:
@@ -128,6 +128,27 @@ sessions so they are identifiable in `pg_stat_activity`. If you provide
|
||||
- Sequence properties
|
||||
- Associated tables
|
||||
|
||||
## Extension Types (PostGIS, pgvector)
|
||||
|
||||
- Extension column types keep their catalog-formatted form: `geometry(Point,4326)`,
|
||||
`geography(Point)`, `vector(1536)`, `halfvec(768)`, `citext`, arrays included.
|
||||
- Built-in types are canonicalized and their dimensions moved to
|
||||
`Column.Length` / `Precision` / `Scale`; extension modifiers stay in `Column.Type`.
|
||||
- Index access methods are read from the definition as-is: `gist`, `spgist`, `brin`, `hnsw`,
|
||||
`ivfflat`, `vchordrq`, `vchordg`, `bm25`.
|
||||
- Operator class and `WITH (...)` parameters have no model field, so they are stored in
|
||||
`Index.Comment` in the form the PostgreSQL writer reads back:
|
||||
|
||||
```
|
||||
opclass=vector_cosine_ops; with (m=16, ef_construction=64)
|
||||
```
|
||||
|
||||
Ordering modifiers (`DESC`, `NULLS LAST`, `COLLATE`) are not treated as operator classes.
|
||||
Numeric parameter values are unquoted (`lists='100'` -> `lists=100`); string values keep
|
||||
their quotes (`key_field='id'`), and dollar-quoted values are preserved whole.
|
||||
- Installed extensions are read from `pg_extension` into `schema.Metadata["extensions"]`
|
||||
(only extensions RelSpec recognizes), so a read/write round-trip re-creates them.
|
||||
|
||||
## Notes
|
||||
|
||||
- Requires PostgreSQL connection permissions
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
||||
"git.warky.dev/wdevs/relspecgo/pkg/pgsql"
|
||||
)
|
||||
|
||||
// querySchemas retrieves all non-system schemas from the database
|
||||
@@ -46,6 +47,41 @@ func (r *Reader) querySchemas() ([]*models.Schema, error) {
|
||||
return schemas, rows.Err()
|
||||
}
|
||||
|
||||
// queryExtensions retrieves the extensions installed into a schema. Only extensions RelSpec
|
||||
// recognizes are kept, so a round-trip never emits a CREATE EXTENSION the writer cannot
|
||||
// order; plpgsql is not registered and is therefore skipped along with other built-ins.
|
||||
func (r *Reader) queryExtensions(schemaName string) ([]string, error) {
|
||||
query := `
|
||||
SELECT e.extname
|
||||
FROM pg_extension e
|
||||
JOIN pg_namespace n ON n.oid = e.extnamespace
|
||||
WHERE n.nspname = $1
|
||||
ORDER BY e.extname
|
||||
`
|
||||
|
||||
rows, err := r.conn.Query(r.ctx, query, schemaName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
extensions := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if pgsql.IsKnownExtension(name) {
|
||||
extensions = append(extensions, name)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return pgsql.SortExtensions(extensions), nil
|
||||
}
|
||||
|
||||
// queryTables retrieves all tables for a given schema
|
||||
func (r *Reader) queryTables(schemaName string) ([]*models.Table, error) {
|
||||
query := `
|
||||
@@ -597,6 +633,7 @@ func (r *Reader) parseIndexDefinition(indexName, tableName, schema, indexDef str
|
||||
}
|
||||
|
||||
// Extract columns - pattern: (column1, column2, ...)
|
||||
opClass := ""
|
||||
columnsRegex := regexp.MustCompile(`\(([^)]+)\)`)
|
||||
if matches := columnsRegex.FindStringSubmatch(indexDef); len(matches) > 1 {
|
||||
columnsStr := matches[1]
|
||||
@@ -604,8 +641,17 @@ func (r *Reader) parseIndexDefinition(indexName, tableName, schema, indexDef str
|
||||
columnParts := strings.Split(columnsStr, ",")
|
||||
for _, col := range columnParts {
|
||||
col = strings.TrimSpace(col)
|
||||
fields := strings.Fields(col)
|
||||
if len(fields) == 0 {
|
||||
continue
|
||||
}
|
||||
// Remember an explicit operator class (e.g. "embedding vector_cosine_ops")
|
||||
// so the writer can reproduce it; ordering modifiers are not operator classes.
|
||||
if opClass == "" && len(fields) > 1 {
|
||||
opClass = extractIndexOperatorClass(fields[1:])
|
||||
}
|
||||
// Remove any ordering (ASC/DESC) or other modifiers
|
||||
col = strings.Fields(col)[0]
|
||||
col = fields[0]
|
||||
// Remove parentheses if it's an expression
|
||||
if !strings.Contains(col, "(") {
|
||||
index.Columns = append(index.Columns, col)
|
||||
@@ -613,6 +659,15 @@ func (r *Reader) parseIndexDefinition(indexName, tableName, schema, indexDef str
|
||||
}
|
||||
}
|
||||
|
||||
// Extract access method storage parameters, e.g. WITH (lists='100')
|
||||
storageParams := normalizeIndexStorageParams(pgsql.ExtractWithClause(indexDef))
|
||||
|
||||
// Operator class and storage parameters have no dedicated model fields; carry them in
|
||||
// the comment hint the PostgreSQL writer reads back.
|
||||
if hint := buildIndexHint(opClass, storageParams); hint != "" && index.Comment == "" {
|
||||
index.Comment = hint
|
||||
}
|
||||
|
||||
// Extract WHERE clause for partial indexes
|
||||
whereRegex := regexp.MustCompile(`WHERE\s+(.+)$`)
|
||||
if matches := whereRegex.FindStringSubmatch(indexDef); len(matches) > 1 {
|
||||
@@ -622,6 +677,52 @@ func (r *Reader) parseIndexDefinition(indexName, tableName, schema, indexDef str
|
||||
return index, nil
|
||||
}
|
||||
|
||||
// indexOrderingKeywords are column modifiers that are not operator classes.
|
||||
var indexOrderingKeywords = map[string]bool{
|
||||
"asc": true, "desc": true, "nulls": true, "first": true, "last": true, "collate": true,
|
||||
}
|
||||
|
||||
// extractIndexOperatorClass picks the operator class out of a column's trailing modifiers.
|
||||
// Returns "" when the modifiers are only ordering keywords.
|
||||
func extractIndexOperatorClass(modifiers []string) string {
|
||||
for _, modifier := range modifiers {
|
||||
lower := strings.ToLower(strings.TrimSpace(modifier))
|
||||
if lower == "" || indexOrderingKeywords[lower] {
|
||||
continue
|
||||
}
|
||||
return lower
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// normalizeIndexStorageParams rewrites "m='16', ef_construction='64'" as "m=16,
|
||||
// ef_construction=64". Non-numeric values keep their quotes because some access methods
|
||||
// require a string literal (pg_search's key_field='id').
|
||||
func normalizeIndexStorageParams(params string) string {
|
||||
normalized := make([]string, 0, 4)
|
||||
for _, part := range pgsql.SplitStorageParameters(params) {
|
||||
key, value, ok := pgsql.ParseStorageParameter(part)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
normalized = append(normalized, key+"="+pgsql.NormalizeStorageParameterValue(value))
|
||||
}
|
||||
return strings.Join(normalized, ", ")
|
||||
}
|
||||
|
||||
// buildIndexHint renders the operator class and storage parameters in the form the
|
||||
// PostgreSQL writer parses back out of an index comment.
|
||||
func buildIndexHint(opClass, storageParams string) string {
|
||||
parts := make([]string, 0, 2)
|
||||
if opClass != "" {
|
||||
parts = append(parts, "opclass="+opClass)
|
||||
}
|
||||
if storageParams != "" {
|
||||
parts = append(parts, "with ("+storageParams+")")
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// normalizePostgresDefault converts a raw PostgreSQL column_default expression into the
|
||||
// unquoted string value that the model convention expects. PostgreSQL stores string
|
||||
// literal defaults as 'value' or 'value'::type (e.g. '{}'::text[]), while every other
|
||||
|
||||
@@ -88,6 +88,18 @@ func (r *Reader) ReadDatabase() (*models.Database, error) {
|
||||
}
|
||||
schema.Sequences = sequences
|
||||
|
||||
// Query extensions installed into this schema
|
||||
extensions, err := r.queryExtensions(schema.Name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query extensions for schema %s: %w", schema.Name, err)
|
||||
}
|
||||
if len(extensions) > 0 {
|
||||
if schema.Metadata == nil {
|
||||
schema.Metadata = make(map[string]any)
|
||||
}
|
||||
schema.Metadata["extensions"] = extensions
|
||||
}
|
||||
|
||||
// Query columns for tables and views
|
||||
columnsMap, err := r.queryColumns(schema.Name)
|
||||
if err != nil {
|
||||
@@ -278,11 +290,6 @@ func (r *Reader) mapDataType(pgType, udtName, formattedType string, hasNextval b
|
||||
}
|
||||
}
|
||||
|
||||
// information_schema reports arrays generically as "ARRAY" with udt_name like "_text".
|
||||
if strings.EqualFold(pgType, "ARRAY") && strings.HasPrefix(udtName, "_") && len(udtName) > 1 {
|
||||
return udtName[1:] + "[]"
|
||||
}
|
||||
|
||||
// Use the database-formatted type when available. For known built-in types, strip
|
||||
// embedded dimensions (they are stored in column.Length/Precision/Scale separately).
|
||||
// For unknown/custom types, keep the full formatted string (e.g. vector(1536)).
|
||||
@@ -303,6 +310,13 @@ func (r *Reader) mapDataType(pgType, udtName, formattedType string, hasNextval b
|
||||
return formattedType
|
||||
}
|
||||
|
||||
// information_schema reports arrays generically as "ARRAY" with udt_name like "_text".
|
||||
// Only reached when the catalog-formatted type is unavailable, which is the one case
|
||||
// where the element modifier (e.g. geometry(Point,4326)[]) cannot be recovered.
|
||||
if strings.EqualFold(pgType, "ARRAY") && strings.HasPrefix(udtName, "_") && len(udtName) > 1 {
|
||||
return udtName[1:] + "[]"
|
||||
}
|
||||
|
||||
// Fall back to normalizing the information_schema type name directly.
|
||||
canonical := pgsql.NormalizePGType(normalizedPGType)
|
||||
if pgsql.IsKnownPGBaseType(canonical) {
|
||||
|
||||
@@ -392,3 +392,101 @@ func BenchmarkReader_ReadDatabase(b *testing.B) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIndexDefinition_ExtensionIndexes(t *testing.T) {
|
||||
reader := &Reader{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
indexDef string
|
||||
wantType string
|
||||
wantColumns []string
|
||||
wantComment string
|
||||
}{
|
||||
{
|
||||
name: "hnsw vector index with storage parameters",
|
||||
indexDef: "CREATE INDEX idx_docs_embedding ON public.docs USING hnsw (embedding vector_cosine_ops) WITH (m='16', ef_construction='64')",
|
||||
wantType: "hnsw",
|
||||
wantColumns: []string{"embedding"},
|
||||
wantComment: "opclass=vector_cosine_ops; with (m=16, ef_construction=64)",
|
||||
},
|
||||
{
|
||||
name: "ivfflat vector index",
|
||||
indexDef: "CREATE INDEX idx_docs_embedding ON public.docs USING ivfflat (embedding vector_l2_ops) WITH (lists='100')",
|
||||
wantType: "ivfflat",
|
||||
wantColumns: []string{"embedding"},
|
||||
wantComment: "opclass=vector_l2_ops; with (lists=100)",
|
||||
},
|
||||
{
|
||||
name: "gist geometry index with default operator class",
|
||||
indexDef: "CREATE INDEX idx_places_geom ON public.places USING gist (geom)",
|
||||
wantType: "gist",
|
||||
wantColumns: []string{"geom"},
|
||||
wantComment: "",
|
||||
},
|
||||
{
|
||||
name: "gist geometry index with explicit operator class",
|
||||
indexDef: "CREATE INDEX idx_places_geom ON public.places USING gist (geom gist_geometry_ops_nd)",
|
||||
wantType: "gist",
|
||||
wantColumns: []string{"geom"},
|
||||
wantComment: "opclass=gist_geometry_ops_nd",
|
||||
},
|
||||
{
|
||||
name: "btree ordering modifiers are not operator classes",
|
||||
indexDef: "CREATE INDEX idx_users_created ON public.users USING btree (created_at DESC NULLS LAST)",
|
||||
wantType: "btree",
|
||||
wantColumns: []string{"created_at"},
|
||||
wantComment: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
index, err := reader.parseIndexDefinition("idx", "tbl", "public", tt.indexDef)
|
||||
if err != nil {
|
||||
t.Fatalf("parseIndexDefinition() error = %v", err)
|
||||
}
|
||||
|
||||
if index.Type != tt.wantType {
|
||||
t.Errorf("Type = %q, want %q", index.Type, tt.wantType)
|
||||
}
|
||||
if len(index.Columns) != len(tt.wantColumns) {
|
||||
t.Fatalf("Columns = %v, want %v", index.Columns, tt.wantColumns)
|
||||
}
|
||||
for i, col := range tt.wantColumns {
|
||||
if index.Columns[i] != col {
|
||||
t.Errorf("Columns[%d] = %q, want %q", i, index.Columns[i], col)
|
||||
}
|
||||
}
|
||||
if index.Comment != tt.wantComment {
|
||||
t.Errorf("Comment = %q, want %q", index.Comment, tt.wantComment)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapDataType_ExtensionTypesPreserveModifiers(t *testing.T) {
|
||||
reader := &Reader{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
pgType string
|
||||
udtName string
|
||||
formattedType string
|
||||
want string
|
||||
}{
|
||||
{"postgis geometry", "USER-DEFINED", "geometry", "geometry(Point,4326)", "geometry(Point,4326)"},
|
||||
{"postgis geography", "USER-DEFINED", "geography", "geography(Point,4326)", "geography(Point,4326)"},
|
||||
{"postgis geometry without modifier", "USER-DEFINED", "geometry", "geometry", "geometry"},
|
||||
{"pgvector halfvec", "USER-DEFINED", "halfvec", "halfvec(768)", "halfvec(768)"},
|
||||
{"postgis geometry array", "ARRAY", "_geometry", "geometry(Point,4326)[]", "geometry(Point,4326)[]"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := reader.mapDataType(tt.pgType, tt.udtName, tt.formattedType, false); got != tt.want {
|
||||
t.Errorf("mapDataType() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user