381 lines
12 KiB
Go
381 lines
12 KiB
Go
// Copyright 2025 Dolthub, Inc.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package pg_extension
|
|
|
|
import (
|
|
"cmp"
|
|
"fmt"
|
|
"maps"
|
|
"os"
|
|
"regexp"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// sqlFunctionCapture is a regex to capture the function name as defined in the library. We'll eventually replace this
|
|
// and use the nodes from the parser, but this is good enough for the default extensions.
|
|
var sqlFunctionCapture = regexp.MustCompile(`(?is)create\s+(?:or\s+replace\s+)?function\s+(.*?)\s*\(.*?\)\s+(?:.*?language c.*?as\s+'.*?'\s*,\s*'(.*?)'.*?;|.*?as\s+'.*?'\s*,\s*'(.*?)'.*?language c.*?;|.*?language c.*?;)`)
|
|
|
|
// createFunctionStart is a regex to find the beginning of a CREATE FUNCTION statement.
|
|
var createFunctionStart = regexp.MustCompile(`(?is)create\s+(?:or\s+replace\s+)?function`)
|
|
|
|
// ExtensionFiles contains all of the files that are related to or used by an extension.
|
|
type ExtensionFiles struct {
|
|
Name string
|
|
ControlFileName string
|
|
SQLFileNames []string
|
|
LibraryFileName string
|
|
ControlFileDir string
|
|
LibraryFileDir string
|
|
Control Control
|
|
}
|
|
|
|
// Control contains the contents of the control file.
|
|
// https://www.postgresql.org/docs/15/extend-extensions.html#id-1.8.3.20.11
|
|
type Control struct {
|
|
Directory string
|
|
DefaultVersion Version
|
|
Comment string
|
|
Encoding string
|
|
ModulePathname string
|
|
Requires []string
|
|
Superuser bool
|
|
Trusted bool
|
|
Relocatable bool
|
|
Schema string
|
|
Extra map[string]string // All entries in here could not be matched to an expected field
|
|
}
|
|
|
|
// Version specifies the major and minor version numbers for an extension.
|
|
type Version uint32
|
|
|
|
// FilenameVersions returns the versions that were encoded in a filename. `From` is the first number, while `To` is the
|
|
// second number. If a filename only specifies a single version, this both `From` and `To` will equal one another.
|
|
type FilenameVersions struct {
|
|
From Version
|
|
To Version
|
|
}
|
|
|
|
// LoadExtensions loads information for all extensions that are in the extensions directory of a local Postgres installation.
|
|
func LoadExtensions() (map[string]*ExtensionFiles, error) {
|
|
libDir, extDir, err := PostgresDirectories()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dirEntries, err := os.ReadDir(extDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
libEntries, err := os.ReadDir(libDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
extensionFiles := make(map[string]*ExtensionFiles)
|
|
// Look for the control files first
|
|
for _, dirEntry := range dirEntries {
|
|
fileName := dirEntry.Name()
|
|
if !dirEntry.IsDir() && strings.HasSuffix(fileName, ".control") {
|
|
extensionName := strings.TrimSuffix(fileName, ".control")
|
|
extensionFiles[extensionName] = &ExtensionFiles{
|
|
Name: extensionName,
|
|
ControlFileName: fileName,
|
|
ControlFileDir: extDir,
|
|
}
|
|
}
|
|
}
|
|
// Associate the SQL files and libraries
|
|
for _, extFile := range extensionFiles {
|
|
for _, dirEntry := range dirEntries {
|
|
fileName := dirEntry.Name()
|
|
if !dirEntry.IsDir() && strings.HasPrefix(fileName, extFile.Name+"--") && strings.HasSuffix(fileName, ".sql") {
|
|
extFile.SQLFileNames = append(extFile.SQLFileNames, fileName)
|
|
}
|
|
}
|
|
for _, libEntry := range libEntries {
|
|
fileName := libEntry.Name()
|
|
if !libEntry.IsDir() && strings.HasPrefix(fileName, extFile.Name+".") {
|
|
extFile.LibraryFileName = fileName
|
|
extFile.LibraryFileDir = libDir
|
|
}
|
|
}
|
|
slices.SortFunc(extFile.SQLFileNames, func(aStr, bStr string) int {
|
|
a := DecodeFilenameVersions(extFile.Name, aStr)
|
|
b := DecodeFilenameVersions(extFile.Name, bStr)
|
|
return cmp.Or(
|
|
cmp.Compare(a.From, b.From),
|
|
cmp.Compare(a.To, b.To),
|
|
)
|
|
})
|
|
// Some SQL files are old migration files that won't apply to us, so we can remove them by starting at the first
|
|
// non-migration file.
|
|
for nextLoop := true; nextLoop; {
|
|
nextLoop = false
|
|
for i := 1; i < len(extFile.SQLFileNames); i++ {
|
|
if strings.Count(extFile.SQLFileNames[i], "--") == 1 {
|
|
extFile.SQLFileNames = extFile.SQLFileNames[i:]
|
|
nextLoop = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
// Load the control file
|
|
if err = extFile.loadControl(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return extensionFiles, nil
|
|
}
|
|
|
|
// loadControl loads the control file of an extension.
|
|
func (extFile *ExtensionFiles) loadControl() error {
|
|
data, err := os.ReadFile(fmt.Sprintf("%s/%s", extFile.ControlFileDir, extFile.ControlFileName))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
extFile.Control = Control{ // These are the default values
|
|
Directory: "",
|
|
DefaultVersion: 0,
|
|
Comment: "",
|
|
Encoding: "",
|
|
ModulePathname: "",
|
|
Requires: nil,
|
|
Superuser: true,
|
|
Trusted: false,
|
|
Relocatable: false,
|
|
Schema: "",
|
|
Extra: make(map[string]string),
|
|
}
|
|
lines := strings.Split(strings.ReplaceAll(string(data), "\r", ""), "\n")
|
|
for _, originalLine := range lines {
|
|
line := strings.TrimSpace(originalLine)
|
|
if commentIdx := strings.Index(line, "#"); commentIdx != -1 {
|
|
line = line[:commentIdx]
|
|
}
|
|
// Line may be empty if it only contained a comment
|
|
if len(line) == 0 {
|
|
continue
|
|
}
|
|
equalsSplit := strings.Index(line, "=")
|
|
if equalsSplit == -1 {
|
|
return fmt.Errorf("malformed `%s.control`:\n%s", extFile.Name, string(data))
|
|
}
|
|
name := strings.ToLower(strings.TrimSpace(line[:equalsSplit]))
|
|
value := line[equalsSplit+1:]
|
|
switch name {
|
|
case "directory":
|
|
extFile.Control.Directory = removeStringQuotations(value)
|
|
case "default_version":
|
|
value = removeStringQuotations(value)
|
|
separator := strings.Index(value, ".")
|
|
if separator == -1 {
|
|
return fmt.Errorf("malformed `%s.control` line:\n%s", extFile.Name, originalLine)
|
|
}
|
|
major, err := strconv.Atoi(value[:separator])
|
|
if err != nil {
|
|
return fmt.Errorf("malformed `%s.control` line:\n%s", extFile.Name, originalLine)
|
|
}
|
|
minor, err := strconv.Atoi(value[separator+1:])
|
|
if err != nil {
|
|
return fmt.Errorf("malformed `%s.control` line:\n%s", extFile.Name, originalLine)
|
|
}
|
|
extFile.Control.DefaultVersion = ToVersion(uint16(major), uint16(minor))
|
|
case "comment":
|
|
extFile.Control.Comment = removeStringQuotations(value)
|
|
case "encoding":
|
|
extFile.Control.Encoding = removeStringQuotations(value)
|
|
case "module_pathname":
|
|
extFile.Control.ModulePathname = removeStringQuotations(value)
|
|
case "requires":
|
|
value = removeStringQuotations(value)
|
|
var entries []string
|
|
for _, entry := range strings.Split(value, ",") {
|
|
entries = append(entries, strings.TrimSpace(entry))
|
|
}
|
|
extFile.Control.Requires = entries
|
|
case "superuser", "trusted", "relocatable":
|
|
value = strings.ToLower(strings.TrimSpace(value))
|
|
var boolValue bool
|
|
if value == "true" {
|
|
boolValue = true
|
|
} else if value == "false" {
|
|
boolValue = false
|
|
} else {
|
|
return fmt.Errorf("malformed `%s.control` line:\n%s", extFile.Name, originalLine)
|
|
}
|
|
switch name {
|
|
case "superuser":
|
|
extFile.Control.Superuser = boolValue
|
|
case "trusted":
|
|
extFile.Control.Trusted = boolValue
|
|
case "relocatable":
|
|
extFile.Control.Relocatable = boolValue
|
|
}
|
|
case "schema":
|
|
extFile.Control.Schema = removeStringQuotations(value)
|
|
default:
|
|
extFile.Control.Extra[name] = value
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// LoadSQLFiles loads the contents of the SQL files used by the extension. These will be in the order that they need to
|
|
// be executed.
|
|
func (extFile *ExtensionFiles) LoadSQLFiles() ([]string, error) {
|
|
sqlFiles := make([]string, len(extFile.SQLFileNames))
|
|
for i, sqlFileName := range extFile.SQLFileNames {
|
|
data, err := os.ReadFile(fmt.Sprintf("%s/%s", extFile.ControlFileDir, sqlFileName))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sqlFiles[i] = string(data)
|
|
}
|
|
return sqlFiles, nil
|
|
}
|
|
|
|
// LoadSQLFunctionNames loads all of the library function names that are used by the extension.
|
|
func (extFile *ExtensionFiles) LoadSQLFunctionNames() ([]string, error) {
|
|
funcNames := make(map[string]struct{})
|
|
for _, sqlFileName := range extFile.SQLFileNames {
|
|
data, err := os.ReadFile(fmt.Sprintf("%s/%s", extFile.ControlFileDir, sqlFileName))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
fileRemaining := string(data)
|
|
OuterLoop:
|
|
for {
|
|
// We want to advance the file to the start of the next CREATE FUNCTION if one is present
|
|
startIdx := createFunctionStart.FindStringIndex(fileRemaining)
|
|
if startIdx == nil {
|
|
break
|
|
}
|
|
fileRemaining = fileRemaining[startIdx[0]:]
|
|
// We capture the ending semicolon so the regex doesn't match beyond the function definition's boundaries.
|
|
endIdx := strings.IndexRune(fileRemaining, ';')
|
|
if endIdx == -1 {
|
|
break
|
|
}
|
|
matches := sqlFunctionCapture.FindStringSubmatch(fileRemaining[:endIdx+1])
|
|
switch len(matches) {
|
|
case 0:
|
|
break OuterLoop
|
|
case 4:
|
|
if len(matches[2]) > 0 {
|
|
funcNames[matches[2]] = struct{}{}
|
|
} else if len(matches[3]) > 0 {
|
|
funcNames[matches[3]] = struct{}{}
|
|
} else {
|
|
funcNames[matches[1]] = struct{}{}
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("invalid CREATE FUNCTION string: %s", string(data))
|
|
}
|
|
// We nudge it forward to guarantee that our next CREATE FUNCTION search will grab the next one
|
|
fileRemaining = fileRemaining[6:]
|
|
}
|
|
}
|
|
sortedFuncNames := slices.Sorted(maps.Keys(funcNames))
|
|
return sortedFuncNames, nil
|
|
}
|
|
|
|
// LoadLibrary loads the extension as a library.
|
|
func (extFile *ExtensionFiles) LoadLibrary() (*Library, error) {
|
|
if len(extFile.LibraryFileName) == 0 {
|
|
return nil, fmt.Errorf("extension `%s` does not reference a library", extFile.Name)
|
|
}
|
|
funcNames, err := extFile.LoadSQLFunctionNames()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return LoadLibrary(fmt.Sprintf("%s/%s", extFile.LibraryFileDir, extFile.LibraryFileName), funcNames)
|
|
}
|
|
|
|
// ToVersion creates a version from the given major and minor version numbers.
|
|
func ToVersion(major uint16, minor uint16) Version {
|
|
return (Version(major) << 16) + Version(minor)
|
|
}
|
|
|
|
// Major returns the encoded major version number.
|
|
func (v Version) Major() uint16 {
|
|
return uint16(v >> 16)
|
|
}
|
|
|
|
// Minor returns the encoded minor version number.
|
|
func (v Version) Minor() uint16 {
|
|
return uint16(v)
|
|
}
|
|
|
|
// String returns the version in the `major.minor` format.
|
|
func (v Version) String() string {
|
|
return fmt.Sprintf("%d.%d", v.Major(), v.Minor())
|
|
}
|
|
|
|
// DecodeFilenameVersions decodes the version information within the file name. The `sqlFileName` should be the full
|
|
// file name (excluding the path), and the SQL file name should contain only the name as
|
|
func DecodeFilenameVersions(name string, fileName string) FilenameVersions {
|
|
var versionSubsection string
|
|
if strings.HasSuffix(fileName, ".sql") {
|
|
versionSubsection = strings.TrimSuffix(fileName[len(name)+2: /* We add 2 to account for the -- */], ".sql")
|
|
} else if strings.HasSuffix(fileName, ".control") {
|
|
versionSubsection = strings.TrimSuffix(fileName[len(name)+2: /* We add 2 to account for the -- */], ".control")
|
|
} else {
|
|
// The given name is not a .SQL or .CONTROL file, so we'll just return
|
|
return FilenameVersions{}
|
|
}
|
|
var from, to string
|
|
if dashIdx := strings.Index(versionSubsection, "--"); dashIdx == -1 {
|
|
from = versionSubsection
|
|
to = versionSubsection
|
|
} else {
|
|
from = versionSubsection[:dashIdx]
|
|
to = versionSubsection[dashIdx+2:]
|
|
}
|
|
fromSplit := strings.Index(from, ".")
|
|
toSplit := strings.Index(to, ".")
|
|
if fromSplit == -1 || toSplit == -1 {
|
|
return FilenameVersions{}
|
|
}
|
|
fromMajor, err := strconv.Atoi(from[:fromSplit])
|
|
if err != nil {
|
|
return FilenameVersions{}
|
|
}
|
|
fromMinor, err := strconv.Atoi(from[fromSplit+1:])
|
|
if err != nil {
|
|
return FilenameVersions{}
|
|
}
|
|
toMajor, err := strconv.Atoi(to[:toSplit])
|
|
if err != nil {
|
|
return FilenameVersions{}
|
|
}
|
|
toMinor, err := strconv.Atoi(to[toSplit+1:])
|
|
if err != nil {
|
|
return FilenameVersions{}
|
|
}
|
|
return FilenameVersions{
|
|
From: ToVersion(uint16(fromMajor), uint16(fromMinor)),
|
|
To: ToVersion(uint16(toMajor), uint16(toMinor)),
|
|
}
|
|
}
|
|
|
|
// removeStringQuotations removes the single quotes that are used to specify that a value is a string.
|
|
func removeStringQuotations(str string) string {
|
|
str = strings.TrimSpace(str)
|
|
if strings.HasPrefix(str, "'") {
|
|
return (str[:len(str)-1])[1:]
|
|
}
|
|
return str
|
|
}
|