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,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
|
||||
}
|
||||
Reference in New Issue
Block a user