* 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
261 lines
7.3 KiB
Go
261 lines
7.3 KiB
Go
package pgsql
|
|
|
|
import (
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.warky.dev/wdevs/relspecgo/pkg/models"
|
|
"git.warky.dev/wdevs/relspecgo/pkg/writers"
|
|
)
|
|
|
|
// buildExtensionSchema returns a single-table schema the extension detection tests mutate.
|
|
func buildExtensionSchema(t *testing.T) (*models.Schema, *models.Table) {
|
|
t.Helper()
|
|
|
|
schema := models.InitSchema("public")
|
|
table := models.InitTable("documents", "public")
|
|
schema.Tables = append(schema.Tables, table)
|
|
return schema, table
|
|
}
|
|
|
|
func addColumn(table *models.Table, name, sqlType string) *models.Column {
|
|
col := models.InitColumn(name, table.Name, table.Schema)
|
|
col.Type = sqlType
|
|
table.Columns[name] = col
|
|
return col
|
|
}
|
|
|
|
func TestRequiredExtensions_Detection(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
build func(schema *models.Schema, table *models.Table)
|
|
want []string
|
|
}{
|
|
{
|
|
name: "no extensions",
|
|
build: func(_ *models.Schema, table *models.Table) { addColumn(table, "id", "integer") },
|
|
want: nil,
|
|
},
|
|
{
|
|
name: "column type",
|
|
build: func(_ *models.Schema, table *models.Table) {
|
|
addColumn(table, "embedding", "vector(1536)")
|
|
addColumn(table, "name", "citext")
|
|
},
|
|
want: []string{"citext", "vector"},
|
|
},
|
|
{
|
|
name: "column default function",
|
|
build: func(_ *models.Schema, table *models.Table) {
|
|
addColumn(table, "id", "uuid").Default = "uuid_generate_v4()"
|
|
},
|
|
want: []string{"uuid-ossp"},
|
|
},
|
|
{
|
|
name: "check constraint expression",
|
|
build: func(_ *models.Schema, table *models.Table) {
|
|
addColumn(table, "geom", "geometry")
|
|
table.Constraints["chk_geom"] = &models.Constraint{
|
|
Name: "chk_geom",
|
|
Type: models.CheckConstraint,
|
|
Expression: "ST_IsValid(geom)",
|
|
}
|
|
},
|
|
want: []string{"postgis"},
|
|
},
|
|
{
|
|
name: "partial index predicate",
|
|
build: func(_ *models.Schema, table *models.Table) {
|
|
addColumn(table, "title", "text")
|
|
table.Indexes["idx_title"] = &models.Index{
|
|
Name: "idx_title",
|
|
Type: "btree",
|
|
Columns: []string{"title"},
|
|
Where: "similarity(title, 'x') > 0.3",
|
|
}
|
|
},
|
|
want: []string{"pg_trgm"},
|
|
},
|
|
{
|
|
name: "view definition",
|
|
build: func(schema *models.Schema, table *models.Table) {
|
|
addColumn(table, "title", "text")
|
|
schema.Views = append(schema.Views, &models.View{
|
|
Name: "v_documents",
|
|
Schema: "public",
|
|
Definition: "SELECT unaccent(title) FROM documents",
|
|
})
|
|
},
|
|
want: []string{"unaccent"},
|
|
},
|
|
{
|
|
name: "index access method",
|
|
build: func(_ *models.Schema, table *models.Table) {
|
|
addColumn(table, "body", "text")
|
|
table.Indexes["idx_body"] = &models.Index{
|
|
Name: "idx_body",
|
|
Type: "bm25",
|
|
Columns: []string{"body"},
|
|
Comment: "with (key_field='id')",
|
|
}
|
|
},
|
|
want: []string{"pg_search"},
|
|
},
|
|
{
|
|
name: "vchord depends on vector",
|
|
build: func(_ *models.Schema, table *models.Table) {
|
|
addColumn(table, "embedding", "vector(3)")
|
|
table.Indexes["idx_embedding"] = &models.Index{
|
|
Name: "idx_embedding",
|
|
Type: "vchordrq",
|
|
Columns: []string{"embedding"},
|
|
}
|
|
},
|
|
want: []string{"vector", "vchord"},
|
|
},
|
|
{
|
|
name: "gin on scalar needs btree_gin",
|
|
build: func(_ *models.Schema, table *models.Table) {
|
|
addColumn(table, "views", "integer")
|
|
table.Indexes["idx_views"] = &models.Index{
|
|
Name: "idx_views",
|
|
Type: "gin",
|
|
Columns: []string{"views"},
|
|
}
|
|
},
|
|
want: []string{"btree_gin"},
|
|
},
|
|
{
|
|
name: "gist on scalar needs btree_gist",
|
|
build: func(_ *models.Schema, table *models.Table) {
|
|
addColumn(table, "views", "integer")
|
|
table.Indexes["idx_views"] = &models.Index{
|
|
Name: "idx_views",
|
|
Type: "gist",
|
|
Columns: []string{"views"},
|
|
}
|
|
},
|
|
want: []string{"btree_gist"},
|
|
},
|
|
{
|
|
name: "gist on geometry uses postgis operator classes",
|
|
build: func(_ *models.Schema, table *models.Table) {
|
|
addColumn(table, "location", "geometry(Point,4326)")
|
|
table.Indexes["idx_location"] = &models.Index{
|
|
Name: "idx_location",
|
|
Type: "gist",
|
|
Columns: []string{"location"},
|
|
}
|
|
},
|
|
want: []string{"postgis"},
|
|
},
|
|
{
|
|
name: "gin on jsonb needs no companion",
|
|
build: func(_ *models.Schema, table *models.Table) {
|
|
addColumn(table, "payload", "jsonb")
|
|
table.Indexes["idx_payload"] = &models.Index{
|
|
Name: "idx_payload",
|
|
Type: "gin",
|
|
Columns: []string{"payload"},
|
|
}
|
|
},
|
|
want: nil,
|
|
},
|
|
{
|
|
name: "gin on text uses pg_trgm",
|
|
build: func(_ *models.Schema, table *models.Table) {
|
|
addColumn(table, "title", "text")
|
|
table.Indexes["idx_title"] = &models.Index{
|
|
Name: "idx_title",
|
|
Type: "gin",
|
|
Columns: []string{"title"},
|
|
}
|
|
},
|
|
want: []string{"pg_trgm"},
|
|
},
|
|
{
|
|
name: "gin on array needs no companion",
|
|
build: func(_ *models.Schema, table *models.Table) {
|
|
addColumn(table, "tags", "text[]")
|
|
table.Indexes["idx_tags"] = &models.Index{
|
|
Name: "idx_tags",
|
|
Type: "gin",
|
|
Columns: []string{"tags"},
|
|
}
|
|
},
|
|
want: nil,
|
|
},
|
|
{
|
|
name: "declared in metadata as string",
|
|
build: func(schema *models.Schema, _ *models.Table) {
|
|
schema.Metadata = map[string]any{"extensions": "pg_cron, timescaledb"}
|
|
},
|
|
want: []string{"pg_cron", "timescaledb"},
|
|
},
|
|
{
|
|
name: "declared in metadata as list",
|
|
build: func(schema *models.Schema, _ *models.Table) {
|
|
schema.Metadata = map[string]any{"extensions": []any{"postgis_topology", "pg_stat_statements"}}
|
|
},
|
|
// postgis is pulled in as a dependency of postgis_topology and emitted first.
|
|
want: []string{"pg_stat_statements", "postgis", "postgis_topology"},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
schema, table := buildExtensionSchema(t)
|
|
tt.build(schema, table)
|
|
|
|
if got := requiredExtensions(schema); !reflect.DeepEqual(got, tt.want) {
|
|
t.Errorf("requiredExtensions() = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRequiredExtensions_NilSchema(t *testing.T) {
|
|
if got := requiredExtensions(nil); got != nil {
|
|
t.Errorf("requiredExtensions(nil) = %v, want nil", got)
|
|
}
|
|
}
|
|
|
|
func TestWriteDatabase_QuotesExtensionNames(t *testing.T) {
|
|
db := models.InitDatabase("testdb")
|
|
schema, table := buildExtensionSchema(t)
|
|
addColumn(table, "id", "uuid").Default = "uuid_generate_v4()"
|
|
db.Schemas = append(db.Schemas, schema)
|
|
|
|
output := writeDatabaseOutput(t, db)
|
|
if !strings.Contains(output, `CREATE EXTENSION IF NOT EXISTS "uuid-ossp";`) {
|
|
t.Fatalf("expected quoted extension name, got:\n%s", output)
|
|
}
|
|
}
|
|
|
|
func TestGenerateSchemaStatements_ExtensionDependencyOrder(t *testing.T) {
|
|
schema, table := buildExtensionSchema(t)
|
|
addColumn(table, "embedding", "vector(3)")
|
|
table.Indexes["idx_embedding"] = &models.Index{
|
|
Name: "idx_embedding",
|
|
Type: "vchordrq",
|
|
Columns: []string{"embedding"},
|
|
}
|
|
|
|
writer := NewWriter(&writers.WriterOptions{})
|
|
statements, err := writer.GenerateSchemaStatements(schema)
|
|
if err != nil {
|
|
t.Fatalf("GenerateSchemaStatements failed: %v", err)
|
|
}
|
|
|
|
joined := strings.Join(statements, "\n")
|
|
vector := strings.Index(joined, "CREATE EXTENSION IF NOT EXISTS vector")
|
|
vchord := strings.Index(joined, "CREATE EXTENSION IF NOT EXISTS vchord")
|
|
if vector < 0 || vchord < 0 {
|
|
t.Fatalf("expected vector and vchord extensions, got:\n%s", joined)
|
|
}
|
|
if vector > vchord {
|
|
t.Fatalf("expected vector to be created before vchord, got:\n%s", joined)
|
|
}
|
|
}
|