Files
2026-07-13 12:32:25 +08:00

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
}