* 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
249 lines
6.3 KiB
Go
249 lines
6.3 KiB
Go
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
|
|
}
|