diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b1b61cf..16bb7bd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,10 +1,16 @@ name: Test on: + push: pull_request: branches: - main +permissions: + contents: read + statuses: write + pull-requests: write + jobs: test: runs-on: ubuntu-latest @@ -15,7 +21,81 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: "1.24" + go-version: "1.25" - name: Run tests - run: make test + run: make test coverage-test + + - name: Extract coverage percentage + id: coverage + run: | + COVERAGE="$(awk '/^total:/{print $3}' build/coverage.txt | tr -d '%')" + echo "coverage=${COVERAGE}" >> "$GITHUB_OUTPUT" + + - name: Publish coverage summary + if: always() + run: | + echo "## Test Coverage" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "Total: **${{ steps.coverage.outputs.coverage }}%**" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "\`\`\`text" >> "$GITHUB_STEP_SUMMARY" + tail -n 20 build/coverage.txt >> "$GITHUB_STEP_SUMMARY" + echo "\`\`\`" >> "$GITHUB_STEP_SUMMARY" + + - name: Set commit coverage status + if: always() && steps.coverage.outputs.coverage != '' + uses: actions/github-script@v7 + env: + COVERAGE: ${{ steps.coverage.outputs.coverage }} + with: + script: | + const sha = context.payload.pull_request?.head?.sha || context.sha; + const state = '${{ job.status }}' === 'success' ? 'success' : 'failure'; + await github.rest.repos.createCommitStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + sha, + state, + context: 'go-pg/coverage', + description: `Coverage ${process.env.COVERAGE}%`, + target_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`, + }); + + - name: Comment coverage on pull request + if: always() && github.event_name == 'pull_request' && steps.coverage.outputs.coverage != '' + uses: actions/github-script@v7 + env: + COVERAGE: ${{ steps.coverage.outputs.coverage }} + with: + script: | + const body = [ + '', + `Coverage for this change: **${process.env.COVERAGE}%**`, + ].join('\n'); + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const previous = comments.find(c => + c.user?.type === 'Bot' && c.body?.includes('') + ); + + if (previous) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: previous.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } diff --git a/Makefile b/Makefile index 27bd110..1d7276e 100644 --- a/Makefile +++ b/Makefile @@ -2,45 +2,43 @@ GO ?= $(shell which go 2>/dev/null) DOCKER ?= $(shell which docker 2>/dev/null) WASMBUILD ?= $(shell which wasmbuild 2>/dev/null) -BUILDDIR ?= build -CMDDIR=$(wildcard cmd/*) + +# Locations +BUILD_DIR ?= build +CMD_DIR := $(filter-out cmd/_%,$(wildcard cmd/*)) # Set OS and Architecture ARCH ?= $(shell arch | tr A-Z a-z | sed 's/x86_64/amd64/' | sed 's/i386/amd64/' | sed 's/armv7l/arm/' | sed 's/aarch64/arm64/') OS ?= $(shell uname | tr A-Z a-z) VERSION ?= $(shell git describe --tags --always | sed 's/^v//') -# Build flags -BUILD_MODULE = $(shell cat go.mod | head -1 | cut -d ' ' -f 2) -BUILD_LD_FLAGS += -X $(BUILD_MODULE)/pkg/version.GitSource=${BUILD_MODULE} -BUILD_LD_FLAGS += -X $(BUILD_MODULE)/pkg/version.GitTag=$(shell git describe --tags --always) -BUILD_LD_FLAGS += -X $(BUILD_MODULE)/pkg/version.GitBranch=$(shell git name-rev HEAD --name-only --always) -BUILD_LD_FLAGS += -X $(BUILD_MODULE)/pkg/version.GitHash=$(shell git rev-parse HEAD) -BUILD_LD_FLAGS += -X $(BUILD_MODULE)/pkg/version.GoBuildTime=$(shell date -u '+%Y-%m-%dT%H:%M:%SZ') -BUILD_FLAGS = -ldflags="-s -w ${BUILD_LD_FLAGS}" +# Set build flags +VERSION_PKG = "github.com/mutablelogic/go-server/pkg/version" +BUILD_LD_FLAGS += -X $(VERSION_PKG)/GitTag=$(shell git describe --tags --always) +BUILD_LD_FLAGS += -X $(VERSION_PKG)/GitBranch=$(shell git name-rev HEAD --name-only --always) +BUILD_FLAGS = -ldflags "-s -w ${BUILD_LD_FLAGS}" # Docker DOCKER_REPO ?= ghcr.io/mutablelogic/pgmanager -DOCKER_SOURCE ?= ${BUILD_MODULE} +DOCKER_SOURCE ?= $(shell cat go.mod | head -1 | cut -d ' ' -f 2) DOCKER_TAG = ${DOCKER_REPO}-${OS}-${ARCH}:${VERSION} -# All targets -all: tidy $(CMDDIR) +############################################################################### +# ALL + +.PHONY: all +all: build -# Rules for building -.PHONY: $(CMDDIR) -$(CMDDIR): go-dep mkdir - @echo 'go build $@' - @rm -rf ${BUILDDIR}/$(shell basename $@) - @$(GO) build -tags frontend $(BUILD_FLAGS) -o ${BUILDDIR}/$(shell basename $@) ./$@ +############################################################################### +# BUILD + +# Build the commands in the cmd directory +.PHONY: build +build: tidy $(CMD_DIR) -# Build pgmanager with embedded frontend -.PHONY: pgmanager -pgmanager: go-dep wasmbuild-dep tidy mkdir - @echo 'go generate frontend' - @$(GO) generate -tags frontend ./pkg/manager/httphandler/... - @echo 'go build cmd/pgmanager' - @$(GO) build -tags frontend $(BUILD_FLAGS) -o ${BUILDDIR}/pgmanager ./cmd/pgmanager +$(CMD_DIR): go-dep mkdir + @echo Build command $(notdir $@) GOOS=${OS} GOARCH=${ARCH} + @GOOS=${OS} GOARCH=${ARCH} ${GO} build ${BUILD_FLAGS} -o ${BUILD_DIR}/$(notdir $@) ./$@ # Build the docker image .PHONY: docker @@ -48,6 +46,7 @@ docker: docker-dep ${NPM_DIR} @echo build docker image ${DOCKER_TAG} OS=${OS} ARCH=${ARCH} SOURCE=${DOCKER_SOURCE} VERSION=${VERSION} @${DOCKER} build \ --tag ${DOCKER_TAG} \ + --provenance=false \ --build-arg ARCH=${ARCH} \ --build-arg OS=${OS} \ --build-arg SOURCE=${DOCKER_SOURCE} \ @@ -65,17 +64,32 @@ docker-push: docker-dep docker-version: docker-dep @echo "tag=${VERSION}" -# Rules for testing +############################################################################### +# TEST + .PHONY: test -test: tidy - @echo 'running tests...' - @$(GO) test . - @$(GO) test ./pkg/... +test: unit-test coverage-test + +.PHONY: unit-test +unit-test: go-dep + @echo Unit Tests + @${GO} test . + @${GO} test ./pgmanager/... + @${GO} test ./pkg/... + +.PHONY: coverage-test +coverage-test: go-dep mkdir + @echo Test Coverage + @${GO} test -v -coverprofile ${BUILD_DIR}/coverprofile.out ./pkg/... + @${GO} tool cover -func ${BUILD_DIR}/coverprofile.out > ${BUILD_DIR}/coverage.txt + +############################################################################### +# CLEAN # Other rules .PHONY: mkdir mkdir: - @install -d $(BUILDDIR) + @install -d $(BUILD_DIR) .PHONY: go-dep tidy tidy: @@ -85,7 +99,7 @@ tidy: .PHONY: clean clean: tidy @echo 'clean' - @rm -fr $(BUILDDIR) + @rm -fr $(BUILD_DIR) @$(GO) clean ############################################################################### diff --git a/cmd/pgqueue/main.go b/cmd/_pgqueue/main.go similarity index 100% rename from cmd/pgqueue/main.go rename to cmd/_pgqueue/main.go diff --git a/cmd/pgqueue/namespace.go b/cmd/_pgqueue/namespace.go similarity index 100% rename from cmd/pgqueue/namespace.go rename to cmd/_pgqueue/namespace.go diff --git a/cmd/pgqueue/queue.go b/cmd/_pgqueue/queue.go similarity index 100% rename from cmd/pgqueue/queue.go rename to cmd/_pgqueue/queue.go diff --git a/cmd/pgqueue/server.go b/cmd/_pgqueue/server.go similarity index 100% rename from cmd/pgqueue/server.go rename to cmd/_pgqueue/server.go diff --git a/cmd/pgqueue/task.go b/cmd/_pgqueue/task.go similarity index 100% rename from cmd/pgqueue/task.go rename to cmd/_pgqueue/task.go diff --git a/cmd/pgqueue/ticker.go b/cmd/_pgqueue/ticker.go similarity index 100% rename from cmd/pgqueue/ticker.go rename to cmd/_pgqueue/ticker.go diff --git a/cmd/pgqueue/version.go b/cmd/_pgqueue/version.go similarity index 100% rename from cmd/pgqueue/version.go rename to cmd/_pgqueue/version.go diff --git a/cmd/pgmanager/connection.go b/cmd/pgmanager/connection.go deleted file mode 100644 index 29687df..0000000 --- a/cmd/pgmanager/connection.go +++ /dev/null @@ -1,95 +0,0 @@ -package main - -import ( - "fmt" - - // Packages - httpclient "github.com/mutablelogic/go-pg/pkg/manager/httpclient" -) - -/////////////////////////////////////////////////////////////////////////////// -// TYPES - -type ConnectionCommands struct { - ListConnection ListConnectionCommand `cmd:"" name:"connections" help:"List connections."` - GetConnection GetConnectionCommand `cmd:"" name:"connection" help:"Get connection."` - DeleteConnection DeleteConnectionCommand `cmd:"" name:"delete-connection" help:"Delete (terminate) connection."` -} - -type ListConnectionCommand struct { - Database string `name:"database" help:"Filter by database name"` - Role string `name:"role" help:"Filter by role name"` - State string `name:"state" help:"Filter by state (active, idle, etc.)"` -} - -type GetConnectionCommand struct { - Pid uint64 `arg:"" name:"pid" help:"Process ID"` -} - -type DeleteConnectionCommand struct { - GetConnectionCommand -} - -/////////////////////////////////////////////////////////////////////////////// -// COMMANDS - -func (cmd *ListConnectionCommand) Run(ctx *Globals) error { - client, err := ctx.Client() - if err != nil { - return err - } - - // Build options - opts := []httpclient.Opt{} - if cmd.Database != "" { - opts = append(opts, httpclient.OptDatabase(cmd.Database)) - } - if cmd.Role != "" { - opts = append(opts, httpclient.OptRole(cmd.Role)) - } - if cmd.State != "" { - opts = append(opts, httpclient.OptState(cmd.State)) - } - - // List connections - connections, err := client.ListConnections(ctx.ctx, opts...) - if err != nil { - return err - } - - // Print - fmt.Println(connections) - return nil -} - -func (cmd *GetConnectionCommand) Run(ctx *Globals) error { - client, err := ctx.Client() - if err != nil { - return err - } - - // Get one connection - connection, err := client.GetConnection(ctx.ctx, cmd.Pid) - if err != nil { - return err - } - - // Print - fmt.Println(connection) - return nil -} - -func (cmd *DeleteConnectionCommand) Run(ctx *Globals) error { - client, err := ctx.Client() - if err != nil { - return err - } - - // Delete (terminate) connection - if err := client.DeleteConnection(ctx.ctx, cmd.Pid); err != nil { - return err - } - - // Return success - return nil -} diff --git a/cmd/pgmanager/database.go b/cmd/pgmanager/database.go deleted file mode 100644 index 022dec7..0000000 --- a/cmd/pgmanager/database.go +++ /dev/null @@ -1,165 +0,0 @@ -package main - -import ( - "fmt" - - // Packages - httpclient "github.com/mutablelogic/go-pg/pkg/manager/httpclient" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" -) - -/////////////////////////////////////////////////////////////////////////////// -// TYPES - -type DatabaseCommands struct { - ListDatabase ListDatabaseCommand `cmd:"" name:"databases" help:"List databases."` - GetDatabase GetDatabaseCommand `cmd:"" name:"database" help:"Get database."` - CreateDatabase CreateDatabaseCommand `cmd:"" name:"create-database" help:"Create database."` - DeleteDatabase DeleteDatabaseCommand `cmd:"" name:"delete-database" help:"Delete database."` - UpdateDatabase UpdateDatabaseCommand `cmd:"" name:"update-database" help:"Update database."` -} - -type ListDatabaseCommand struct { - Offset uint64 `name:"offset" help:"Offset for pagination"` - Limit *uint64 `name:"limit" help:"Limit for pagination"` -} - -type GetDatabaseCommand struct { - Name string `arg:"" name:"name" help:"Database name"` -} - -type DeleteDatabaseCommand struct { - GetDatabaseCommand -} - -type CreateDatabaseCommand struct { - GetDatabaseCommand - Owner string `name:"owner" help:"Database owner"` - Acl []string `name:"acl" help:"Access control list entries (format: role:priv,priv,... e.g. myuser:SELECT,INSERT)"` -} - -type UpdateDatabaseCommand struct { - GetDatabaseCommand - NewName string `name:"rename" help:"Rename database to this name"` - Owner string `name:"owner" help:"Database owner"` - Acl []string `name:"acl" help:"Access control list entries (format: role:priv,priv,... e.g. myuser:SELECT,INSERT)"` -} - -/////////////////////////////////////////////////////////////////////////////// -// COMMANDS - -func (cmd *ListDatabaseCommand) Run(ctx *Globals) error { - client, err := ctx.Client() - if err != nil { - return err - } - - // List databases - databases, err := client.ListDatabases(ctx.ctx, httpclient.WithOffsetLimit(cmd.Offset, cmd.Limit)) - if err != nil { - return err - } - - // Print - fmt.Println(databases) - return nil -} - -func (cmd *GetDatabaseCommand) Run(ctx *Globals) error { - client, err := ctx.Client() - if err != nil { - return err - } - - // Get one database - database, err := client.GetDatabase(ctx.ctx, cmd.Name) - if err != nil { - return err - } - - // Print - fmt.Println(database) - return nil -} - -func (cmd *CreateDatabaseCommand) Run(ctx *Globals) error { - client, err := ctx.Client() - if err != nil { - return err - } - - // Parse ACL entries - var acl schema.ACLList - for _, aclStr := range cmd.Acl { - item, err := schema.ParseACLItem(aclStr) - if err != nil { - return fmt.Errorf("invalid ACL %q: %w", aclStr, err) - } - acl = append(acl, item) - } - - // Create database - database, err := client.CreateDatabase(ctx.ctx, schema.DatabaseMeta{ - Name: cmd.Name, - Owner: cmd.Owner, - Acl: acl, - }) - if err != nil { - return err - } - - // Print - fmt.Println(database) - return nil -} - -func (cmd *DeleteDatabaseCommand) Run(ctx *Globals) error { - client, err := ctx.Client() - if err != nil { - return err - } - - // Delete database - if err := client.DeleteDatabase(ctx.ctx, cmd.Name); err != nil { - return err - } - - // Return success - return nil -} - -func (cmd *UpdateDatabaseCommand) Run(ctx *Globals) error { - client, err := ctx.Client() - if err != nil { - return err - } - - // Parse ACL entries - var acl schema.ACLList - for _, aclStr := range cmd.Acl { - item, err := schema.ParseACLItem(aclStr) - if err != nil { - return fmt.Errorf("invalid ACL %q: %w", aclStr, err) - } - acl = append(acl, item) - } - - // Build meta - meta := schema.DatabaseMeta{ - Owner: cmd.Owner, - Acl: acl, - } - if cmd.NewName != "" { - meta.Name = cmd.NewName - } - - // Update database - database, err := client.UpdateDatabase(ctx.ctx, cmd.Name, meta) - if err != nil { - return err - } - - // Print - fmt.Println(database) - return nil -} diff --git a/cmd/pgmanager/extension.go b/cmd/pgmanager/extension.go deleted file mode 100644 index 74ac92a..0000000 --- a/cmd/pgmanager/extension.go +++ /dev/null @@ -1,176 +0,0 @@ -package main - -import ( - "fmt" - - // Packages - httpclient "github.com/mutablelogic/go-pg/pkg/manager/httpclient" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" -) - -/////////////////////////////////////////////////////////////////////////////// -// TYPES - -type ExtensionCommands struct { - ListExtension ListExtensionCommand `cmd:"" name:"extensions" help:"List extensions."` - GetExtension GetExtensionCommand `cmd:"" name:"extension" help:"Get extension."` - CreateExtension CreateExtensionCommand `cmd:"" name:"create-extension" help:"Create extension."` - DeleteExtension DeleteExtensionCommand `cmd:"" name:"delete-extension" help:"Delete extension."` - UpdateExtension UpdateExtensionCommand `cmd:"" name:"update-extension" help:"Update extension."` -} - -type ListExtensionCommand struct { - Database string `name:"database" help:"Filter by database name"` - Installed *bool `name:"installed" help:"Filter by installed status (true/false)"` - Offset uint64 `name:"offset" help:"Offset for pagination"` - Limit *uint64 `name:"limit" help:"Limit for pagination"` -} - -type GetExtensionCommand struct { - Name string `arg:"" name:"name" help:"Extension name"` -} - -type DeleteExtensionCommand struct { - GetExtensionCommand - Database string `arg:"" required:"" name:"database" help:"Database to delete extension from"` - Cascade bool `name:"cascade" help:"Cascade to dependent objects"` -} - -type CreateExtensionCommand struct { - GetExtensionCommand - Database string `arg:"" required:"" name:"database" help:"Database to install extension into"` - Schema string `name:"schema" help:"Schema to install extension into"` - Version string `name:"version" help:"Extension version"` - Cascade bool `name:"cascade" help:"Cascade to dependent objects"` -} - -type UpdateExtensionCommand struct { - GetExtensionCommand - Database string `arg:"" required:"" name:"database" help:"Database containing the extension"` - Version string `name:"version" help:"Update to this version"` - Schema string `name:"schema" help:"Move extension to this schema (only for relocatable extensions)"` -} - -/////////////////////////////////////////////////////////////////////////////// -// COMMANDS - -func (cmd *ListExtensionCommand) Run(ctx *Globals) error { - client, err := ctx.Client() - if err != nil { - return err - } - - // Build options - opts := []httpclient.Opt{httpclient.WithOffsetLimit(cmd.Offset, cmd.Limit)} - if cmd.Database != "" { - opts = append(opts, httpclient.OptDatabase(cmd.Database)) - // When a database is specified, default to showing only installed extensions - if cmd.Installed == nil { - installed := true - opts = append(opts, httpclient.WithInstalled(&installed)) - } else { - opts = append(opts, httpclient.WithInstalled(cmd.Installed)) - } - } else if cmd.Installed != nil { - opts = append(opts, httpclient.WithInstalled(cmd.Installed)) - } - - // List extensions - extensions, err := client.ListExtensions(ctx.ctx, opts...) - if err != nil { - return err - } - - // Print - fmt.Println(extensions) - return nil -} - -func (cmd *GetExtensionCommand) Run(ctx *Globals) error { - client, err := ctx.Client() - if err != nil { - return err - } - - // Get one extension - extension, err := client.GetExtension(ctx.ctx, cmd.Name) - if err != nil { - return err - } - - // Print - fmt.Println(extension) - return nil -} - -func (cmd *CreateExtensionCommand) Run(ctx *Globals) error { - client, err := ctx.Client() - if err != nil { - return err - } - - // Build options - opts := []httpclient.Opt{} - if cmd.Cascade { - opts = append(opts, httpclient.OptCascade(true)) - } - - // Create extension - extension, err := client.CreateExtension(ctx.ctx, schema.ExtensionMeta{ - Name: cmd.Name, - Database: cmd.Database, - Schema: cmd.Schema, - Version: cmd.Version, - }, opts...) - if err != nil { - return err - } - - // Print - fmt.Println(extension) - return nil -} - -func (cmd *DeleteExtensionCommand) Run(ctx *Globals) error { - client, err := ctx.Client() - if err != nil { - return err - } - - // Build options - opts := []httpclient.Opt{ - httpclient.OptDatabase(cmd.Database), - } - if cmd.Cascade { - opts = append(opts, httpclient.OptCascade(true)) - } - - // Delete extension - if err := client.DeleteExtension(ctx.ctx, cmd.Name, opts...); err != nil { - return err - } - - // Return success - return nil -} - -func (cmd *UpdateExtensionCommand) Run(ctx *Globals) error { - client, err := ctx.Client() - if err != nil { - return err - } - - // Update extension - extension, err := client.UpdateExtension(ctx.ctx, cmd.Name, schema.ExtensionMeta{ - Database: cmd.Database, - Version: cmd.Version, - Schema: cmd.Schema, - }) - if err != nil { - return err - } - - // Print - fmt.Println(extension) - return nil -} diff --git a/cmd/pgmanager/main.go b/cmd/pgmanager/main.go index 5409be3..989ddcc 100644 --- a/cmd/pgmanager/main.go +++ b/cmd/pgmanager/main.go @@ -1,101 +1,35 @@ package main import ( - "context" "fmt" - "net" "os" - "os/signal" - "strconv" // Packages - kong "github.com/alecthomas/kong" - client "github.com/mutablelogic/go-client" - httpclient "github.com/mutablelogic/go-pg/pkg/manager/httpclient" + pgmanagercmd "github.com/mutablelogic/go-pg/pgmanager/cmd" + servercmd "github.com/mutablelogic/go-server/pkg/cmd" + version "github.com/mutablelogic/go-server/pkg/version" ) /////////////////////////////////////////////////////////////////////////////// // TYPES -type Globals struct { - // Debug option - Debug bool `name:"debug" help:"Enable debug logging"` - - // HTTP server options - HTTP struct { - Prefix string `name:"prefix" help:"HTTP path prefix" default:"/api/v1"` - Addr string `name:"addr" env:"PG_ADDR" help:"HTTP Listen address" default:":8080"` - } `embed:"" prefix:"http."` - - // Private fields - ctx context.Context - cancel context.CancelFunc -} - type CLI struct { - Globals - ConnectionCommands - DatabaseCommands - ExtensionCommands - ReplicationSlotCommands - RoleCommands - SchemaCommands - ObjectCommands - ServerCommands - SettingCommands - StatementCommands - TablespaceCommands - VersionCommands + pgmanagercmd.ClientCommands // pgmanager client commands + pgmanagercmd.ServerCommands // pgmanager run + servercmd.OpenAPICommands // pgmanager openapi } /////////////////////////////////////////////////////////////////////////////// -// LIFECYCLE +// GLOBALS -func main() { - cli := new(CLI) - ctx := kong.Parse(cli) +const description = "PostgreSQL Manager is an application for managing a database server" - // Create the context and cancel function - cli.Globals.ctx, cli.Globals.cancel = signal.NotifyContext(context.Background(), os.Interrupt) - defer cli.Globals.cancel() +/////////////////////////////////////////////////////////////////////////////// +// LIFECYCLE - // Call the Run() method of the selected parsed command. - if err := ctx.Run(&cli.Globals); err != nil { +func main() { + if err := servercmd.Main(CLI{}, description, version.Version()); err != nil { fmt.Fprintln(os.Stderr, "Error:", err) os.Exit(1) } } - -/////////////////////////////////////////////////////////////////////////////// -// PRIVATE METHODS - -func (g *Globals) Client() (*httpclient.Client, error) { - scheme := "http" - host, port, err := net.SplitHostPort(g.HTTP.Addr) - if err != nil { - return nil, err - } - - // Default host to localhost if empty (e.g., ":8080") - if host == "" { - host = "localhost" - } - - // Parse port - portn, err := strconv.ParseUint(port, 10, 16) - if err != nil { - return nil, err - } - if portn == 443 { - scheme = "https" - } - - // Client options - opts := []client.ClientOpt{} - if g.Debug { - opts = append(opts, client.OptTrace(os.Stderr, true)) - } - - // Create a client with the calculated endpoint - return httpclient.New(fmt.Sprintf("%s://%s:%v%s", scheme, host, portn, g.HTTP.Prefix), opts...) -} diff --git a/cmd/pgmanager/object.go b/cmd/pgmanager/object.go deleted file mode 100644 index af83665..0000000 --- a/cmd/pgmanager/object.go +++ /dev/null @@ -1,82 +0,0 @@ -package main - -import ( - "fmt" - - // Packages - httpclient "github.com/mutablelogic/go-pg/pkg/manager/httpclient" -) - -/////////////////////////////////////////////////////////////////////////////// -// TYPES - -type ObjectCommands struct { - ListObjects ListObjectsCommand `cmd:"" name:"objects" help:"List objects."` - GetObject GetObjectCommand `cmd:"" name:"object" help:"Get object."` -} - -type ListObjectsCommand struct { - Database string `name:"database" short:"d" help:"Filter by database name"` - Namespace string `name:"schema" short:"s" help:"Filter by schema (namespace) name"` - Type string `name:"type" short:"t" help:"Filter by object type (TABLE, VIEW, INDEX, SEQUENCE, etc.)"` - Offset uint64 `name:"offset" help:"Offset for pagination"` - Limit *uint64 `name:"limit" help:"Limit for pagination"` -} - -type GetObjectCommand struct { - Database string `arg:"" name:"database" help:"Database name"` - Namespace string `arg:"" name:"schema" help:"Schema (namespace) name"` - Name string `arg:"" name:"name" help:"Object name"` -} - -/////////////////////////////////////////////////////////////////////////////// -// COMMANDS - -func (cmd *ListObjectsCommand) Run(ctx *Globals) error { - client, err := ctx.Client() - if err != nil { - return err - } - - // List objects - objects, err := client.ListObjects(ctx.ctx, cmd.Database, cmd.Namespace, httpclient.WithOffsetLimit(cmd.Offset, cmd.Limit)) - if err != nil { - return err - } - - // Filter by type if specified (client-side filtering since API may not support it in path) - if cmd.Type != "" { - var filtered []interface{} - for _, obj := range objects.Body { - if obj.Type == cmd.Type { - filtered = append(filtered, obj) - } - } - fmt.Printf("Count: %d (filtered from %d)\n", len(filtered), objects.Count) - for _, obj := range filtered { - fmt.Println(obj) - } - return nil - } - - // Print - fmt.Println(objects) - return nil -} - -func (cmd *GetObjectCommand) Run(ctx *Globals) error { - client, err := ctx.Client() - if err != nil { - return err - } - - // Get one object - obj, err := client.GetObject(ctx.ctx, cmd.Database, cmd.Namespace, cmd.Name) - if err != nil { - return err - } - - // Print - fmt.Println(obj) - return nil -} diff --git a/cmd/pgmanager/replicationslot.go b/cmd/pgmanager/replicationslot.go deleted file mode 100644 index 9b7a548..0000000 --- a/cmd/pgmanager/replicationslot.go +++ /dev/null @@ -1,117 +0,0 @@ -package main - -import ( - "fmt" - - // Packages - httpclient "github.com/mutablelogic/go-pg/pkg/manager/httpclient" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" -) - -/////////////////////////////////////////////////////////////////////////////// -// TYPES - -type ReplicationSlotCommands struct { - ListReplicationSlot ListReplicationSlotCommand `cmd:"" name:"replication-slots" help:"List replication slots."` - GetReplicationSlot GetReplicationSlotCommand `cmd:"" name:"replication-slot" help:"Get replication slot."` - CreateReplicationSlot CreateReplicationSlotCommand `cmd:"" name:"create-replication-slot" help:"Create replication slot."` - DeleteReplicationSlot DeleteReplicationSlotCommand `cmd:"" name:"delete-replication-slot" help:"Delete replication slot."` -} - -type ListReplicationSlotCommand struct { - Offset uint64 `name:"offset" help:"Offset for pagination"` - Limit *uint64 `name:"limit" help:"Limit for pagination"` -} - -type GetReplicationSlotCommand struct { - Name string `arg:"" name:"name" help:"Replication slot name"` -} - -type DeleteReplicationSlotCommand struct { - GetReplicationSlotCommand -} - -type CreateReplicationSlotCommand struct { - Name string `arg:"" name:"name" help:"Replication slot name"` - Type string `name:"type" required:"" enum:"physical,logical" help:"Slot type (physical or logical)"` - Plugin string `name:"plugin" help:"Output plugin for logical slots (e.g., pgoutput)"` - Database string `name:"database" help:"Database for logical slots"` - Temporary bool `name:"temporary" help:"Create a temporary slot"` - TwoPhase bool `name:"two-phase" help:"Enable two-phase commit support (PG14+)"` -} - -/////////////////////////////////////////////////////////////////////////////// -// COMMANDS - -func (cmd *ListReplicationSlotCommand) Run(ctx *Globals) error { - client, err := ctx.Client() - if err != nil { - return err - } - - // List replication slots - slots, err := client.ListReplicationSlots(ctx.ctx, httpclient.WithOffsetLimit(cmd.Offset, cmd.Limit)) - if err != nil { - return err - } - - // Print - fmt.Println(slots) - return nil -} - -func (cmd *GetReplicationSlotCommand) Run(ctx *Globals) error { - client, err := ctx.Client() - if err != nil { - return err - } - - // Get one replication slot - slot, err := client.GetReplicationSlot(ctx.ctx, cmd.Name) - if err != nil { - return err - } - - // Print - fmt.Println(slot) - return nil -} - -func (cmd *CreateReplicationSlotCommand) Run(ctx *Globals) error { - client, err := ctx.Client() - if err != nil { - return err - } - - // Create replication slot - slot, err := client.CreateReplicationSlot(ctx.ctx, schema.ReplicationSlotMeta{ - Name: cmd.Name, - Type: cmd.Type, - Plugin: cmd.Plugin, - Database: cmd.Database, - Temporary: cmd.Temporary, - TwoPhase: cmd.TwoPhase, - }) - if err != nil { - return err - } - - // Print - fmt.Println(slot) - return nil -} - -func (cmd *DeleteReplicationSlotCommand) Run(ctx *Globals) error { - client, err := ctx.Client() - if err != nil { - return err - } - - // Delete replication slot - if err := client.DeleteReplicationSlot(ctx.ctx, cmd.Name); err != nil { - return err - } - - // Return success - return nil -} diff --git a/cmd/pgmanager/role.go b/cmd/pgmanager/role.go deleted file mode 100644 index 1142e9e..0000000 --- a/cmd/pgmanager/role.go +++ /dev/null @@ -1,253 +0,0 @@ -package main - -import ( - "fmt" - - // Packages - httpclient "github.com/mutablelogic/go-pg/pkg/manager/httpclient" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" -) - -/////////////////////////////////////////////////////////////////////////////// -// TYPES - -type RoleCommands struct { - ListRole ListRoleCommand `cmd:"" name:"roles" help:"List roles."` - GetRole GetRoleCommand `cmd:"" name:"role" help:"Get role."` - CreateRole CreateRoleCommand `cmd:"" name:"create-role" help:"Create role."` - DeleteRole DeleteRoleCommand `cmd:"" name:"delete-role" help:"Delete role."` - UpdateRole UpdateRoleCommand `cmd:"" name:"update-role" help:"Update role."` -} - -type ListRoleCommand struct { - Offset uint64 `name:"offset" help:"Offset for pagination"` - Limit *uint64 `name:"limit" help:"Limit for pagination"` -} - -type GetRoleCommand struct { - Name string `arg:"" name:"name" help:"Role name"` -} - -type DeleteRoleCommand struct { - GetRoleCommand -} - -type CreateRoleCommand struct { - GetRoleCommand - Superuser bool `name:"superuser" help:"Superuser permission"` - NoSuperuser bool `name:"no-superuser" help:"No superuser permission"` - Login bool `name:"login" help:"Login permission"` - NoLogin bool `name:"no-login" help:"No login permission"` - CreateDB bool `name:"createdb" help:"Create database permission"` - NoCreateDB bool `name:"no-createdb" help:"No create database permission"` - CreateRole bool `name:"createrole" help:"Create role permission"` - NoCreateRole bool `name:"no-createrole" help:"No create role permission"` - Replication bool `name:"replication" help:"Replication permission"` - NoReplication bool `name:"no-replication" help:"No replication permission"` - Inherit bool `name:"inherit" help:"Inherit permissions from groups"` - NoInherit bool `name:"no-inherit" help:"Do not inherit permissions from groups"` - BypassRLS bool `name:"bypassrls" help:"Bypass row-level security"` - NoBypassRLS bool `name:"no-bypassrls" help:"Do not bypass row-level security"` - ConnectionLimit *uint64 `name:"connection-limit" help:"Connection limit (-1 for unlimited)"` - Password string `name:"password" help:"Role password"` - Groups []string `name:"memberof" help:"Group memberships (role names)"` -} - -type UpdateRoleCommand struct { - GetRoleCommand - NewName string `name:"rename" help:"Rename role to this name"` - Superuser *bool `name:"superuser" help:"Superuser permission"` - Login *bool `name:"login" help:"Login permission"` - CreateDB *bool `name:"createdb" help:"Create database permission"` - CreateRole *bool `name:"createrole" help:"Create role permission"` - Replication *bool `name:"replication" help:"Replication permission"` - Inherit *bool `name:"inherit" help:"Inherit permissions from groups"` - BypassRLS *bool `name:"bypassrls" help:"Bypass row-level security"` - ConnectionLimit *uint64 `name:"connection-limit" help:"Connection limit (-1 for unlimited)"` - Password string `name:"password" help:"Role password"` - Groups []string `name:"memberof" help:"Group memberships (role names)"` -} - -/////////////////////////////////////////////////////////////////////////////// -// COMMANDS - -func (cmd *ListRoleCommand) Run(ctx *Globals) error { - client, err := ctx.Client() - if err != nil { - return err - } - - // List roles - roles, err := client.ListRoles(ctx.ctx, httpclient.WithOffsetLimit(cmd.Offset, cmd.Limit)) - if err != nil { - return err - } - - // Print - fmt.Println(roles) - return nil -} - -func (cmd *GetRoleCommand) Run(ctx *Globals) error { - client, err := ctx.Client() - if err != nil { - return err - } - - // Get one role - role, err := client.GetRole(ctx.ctx, cmd.Name) - if err != nil { - return err - } - - // Print - fmt.Println(role) - return nil -} - -func (cmd *CreateRoleCommand) Run(ctx *Globals) error { - client, err := ctx.Client() - if err != nil { - return err - } - - // Build role meta - meta := schema.RoleMeta{ - Name: cmd.Name, - Groups: cmd.Groups, - } - - // Handle boolean flags with explicit true/false - if cmd.Superuser { - t := true - meta.Superuser = &t - } else if cmd.NoSuperuser { - f := false - meta.Superuser = &f - } - if cmd.Login { - t := true - meta.Login = &t - } else if cmd.NoLogin { - f := false - meta.Login = &f - } - if cmd.CreateDB { - t := true - meta.CreateDatabases = &t - } else if cmd.NoCreateDB { - f := false - meta.CreateDatabases = &f - } - if cmd.CreateRole { - t := true - meta.CreateRoles = &t - } else if cmd.NoCreateRole { - f := false - meta.CreateRoles = &f - } - if cmd.Replication { - t := true - meta.Replication = &t - } else if cmd.NoReplication { - f := false - meta.Replication = &f - } - if cmd.Inherit { - t := true - meta.Inherit = &t - } else if cmd.NoInherit { - f := false - meta.Inherit = &f - } - if cmd.BypassRLS { - t := true - meta.BypassRowLevelSecurity = &t - } else if cmd.NoBypassRLS { - f := false - meta.BypassRowLevelSecurity = &f - } - if cmd.ConnectionLimit != nil { - meta.ConnectionLimit = cmd.ConnectionLimit - } - if cmd.Password != "" { - meta.Password = &cmd.Password - } - - // Create role - role, err := client.CreateRole(ctx.ctx, meta) - if err != nil { - return err - } - - // Print - fmt.Println(role) - return nil -} - -func (cmd *DeleteRoleCommand) Run(ctx *Globals) error { - client, err := ctx.Client() - if err != nil { - return err - } - - // Delete role - if err := client.DeleteRole(ctx.ctx, cmd.Name); err != nil { - return err - } - - // Return success - return nil -} - -func (cmd *UpdateRoleCommand) Run(ctx *Globals) error { - client, err := ctx.Client() - if err != nil { - return err - } - - // Build role meta - meta := schema.RoleMeta{ - Groups: cmd.Groups, - } - if cmd.NewName != "" { - meta.Name = cmd.NewName - } - if cmd.Superuser != nil { - meta.Superuser = cmd.Superuser - } - if cmd.Login != nil { - meta.Login = cmd.Login - } - if cmd.CreateDB != nil { - meta.CreateDatabases = cmd.CreateDB - } - if cmd.CreateRole != nil { - meta.CreateRoles = cmd.CreateRole - } - if cmd.Replication != nil { - meta.Replication = cmd.Replication - } - if cmd.Inherit != nil { - meta.Inherit = cmd.Inherit - } - if cmd.BypassRLS != nil { - meta.BypassRowLevelSecurity = cmd.BypassRLS - } - if cmd.ConnectionLimit != nil { - meta.ConnectionLimit = cmd.ConnectionLimit - } - if cmd.Password != "" { - meta.Password = &cmd.Password - } - - // Update role - role, err := client.UpdateRole(ctx.ctx, cmd.Name, meta) - if err != nil { - return err - } - - // Print - fmt.Println(role) - return nil -} diff --git a/cmd/pgmanager/schema.go b/cmd/pgmanager/schema.go deleted file mode 100644 index 442abcf..0000000 --- a/cmd/pgmanager/schema.go +++ /dev/null @@ -1,169 +0,0 @@ -package main - -import ( - "fmt" - - // Packages - httpclient "github.com/mutablelogic/go-pg/pkg/manager/httpclient" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" -) - -/////////////////////////////////////////////////////////////////////////////// -// TYPES - -type SchemaCommands struct { - ListSchema ListSchemaCommand `cmd:"" name:"schemas" help:"List schemas."` - GetSchema GetSchemaCommand `cmd:"" name:"schema" help:"Get schema."` - CreateSchema CreateSchemaCommand `cmd:"" name:"create-schema" help:"Create schema."` - DeleteSchema DeleteSchemaCommand `cmd:"" name:"delete-schema" help:"Delete schema."` - UpdateSchema UpdateSchemaCommand `cmd:"" name:"update-schema" help:"Update schema."` -} - -type ListSchemaCommand struct { - Database string `name:"database" short:"d" help:"Filter by database name"` - Offset uint64 `name:"offset" help:"Offset for pagination"` - Limit *uint64 `name:"limit" help:"Limit for pagination"` -} - -type GetSchemaCommand struct { - Database string `arg:"" name:"database" help:"Database name"` - Namespace string `arg:"" name:"namespace" help:"Schema (namespace) name"` -} - -type DeleteSchemaCommand struct { - GetSchemaCommand - Force bool `name:"force" help:"Force delete with CASCADE"` -} - -type CreateSchemaCommand struct { - Database string `arg:"" name:"database" help:"Database name"` - Name string `arg:"" name:"name" help:"Schema name"` - Owner string `name:"owner" help:"Schema owner (defaults to current user)"` - Acl []string `name:"acl" help:"Access control list entries (format: role:priv,priv,... e.g. myuser:USAGE,CREATE)"` -} - -type UpdateSchemaCommand struct { - GetSchemaCommand - NewName string `name:"rename" help:"Rename schema to this name"` - Owner string `name:"owner" help:"Schema owner"` - Acl []string `name:"acl" help:"Access control list entries (format: role:priv,priv,... e.g. myuser:USAGE,CREATE)"` -} - -/////////////////////////////////////////////////////////////////////////////// -// COMMANDS - -func (cmd *ListSchemaCommand) Run(ctx *Globals) error { - client, err := ctx.Client() - if err != nil { - return err - } - - // List schemas - schemas, err := client.ListSchemas(ctx.ctx, cmd.Database, httpclient.WithOffsetLimit(cmd.Offset, cmd.Limit)) - if err != nil { - return err - } - - // Print - fmt.Println(schemas) - return nil -} - -func (cmd *GetSchemaCommand) Run(ctx *Globals) error { - client, err := ctx.Client() - if err != nil { - return err - } - - // Get one schema - s, err := client.GetSchema(ctx.ctx, cmd.Database, cmd.Namespace) - if err != nil { - return err - } - - // Print - fmt.Println(s) - return nil -} - -func (cmd *CreateSchemaCommand) Run(ctx *Globals) error { - client, err := ctx.Client() - if err != nil { - return err - } - - // Parse ACL entries - var acl schema.ACLList - for _, aclStr := range cmd.Acl { - item, err := schema.ParseACLItem(aclStr) - if err != nil { - return fmt.Errorf("invalid ACL %q: %w", aclStr, err) - } - acl = append(acl, item) - } - - // Create schema - s, err := client.CreateSchema(ctx.ctx, cmd.Database, schema.SchemaMeta{ - Name: cmd.Name, - Owner: cmd.Owner, - Acl: acl, - }) - if err != nil { - return err - } - - // Print - fmt.Println(s) - return nil -} - -func (cmd *DeleteSchemaCommand) Run(ctx *Globals) error { - client, err := ctx.Client() - if err != nil { - return err - } - - // Delete schema - if err := client.DeleteSchema(ctx.ctx, cmd.Database, cmd.Namespace, httpclient.WithForce(cmd.Force)); err != nil { - return err - } - - // Return success - return nil -} - -func (cmd *UpdateSchemaCommand) Run(ctx *Globals) error { - client, err := ctx.Client() - if err != nil { - return err - } - - // Parse ACL entries - var acl schema.ACLList - for _, aclStr := range cmd.Acl { - item, err := schema.ParseACLItem(aclStr) - if err != nil { - return fmt.Errorf("invalid ACL %q: %w", aclStr, err) - } - acl = append(acl, item) - } - - // Build meta - meta := schema.SchemaMeta{ - Owner: cmd.Owner, - Acl: acl, - } - if cmd.NewName != "" { - meta.Name = cmd.NewName - } - - // Update schema - s, err := client.UpdateSchema(ctx.ctx, cmd.Database, cmd.Namespace, meta) - if err != nil { - return err - } - - // Print - fmt.Println(s) - return nil -} diff --git a/cmd/pgmanager/server.go b/cmd/pgmanager/server.go deleted file mode 100644 index 58e59f2..0000000 --- a/cmd/pgmanager/server.go +++ /dev/null @@ -1,109 +0,0 @@ -package main - -import ( - "context" - "crypto/tls" - "fmt" - "os" - - // Packages - pg "github.com/mutablelogic/go-pg" - manager "github.com/mutablelogic/go-pg/pkg/manager" - httphandler "github.com/mutablelogic/go-pg/pkg/manager/httphandler" - version "github.com/mutablelogic/go-pg/pkg/version" - httpserver "github.com/mutablelogic/go-server/pkg/httpserver" -) - -/////////////////////////////////////////////////////////////////////////////// -// TYPES - -type ServerCommands struct { - RunServer RunServer `cmd:"" name:"run" help:"Run server."` -} - -type RunServer struct { - URL string `arg:"" name:"url" help:"Database URL" default:""` - UI bool `name:"ui" help:"Enable frontend UI" default:"false"` - - // Postgres options - PG struct { - // Database options - User string `name:"user" env:"PG_USER" help:"Database user"` - Password string `name:"password" env:"PG_PASSWORD" help:"Database password"` - } `embed:"" prefix:"pg."` - - // TLS server options - TLS struct { - ServerName string `name:"name" help:"TLS server name"` - CertFile string `name:"cert" help:"TLS certificate file"` - KeyFile string `name:"key" help:"TLS key file"` - } `embed:"" prefix:"tls."` -} - -/////////////////////////////////////////////////////////////////////////////// -// COMMANDS - -func (cmd *RunServer) Run(ctx *Globals) error { - opts := []pg.Opt{ - pg.WithURL(cmd.URL), - } - if cmd.PG.User != "" || cmd.PG.Password != "" { - opts = append(opts, pg.WithCredentials(cmd.PG.User, cmd.PG.Password)) - } - if ctx.Debug { - opts = append(opts, pg.WithTrace(func(ctx context.Context, query string, args any, err error) { - fmt.Println("PG TRACE:", query, args, err) - })) - } - - // Create a pool connection - conn, err := pg.NewPool(ctx.ctx, opts...) - if err != nil { - return err - } - defer conn.Close() - - // Ping the database - if err := conn.Ping(ctx.ctx); err != nil { - return err - } - - // Create the manager - manager, err := manager.New(ctx.ctx, conn) - if err != nil { - return err - } - - // Create a TLS config - var tlsconfig *tls.Config - if cmd.TLS.CertFile != "" || cmd.TLS.KeyFile != "" { - cert, err := os.ReadFile(cmd.TLS.CertFile) - if err != nil { - return err - } - key, err := os.ReadFile(cmd.TLS.KeyFile) - if err != nil { - return err - } - tlsconfig, err = httpserver.TLSConfig(cmd.TLS.ServerName, true, cert, key) - if err != nil { - return err - } - } - - // Create a HTTP server - server, err := httpserver.New(ctx.HTTP.Addr, tlsconfig) - if err != nil { - return err - } - - // Register HTTP handlers - router := server.Router() - httphandler.RegisterBackendHandlers(router, ctx.HTTP.Prefix, manager) - httphandler.RegisterFrontendHandler(router, "", cmd.UI) - - // Run the server - fmt.Println(version.ExecName(), version.Version()) - fmt.Println("Listening on", ctx.HTTP.Addr+ctx.HTTP.Prefix) - return server.Run(ctx.ctx) -} diff --git a/cmd/pgmanager/setting.go b/cmd/pgmanager/setting.go deleted file mode 100644 index a29023b..0000000 --- a/cmd/pgmanager/setting.go +++ /dev/null @@ -1,159 +0,0 @@ -package main - -import ( - "fmt" - - // Packages - httpclient "github.com/mutablelogic/go-pg/pkg/manager/httpclient" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" -) - -/////////////////////////////////////////////////////////////////////////////// -// TYPES - -type SettingCommands struct { - ListSetting ListSettingCommand `cmd:"" name:"settings" help:"List server settings."` - ListCategory ListCategoryCommand `cmd:"" name:"setting-categories" help:"List setting categories."` - GetSetting GetSettingCommand `cmd:"" name:"setting" help:"Get a server setting."` - UpdateSetting UpdateSettingCommand `cmd:"" name:"update-setting" help:"Update a server setting."` - ResetSetting ResetSettingCommand `cmd:"" name:"reset-setting" help:"Reset a server setting to default."` -} - -type ListSettingCommand struct { - Category string `name:"category" help:"Filter by category name"` - Offset uint64 `name:"offset" help:"Offset for pagination"` - Limit *uint64 `name:"limit" help:"Limit for pagination"` -} - -type ListCategoryCommand struct{} - -type GetSettingCommand struct { - Name string `arg:"" name:"name" help:"Setting name"` -} - -type UpdateSettingCommand struct { - Name string `arg:"" name:"name" help:"Setting name"` - Value string `arg:"" name:"value" help:"New setting value"` - Reload bool `name:"reload" help:"Reload configuration after update (only for sighup context settings)"` -} - -type ResetSettingCommand struct { - Name string `arg:"" name:"name" help:"Setting name"` - Reload bool `name:"reload" help:"Reload configuration after reset (only for sighup context settings)"` -} - -/////////////////////////////////////////////////////////////////////////////// -// COMMANDS - -func (cmd *ListSettingCommand) Run(ctx *Globals) error { - client, err := ctx.Client() - if err != nil { - return err - } - - // Build options - opts := []httpclient.Opt{httpclient.WithOffsetLimit(cmd.Offset, cmd.Limit)} - if cmd.Category != "" { - opts = append(opts, httpclient.WithCategory(&cmd.Category)) - } - - // List settings - settings, err := client.ListSettings(ctx.ctx, opts...) - if err != nil { - return err - } - - // Print - fmt.Println(settings) - return nil -} - -func (cmd *ListCategoryCommand) Run(ctx *Globals) error { - client, err := ctx.Client() - if err != nil { - return err - } - - // List setting categories - categories, err := client.ListSettingCategories(ctx.ctx) - if err != nil { - return err - } - - // Print - fmt.Println(categories) - return nil -} - -func (cmd *GetSettingCommand) Run(ctx *Globals) error { - client, err := ctx.Client() - if err != nil { - return err - } - - // Get one setting - setting, err := client.GetSetting(ctx.ctx, cmd.Name) - if err != nil { - return err - } - - // Print - fmt.Println(setting) - return nil -} - -func (cmd *UpdateSettingCommand) Run(ctx *Globals) error { - client, err := ctx.Client() - if err != nil { - return err - } - - // Build meta - meta := schema.SettingMeta{ - Value: &cmd.Value, - } - - // Build options - opts := []httpclient.Opt{} - if cmd.Reload { - opts = append(opts, httpclient.WithReload(true)) - } - - // Update the setting - setting, err := client.UpdateSetting(ctx.ctx, cmd.Name, meta, opts...) - if err != nil { - return err - } - - // Print - fmt.Println(setting) - return nil -} - -func (cmd *ResetSettingCommand) Run(ctx *Globals) error { - client, err := ctx.Client() - if err != nil { - return err - } - - // Build meta with nil value (means reset) - meta := schema.SettingMeta{ - Value: nil, - } - - // Build options - opts := []httpclient.Opt{} - if cmd.Reload { - opts = append(opts, httpclient.WithReload(true)) - } - - // Reset the setting - setting, err := client.UpdateSetting(ctx.ctx, cmd.Name, meta, opts...) - if err != nil { - return err - } - - // Print - fmt.Println(setting) - return nil -} diff --git a/cmd/pgmanager/statement.go b/cmd/pgmanager/statement.go deleted file mode 100644 index 25f083e..0000000 --- a/cmd/pgmanager/statement.go +++ /dev/null @@ -1,73 +0,0 @@ -package main - -import ( - "fmt" - - // Packages - httpclient "github.com/mutablelogic/go-pg/pkg/manager/httpclient" -) - -/////////////////////////////////////////////////////////////////////////////// -// TYPES - -type StatementCommands struct { - ListStatement ListStatementCommand `cmd:"" name:"statements" help:"List query statistics from pg_stat_statements."` - ResetStatement ResetStatementCommand `cmd:"" name:"reset-statements" help:"Reset all statement statistics."` -} - -type ListStatementCommand struct { - Database string `name:"database" help:"Filter by database name"` - Role string `name:"role" help:"Filter by role name"` - Sort string `name:"sort" help:"Sort by field (calls, rows, total_ms, min_ms, max_ms, mean_ms)"` - Offset uint64 `name:"offset" help:"Offset for pagination"` - Limit *uint64 `name:"limit" help:"Limit for pagination"` -} - -type ResetStatementCommand struct{} - -/////////////////////////////////////////////////////////////////////////////// -// COMMANDS - -func (cmd *ListStatementCommand) Run(ctx *Globals) error { - client, err := ctx.Client() - if err != nil { - return err - } - - // Build options - opts := []httpclient.Opt{httpclient.WithOffsetLimit(cmd.Offset, cmd.Limit)} - if cmd.Database != "" { - opts = append(opts, httpclient.WithDatabase(&cmd.Database)) - } - if cmd.Role != "" { - opts = append(opts, httpclient.WithRole(&cmd.Role)) - } - if cmd.Sort != "" { - opts = append(opts, httpclient.WithSort(cmd.Sort)) - } - - // List statements - statements, err := client.ListStatements(ctx.ctx, opts...) - if err != nil { - return err - } - - // Print - fmt.Println(statements) - return nil -} - -func (cmd *ResetStatementCommand) Run(ctx *Globals) error { - client, err := ctx.Client() - if err != nil { - return err - } - - // Reset statements - if err := client.ResetStatements(ctx.ctx); err != nil { - return err - } - - fmt.Println("Statement statistics reset successfully") - return nil -} diff --git a/cmd/pgmanager/tablespace.go b/cmd/pgmanager/tablespace.go deleted file mode 100644 index b003555..0000000 --- a/cmd/pgmanager/tablespace.go +++ /dev/null @@ -1,166 +0,0 @@ -package main - -import ( - "fmt" - - // Packages - httpclient "github.com/mutablelogic/go-pg/pkg/manager/httpclient" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" -) - -/////////////////////////////////////////////////////////////////////////////// -// TYPES - -type TablespaceCommands struct { - ListTablespace ListTablespaceCommand `cmd:"" name:"tablespaces" help:"List tablespaces."` - GetTablespace GetTablespaceCommand `cmd:"" name:"tablespace" help:"Get tablespace."` - CreateTablespace CreateTablespaceCommand `cmd:"" name:"create-tablespace" help:"Create tablespace."` - DeleteTablespace DeleteTablespaceCommand `cmd:"" name:"delete-tablespace" help:"Delete tablespace."` - UpdateTablespace UpdateTablespaceCommand `cmd:"" name:"update-tablespace" help:"Update tablespace."` -} - -type ListTablespaceCommand struct { - Offset uint64 `name:"offset" help:"Offset for pagination"` - Limit *uint64 `name:"limit" help:"Limit for pagination"` -} - -type GetTablespaceCommand struct { - Name string `arg:"" name:"name" help:"Tablespace name"` -} - -type DeleteTablespaceCommand struct { - GetTablespaceCommand -} - -type CreateTablespaceCommand struct { - GetTablespaceCommand - Location string `name:"location" required:"" help:"Absolute path to tablespace directory"` - Owner string `name:"owner" help:"Tablespace owner"` - Acl []string `name:"acl" help:"Access control list entries (format: role:priv,priv,... e.g. myuser:CREATE)"` -} - -type UpdateTablespaceCommand struct { - GetTablespaceCommand - NewName string `name:"rename" help:"Rename tablespace to this name"` - Owner string `name:"owner" help:"Tablespace owner"` - Acl []string `name:"acl" help:"Access control list entries (format: role:priv,priv,... e.g. myuser:CREATE)"` -} - -/////////////////////////////////////////////////////////////////////////////// -// COMMANDS - -func (cmd *ListTablespaceCommand) Run(ctx *Globals) error { - client, err := ctx.Client() - if err != nil { - return err - } - - // List tablespaces - tablespaces, err := client.ListTablespaces(ctx.ctx, httpclient.WithOffsetLimit(cmd.Offset, cmd.Limit)) - if err != nil { - return err - } - - // Print - fmt.Println(tablespaces) - return nil -} - -func (cmd *GetTablespaceCommand) Run(ctx *Globals) error { - client, err := ctx.Client() - if err != nil { - return err - } - - // Get one tablespace - tablespace, err := client.GetTablespace(ctx.ctx, cmd.Name) - if err != nil { - return err - } - - // Print - fmt.Println(tablespace) - return nil -} - -func (cmd *CreateTablespaceCommand) Run(ctx *Globals) error { - client, err := ctx.Client() - if err != nil { - return err - } - - // Parse ACL entries - var acl schema.ACLList - for _, aclStr := range cmd.Acl { - item, err := schema.ParseACLItem(aclStr) - if err != nil { - return fmt.Errorf("invalid ACL %q: %w", aclStr, err) - } - acl = append(acl, item) - } - - // Create tablespace - tablespace, err := client.CreateTablespace(ctx.ctx, schema.TablespaceMeta{ - Name: cmd.Name, - Owner: cmd.Owner, - Acl: acl, - }, cmd.Location) - if err != nil { - return err - } - - // Print - fmt.Println(tablespace) - return nil -} - -func (cmd *DeleteTablespaceCommand) Run(ctx *Globals) error { - client, err := ctx.Client() - if err != nil { - return err - } - - // Delete tablespace - if err := client.DeleteTablespace(ctx.ctx, cmd.Name); err != nil { - return err - } - - // Return success - return nil -} - -func (cmd *UpdateTablespaceCommand) Run(ctx *Globals) error { - client, err := ctx.Client() - if err != nil { - return err - } - - // Parse ACL entries - var acl schema.ACLList - for _, aclStr := range cmd.Acl { - item, err := schema.ParseACLItem(aclStr) - if err != nil { - return fmt.Errorf("invalid ACL %q: %w", aclStr, err) - } - acl = append(acl, item) - } - - // Build meta - meta := schema.TablespaceMeta{ - Owner: cmd.Owner, - Acl: acl, - } - if cmd.NewName != "" { - meta.Name = cmd.NewName - } - - // Update tablespace - tablespace, err := client.UpdateTablespace(ctx.ctx, cmd.Name, meta) - if err != nil { - return err - } - - // Print - fmt.Println(tablespace) - return nil -} diff --git a/cmd/pgmanager/version.go b/cmd/pgmanager/version.go deleted file mode 100644 index bac3806..0000000 --- a/cmd/pgmanager/version.go +++ /dev/null @@ -1,39 +0,0 @@ -package main - -import ( - "fmt" - - // Packages - "github.com/mutablelogic/go-pg/pkg/version" -) - -/////////////////////////////////////////////////////////////////////////////// -// TYPES - -type VersionCommands struct { - Version VersionCommand `cmd:"version" help:"Print version information"` -} - -type VersionCommand struct{} - -/////////////////////////////////////////////////////////////////////////////// -// PUBLIC METHODS - -func (cmd *VersionCommand) Run(g *Globals) error { - if version.GitSource != "" { - fmt.Printf("%s@%s\n\n", version.GitSource, version.Version()) - } else { - fmt.Printf("pgmanager %s\n\n", version.Version()) - } - if version.GitHash != "" { - fmt.Printf("Commit: %s\n", version.GitHash) - } - if version.GitBranch != "" { - fmt.Printf("Branch: %s\n", version.GitBranch) - } - if version.GoBuildTime != "" { - fmt.Printf("Build Time: %s\n", version.GoBuildTime) - } - fmt.Printf("Compiler: %s\n", version.Compiler()) - return nil -} diff --git a/conn.go b/conn.go index 32ce9a3..d17beb8 100644 --- a/conn.go +++ b/conn.go @@ -224,15 +224,19 @@ func insert(ctx context.Context, conn pgx.Tx, bind *Bind, reader Reader, writer } func update(ctx context.Context, conn pgx.Tx, bind *Bind, reader Reader, sel Selector, writer Writer) error { - query, err := sel.Select(bind, Update) - if err != nil { - return err - } + // Perform the update before the select, so we can choose the query + // based on the update if writer != nil { if err := writer.Update(bind); err != nil { return err } } + + // Now select the query based on the selector and operation + query, err := sel.Select(bind, Update) + if err != nil { + return err + } return exec(ctx, conn, bind, query, reader) } diff --git a/error.go b/error.go index 44e37f6..ce707af 100644 --- a/error.go +++ b/error.go @@ -7,6 +7,7 @@ import ( // Packages pgx "github.com/jackc/pgx/v5" pgconn "github.com/jackc/pgx/v5/pgconn" + "github.com/mutablelogic/go-server/pkg/httpresponse" ) ///////////////////////////////////////////////////////////////////// @@ -39,16 +40,23 @@ const ( ErrInvalidTextRepresentation ErrInvalidDatetimeFormat ErrDatetimeFieldOverflow + ErrInternalServerError ) const ( - sqlStateUniqueViolation = "23505" - sqlStateForeignKeyViolation = "23503" - sqlStateNotNullViolation = "23502" - sqlStateCheckViolation = "23514" - sqlStateInvalidTextRepresentation = "22P02" - sqlStateInvalidDatetimeFormat = "22007" - sqlStateDatetimeFieldOverflow = "22008" + sqlStateUniqueViolation = "23505" + sqlStateForeignKeyViolation = "23503" + sqlStateNotNullViolation = "23502" + sqlStateCheckViolation = "23514" + sqlStateInvalidTextRepresentation = "22P02" + sqlStateInvalidDatetimeFormat = "22007" + sqlStateDatetimeFieldOverflow = "22008" + sqlStateInsufficientPrivilege = "42501" + sqlStateUndefinedFile = "58P01" + sqlStateUndefinedObject = "42704" + sqlStateDuplicateObject = "42710" + sqlStateRoleMembershipLoop = "0LP01" + sqlStateDependentObjectsStillExist = "2BP01" ) // Error returns the string representation of the error. @@ -82,6 +90,8 @@ func (e Err) Error() string { return "invalid datetime format" case ErrDatetimeFieldOverflow: return "datetime field overflow" + case ErrInternalServerError: + return "internal server error" default: return fmt.Sprint("Unknown error ", int(e)) } @@ -175,6 +185,23 @@ func NormalizeError(err error) error { return err } +// HTTPError returns the appropriate HTTP status code for the given error. +func HTTPError(err error) error { + err = NormalizeError(err) + switch { + case errors.Is(err, ErrNotFound): + return httpresponse.ErrNotFound.With(err.Error()) + case errors.Is(err, ErrBadParameter): + return httpresponse.ErrBadRequest.With(err.Error()) + case errors.Is(err, ErrNotImplemented): + return httpresponse.ErrNotImplemented.With(err.Error()) + case errors.Is(err, ErrConflict): + return httpresponse.ErrConflict.With(err.Error()) + default: + return httpresponse.ErrInternalError.With(err.Error()) + } +} + // IsDatabaseError reports whether err is a PostgreSQL error with a SQLSTATE code. func IsDatabaseError(err error) bool { return SQLState(err) != "" @@ -224,6 +251,18 @@ func newDatabaseError(err *pgconn.PgError) error { kinds = append(kinds, ErrBadParameter, ErrInvalidDatetimeFormat) case sqlStateDatetimeFieldOverflow: kinds = append(kinds, ErrBadParameter, ErrDatetimeFieldOverflow) + case sqlStateInsufficientPrivilege: + kinds = append(kinds, ErrBadParameter) + case sqlStateUndefinedFile: + kinds = append(kinds, ErrNotFound) + case sqlStateUndefinedObject: + kinds = append(kinds, ErrBadParameter, ErrNotFound) + case sqlStateDuplicateObject: + kinds = append(kinds, ErrConflict) + case sqlStateRoleMembershipLoop: + kinds = append(kinds, ErrConflict) + case sqlStateDependentObjectsStillExist: + kinds = append(kinds, ErrConflict) } return &DatabaseError{ @@ -232,4 +271,4 @@ func newDatabaseError(err *pgconn.PgError) error { err: err, kinds: kinds, } -} \ No newline at end of file +} diff --git a/error_test.go b/error_test.go index 4f110cb..5ee3312 100644 --- a/error_test.go +++ b/error_test.go @@ -6,6 +6,7 @@ import ( // Packages pgx "github.com/jackc/pgx/v5" pgconn "github.com/jackc/pgx/v5/pgconn" + "github.com/mutablelogic/go-server/pkg/httpresponse" assert "github.com/stretchr/testify/assert" require "github.com/stretchr/testify/require" ) @@ -51,7 +52,6 @@ func Test_NormalizeError_003(t *testing.T) { {"invalid_datetime", sqlStateInvalidDatetimeFormat, ErrInvalidDatetimeFormat}, {"datetime_overflow", sqlStateDatetimeFieldOverflow, ErrDatetimeFieldOverflow}, } - for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { assert := assert.New(t) @@ -62,5 +62,27 @@ func Test_NormalizeError_003(t *testing.T) { assert.ErrorIs(err, tc.kind) assert.Equal(tc.code, SQLState(err)) }) - } - } \ No newline at end of file + } + +} + +func Test_HTTPError_001(t *testing.T) { + assert := assert.New(t) + + err := HTTPError(&pgconn.PgError{Code: sqlStateDependentObjectsStillExist, Message: "cannot drop schema public because other objects depend on it"}) + assert.ErrorIs(err, httpresponse.ErrConflict) +} + +func Test_HTTPError_002(t *testing.T) { + assert := assert.New(t) + + err := HTTPError(&pgconn.PgError{Code: sqlStateInsufficientPrivilege, Message: "could not set permissions on directory \"/media\": Operation not permitted"}) + assert.ErrorIs(err, httpresponse.ErrBadRequest) +} + +func Test_HTTPError_003(t *testing.T) { + assert := assert.New(t) + + err := HTTPError(&pgconn.PgError{Code: sqlStateUndefinedFile, Message: "directory \"/tmp/test\" does not exist"}) + assert.ErrorIs(err, httpresponse.ErrNotFound) +} diff --git a/etc/Dockerfile b/etc/Dockerfile index d73253c..3fa4456 100644 --- a/etc/Dockerfile +++ b/etc/Dockerfile @@ -4,14 +4,14 @@ ARG ARCH=amd64 # Creates a docker image for pgmanager # Build stage -FROM --platform=${OS}/${ARCH} golang:1.24 AS builder +FROM --platform=${OS}/${ARCH} golang:1.25 AS builder WORKDIR /usr/src/app COPY . . RUN go install github.com/djthorpe/go-wasmbuild/cmd/wasmbuild@latest && \ - OS=${OS} ARCH=${ARCH} make pgmanager + OS=${OS} ARCH=${ARCH} make cmd/pgmanager # Runtime stage -FROM --platform=${OS}/${ARCH} debian:bookworm-slim +FROM --platform=${OS}/${ARCH} debian:trixie-slim ARG SOURCE=https://github.com/mutablelogic/go-pg COPY --from=builder /usr/src/app/build/ /usr/local/bin/ RUN apt-get update && \ diff --git a/go.mod b/go.mod index 224b693..2bb22fa 100644 --- a/go.mod +++ b/go.mod @@ -3,27 +3,36 @@ module github.com/mutablelogic/go-pg go 1.25.0 require ( - github.com/alecthomas/kong v1.15.0 github.com/djthorpe/go-wasmbuild v0.0.6 - github.com/jackc/pgx/v5 v5.9.2 + github.com/jackc/pgx/v5 v5.10.0 github.com/mutablelogic/go-client v1.4.9 - github.com/mutablelogic/go-server v1.6.34 + github.com/mutablelogic/go-server v1.6.36 github.com/prometheus/client_golang v1.23.2 github.com/stretchr/testify v1.11.1 github.com/testcontainers/testcontainers-go v0.42.0 - go.opentelemetry.io/otel v1.43.0 - go.opentelemetry.io/otel/trace v1.43.0 - golang.org/x/term v0.42.0 + go.opentelemetry.io/otel v1.44.0 + go.opentelemetry.io/otel/metric v1.44.0 + go.opentelemetry.io/otel/trace v1.44.0 + golang.org/x/sync v0.21.0 ) require ( dario.cat/mergo v1.0.2 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/alecthomas/kong v1.15.0 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.11.7 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect @@ -33,7 +42,7 @@ require ( github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-connections v0.7.0 // indirect github.com/docker/go-units v0.5.0 // indirect - github.com/ebitengine/purego v0.10.0 // indirect + github.com/ebitengine/purego v0.10.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -45,48 +54,61 @@ require ( github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/klauspost/compress v1.18.6 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect github.com/magiconair/properties v1.8.10 // indirect + github.com/mattn/go-isatty v0.0.21 // indirect + github.com/mattn/go-runewidth v0.0.23 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/go-archive v0.2.0 // indirect github.com/moby/moby/api v1.54.2 // indirect github.com/moby/moby/client v0.4.1 // indirect github.com/moby/patternmatcher v0.6.1 // indirect - github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/sequential v0.7.0 // indirect github.com/moby/sys/user v0.4.0 // indirect github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mutablelogic/go-tokenizer v0.0.3 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/common v0.68.1 // indirect github.com/prometheus/procfs v0.20.1 // indirect - github.com/shirou/gopsutil/v4 v4.26.4 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/shirou/gopsutil/v4 v4.26.5 // indirect github.com/sirupsen/logrus v1.9.4 // indirect - github.com/tklauser/go-sysconf v0.3.16 // indirect - github.com/tklauser/numcpus v0.11.0 // indirect + github.com/tklauser/go-sysconf v0.4.0 // indirect + github.com/tklauser/numcpus v0.12.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect - go.opentelemetry.io/otel/metric v1.43.0 // indirect - go.opentelemetry.io/otel/sdk v1.43.0 // indirect + go.opentelemetry.io/contrib/bridges/otelslog v0.19.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 // indirect + go.opentelemetry.io/otel/log v0.20.0 // indirect + go.opentelemetry.io/otel/sdk v1.44.0 // indirect + go.opentelemetry.io/otel/sdk/log v0.20.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect - go.yaml.in/yaml/v2 v2.4.4 // indirect - golang.org/x/crypto v0.50.0 // indirect - golang.org/x/net v0.53.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.43.0 // indirect - golang.org/x/text v0.36.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 // indirect - google.golang.org/grpc v1.80.0 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/term v0.44.0 // indirect + golang.org/x/text v0.38.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260608224507-4308a22a1bab // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260608224507-4308a22a1bab // indirect + google.golang.org/grpc v1.81.1 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 0364c2c..f82d5ad 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,10 @@ github.com/alecthomas/kong v1.15.0 h1:BVJstKbpO73zKpmIu+m/aLRrNmWwxXPIGTNin9VmLV github.com/alecthomas/kong v1.15.0/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= @@ -20,6 +24,22 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= +github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= @@ -37,18 +57,14 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/djthorpe/go-wasmbuild v0.0.1 h1:HoPNhNcrK5S0ze2TS46NouoAyXPqupgQImNfwaM1b0o= -github.com/djthorpe/go-wasmbuild v0.0.1/go.mod h1:T3vqsVbmzws0VG50oXY5OiQMDIdBQsgYRjkoVIedPFM= github.com/djthorpe/go-wasmbuild v0.0.6 h1:GwcXGsObsC4IqpOlLppK2eX8T480AWIA9oNElyQb3Do= github.com/djthorpe/go-wasmbuild v0.0.6/go.mod h1:aFsyWZA+tyJmGpL1ZQvvdzi9k3dEXGoDZpj4lODjuzk= -github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= -github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= -github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= -github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= +github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -63,14 +79,10 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= -github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= @@ -79,14 +91,10 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc= -github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= -github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= -github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= -github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= -github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -95,48 +103,50 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= +github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= -github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4= -github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg= github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= -github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw= -github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g= github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY= github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ= github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= -github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= -github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/sequential v0.7.0 h1:ASQNGNROJSuOO6LL6bPHbKvuZu6NU8P4ldPWk31zj/8= +github.com/moby/sys/sequential v0.7.0/go.mod h1:NfSTAp6V3fw4tmkD62PEcOKeZKquXT8VKCkf7aVR79o= github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/mutablelogic/go-client v1.4.6 h1:ZbyeKDbaVVpqjXocMSInETcI6hq+NqQKybVTIfig3rI= -github.com/mutablelogic/go-client v1.4.6/go.mod h1:+a2GnKNIoIw0HoYNerK2H3buxTfygzH77Hp0vCzanx4= github.com/mutablelogic/go-client v1.4.9 h1:1JHLha6u0u0mEQEHc4K0Pq6EJMwd8F0DR/ahQrzTAlA= github.com/mutablelogic/go-client v1.4.9/go.mod h1:g8c6RlvIC0wC5rpoqtqk7eznBxormwrxArxH9I5rNRQ= -github.com/mutablelogic/go-server v1.6.22 h1:0oD1rzVuD/f0OV9vUJXvnEEZko5S9Kx+/2bZU4o6T70= -github.com/mutablelogic/go-server v1.6.22/go.mod h1:HXs7Mgz3e2pXZuO+BoUMpKNrYcw85OnKEDILITbETXs= -github.com/mutablelogic/go-server v1.6.34 h1:fkeZM4Raaryt86/HDzVruQeXBCpvLoB725PSIpQWHS8= -github.com/mutablelogic/go-server v1.6.34/go.mod h1:WEitTi2S39tM0xkmhm0aMNzb7NqhkaqJmwfHpaVtycw= +github.com/mutablelogic/go-server v1.6.36 h1:zJl6Fju8Q0XEa/D3jRWeKNskMCd9LjSC8UyUJAzQC7A= +github.com/mutablelogic/go-server v1.6.36/go.mod h1:WEitTi2S39tM0xkmhm0aMNzb7NqhkaqJmwfHpaVtycw= github.com/mutablelogic/go-tokenizer v0.0.3 h1:6oaa80TaAl+nVpd+M9QhlJPbP7y5/thq4d0dKjreiLs= github.com/mutablelogic/go-tokenizer v0.0.3/go.mod h1:zdAyIhfqUKxFXb8MwChbXNwMOZt/5NlUylmx6Qjr4v8= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -146,16 +156,16 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= -github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/common v0.68.1 h1:omjRRl4QP4komogpXuhfeOiisQg7xdy8VM1UY+pStaY= +github.com/prometheus/common v0.68.1/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y= github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= -github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= -github.com/shirou/gopsutil/v4 v4.26.4 h1:B4SXVbcwTyrocPHEmWBC4uCYr4Xcu3MK1TXqbprAOWY= -github.com/shirou/gopsutil/v4 v4.26.4/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/shirou/gopsutil/v4 v4.26.5 h1:RPcBXkpz7kOj9PqGFQOlBPZHsyaPvPVQc098y9RmCNM= +github.com/shirou/gopsutil/v4 v4.26.5/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -167,78 +177,84 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY= github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30= -github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= -github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= -github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= -github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU= +github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI= +github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4= +github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= -go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= -go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= -go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= -go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= -go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= -go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= -go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= -go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= -go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= -go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/contrib/bridges/otelslog v0.19.0 h1:5RgvxieNq9tS3ewrV1vnODvbHPfKUIJcYtF9Cvz+6aQ= +go.opentelemetry.io/contrib/bridges/otelslog v0.19.0/go.mod h1:iTBIdNwx/xmUhfgJs6+84S4dIK059811cO1eUBjKcHY= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 h1:rydZ9sxbcFdm/oWrVyfLTjHIygMgv0bEeMd+3B/BvoM= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0/go.mod h1:earQ25dooT0Hhspq59DZ8YCC50jWfOlFEeWoxy/P444= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 h1:owlhcJ3QO3X0YTDTCcDZ4V+6aVDkWbNmBoQ5NUp7Oww= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0/go.mod h1:MP4eemTiI9zC8fgg+DYynhYDYf3ba72S376TvP+Ye0Q= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 h1:SUplec5dp06reu1zaXmOXdvqH398taqrDXqUl99jxSc= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0/go.mod h1:ho2g4N+ane+swq5I/VBkKWnRDY4kUINH3FuqyZqX/Ug= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 h1:RuynHbfU8JUEw7DyONgkVYg2SVtsoF28y0LGIr69jgA= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0/go.mod h1:qZF+/lBs71APw8mlnEZcqZHMzqrYrsFiJOv83lX1OGo= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s= +go.opentelemetry.io/otel/log v0.20.0 h1:/5i0vuHxCLWUfChWG41K9wkM0jafruPw9NU1/RCJirs= +go.opentelemetry.io/otel/log v0.20.0/go.mod h1:wOcMcjsZpG8x7Bak7IhSi/lg8wscV2C1VdrKCLPlt0E= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA= +go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/log v0.20.0 h1:vM3xI7TQgKPiSghe6urZtAkyFY7SodrSpC83CffDFuY= +go.opentelemetry.io/otel/sdk/log v0.20.0/go.mod h1:Knej2nmsTUzN79T2eeXdRsjjPcoxoq2pUyUHz9TFyyU= +go.opentelemetry.io/otel/sdk/log/logtest v0.20.0 h1:OqdRZ1guyzamK3M6LlRsmGqRrjkHWw6WZOKKli5ELpg= +go.opentelemetry.io/otel/sdk/log/logtest v0.20.0/go.mod h1:PuMIlm7zAt7c3z8zfOI5ox4iT1Z87We+PF6YoINux/M= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= -golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= -golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= -google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= -google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4 h1:yOzSCGPx+cp5VO7IxvZ9SBFF7j1tZVcNtlHR2iYKtVo= -google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:Q9HWtNeE7tM9npdIsEvqXj1QJIvVoeAV3rtXtS715Cw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 h1:tEkOQcXgF6dH1G+MVKZrfpYvozGrzb91k6ha7jireSM= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= -google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/genproto/googleapis/api v0.0.0-20260608224507-4308a22a1bab h1:Foefixyu0l973HSYkX8Etw/fPxAmKRhyMGwuqXFiVI0= +google.golang.org/genproto/googleapis/api v0.0.0-20260608224507-4308a22a1bab/go.mod h1:KdNqO+rCIWgFumrNBSEDlDNrkrQnpkax7Tv1WxNY8V4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260608224507-4308a22a1bab h1:cY0oV1VnAqvaim8VsR8ZyEKAudzbRJMRGwD3W/L7yOw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260608224507-4308a22a1bab/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/offsetlimit.go b/offsetlimit.go index d491a40..cb06c19 100644 --- a/offsetlimit.go +++ b/offsetlimit.go @@ -31,9 +31,19 @@ func (r *OffsetLimit) Bind(bind *Bind, max uint64) { } } -// Clamp restricts the limit to the maximum length. -func (r *OffsetLimit) Clamp(len uint64) { - if r.Limit != nil { - *r.Limit = min(*r.Limit, len) +// Clamp restricts the limit to the number of rows available after offset. +func (r *OffsetLimit) Clamp(total uint64) { + if r.Limit == nil { + return } + + // Available rows are constrained by total count and current offset. + available := total + if r.Offset >= total { + available = 0 + } else { + available = total - r.Offset + } + + *r.Limit = min(*r.Limit, available) } diff --git a/offsetlimit_test.go b/offsetlimit_test.go new file mode 100644 index 0000000..c7a2205 --- /dev/null +++ b/offsetlimit_test.go @@ -0,0 +1,52 @@ +package pg + +import ( + "testing" + + assert "github.com/stretchr/testify/assert" +) + +func Test_OffsetLimit_Clamp_001(t *testing.T) { + assert := assert.New(t) + + limit := uint64(50) + offsetLimit := OffsetLimit{Offset: 0, Limit: &limit} + offsetLimit.Clamp(100) + + assert.Equal(uint64(50), limit) +} + +func Test_OffsetLimit_Clamp_002(t *testing.T) { + assert := assert.New(t) + + limit := uint64(50) + offsetLimit := OffsetLimit{Offset: 90, Limit: &limit} + offsetLimit.Clamp(100) + + assert.Equal(uint64(10), limit) +} + +func Test_OffsetLimit_Clamp_003(t *testing.T) { + assert := assert.New(t) + + limit := uint64(50) + offsetLimit := OffsetLimit{Offset: 100, Limit: &limit} + offsetLimit.Clamp(100) + + assert.Equal(uint64(0), limit) +} + +func Test_OffsetLimit_Clamp_004(t *testing.T) { + assert := assert.New(t) + + limit := uint64(50) + offsetLimit := OffsetLimit{Offset: 120, Limit: &limit} + offsetLimit.Clamp(100) + + assert.Equal(uint64(0), limit) +} + +func Test_OffsetLimit_Clamp_005(t *testing.T) { + offsetLimit := OffsetLimit{Offset: 10, Limit: nil} + offsetLimit.Clamp(100) +} diff --git a/pgmanager/cmd/client.go b/pgmanager/cmd/client.go new file mode 100644 index 0000000..c97a9a8 --- /dev/null +++ b/pgmanager/cmd/client.go @@ -0,0 +1,934 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + // Packages + otel "github.com/mutablelogic/go-client/pkg/otel" + httpclient "github.com/mutablelogic/go-pg/pgmanager/httpclient" + schema "github.com/mutablelogic/go-pg/pgmanager/schema" + server "github.com/mutablelogic/go-server" + tui "github.com/mutablelogic/go-server/pkg/tui" +) + +/////////////////////////////////////////////////////////////////////////////// +// TYPES + +type ClientCommands struct { + Ping PingCmd `cmd:"" name:"ping" help:"Ping the server." group:"STATUS"` + RoleClientCommands + DatabaseClientCommands + SchemaClientCommands + ObjectClientCommands + StatementCommands + TablespaceClientCommands + ConnectionClientCommands + ReplicationSlotClientCommands + ExtensionClientCommands + SettingClientCommands +} + +type PingCmd struct{} + +type RoleClientCommands struct { + RoleList RoleListCmd `cmd:"" name:"roles" help:"List roles." group:"ROLE"` + RoleGet RoleGetCmd `cmd:"" name:"role" help:"Get role details." group:"ROLE"` + RoleCreate RoleCreateCmd `cmd:"" name:"role-create" help:"Create a new role." group:"ROLE"` + RoleDelete RoleDeleteCmd `cmd:"" name:"role-delete" help:"Delete a role." group:"ROLE"` + RoleUpdate RoleUpdateCmd `cmd:"" name:"role-update" help:"Update a role." group:"ROLE"` +} + +type DatabaseClientCommands struct { + DatabaseList DatabaseListCmd `cmd:"" name:"databases" help:"List databases." group:"DATABASE"` + DatabaseGet DatabaseGetCmd `cmd:"" name:"database" help:"Get database details." group:"DATABASE"` + DatabaseCreate DatabaseCreateCmd `cmd:"" name:"database-create" help:"Create a new database." group:"DATABASE"` + DatabaseDelete DatabaseDeleteCmd `cmd:"" name:"database-delete" help:"Delete a database." group:"DATABASE"` + DatabaseUpdate DatabaseUpdateCmd `cmd:"" name:"database-update" help:"Update a database." group:"DATABASE"` +} + +type SchemaClientCommands struct { + SchemaList SchemaListCmd `cmd:"" name:"schemas" help:"List schemas." group:"SCHEMA"` + SchemaGet SchemaGetCmd `cmd:"" name:"schema" help:"Get schema details." group:"SCHEMA"` + SchemaCreate SchemaCreateCmd `cmd:"" name:"schema-create" help:"Create a new schema in a database." group:"SCHEMA"` + SchemaDelete SchemaDeleteCmd `cmd:"" name:"schema-delete" help:"Delete a schema from a database." group:"SCHEMA"` + SchemaUpdate SchemaUpdateCmd `cmd:"" name:"schema-update" help:"Update a schema in a database." group:"SCHEMA"` +} + +type ObjectClientCommands struct { + ObjectList ObjectListCmd `cmd:"" name:"objects" help:"List objects." group:"OBJECT"` + ObjectGet ObjectGetCmd `cmd:"" name:"object" help:"Get object details." group:"OBJECT"` +} + +type StatementCommands struct { + StatementList StatementListCmd `cmd:"" name:"statements" help:"Return statement statistics." group:"STATEMENT"` + StatementReset StatementResetCmd `cmd:"" name:"statement-reset" help:"Reset statement statistics." group:"STATEMENT"` +} + +type TablespaceClientCommands struct { + TablespaceList TablespaceListCmd `cmd:"" name:"tablespaces" help:"List tablespaces." group:"TABLESPACE"` + TablespaceGet TablespaceGetCmd `cmd:"" name:"tablespace" help:"Get tablespace details." group:"TABLESPACE"` + TablespaceCreate TablespaceCreateCmd `cmd:"" name:"tablespace-create" help:"Create a new tablespace." group:"TABLESPACE"` + TablespaceDelete TablespaceDeleteCmd `cmd:"" name:"tablespace-delete" help:"Delete a tablespace." group:"TABLESPACE"` + TablespaceUpdate TablespaceUpdateCmd `cmd:"" name:"tablespace-update" help:"Update a tablespace." group:"TABLESPACE"` +} + +type ConnectionClientCommands struct { + ConnectionList ConnectionListCmd `cmd:"" name:"connections" help:"List connections." group:"CONNECTION"` + ConnectionGet ConnectionGetCmd `cmd:"" name:"connection" help:"Get connection details." group:"CONNECTION"` + ConnectionDelete ConnectionDeleteCmd `cmd:"" name:"connection-delete" help:"Delete a connection." group:"CONNECTION"` +} + +type ReplicationSlotClientCommands struct { + ReplicationSlotList ReplicationSlotListCmd `cmd:"" name:"replication-slots" help:"List replication slots." group:"REPLICATION SLOT"` + ReplicationSlotGet ReplicationSlotGetCmd `cmd:"" name:"replication-slot" help:"Get replication slot details." group:"REPLICATION SLOT"` + ReplicationSlotCreate ReplicationSlotCreateCmd `cmd:"" name:"replication-slot-create" help:"Create a new replication slot." group:"REPLICATION SLOT"` + ReplicationSlotDelete ReplicationSlotDeleteCmd `cmd:"" name:"replication-slot-delete" help:"Delete a replication slot." group:"REPLICATION SLOT"` +} + +type ExtensionClientCommands struct { + ExtensionList ExtensionListCmd `cmd:"" name:"extensions" help:"List extensions." group:"EXTENSION"` + ExtensionGet ExtensionGetCmd `cmd:"" name:"extension" help:"Get extension details." group:"EXTENSION"` + ExtensionCreate ExtensionCreateCmd `cmd:"" name:"extension-install" help:"Install an extension into a database schema." group:"EXTENSION"` + ExtensionDelete ExtensionDeleteCmd `cmd:"" name:"extension-remove" help:"Remove an extension from one or more database schemas." group:"EXTENSION"` +} + +type SettingClientCommands struct { + SettingList SettingListCmd `cmd:"" name:"settings" help:"List server settings." group:"SETTING"` + SettingCategoryList SettingCategoryListCmd `cmd:"" name:"categories" help:"List distinct setting categories." group:"SETTING"` + SettingGet SettingGetCmd `cmd:"" name:"setting" help:"Get setting details." group:"SETTING"` + SettingUpdate SettingUpdateCmd `cmd:"" name:"setting-update" help:"Update a setting." group:"SETTING"` +} + +type RoleListCmd struct { + schema.RoleListRequest +} + +type RoleCreateCmd struct { + schema.RoleMeta +} + +type RoleGetCmd struct { + Name string `arg:"" name:"name" help:"Name of the role."` +} + +type RoleDeleteCmd struct { + Name string `arg:"" name:"name" help:"Name of the role."` +} + +type RoleUpdateCmd struct { + NewName string `flag:"" name:"role" help:"New name for the role."` + schema.RoleMeta +} + +type DatabaseListCmd struct { + schema.DatabaseListRequest +} + +type DatabaseGetCmd struct { + Name string `arg:"" name:"name" help:"Name of the database."` +} + +type DatabaseCreateCmd struct { + schema.DatabaseMeta +} + +type DatabaseDeleteCmd struct { + Name string `arg:"" name:"name" help:"Name of the database."` +} + +type DatabaseUpdateCmd struct { + NewName string `flag:"" name:"name" help:"New name of the database."` + schema.DatabaseMeta +} + +type SchemaListCmd struct { + schema.SchemaListRequest +} + +type SchemaGetCmd struct { + Database string `arg:"" name:"database" help:"Name of the database."` + Namespace string `arg:"" name:"schema" help:"Name of the schema."` +} + +type SchemaCreateCmd struct { + Database string `arg:"" name:"database" help:"Name of the database."` + schema.SchemaMeta +} + +type SchemaDeleteCmd struct { + Database string `arg:"" name:"database" help:"Name of the database."` + Namespace string `arg:"" name:"schema" help:"Name of the schema."` + Force bool `flag:"" name:"force" help:"Force deletion of the schema."` +} + +type SchemaUpdateCmd struct { + Database string `arg:"" name:"database" help:"Name of the database."` + NewNamespace string `flag:"" name:"name" help:"New name of the schema."` + schema.SchemaMeta +} + +type ObjectListCmd struct { + schema.ObjectListRequest +} + +type ObjectGetCmd struct { + Database string `arg:"" name:"database" help:"Name of the database."` + Namespace string `arg:"" name:"schema" help:"Name of the schema."` + Name string `arg:"" name:"name" help:"Name of the object."` +} + +type StatementListCmd struct { + schema.StatementListRequest +} + +type StatementResetCmd struct{} + +type TablespaceListCmd struct { + schema.TablespaceListRequest +} + +type TablespaceGetCmd struct { + Name string `arg:"" name:"name" help:"Name of the tablespace."` +} + +type TablespaceCreateCmd struct { + schema.TablespaceMeta + Location string `arg:"" name:"location" help:"Location for the tablespace."` +} + +type TablespaceDeleteCmd struct { + Name string `arg:"" name:"name" help:"Name of the tablespace."` +} + +type TablespaceUpdateCmd struct { + NewName string `flag:"" name:"name" help:"New name of the tablespace."` + schema.TablespaceMeta +} + +type ConnectionListCmd struct { + schema.ConnectionListRequest +} + +type ConnectionGetCmd struct { + Pid uint64 `arg:"" name:"pid" help:"PID of the connection."` +} + +type ConnectionDeleteCmd struct { + Pid uint64 `arg:"" name:"pid" help:"PID of the connection."` +} + +type ReplicationSlotListCmd struct { + schema.ReplicationSlotListRequest +} + +type ReplicationSlotGetCmd struct { + Name string `arg:"" name:"name" help:"Name of the replication slot."` +} + +type ReplicationSlotCreateCmd struct { + schema.ReplicationSlotMeta +} + +type ReplicationSlotDeleteCmd struct { + Name string `arg:"" name:"name" help:"Name of the replication slot."` +} + +type ExtensionListCmd struct { + schema.ExtensionListRequest +} + +type ExtensionGetCmd struct { + Name string `arg:"" name:"name" help:"Name of the extension."` +} + +type ExtensionCreateCmd struct { + schema.ExtensionMeta + Cascade bool `flag:"" name:"cascade" help:"Cascade option."` +} + +type ExtensionDeleteCmd struct { + Name string `arg:"" name:"name" help:"Name of the extension."` + Cascade bool `flag:"" name:"cascade" help:"Cascade option."` +} + +type SettingListCmd struct { + schema.SettingListRequest +} + +type SettingCategoryListCmd struct { + schema.SettingCategoryListRequest +} + +type SettingGetCmd struct { + Name string `arg:"" name:"name" help:"Name of the setting."` +} + +type SettingUpdateCmd struct { + Name string `arg:"" name:"name" help:"Name of the setting."` + schema.SettingMeta +} + +/////////////////////////////////////////////////////////////////////////////// +// PRIVATE METHODS + +func withClient(ctx server.Cmd, span string, fn func(context.Context, *httpclient.Client) error) error { + endpoint, opts, err := ctx.ClientEndpoint() + if err != nil { + return err + } else if client, err := httpclient.New(endpoint, opts...); err != nil { + return err + } else { + var err error + ctx, endfn := otel.StartSpan(ctx.Tracer(), ctx.Context(), span) + defer func() { endfn(err) }() + err = fn(ctx, client) + return err + } +} + +/////////////////////////////////////////////////////////////////////////////// +// STATUS COMMANDS + +func (cmd *PingCmd) Run(ctx server.Cmd) error { + return withClient(ctx, "ping", func(ctx context.Context, client *httpclient.Client) error { + return client.Ping(ctx) + }) +} + +/////////////////////////////////////////////////////////////////////////////// +// ROLE COMMANDS + +func (cmd *RoleListCmd) Run(ctx server.Cmd) error { + // Set the width of the terminal + width := ctx.IsTerm() + + // Perform the request + return withClient(ctx, "roles", func(ctx context.Context, client *httpclient.Client) error { + roles, err := client.ListRoles(ctx, cmd.RoleListRequest) + if err != nil { + return err + } + + // Roles list table + table := tui.TableFor[schema.Role](tui.SetWidth(width)) + if _, err := table.Write(os.Stdout, roles.Body...); err != nil { + return err + } + + // Roles list summary + summary := tui.TableSummary("roles", uint(roles.Count), roles.Offset, roles.Limit) + if _, err := summary.Write(os.Stdout); err != nil { + return err + } + + return nil + }) +} + +func (cmd *RoleCreateCmd) Run(ctx server.Cmd) error { + return withClient(ctx, "role-create", func(ctx context.Context, client *httpclient.Client) error { + role, err := client.CreateRole(ctx, cmd.RoleMeta) + if err != nil { + return err + } + + fmt.Println(role) + return nil + }) +} + +func (cmd *RoleGetCmd) Run(ctx server.Cmd) error { + return withClient(ctx, "role", func(ctx context.Context, client *httpclient.Client) error { + role, err := client.GetRole(ctx, cmd.Name) + if err != nil { + return err + } + + fmt.Println(role) + return nil + }) +} + +func (cmd *RoleDeleteCmd) Run(ctx server.Cmd) error { + return withClient(ctx, "role-delete", func(ctx context.Context, client *httpclient.Client) error { + if _, err := client.DeleteRole(ctx, cmd.Name); err != nil { + return err + } + return nil + }) +} + +func (cmd *RoleUpdateCmd) Run(ctx server.Cmd) error { + return withClient(ctx, "role-update", func(ctx context.Context, client *httpclient.Client) error { + // We swap the name in the meta with the new name + cmd.NewName, cmd.RoleMeta.Name = cmd.RoleMeta.Name, cmd.NewName + + // Perform the update + role, err := client.UpdateRole(ctx, cmd.NewName, cmd.RoleMeta) + if err != nil { + return err + } + + fmt.Println(role) + return nil + }) +} + +/////////////////////////////////////////////////////////////////////////////// +// DATABASE COMMANDS + +func (cmd *DatabaseListCmd) Run(ctx server.Cmd) error { + // Set the width of the terminal + width := ctx.IsTerm() + + // Perform the request + return withClient(ctx, "databases", func(ctx context.Context, client *httpclient.Client) error { + databases, err := client.ListDatabases(ctx, cmd.DatabaseListRequest) + if err != nil { + return err + } + + // Databases list table + table := tui.TableFor[schema.Database](tui.SetWidth(width)) + if _, err := table.Write(os.Stdout, databases.Body...); err != nil { + return err + } + + // Databases list summary + summary := tui.TableSummary("databases", uint(databases.Count), databases.Offset, databases.Limit) + if _, err := summary.Write(os.Stdout); err != nil { + return err + } + + return nil + }) +} + +func (cmd *DatabaseGetCmd) Run(ctx server.Cmd) error { + return withClient(ctx, "database", func(ctx context.Context, client *httpclient.Client) error { + database, err := client.GetDatabase(ctx, cmd.Name) + if err != nil { + return err + } + + fmt.Println(database) + return nil + }) +} + +func (cmd *DatabaseCreateCmd) Run(ctx server.Cmd) error { + return withClient(ctx, "database-create", func(ctx context.Context, client *httpclient.Client) error { + database, err := client.CreateDatabase(ctx, cmd.DatabaseMeta) + if err != nil { + return err + } + + fmt.Println(database) + return nil + }) +} + +func (cmd *DatabaseDeleteCmd) Run(ctx server.Cmd) error { + return withClient(ctx, "database-delete", func(ctx context.Context, client *httpclient.Client) error { + return client.DeleteDatabase(ctx, cmd.Name, false) + }) +} + +func (cmd *DatabaseUpdateCmd) Run(ctx server.Cmd) error { + return withClient(ctx, "database-update", func(ctx context.Context, client *httpclient.Client) error { + // We swap the name in the meta with the new name + cmd.Name, cmd.NewName = cmd.NewName, cmd.Name + + // Perform the update + database, err := client.UpdateDatabase(ctx, cmd.NewName, cmd.DatabaseMeta) + if err != nil { + return err + } + + fmt.Println(database) + return nil + }) +} + +/////////////////////////////////////////////////////////////////////////////// +// SCHEMA COMMANDS + +func (cmd *SchemaListCmd) Run(ctx server.Cmd) error { + // Set the width of the terminal + width := ctx.IsTerm() + + // Perform the request + return withClient(ctx, "schemas", func(ctx context.Context, client *httpclient.Client) error { + schemas, err := client.ListSchemas(ctx, cmd.SchemaListRequest) + if err != nil { + return err + } + + // Schemas list table + table := tui.TableFor[schema.Schema](tui.SetWidth(width)) + if _, err := table.Write(os.Stdout, schemas.Body...); err != nil { + return err + } + + // Schemas list summary + summary := tui.TableSummary("schemas", uint(schemas.Count), schemas.Offset, schemas.Limit) + if _, err := summary.Write(os.Stdout); err != nil { + return err + } + + return nil + }) +} + +func (cmd *SchemaGetCmd) Run(ctx server.Cmd) error { + return withClient(ctx, "schema", func(ctx context.Context, client *httpclient.Client) error { + schema, err := client.GetSchema(ctx, cmd.Database, cmd.Namespace) + if err != nil { + return err + } + + fmt.Println(schema) + return nil + }) +} + +func (cmd *SchemaCreateCmd) Run(ctx server.Cmd) error { + return withClient(ctx, "schema-create", func(ctx context.Context, client *httpclient.Client) error { + schema, err := client.CreateSchema(ctx, cmd.Database, cmd.SchemaMeta) + if err != nil { + return err + } + + fmt.Println(schema) + return nil + }) +} + +func (cmd *SchemaDeleteCmd) Run(ctx server.Cmd) error { + return withClient(ctx, "schema-delete", func(ctx context.Context, client *httpclient.Client) error { + if _, err := client.DeleteSchema(ctx, cmd.Database, cmd.Namespace, cmd.Force); err != nil { + return err + } + return nil + }) +} + +func (cmd *SchemaUpdateCmd) Run(ctx server.Cmd) error { + return withClient(ctx, "schema-update", func(ctx context.Context, client *httpclient.Client) error { + // We swap the name in the meta with the new name + cmd.Name, cmd.NewNamespace = cmd.NewNamespace, cmd.Name + + // Perform the update + schema, err := client.UpdateSchema(ctx, cmd.Database, cmd.NewNamespace, cmd.SchemaMeta) + if err != nil { + return err + } + + fmt.Println(schema) + return nil + }) +} + +/////////////////////////////////////////////////////////////////////////////// +// OBJECT COMMANDS + +func (cmd *ObjectListCmd) Run(ctx server.Cmd) error { + // Set the width of the terminal + width := ctx.IsTerm() + + // Perform the request + return withClient(ctx, "objects", func(ctx context.Context, client *httpclient.Client) error { + objects, err := client.ListObjects(ctx, cmd.ObjectListRequest) + if err != nil { + return err + } + + // Objects list table + table := tui.TableFor[schema.Object](tui.SetWidth(width)) + if _, err := table.Write(os.Stdout, objects.Body...); err != nil { + return err + } + + // Objects list summary + summary := tui.TableSummary("objects", uint(objects.Count), objects.Offset, objects.Limit) + if _, err := summary.Write(os.Stdout); err != nil { + return err + } + + return nil + }) +} + +func (cmd *ObjectGetCmd) Run(ctx server.Cmd) error { + return withClient(ctx, "object", func(ctx context.Context, client *httpclient.Client) error { + object, err := client.GetObject(ctx, cmd.Database, cmd.Namespace, cmd.Name) + if err != nil { + return err + } + + fmt.Println(object) + return nil + }) +} + +/////////////////////////////////////////////////////////////////////////////// +// STATEMENT STAT COMMANDS + +func (cmd *StatementListCmd) Run(ctx server.Cmd) error { + // Set the width of the terminal + width := ctx.IsTerm() + + // Perform the request + return withClient(ctx, "statements", func(ctx context.Context, client *httpclient.Client) error { + statements, err := client.ListStatements(ctx, cmd.StatementListRequest) + if err != nil { + return err + } + // Statements list table + table := tui.TableFor[schema.Statement](tui.SetWidth(width)) + if _, err := table.Write(os.Stdout, statements.Body...); err != nil { + return err + } + + // Statements list summary + summary := tui.TableSummary("statements", uint(statements.Count), statements.Offset, statements.Limit) + if _, err := summary.Write(os.Stdout); err != nil { + return err + } + + return nil + }) +} + +func (cmd *StatementResetCmd) Run(ctx server.Cmd) error { + return withClient(ctx, "statement-reset", func(ctx context.Context, client *httpclient.Client) error { + return client.ResetStatementStats(ctx) + }) +} + +/////////////////////////////////////////////////////////////////////////////// +// TABLESPACE COMMANDS + +func (cmd *TablespaceListCmd) Run(ctx server.Cmd) error { + // Set the width of the terminal + width := ctx.IsTerm() + + // Perform the request + return withClient(ctx, "tablespaces", func(ctx context.Context, client *httpclient.Client) error { + tablespaces, err := client.ListTablespaces(ctx, cmd.TablespaceListRequest) + if err != nil { + return err + } + + // Tablespaces list table + table := tui.TableFor[schema.Tablespace](tui.SetWidth(width)) + if _, err := table.Write(os.Stdout, tablespaces.Body...); err != nil { + return err + } + + // Tablespaces list summary + summary := tui.TableSummary("tablespaces", uint(tablespaces.Count), tablespaces.Offset, tablespaces.Limit) + if _, err := summary.Write(os.Stdout); err != nil { + return err + } + + return nil + }) +} + +func (cmd *TablespaceGetCmd) Run(ctx server.Cmd) error { + return withClient(ctx, "tablespace", func(ctx context.Context, client *httpclient.Client) error { + tablespace, err := client.GetTablespace(ctx, cmd.Name) + if err != nil { + return err + } + + fmt.Println(tablespace) + return nil + }) +} + +func (cmd *TablespaceCreateCmd) Run(ctx server.Cmd) error { + return withClient(ctx, "tablespace-create", func(ctx context.Context, client *httpclient.Client) error { + tablespace, err := client.CreateTablespace(ctx, cmd.TablespaceMeta, cmd.Location) + if err != nil { + return err + } + + fmt.Println(tablespace) + return nil + }) +} + +func (cmd *TablespaceDeleteCmd) Run(ctx server.Cmd) error { + return withClient(ctx, "tablespace-delete", func(ctx context.Context, client *httpclient.Client) error { + return client.DeleteTablespace(ctx, cmd.Name) + }) +} + +func (cmd *TablespaceUpdateCmd) Run(ctx server.Cmd) error { + return withClient(ctx, "tablespace-update", func(ctx context.Context, client *httpclient.Client) error { + // We swap the name in the meta with the new name + cmd.NewName, cmd.TablespaceMeta.Name = cmd.TablespaceMeta.Name, cmd.NewName + + // Perform the update + tablespace, err := client.UpdateTablespace(ctx, cmd.NewName, cmd.TablespaceMeta) + if err != nil { + return err + } + + fmt.Println(tablespace) + return nil + }) +} + +/////////////////////////////////////////////////////////////////////////////// +// CONNECTION COMMANDS + +func (cmd *ConnectionListCmd) Run(ctx server.Cmd) error { + // Set the width of the terminal + width := ctx.IsTerm() + + // Perform the request + return withClient(ctx, "connections", func(ctx context.Context, client *httpclient.Client) error { + connections, err := client.ListConnections(ctx, cmd.ConnectionListRequest) + if err != nil { + return err + } + + // Connections list table + table := tui.TableFor[schema.Connection](tui.SetWidth(width)) + if _, err := table.Write(os.Stdout, connections.Body...); err != nil { + return err + } + + // Connections list summary + summary := tui.TableSummary("connections", uint(connections.Count), connections.Offset, connections.Limit) + if _, err := summary.Write(os.Stdout); err != nil { + return err + } + + return nil + }) +} + +func (cmd *ConnectionGetCmd) Run(ctx server.Cmd) error { + return withClient(ctx, "connection", func(ctx context.Context, client *httpclient.Client) error { + connection, err := client.GetConnection(ctx, cmd.Pid) + if err != nil { + return err + } + + fmt.Println(connection) + return nil + }) +} + +func (cmd *ConnectionDeleteCmd) Run(ctx server.Cmd) error { + return withClient(ctx, "connection-delete", func(ctx context.Context, client *httpclient.Client) error { + return client.DeleteConnection(ctx, cmd.Pid) + }) +} + +/////////////////////////////////////////////////////////////////////////////// +// REPLICATION SLOT COMMANDS + +func (cmd *ReplicationSlotListCmd) Run(ctx server.Cmd) error { + // Set the width of the terminal + width := ctx.IsTerm() + + // Perform the request + return withClient(ctx, "replication-slots", func(ctx context.Context, client *httpclient.Client) error { + slots, err := client.ListReplicationSlots(ctx, cmd.ReplicationSlotListRequest) + if err != nil { + return err + } + + // Replication slots list table + table := tui.TableFor[schema.ReplicationSlot](tui.SetWidth(width)) + if _, err := table.Write(os.Stdout, slots.Body...); err != nil { + return err + } + + // Replication slots list summary + summary := tui.TableSummary("replication slots", uint(slots.Count), slots.Offset, slots.Limit) + if _, err := summary.Write(os.Stdout); err != nil { + return err + } + + return nil + }) +} + +func (cmd *ReplicationSlotGetCmd) Run(ctx server.Cmd) error { + return withClient(ctx, "replication-slot", func(ctx context.Context, client *httpclient.Client) error { + slot, err := client.GetReplicationSlot(ctx, cmd.Name) + if err != nil { + return err + } + + fmt.Println(slot) + return nil + }) +} + +func (cmd *ReplicationSlotCreateCmd) Run(ctx server.Cmd) error { + return withClient(ctx, "replication-slot-create", func(ctx context.Context, client *httpclient.Client) error { + slot, err := client.CreateReplicationSlot(ctx, cmd.ReplicationSlotMeta) + if err != nil { + return err + } + + fmt.Println(slot) + return nil + }) +} + +func (cmd *ReplicationSlotDeleteCmd) Run(ctx server.Cmd) error { + return withClient(ctx, "replication-slot-delete", func(ctx context.Context, client *httpclient.Client) error { + return client.DeleteReplicationSlot(ctx, cmd.Name) + }) +} + +/////////////////////////////////////////////////////////////////////////////// +// EXTENSION COMMANDS + +func (cmd *ExtensionListCmd) Run(ctx server.Cmd) error { + // Set the width of the terminal + width := ctx.IsTerm() + + // Perform the request + return withClient(ctx, "extensions", func(ctx context.Context, client *httpclient.Client) error { + extensions, err := client.ListExtensions(ctx, cmd.ExtensionListRequest) + if err != nil { + return err + } + + // Extensions list table + table := tui.TableFor[schema.Extension](tui.SetWidth(width)) + if _, err := table.Write(os.Stdout, extensions.Body...); err != nil { + return err + } + + // Extensions list summary + summary := tui.TableSummary("extensions", uint(extensions.Count), extensions.Offset, extensions.Limit) + if _, err := summary.Write(os.Stdout); err != nil { + return err + } + + return nil + }) +} + +func (cmd *ExtensionGetCmd) Run(ctx server.Cmd) error { + return withClient(ctx, "extension", func(ctx context.Context, client *httpclient.Client) error { + extension, err := client.GetExtension(ctx, cmd.Name) + if err != nil { + return err + } + + fmt.Println(extension) + return nil + }) +} + +func (cmd *ExtensionCreateCmd) Run(ctx server.Cmd) error { + return withClient(ctx, "extension-install", func(ctx context.Context, client *httpclient.Client) error { + extension, err := client.CreateExtension(ctx, cmd.ExtensionMeta, cmd.Cascade) + if err != nil { + return err + } + + fmt.Println(extension) + return nil + }) +} + +/////////////////////////////////////////////////////////////////////////////// +// SETTING COMMANDS + +func (cmd *SettingListCmd) Run(ctx server.Cmd) error { + // Set the width of the terminal + width := ctx.IsTerm() + + // Perform the request + return withClient(ctx, "settings", func(ctx context.Context, client *httpclient.Client) error { + settings, err := client.ListSettings(ctx, cmd.SettingListRequest) + if err != nil { + return err + } + + // Settings list table + table := tui.TableFor[schema.Setting](tui.SetWidth(width)) + if _, err := table.Write(os.Stdout, settings.Body...); err != nil { + return err + } + + // Settings list summary + summary := tui.TableSummary("settings", uint(settings.Count), settings.Offset, settings.Limit) + if _, err := summary.Write(os.Stdout); err != nil { + return err + } + + return nil + }) +} + +func (cmd *SettingCategoryListCmd) Run(ctx server.Cmd) error { + // Set the width of the terminal + width := ctx.IsTerm() + + // Perform the request + return withClient(ctx, "setting-categories", func(ctx context.Context, client *httpclient.Client) error { + categories, err := client.ListSettingCategories(ctx, cmd.SettingCategoryListRequest) + if err != nil { + return err + } + + // Comvert string to schema.CategoryName for table rendering + categoryNames := make([]schema.CategoryName, len(categories.Body)) + for i, category := range categories.Body { + categoryNames[i] = schema.CategoryName(category) + } + + // Categories list table + table := tui.TableFor[schema.CategoryName](tui.SetWidth(width)) + if _, err := table.Write(os.Stdout, categoryNames...); err != nil { + return err + } + + // Categories list summary + summary := tui.TableSummary("categories", uint(categories.Count), 0, nil) + if _, err := summary.Write(os.Stdout); err != nil { + return err + } + + return nil + }) +} + +func (cmd *SettingGetCmd) Run(ctx server.Cmd) error { + return withClient(ctx, "setting", func(ctx context.Context, client *httpclient.Client) error { + setting, err := client.GetSetting(ctx, cmd.Name) + if err != nil { + return err + } + + fmt.Println(setting) + return nil + }) +} + +func (cmd *SettingUpdateCmd) Run(ctx server.Cmd) error { + return withClient(ctx, "setting-update", func(ctx context.Context, client *httpclient.Client) error { + setting, err := client.UpdateSetting(ctx, cmd.Name, cmd.SettingMeta) + if err != nil { + return err + } + + fmt.Println(setting) + return nil + }) +} diff --git a/pgmanager/cmd/server.go b/pgmanager/cmd/server.go new file mode 100644 index 0000000..84ac79c --- /dev/null +++ b/pgmanager/cmd/server.go @@ -0,0 +1,89 @@ +package cmd + +import ( + "errors" + "fmt" + + // Packages + pg "github.com/mutablelogic/go-pg" + httphandlers "github.com/mutablelogic/go-pg/pgmanager/httphandlers" + manager "github.com/mutablelogic/go-pg/pgmanager/manager" + pgpkg "github.com/mutablelogic/go-pg/pkg/cmd" + server "github.com/mutablelogic/go-server" + cmd "github.com/mutablelogic/go-server/pkg/cmd" + httprouter "github.com/mutablelogic/go-server/pkg/httprouter" + errgroup "golang.org/x/sync/errgroup" +) + +/////////////////////////////////////////////////////////////////////////////// +// TYPES + +type ServerCommands struct { + Run RunServer `cmd:"" name:"run" help:"Run the server." group:"SERVER"` +} + +type RunServer struct { + cmd.RunServer + pgpkg.PostgresFlags `embed:"" prefix:"pg."` +} + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +func (runner *RunServer) Run(ctx server.Cmd) error { + // Connect to the database, if configured + conn, err := runner.PostgresFlags.Connect(ctx) + if err != nil { + return err + } else if conn == nil { + return fmt.Errorf("database connection is required") + } + + // Create the manager, run the server, and return any error + return runner.WithManager(ctx, conn, func(pgmanager *manager.Manager) error { + // Create an error context - which will cancel any other goroutine on exit + errgroup, errctx := errgroup.WithContext(ctx.Context()) + + // Register http handlers for the manager + runner.Register(func(router *httprouter.Router) error { + ctx.Logger().DebugContext(ctx.Context(), "registering pgmanager handlers") + return errors.Join( + httphandlers.RegisterStatusHandlers(pgmanager, router), + httphandlers.RegisterRoleHandlers(pgmanager, router), + httphandlers.RegisterDatabaseHandlers(pgmanager, router), + httphandlers.RegisterSchemaHandlers(pgmanager, router), + httphandlers.RegisterObjectHandlers(pgmanager, router), + httphandlers.RegisterStatementHandlers(pgmanager, router), + httphandlers.RegisterTablespaceHandlers(pgmanager, router), + httphandlers.RegisterConnectionHandlers(pgmanager, router), + httphandlers.RegisterReplicationSlotHandlers(pgmanager, router), + httphandlers.RegisterExtensionHandlers(pgmanager, router), + httphandlers.RegisterSettingHandlers(pgmanager, router), + router.RegisterCatchAll("/", true), + ) + }) + + // Run the server - if any co-routine in the error group returns an error, the server will be shutdown + errgroup.Go(func() error { + return runner.RunServer.Run(ctx.WithContext(errctx)) + }) + + // Run the server + return errgroup.Wait() + }) +} + +/////////////////////////////////////////////////////////////////////////////// +// PRIVATE METHODS + +func (runner *RunServer) WithManager(ctx server.Cmd, conn pg.PoolConn, fn func(*manager.Manager) error) error { + opts := []manager.Opt{ + manager.WithMeter(ctx.Meter()), + manager.WithTracer(ctx.Tracer()), + } + if manager, err := manager.New(conn, opts...); err != nil { + return err + } else { + return fn(manager) + } +} diff --git a/pkg/manager/httpclient/client.go b/pgmanager/httpclient/client.go similarity index 100% rename from pkg/manager/httpclient/client.go rename to pgmanager/httpclient/client.go diff --git a/pkg/manager/httpclient/connection.go b/pgmanager/httpclient/connection.go similarity index 65% rename from pkg/manager/httpclient/connection.go rename to pgmanager/httpclient/connection.go index aebbecd..8b1037b 100644 --- a/pkg/manager/httpclient/connection.go +++ b/pgmanager/httpclient/connection.go @@ -4,30 +4,23 @@ import ( "context" // Packages - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" client "github.com/mutablelogic/go-client" + schema "github.com/mutablelogic/go-pg/pgmanager/schema" + types "github.com/mutablelogic/go-server/pkg/types" ) /////////////////////////////////////////////////////////////////////////////// // PUBLIC METHODS -func (c *Client) ListConnections(ctx context.Context, opts ...Opt) (*schema.ConnectionList, error) { - req := client.NewRequest() - - // Apply options - opt, err := applyOpts(opts...) - if err != nil { - return nil, err - } - +func (c *Client) ListConnections(ctx context.Context, req schema.ConnectionListRequest) (*schema.ConnectionList, error) { // Perform request var response schema.ConnectionList - if err := c.DoWithContext(ctx, req, &response, client.OptPath("connection"), client.OptQuery(opt.Values)); err != nil { + if err := c.DoWithContext(ctx, client.MethodGet, &response, client.OptPath("connection"), client.OptQuery(req.Query())); err != nil { return nil, err } // Return the responses - return &response, nil + return types.Ptr(response), nil } func (c *Client) GetConnection(ctx context.Context, pid uint64) (*schema.Connection, error) { diff --git a/pkg/manager/httpclient/database.go b/pgmanager/httpclient/database.go similarity index 64% rename from pkg/manager/httpclient/database.go rename to pgmanager/httpclient/database.go index 69e1275..5c48711 100644 --- a/pkg/manager/httpclient/database.go +++ b/pgmanager/httpclient/database.go @@ -3,45 +3,35 @@ package httpclient import ( "context" "net/http" + "net/url" // Packages - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" client "github.com/mutablelogic/go-client" + schema "github.com/mutablelogic/go-pg/pgmanager/schema" + types "github.com/mutablelogic/go-server/pkg/types" ) /////////////////////////////////////////////////////////////////////////////// // PUBLIC METHODS -func (c *Client) ListDatabases(ctx context.Context, opts ...Opt) (*schema.DatabaseList, error) { - req := client.NewRequest() - - // Apply options - opt, err := applyOpts(opts...) - if err != nil { - return nil, err - } - - // Perform request +func (c *Client) ListDatabases(ctx context.Context, req schema.DatabaseListRequest) (*schema.DatabaseList, error) { var response schema.DatabaseList - if err := c.DoWithContext(ctx, req, &response, client.OptPath("database"), client.OptQuery(opt.Values)); err != nil { + if err := c.DoWithContext(ctx, client.MethodGet, &response, client.OptPath("database"), client.OptQuery(req.Query())); err != nil { return nil, err } // Return the responses - return &response, nil + return types.Ptr(response), nil } func (c *Client) GetDatabase(ctx context.Context, name string) (*schema.Database, error) { - req := client.NewRequest() - - // Perform request var response schema.Database - if err := c.DoWithContext(ctx, req, &response, client.OptPath("database", name)); err != nil { + if err := c.DoWithContext(ctx, client.MethodGet, &response, client.OptPath("database", name)); err != nil { return nil, err } // Return the responses - return &response, nil + return types.Ptr(response), nil } func (c *Client) CreateDatabase(ctx context.Context, database schema.DatabaseMeta) (*schema.Database, error) { @@ -57,15 +47,15 @@ func (c *Client) CreateDatabase(ctx context.Context, database schema.DatabaseMet } // Return the responses - return &response, nil + return types.Ptr(response), nil } -func (c *Client) DeleteDatabase(ctx context.Context, name string, opt ...Opt) error { - opts, err := applyOpts(opt...) - if err != nil { - return err +func (c *Client) DeleteDatabase(ctx context.Context, name string, force bool) error { + query := url.Values{} + if force { + query.Set("force", "true") } - return c.DoWithContext(ctx, client.MethodDelete, nil, client.OptPath("database", name), client.OptQuery(opts.Values)) + return c.DoWithContext(ctx, client.MethodDelete, nil, client.OptPath("database", name), client.OptQuery(query)) } func (c *Client) UpdateDatabase(ctx context.Context, name string, meta schema.DatabaseMeta) (*schema.Database, error) { @@ -81,5 +71,5 @@ func (c *Client) UpdateDatabase(ctx context.Context, name string, meta schema.Da } // Return the responses - return &response, nil + return types.Ptr(response), nil } diff --git a/pgmanager/httpclient/extension.go b/pgmanager/httpclient/extension.go new file mode 100644 index 0000000..f62c4c3 --- /dev/null +++ b/pgmanager/httpclient/extension.go @@ -0,0 +1,60 @@ +package httpclient + +import ( + "context" + "net/url" + + // Packages + client "github.com/mutablelogic/go-client" + schema "github.com/mutablelogic/go-pg/pgmanager/schema" + types "github.com/mutablelogic/go-server/pkg/types" +) + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +func (c *Client) ListExtensions(ctx context.Context, req schema.ExtensionListRequest) (*schema.ExtensionList, error) { + // Perform request + var response schema.ExtensionList + if err := c.DoWithContext(ctx, client.MethodGet, &response, client.OptPath("extension"), client.OptQuery(req.Query())); err != nil { + return nil, err + } + + // Return the responses + return types.Ptr(response), nil +} + +func (c *Client) GetExtension(ctx context.Context, name string) (*schema.Extension, error) { + req := client.NewRequest() + + // Perform request + var response schema.Extension + if err := c.DoWithContext(ctx, req, &response, client.OptPath("extension", name)); err != nil { + return nil, err + } + + // Return the responses + return types.Ptr(response), nil +} + +func (c *Client) CreateExtension(ctx context.Context, meta schema.ExtensionMeta, cascade bool) (*schema.Extension, error) { + req, err := client.NewJSONRequest(meta) + if err != nil { + return nil, err + } + + // cascade value + query := url.Values{} + if cascade { + query.Set("cascade", "true") + } + + // Perform request + var response schema.Extension + if err := c.DoWithContext(ctx, req, &response, client.OptPath("extension"), client.OptQuery(query)); err != nil { + return nil, err + } + + // Return the responses + return types.Ptr(response), nil +} diff --git a/pgmanager/httpclient/object.go b/pgmanager/httpclient/object.go new file mode 100644 index 0000000..4b0a980 --- /dev/null +++ b/pgmanager/httpclient/object.go @@ -0,0 +1,33 @@ +package httpclient + +import ( + "context" + + // Packages + client "github.com/mutablelogic/go-client" + schema "github.com/mutablelogic/go-pg/pgmanager/schema" + types "github.com/mutablelogic/go-server/pkg/types" +) + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +func (c *Client) ListObjects(ctx context.Context, req schema.ObjectListRequest) (*schema.ObjectList, error) { + var response schema.ObjectList + if err := c.DoWithContext(ctx, client.MethodGet, &response, client.OptPath("object"), client.OptQuery(req.Query())); err != nil { + return nil, err + } + + // Return the responses + return types.Ptr(response), nil +} + +func (c *Client) GetObject(ctx context.Context, database, namespace, name string) (*schema.Object, error) { + var response schema.Object + if err := c.DoWithContext(ctx, client.MethodGet, &response, client.OptPath("object", database, namespace, name)); err != nil { + return nil, err + } + + // Return the responses + return types.Ptr(response), nil +} diff --git a/pkg/manager/httpclient/replicationslot.go b/pgmanager/httpclient/replicationslot.go similarity index 54% rename from pkg/manager/httpclient/replicationslot.go rename to pgmanager/httpclient/replicationslot.go index 1c613f8..22aec3b 100644 --- a/pkg/manager/httpclient/replicationslot.go +++ b/pgmanager/httpclient/replicationslot.go @@ -4,43 +4,32 @@ import ( "context" // Packages - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" client "github.com/mutablelogic/go-client" + schema "github.com/mutablelogic/go-pg/pgmanager/schema" + types "github.com/mutablelogic/go-server/pkg/types" ) /////////////////////////////////////////////////////////////////////////////// // PUBLIC METHODS -func (c *Client) ListReplicationSlots(ctx context.Context, opts ...Opt) (*schema.ReplicationSlotList, error) { - req := client.NewRequest() - - // Apply options - opt, err := applyOpts(opts...) - if err != nil { - return nil, err - } - - // Perform request +func (c *Client) ListReplicationSlots(ctx context.Context, req schema.ReplicationSlotListRequest) (*schema.ReplicationSlotList, error) { var response schema.ReplicationSlotList - if err := c.DoWithContext(ctx, req, &response, client.OptPath("replicationslot"), client.OptQuery(opt.Values)); err != nil { + if err := c.DoWithContext(ctx, client.MethodGet, &response, client.OptPath("replication-slot"), client.OptQuery(req.Query())); err != nil { return nil, err } - // Return the response - return &response, nil + // Return the responses + return types.Ptr(response), nil } func (c *Client) GetReplicationSlot(ctx context.Context, name string) (*schema.ReplicationSlot, error) { - req := client.NewRequest() - - // Perform request var response schema.ReplicationSlot - if err := c.DoWithContext(ctx, req, &response, client.OptPath("replicationslot", name)); err != nil { + if err := c.DoWithContext(ctx, client.MethodGet, &response, client.OptPath("replication-slot", name)); err != nil { return nil, err } - // Return the response - return &response, nil + // Return the responses + return types.Ptr(response), nil } func (c *Client) CreateReplicationSlot(ctx context.Context, meta schema.ReplicationSlotMeta) (*schema.ReplicationSlot, error) { @@ -51,14 +40,14 @@ func (c *Client) CreateReplicationSlot(ctx context.Context, meta schema.Replicat // Perform request var response schema.ReplicationSlot - if err := c.DoWithContext(ctx, req, &response, client.OptPath("replicationslot")); err != nil { + if err := c.DoWithContext(ctx, req, &response, client.OptPath("replication-slot")); err != nil { return nil, err } - // Return the response - return &response, nil + // Return the responses + return types.Ptr(response), nil } func (c *Client) DeleteReplicationSlot(ctx context.Context, name string) error { - return c.DoWithContext(ctx, client.MethodDelete, nil, client.OptPath("replicationslot", name)) + return c.DoWithContext(ctx, client.MethodDelete, nil, client.OptPath("replication-slot", name)) } diff --git a/pkg/manager/httpclient/role.go b/pgmanager/httpclient/role.go similarity index 53% rename from pkg/manager/httpclient/role.go rename to pgmanager/httpclient/role.go index 098878f..abb5944 100644 --- a/pkg/manager/httpclient/role.go +++ b/pgmanager/httpclient/role.go @@ -5,34 +5,26 @@ import ( "net/http" // Packages - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" client "github.com/mutablelogic/go-client" + schema "github.com/mutablelogic/go-pg/pgmanager/schema" + types "github.com/mutablelogic/go-server/pkg/types" ) /////////////////////////////////////////////////////////////////////////////// // PUBLIC METHODS -func (c *Client) ListRoles(ctx context.Context, opts ...Opt) (*schema.RoleList, error) { - req := client.NewRequest() - - // Apply options - opt, err := applyOpts(opts...) - if err != nil { - return nil, err - } - - // Perform request +func (c *Client) ListRoles(ctx context.Context, req schema.RoleListRequest) (*schema.RoleList, error) { var response schema.RoleList - if err := c.DoWithContext(ctx, req, &response, client.OptPath("role"), client.OptQuery(opt.Values)); err != nil { + if err := c.DoWithContext(ctx, client.MethodGet, &response, client.OptPath("role"), client.OptQuery(req.Query())); err != nil { return nil, err } // Return the responses - return &response, nil + return types.Ptr(response), nil } -func (c *Client) CreateRole(ctx context.Context, role schema.RoleMeta) (*schema.Role, error) { - req, err := client.NewJSONRequest(role) +func (c *Client) CreateRole(ctx context.Context, meta schema.RoleMeta) (*schema.Role, error) { + req, err := client.NewJSONRequestEx(http.MethodPost, meta, "") if err != nil { return nil, err } @@ -44,24 +36,27 @@ func (c *Client) CreateRole(ctx context.Context, role schema.RoleMeta) (*schema. } // Return the responses - return &response, nil + return types.Ptr(response), nil } func (c *Client) GetRole(ctx context.Context, name string) (*schema.Role, error) { - req := client.NewRequest() - - // Perform request var response schema.Role - if err := c.DoWithContext(ctx, req, &response, client.OptPath("role", name)); err != nil { + if err := c.DoWithContext(ctx, client.MethodGet, &response, client.OptPath("role", name)); err != nil { return nil, err } // Return the responses - return &response, nil + return types.Ptr(response), nil } -func (c *Client) DeleteRole(ctx context.Context, name string) error { - return c.DoWithContext(ctx, client.MethodDelete, nil, client.OptPath("role", name)) +func (c *Client) DeleteRole(ctx context.Context, name string) (*schema.Role, error) { + var response schema.Role + if err := c.DoWithContext(ctx, client.MethodDelete, &response, client.OptPath("role", name)); err != nil { + return nil, err + } + + // Return the responses + return types.Ptr(response), nil } func (c *Client) UpdateRole(ctx context.Context, name string, meta schema.RoleMeta) (*schema.Role, error) { @@ -77,5 +72,5 @@ func (c *Client) UpdateRole(ctx context.Context, name string, meta schema.RoleMe } // Return the responses - return &response, nil + return types.Ptr(response), nil } diff --git a/pkg/manager/httpclient/schema.go b/pgmanager/httpclient/schema.go similarity index 50% rename from pkg/manager/httpclient/schema.go rename to pgmanager/httpclient/schema.go index f23d0ac..17d9cc8 100644 --- a/pkg/manager/httpclient/schema.go +++ b/pgmanager/httpclient/schema.go @@ -3,60 +3,47 @@ package httpclient import ( "context" "net/http" + "net/url" // Packages - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" client "github.com/mutablelogic/go-client" + schema "github.com/mutablelogic/go-pg/pgmanager/schema" + types "github.com/mutablelogic/go-server/pkg/types" ) /////////////////////////////////////////////////////////////////////////////// // PUBLIC METHODS -// ListSchemas returns a list of schemas. If database is non-empty, -// only schemas from that database are returned. -func (c *Client) ListSchemas(ctx context.Context, database string, opts ...Opt) (*schema.SchemaList, error) { - req := client.NewRequest() - - // Apply options - opt, err := applyOpts(opts...) - if err != nil { - return nil, err - } - - // Build path based on whether database is specified - var pathOpt client.RequestOpt - if database != "" { - pathOpt = client.OptPath("schema", database) - } else { - pathOpt = client.OptPath("schema") +func (c *Client) ListSchemas(ctx context.Context, req schema.SchemaListRequest) (*schema.SchemaList, error) { + // Set query parameters + path := client.OptPath("schema") + if req.Database != nil { + path = client.OptPath("schema", types.Value(req.Database)) } // Perform request var response schema.SchemaList - if err := c.DoWithContext(ctx, req, &response, pathOpt, client.OptQuery(opt.Values)); err != nil { + if err := c.DoWithContext(ctx, client.MethodGet, &response, path, client.OptQuery(req.Query())); err != nil { return nil, err } // Return the responses - return &response, nil + return types.Ptr(response), nil } -// GetSchema returns a schema by database and namespace name. func (c *Client) GetSchema(ctx context.Context, database, namespace string) (*schema.Schema, error) { - req := client.NewRequest() - // Perform request var response schema.Schema - if err := c.DoWithContext(ctx, req, &response, client.OptPath("schema", database, namespace)); err != nil { + if err := c.DoWithContext(ctx, client.MethodGet, &response, client.OptPath("schema", database, namespace)); err != nil { return nil, err } // Return the responses - return &response, nil + return types.Ptr(response), nil } -// CreateSchema creates a new schema in the specified database. func (c *Client) CreateSchema(ctx context.Context, database string, meta schema.SchemaMeta) (*schema.Schema, error) { + // Create JSON request body req, err := client.NewJSONRequest(meta) if err != nil { return nil, err @@ -69,19 +56,21 @@ func (c *Client) CreateSchema(ctx context.Context, database string, meta schema. } // Return the responses - return &response, nil + return types.Ptr(response), nil } -// DeleteSchema deletes a schema by database and namespace name. -func (c *Client) DeleteSchema(ctx context.Context, database, namespace string, opt ...Opt) error { - opts, err := applyOpts(opt...) - if err != nil { - return err +func (c *Client) DeleteSchema(ctx context.Context, database, namespace string, force bool) (*schema.Schema, error) { + query := url.Values{} + if force { + query.Set("force", "true") + } + var response schema.Schema + if err := c.DoWithContext(ctx, client.MethodDelete, &response, client.OptPath("schema", database, namespace), client.OptQuery(query)); err != nil { + return nil, err } - return c.DoWithContext(ctx, client.MethodDelete, nil, client.OptPath("schema", database, namespace), client.OptQuery(opts.Values)) + return types.Ptr(response), nil } -// UpdateSchema updates a schema by database and namespace name. func (c *Client) UpdateSchema(ctx context.Context, database, namespace string, meta schema.SchemaMeta) (*schema.Schema, error) { req, err := client.NewJSONRequestEx(http.MethodPatch, meta, "") if err != nil { @@ -95,5 +84,5 @@ func (c *Client) UpdateSchema(ctx context.Context, database, namespace string, m } // Return the responses - return &response, nil + return types.Ptr(response), nil } diff --git a/pgmanager/httpclient/setting.go b/pgmanager/httpclient/setting.go new file mode 100644 index 0000000..9fdf0a0 --- /dev/null +++ b/pgmanager/httpclient/setting.go @@ -0,0 +1,60 @@ +package httpclient + +import ( + "context" + "net/http" + + // Packages + client "github.com/mutablelogic/go-client" + schema "github.com/mutablelogic/go-pg/pgmanager/schema" + types "github.com/mutablelogic/go-server/pkg/types" +) + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +func (c *Client) ListSettings(ctx context.Context, req schema.SettingListRequest) (*schema.SettingList, error) { + var response schema.SettingList + if err := c.DoWithContext(ctx, client.MethodGet, &response, client.OptPath("setting"), client.OptQuery(req.Query())); err != nil { + return nil, err + } + + // Return the responses + return types.Ptr(response), nil +} + +func (c *Client) ListSettingCategories(ctx context.Context, req schema.SettingCategoryListRequest) (*schema.SettingCategoryList, error) { + var response schema.SettingCategoryList + if err := c.DoWithContext(ctx, client.MethodGet, &response, client.OptPath("setting", "category"), client.OptQuery(req.Query())); err != nil { + return nil, err + } + + // Return the responses + return types.Ptr(response), nil +} + +func (c *Client) GetSetting(ctx context.Context, name string) (*schema.Setting, error) { + var response schema.Setting + if err := c.DoWithContext(ctx, client.MethodGet, &response, client.OptPath("setting", name)); err != nil { + return nil, err + } + + // Return the responses + return types.Ptr(response), nil +} + +func (c *Client) UpdateSetting(ctx context.Context, name string, meta schema.SettingMeta) (*schema.Setting, error) { + req, err := client.NewJSONRequestEx(http.MethodPatch, meta, "") + if err != nil { + return nil, err + } + + // Perform request + var response schema.Setting + if err := c.DoWithContext(ctx, req, &response, client.OptPath("setting", name)); err != nil { + return nil, err + } + + // Return the responses + return types.Ptr(response), nil +} diff --git a/pgmanager/httpclient/statement.go b/pgmanager/httpclient/statement.go new file mode 100644 index 0000000..2e96155 --- /dev/null +++ b/pgmanager/httpclient/statement.go @@ -0,0 +1,27 @@ +package httpclient + +import ( + "context" + + // Packages + client "github.com/mutablelogic/go-client" + schema "github.com/mutablelogic/go-pg/pgmanager/schema" + types "github.com/mutablelogic/go-server/pkg/types" +) + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +func (c *Client) ListStatements(ctx context.Context, req schema.StatementListRequest) (*schema.StatementList, error) { + var response schema.StatementList + if err := c.DoWithContext(ctx, client.MethodGet, &response, client.OptPath("statement"), client.OptQuery(req.Query())); err != nil { + return nil, err + } + + // Return the responses + return types.Ptr(response), nil +} + +func (c *Client) ResetStatementStats(ctx context.Context) error { + return c.DoWithContext(ctx, client.MethodDelete, nil, client.OptPath("statement")) +} diff --git a/pgmanager/httpclient/status.go b/pgmanager/httpclient/status.go new file mode 100644 index 0000000..8cb7424 --- /dev/null +++ b/pgmanager/httpclient/status.go @@ -0,0 +1,21 @@ +package httpclient + +import ( + "context" + + // Packages + client "github.com/mutablelogic/go-client" +) + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +func (c *Client) Ping(ctx context.Context) error { + req := client.NewRequest() + if err := c.DoWithContext(ctx, req, nil, client.OptPath("health")); err != nil { + return err + } + + // Return success + return nil +} diff --git a/pkg/manager/httpclient/tablespace.go b/pgmanager/httpclient/tablespace.go similarity index 54% rename from pkg/manager/httpclient/tablespace.go rename to pgmanager/httpclient/tablespace.go index 53c72c1..318ef1c 100644 --- a/pkg/manager/httpclient/tablespace.go +++ b/pgmanager/httpclient/tablespace.go @@ -5,63 +5,55 @@ import ( "net/http" // Packages - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" client "github.com/mutablelogic/go-client" + schema "github.com/mutablelogic/go-pg/pgmanager/schema" + types "github.com/mutablelogic/go-server/pkg/types" ) /////////////////////////////////////////////////////////////////////////////// // PUBLIC METHODS -func (c *Client) ListTablespaces(ctx context.Context, opts ...Opt) (*schema.TablespaceList, error) { - req := client.NewRequest() - - // Apply options - opt, err := applyOpts(opts...) - if err != nil { - return nil, err - } - - // Perform request +func (c *Client) ListTablespaces(ctx context.Context, req schema.TablespaceListRequest) (*schema.TablespaceList, error) { var response schema.TablespaceList - if err := c.DoWithContext(ctx, req, &response, client.OptPath("tablespace"), client.OptQuery(opt.Values)); err != nil { + if err := c.DoWithContext(ctx, client.MethodGet, &response, client.OptPath("tablespace"), client.OptQuery(req.Query())); err != nil { return nil, err } // Return the responses - return &response, nil + return types.Ptr(response), nil } -func (c *Client) CreateTablespace(ctx context.Context, meta schema.TablespaceMeta, location string) (*schema.Tablespace, error) { - type create struct { - schema.TablespaceMeta - Location string `json:"location"` - } - req, err := client.NewJSONRequest(create{meta, location}) - if err != nil { - return nil, err - } - - // Perform request +func (c *Client) GetTablespace(ctx context.Context, name string) (*schema.Tablespace, error) { var response schema.Tablespace - if err := c.DoWithContext(ctx, req, &response, client.OptPath("tablespace")); err != nil { + if err := c.DoWithContext(ctx, client.MethodGet, &response, client.OptPath("tablespace", name)); err != nil { return nil, err } // Return the responses - return &response, nil + return types.Ptr(response), nil } -func (c *Client) GetTablespace(ctx context.Context, name string) (*schema.Tablespace, error) { - req := client.NewRequest() +func (c *Client) CreateTablespace(ctx context.Context, tablespace schema.TablespaceMeta, location string) (*schema.Tablespace, error) { + req := struct { + schema.TablespaceMeta + Location string `json:"location" validate:"required" help:"Location for the tablespace"` + }{ + TablespaceMeta: tablespace, + Location: location, + } + jsonReq, err := client.NewJSONRequest(req) + if err != nil { + return nil, err + } // Perform request var response schema.Tablespace - if err := c.DoWithContext(ctx, req, &response, client.OptPath("tablespace", name)); err != nil { + if err := c.DoWithContext(ctx, jsonReq, &response, client.OptPath("tablespace")); err != nil { return nil, err } // Return the responses - return &response, nil + return types.Ptr(response), nil } func (c *Client) DeleteTablespace(ctx context.Context, name string) error { @@ -81,5 +73,5 @@ func (c *Client) UpdateTablespace(ctx context.Context, name string, meta schema. } // Return the responses - return &response, nil + return types.Ptr(response), nil } diff --git a/pgmanager/httphandlers/connection.go b/pgmanager/httphandlers/connection.go new file mode 100644 index 0000000..5a0ea0f --- /dev/null +++ b/pgmanager/httphandlers/connection.go @@ -0,0 +1,104 @@ +package httphandlers + +import ( + "errors" + "net/http" + "strconv" + + // Packages + pg "github.com/mutablelogic/go-pg" + manager "github.com/mutablelogic/go-pg/pgmanager/manager" + schema "github.com/mutablelogic/go-pg/pgmanager/schema" + httprequest "github.com/mutablelogic/go-server/pkg/httprequest" + httpresponse "github.com/mutablelogic/go-server/pkg/httpresponse" + httprouter "github.com/mutablelogic/go-server/pkg/httprouter" + jsonschema "github.com/mutablelogic/go-server/pkg/jsonschema" + openapi "github.com/mutablelogic/go-server/pkg/openapi" +) + +/////////////////////////////////////////////////////////////////////////////// +// TYPES + +type ConnectionPathParams struct { + Pid uint32 `json:"pid" path:"pid" validate:"required"` +} + +/////////////////////////////////////////////////////////////////////////////// +// LIFECYCLE + +func RegisterConnectionHandlers(manager *manager.Manager, router *httprouter.Router) error { + router.Spec().AddTag("Connections", "Cluster Connection Operations") + + return errors.Join( + router.RegisterPath("connection", nil, httprequest.NewPathItem("Connections", "Manage PostgreSQL connections"). + Get( + func(w http.ResponseWriter, r *http.Request) { + _ = ListConnections(w, r, manager) + }, + "List connections", + openapi.WithTags("Connections"), + openapi.WithQuery(jsonschema.MustFor[schema.ConnectionListRequest]()), + openapi.WithJSONResponse(http.StatusOK, jsonschema.MustFor[schema.ConnectionList]()), + ), + ), + router.RegisterPath("connection/{pid}", jsonschema.MustFor[ConnectionPathParams](), httprequest.NewPathItem("Connection", "Manage a PostgreSQL connection"). + Get( + func(w http.ResponseWriter, r *http.Request) { + _ = GetConnection(w, r, manager, r.PathValue("pid")) + }, + "Get connection", + openapi.WithTags("Connections"), + openapi.WithJSONResponse(http.StatusOK, jsonschema.MustFor[schema.Connection]()), + openapi.WithErrorResponse(http.StatusNotFound, "Process not found"), + ). + Delete( + func(w http.ResponseWriter, r *http.Request) { + _ = DeleteConnection(w, r, manager, r.PathValue("pid")) + }, + "Drop a connection", + openapi.WithTags("Connections"), + openapi.WithJSONResponse(http.StatusOK, jsonschema.MustFor[schema.Connection]()), + openapi.WithErrorResponse(http.StatusNotFound, "Process not found"), + ), + ), + ) +} + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +func ListConnections(w http.ResponseWriter, r *http.Request, manager *manager.Manager) error { + var req schema.ConnectionListRequest + if err := httprequest.Query(r.URL.Query(), &req); err != nil { + return httpresponse.Error(w, httpresponse.ErrBadRequest.With(err.Error())) + } + if connections, err := manager.ListConnections(r.Context(), req); err != nil { + return httpresponse.Error(w, pg.HTTPError(err)) + } else { + return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), connections) + } +} + +func GetConnection(w http.ResponseWriter, r *http.Request, manager *manager.Manager, pid string) error { + pid_, err := strconv.ParseUint(pid, 10, 64) + if err != nil { + return httpresponse.Error(w, httpresponse.ErrBadRequest.With("invalid pid"), pid) + } + if connection, err := manager.GetConnection(r.Context(), pid_); err != nil { + return httpresponse.Error(w, pg.HTTPError(err), pid) + } else { + return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), connection) + } +} + +func DeleteConnection(w http.ResponseWriter, r *http.Request, manager *manager.Manager, pid string) error { + pid_, err := strconv.ParseUint(pid, 10, 64) + if err != nil { + return httpresponse.Error(w, httpresponse.ErrBadRequest.With("invalid pid"), pid) + } + if connection, err := manager.DeleteConnection(r.Context(), pid_); err != nil { + return httpresponse.Error(w, pg.HTTPError(err), pid) + } else { + return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), connection) + } +} diff --git a/pgmanager/httphandlers/database.go b/pgmanager/httphandlers/database.go new file mode 100644 index 0000000..6cad021 --- /dev/null +++ b/pgmanager/httphandlers/database.go @@ -0,0 +1,147 @@ +package httphandlers + +import ( + "errors" + "net/http" + + // Packages + pg "github.com/mutablelogic/go-pg" + manager "github.com/mutablelogic/go-pg/pgmanager/manager" + schema "github.com/mutablelogic/go-pg/pgmanager/schema" + httprequest "github.com/mutablelogic/go-server/pkg/httprequest" + httpresponse "github.com/mutablelogic/go-server/pkg/httpresponse" + httprouter "github.com/mutablelogic/go-server/pkg/httprouter" + jsonschema "github.com/mutablelogic/go-server/pkg/jsonschema" + openapi "github.com/mutablelogic/go-server/pkg/openapi" +) + +/////////////////////////////////////////////////////////////////////////////// +// TYPES + +type DatabasePathParams struct { + Database string `json:"database"` +} + +type ForceQueryParams struct { + Force bool `json:"force" query:"force" help:"Force the operation, even when there are active connections to the database."` +} + +/////////////////////////////////////////////////////////////////////////////// +// LIFECYCLE + +func RegisterDatabaseHandlers(manager *manager.Manager, router *httprouter.Router) error { + router.Spec().AddTag("Databases", "Database Operations") + + return errors.Join( + router.RegisterPath("database", nil, httprequest.NewPathItem("Databases", "Manage PostgreSQL databases"). + Get( + func(w http.ResponseWriter, r *http.Request) { + _ = ListDatabases(w, r, manager) + }, + "List databases", + openapi.WithTags("Databases"), + openapi.WithQuery(jsonschema.MustFor[schema.DatabaseListRequest]()), + openapi.WithJSONResponse(http.StatusOK, jsonschema.MustFor[schema.DatabaseList]()), + ). + Post( + func(w http.ResponseWriter, r *http.Request) { + _ = CreateDatabase(w, r, manager) + }, + "Create database", + openapi.WithTags("Databases"), + openapi.WithJSONRequest(jsonschema.MustFor[schema.DatabaseMeta]()), + openapi.WithJSONResponse(http.StatusCreated, jsonschema.MustFor[schema.Database]()), + ), + ), + router.RegisterPath("database/{database}", jsonschema.MustFor[DatabasePathParams](), httprequest.NewPathItem("Database", "Manage a PostgreSQL database"). + Get( + func(w http.ResponseWriter, r *http.Request) { + _ = GetDatabase(w, r, manager, r.PathValue("database")) + }, + "Get database", + openapi.WithTags("Databases"), + openapi.WithJSONResponse(http.StatusOK, jsonschema.MustFor[schema.Database]()), + openapi.WithErrorResponse(http.StatusNotFound, "Database not found"), + ). + Delete( + func(w http.ResponseWriter, r *http.Request) { + _ = DeleteDatabase(w, r, manager, r.PathValue("database")) + }, + "Delete database", + openapi.WithTags("Databases"), + openapi.WithQuery(jsonschema.MustFor[ForceQueryParams]()), + openapi.WithJSONResponse(http.StatusOK, jsonschema.MustFor[schema.Database]()), + openapi.WithErrorResponse(http.StatusNotFound, "Database not found"), + ). + Patch( + func(w http.ResponseWriter, r *http.Request) { + _ = UpdateDatabase(w, r, manager, r.PathValue("database")) + }, + "Update database", + openapi.WithTags("Databases"), + openapi.WithJSONRequest(jsonschema.MustFor[schema.DatabaseMeta]()), + openapi.WithJSONResponse(http.StatusOK, jsonschema.MustFor[schema.Database]()), + openapi.WithErrorResponse(http.StatusNotFound, "Database not found"), + ), + ), + ) +} + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +func ListDatabases(w http.ResponseWriter, r *http.Request, manager *manager.Manager) error { + var req schema.DatabaseListRequest + if err := httprequest.Query(r.URL.Query(), &req); err != nil { + return httpresponse.Error(w, httpresponse.ErrBadRequest.With(err.Error())) + } + if databases, err := manager.ListDatabases(r.Context(), req); err != nil { + return httpresponse.Error(w, pg.HTTPError(err)) + } else { + return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), databases) + } +} + +func CreateDatabase(w http.ResponseWriter, r *http.Request, manager *manager.Manager) error { + var meta schema.DatabaseMeta + if err := httprequest.Read(r, &meta); err != nil { + return httpresponse.Error(w, httpresponse.ErrBadRequest.With(err.Error())) + } + if database, err := manager.CreateDatabase(r.Context(), meta); err != nil { + return httpresponse.Error(w, pg.HTTPError(err), meta.Name) + } else { + return httpresponse.JSON(w, http.StatusCreated, httprequest.Indent(r), database) + } +} + +func GetDatabase(w http.ResponseWriter, r *http.Request, manager *manager.Manager, name string) error { + if database, err := manager.GetDatabase(r.Context(), name); err != nil { + return httpresponse.Error(w, pg.HTTPError(err), name) + } else { + return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), database) + } +} + +func DeleteDatabase(w http.ResponseWriter, r *http.Request, manager *manager.Manager, name string) error { + var query ForceQueryParams + if err := httprequest.Query(r.URL.Query(), &query); err != nil { + return httpresponse.Error(w, httpresponse.ErrBadRequest.With(err.Error())) + } + if database, err := manager.DeleteDatabase(r.Context(), name, query.Force); err != nil { + return httpresponse.Error(w, pg.HTTPError(err), name) + } else { + return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), database) + } +} + +func UpdateDatabase(w http.ResponseWriter, r *http.Request, manager *manager.Manager, name string) error { + var meta schema.DatabaseMeta + if err := httprequest.Read(r, &meta); err != nil { + return httpresponse.Error(w, httpresponse.ErrBadRequest.With(err.Error())) + } + if database, err := manager.UpdateDatabase(r.Context(), name, meta); err != nil { + return httpresponse.Error(w, pg.HTTPError(err), name) + } else { + return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), database) + } +} diff --git a/pgmanager/httphandlers/extension.go b/pgmanager/httphandlers/extension.go new file mode 100644 index 0000000..9f7318d --- /dev/null +++ b/pgmanager/httphandlers/extension.go @@ -0,0 +1,146 @@ +package httphandlers + +import ( + "errors" + "net/http" + + // Packages + pg "github.com/mutablelogic/go-pg" + manager "github.com/mutablelogic/go-pg/pgmanager/manager" + schema "github.com/mutablelogic/go-pg/pgmanager/schema" + httprequest "github.com/mutablelogic/go-server/pkg/httprequest" + httpresponse "github.com/mutablelogic/go-server/pkg/httpresponse" + httprouter "github.com/mutablelogic/go-server/pkg/httprouter" + jsonschema "github.com/mutablelogic/go-server/pkg/jsonschema" + openapi "github.com/mutablelogic/go-server/pkg/openapi" +) + +/////////////////////////////////////////////////////////////////////////////// +// TYPES + +type ExtensionPathParams struct { + Extension string `json:"extension"` +} + +type ExtensionDeleteQueryParams struct { + Database []string `json:"database" query:"database" help:"Database to delete extension from."` + Cascade bool `json:"cascade" query:"cascade" help:"Cascade option."` +} + +/////////////////////////////////////////////////////////////////////////////// +// LIFECYCLE + +func RegisterExtensionHandlers(manager *manager.Manager, router *httprouter.Router) error { + router.Spec().AddTag("Extensions", "Database Extension Operations") + + return errors.Join( + router.RegisterPath("extension", nil, httprequest.NewPathItem("Extensions", "Manage PostgreSQL extensions"). + Get( + func(w http.ResponseWriter, r *http.Request) { + _ = ListExtensions(w, r, manager) + }, + "List extensions", + openapi.WithTags("Extensions"), + openapi.WithQuery(jsonschema.MustFor[schema.ExtensionListRequest]()), + openapi.WithJSONResponse(http.StatusOK, jsonschema.MustFor[schema.ExtensionList]()), + ). + Post( + func(w http.ResponseWriter, r *http.Request) { + _ = CreateExtension(w, r, manager) + }, + "Install extension into a database", + openapi.WithTags("Extensions"), + openapi.WithJSONRequest(jsonschema.MustFor[schema.ExtensionMeta]()), + openapi.WithJSONResponse(http.StatusOK, jsonschema.MustFor[schema.Extension]()), + ), + ), + router.RegisterPath("extension/{extension}", jsonschema.MustFor[ExtensionPathParams](), httprequest.NewPathItem("Extension", "Manage a PostgreSQL extension"). + Get( + func(w http.ResponseWriter, r *http.Request) { + _ = GetExtension(w, r, manager, r.PathValue("extension")) + }, + "Get extension", + openapi.WithTags("Extensions"), + openapi.WithJSONResponse(http.StatusOK, jsonschema.MustFor[schema.Extension]()), + ). + Delete( + func(w http.ResponseWriter, r *http.Request) { + _ = DeleteExtension(w, r, manager, r.PathValue("extension")) + }, + "Uninstall extension from one or more databases", + openapi.WithTags("Extensions"), + openapi.WithQuery(jsonschema.MustFor[ExtensionDeleteQueryParams]()), + ), + ), + ) +} + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +func ListExtensions(w http.ResponseWriter, r *http.Request, manager *manager.Manager) error { + var req schema.ExtensionListRequest + if err := httprequest.Query(r.URL.Query(), &req); err != nil { + return httpresponse.Error(w, httpresponse.ErrBadRequest.With(err.Error())) + } + if extensions, err := manager.ListExtensions(r.Context(), req); err != nil { + return httpresponse.Error(w, pg.HTTPError(err)) + } else { + return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), extensions) + } +} + +func GetExtension(w http.ResponseWriter, r *http.Request, manager *manager.Manager, name string) error { + if extension, err := manager.GetExtension(r.Context(), name); err != nil { + return httpresponse.Error(w, pg.HTTPError(err)) + } else { + return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), extension) + } +} + +func CreateExtension(w http.ResponseWriter, r *http.Request, manager *manager.Manager) error { + var meta schema.ExtensionMeta + if err := httprequest.Read(r, &meta); err != nil { + return httpresponse.Error(w, httpresponse.ErrBadRequest.With(err.Error())) + } + // TODO: Cascade option + if extension, err := manager.CreateExtension(r.Context(), meta, false); err != nil { + return httpresponse.Error(w, pg.HTTPError(err)) + } else { + return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), extension) + } +} + +func DeleteExtension(w http.ResponseWriter, r *http.Request, manager *manager.Manager, name string) error { + var query ExtensionDeleteQueryParams + if err := httprequest.Query(r.URL.Query(), &query); err != nil { + return httpresponse.Error(w, httpresponse.ErrBadRequest.With(err.Error())) + } + + // Check extension existence + if _, err := manager.GetExtension(r.Context(), name); err != nil { + return httpresponse.Error(w, pg.HTTPError(err)) + } + + // If no databases specified, return bad request + if len(query.Database) == 0 { + return httpresponse.Error(w, httpresponse.ErrBadRequest.With("at least one database must be specified for extension deletion")) + } + + // Check to make sure extension is in all the databases first + for _, database := range query.Database { + if _, err := manager.GetInstalledExtension(r.Context(), name, database); err != nil { + return httpresponse.Error(w, pg.HTTPError(err)) + } + } + + // Now we have a good chance of successfully deleting the extension in each database + for _, database := range query.Database { + if err := manager.DeleteExtension(r.Context(), database, name, query.Cascade); err != nil { + return httpresponse.Error(w, pg.HTTPError(err)) + } + } + + // Return success + return httpresponse.Empty(w, http.StatusNoContent) +} diff --git a/pgmanager/httphandlers/object.go b/pgmanager/httphandlers/object.go new file mode 100644 index 0000000..b1cf7d1 --- /dev/null +++ b/pgmanager/httphandlers/object.go @@ -0,0 +1,78 @@ +package httphandlers + +import ( + "errors" + "net/http" + + pg "github.com/mutablelogic/go-pg" + manager "github.com/mutablelogic/go-pg/pgmanager/manager" + schema "github.com/mutablelogic/go-pg/pgmanager/schema" + httprequest "github.com/mutablelogic/go-server/pkg/httprequest" + httpresponse "github.com/mutablelogic/go-server/pkg/httpresponse" + httprouter "github.com/mutablelogic/go-server/pkg/httprouter" + jsonschema "github.com/mutablelogic/go-server/pkg/jsonschema" + openapi "github.com/mutablelogic/go-server/pkg/openapi" +) + +/////////////////////////////////////////////////////////////////////////////// +// TYPES + +type ObjectPathParams struct { + Database string `json:"database"` + Schema string `json:"schema"` + Name string `json:"name"` +} + +/////////////////////////////////////////////////////////////////////////////// +// LIFECYCLE + +func RegisterObjectHandlers(manager *manager.Manager, router *httprouter.Router) error { + router.Spec().AddTag("Objects", "Object Operations") + + return errors.Join( + router.RegisterPath("object", nil, httprequest.NewPathItem("Objects", "Manage PostgreSQL objects"). + Get( + func(w http.ResponseWriter, r *http.Request) { + _ = ListObjects(w, r, manager) + }, + "List objects", + openapi.WithTags("Objects"), + openapi.WithQuery(jsonschema.MustFor[schema.ObjectListRequest]()), + openapi.WithJSONResponse(http.StatusOK, jsonschema.MustFor[schema.ObjectList]()), + ), + ), + router.RegisterPath("object/{database}/{schema}/{name}", jsonschema.MustFor[ObjectPathParams](), httprequest.NewPathItem("Objects", "Manage PostgreSQL objects"). + Get( + func(w http.ResponseWriter, r *http.Request) { + _ = GetObject(w, r, manager, r.PathValue("database"), r.PathValue("schema"), r.PathValue("name")) + }, + "Get object", + openapi.WithTags("Objects"), + openapi.WithJSONResponse(http.StatusOK, jsonschema.MustFor[schema.Object]()), + ), + ), + ) +} + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +func ListObjects(w http.ResponseWriter, r *http.Request, manager *manager.Manager) error { + var req schema.ObjectListRequest + if err := httprequest.Query(r.URL.Query(), &req); err != nil { + return httpresponse.Error(w, httpresponse.ErrBadRequest.With(err.Error())) + } + if objects, err := manager.ListObjects(r.Context(), req); err != nil { + return httpresponse.Error(w, pg.HTTPError(err)) + } else { + return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), objects) + } +} + +func GetObject(w http.ResponseWriter, r *http.Request, manager *manager.Manager, database, namespace, name string) error { + if object, err := manager.GetObject(r.Context(), database, namespace, name); err != nil { + return httpresponse.Error(w, pg.HTTPError(err)) + } else { + return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), object) + } +} diff --git a/pgmanager/httphandlers/replicationslot.go b/pgmanager/httphandlers/replicationslot.go new file mode 100644 index 0000000..5fe2d0a --- /dev/null +++ b/pgmanager/httphandlers/replicationslot.go @@ -0,0 +1,117 @@ +package httphandlers + +import ( + "errors" + "net/http" + + // Packages + + pg "github.com/mutablelogic/go-pg" + manager "github.com/mutablelogic/go-pg/pgmanager/manager" + schema "github.com/mutablelogic/go-pg/pgmanager/schema" + httprequest "github.com/mutablelogic/go-server/pkg/httprequest" + httpresponse "github.com/mutablelogic/go-server/pkg/httpresponse" + httprouter "github.com/mutablelogic/go-server/pkg/httprouter" + jsonschema "github.com/mutablelogic/go-server/pkg/jsonschema" + openapi "github.com/mutablelogic/go-server/pkg/openapi" +) + +/////////////////////////////////////////////////////////////////////////////// +// TYPES + +type ReplicationSlotPathParams struct { + Name string `json:"name" path:"name" validate:"required"` +} + +/////////////////////////////////////////////////////////////////////////////// +// LIFECYCLE + +func RegisterReplicationSlotHandlers(manager *manager.Manager, router *httprouter.Router) error { + router.Spec().AddTag("Replication Slots", "Manage PostgreSQL replication slots") + + return errors.Join( + router.RegisterPath("replication-slot", nil, httprequest.NewPathItem("Replication Slots", "Manage PostgreSQL replication slots"). + Get( + func(w http.ResponseWriter, r *http.Request) { + _ = ListReplicationSlots(w, r, manager) + }, + "List replication slots", + openapi.WithTags("Replication Slots"), + openapi.WithQuery(jsonschema.MustFor[schema.ReplicationSlotListRequest]()), + openapi.WithJSONResponse(http.StatusOK, jsonschema.MustFor[schema.ReplicationSlotList]()), + ). + Post( + func(w http.ResponseWriter, r *http.Request) { + _ = CreateReplicationSlot(w, r, manager) + }, + "Create replication slot", + openapi.WithTags("Replication Slots"), + openapi.WithJSONRequest(jsonschema.MustFor[schema.ReplicationSlotMeta]()), + openapi.WithJSONResponse(http.StatusCreated, jsonschema.MustFor[schema.ReplicationSlot]()), + ), + ), + router.RegisterPath("replication-slot/{name}", jsonschema.MustFor[ReplicationSlotPathParams](), httprequest.NewPathItem("Replication Slot", "Manage a PostgreSQL replication slot"). + Get( + func(w http.ResponseWriter, r *http.Request) { + _ = GetReplicationSlot(w, r, manager, r.PathValue("name")) + }, + "Get replication slot", + openapi.WithTags("Replication Slots"), + openapi.WithJSONResponse(http.StatusOK, jsonschema.MustFor[schema.ReplicationSlot]()), + openapi.WithErrorResponse(http.StatusNotFound, "Replication slot not found"), + ). + Delete( + func(w http.ResponseWriter, r *http.Request) { + _ = DeleteReplicationSlot(w, r, manager, r.PathValue("name")) + }, + "Drop a replication slot", + openapi.WithTags("Replication Slots"), + openapi.WithJSONResponse(http.StatusOK, jsonschema.MustFor[schema.ReplicationSlot]()), + openapi.WithErrorResponse(http.StatusNotFound, "Replication slot not found"), + ), + ), + ) +} + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +func ListReplicationSlots(w http.ResponseWriter, r *http.Request, manager *manager.Manager) error { + var req schema.ReplicationSlotListRequest + if err := httprequest.Query(r.URL.Query(), &req); err != nil { + return httpresponse.Error(w, httpresponse.ErrBadRequest.With(err.Error())) + } + if slots, err := manager.ListReplicationSlots(r.Context(), req); err != nil { + return httpresponse.Error(w, pg.HTTPError(err)) + } else { + return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), slots) + } +} + +func CreateReplicationSlot(w http.ResponseWriter, r *http.Request, manager *manager.Manager) error { + var meta schema.ReplicationSlotMeta + if err := httprequest.Read(r, &meta); err != nil { + return httpresponse.Error(w, httpresponse.ErrBadRequest.With(err.Error())) + } + if slot, err := manager.CreateReplicationSlot(r.Context(), meta); err != nil { + return httpresponse.Error(w, pg.HTTPError(err)) + } else { + return httpresponse.JSON(w, http.StatusCreated, httprequest.Indent(r), slot) + } +} + +func GetReplicationSlot(w http.ResponseWriter, r *http.Request, manager *manager.Manager, name string) error { + if slot, err := manager.GetReplicationSlot(r.Context(), name); err != nil { + return httpresponse.Error(w, pg.HTTPError(err), name) + } else { + return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), slot) + } +} + +func DeleteReplicationSlot(w http.ResponseWriter, r *http.Request, manager *manager.Manager, name string) error { + if slot, err := manager.DeleteReplicationSlot(r.Context(), name); err != nil { + return httpresponse.Error(w, pg.HTTPError(err), name) + } else { + return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), slot) + } +} diff --git a/pgmanager/httphandlers/role.go b/pgmanager/httphandlers/role.go new file mode 100644 index 0000000..0804edf --- /dev/null +++ b/pgmanager/httphandlers/role.go @@ -0,0 +1,138 @@ +package httphandlers + +import ( + "errors" + "net/http" + + // Packages + pg "github.com/mutablelogic/go-pg" + manager "github.com/mutablelogic/go-pg/pgmanager/manager" + schema "github.com/mutablelogic/go-pg/pgmanager/schema" + httprequest "github.com/mutablelogic/go-server/pkg/httprequest" + httpresponse "github.com/mutablelogic/go-server/pkg/httpresponse" + httprouter "github.com/mutablelogic/go-server/pkg/httprouter" + jsonschema "github.com/mutablelogic/go-server/pkg/jsonschema" + openapi "github.com/mutablelogic/go-server/pkg/openapi" +) + +/////////////////////////////////////////////////////////////////////////////// +// TYPES + +type RolePathParams struct { + Role string `json:"role"` +} + +/////////////////////////////////////////////////////////////////////////////// +// LIFECYCLE + +func RegisterRoleHandlers(manager *manager.Manager, router *httprouter.Router) error { + router.Spec().AddTag("Roles", "Role Operations") + + return errors.Join( + router.RegisterPath("role", nil, httprequest.NewPathItem("Roles", "Manage PostgreSQL roles"). + Get( + func(w http.ResponseWriter, r *http.Request) { + _ = ListRoles(w, r, manager) + }, + "List roles", + openapi.WithTags("Roles"), + openapi.WithQuery(jsonschema.MustFor[schema.RoleListRequest]()), + openapi.WithJSONResponse(http.StatusOK, jsonschema.MustFor[schema.RoleList]()), + ). + Post( + func(w http.ResponseWriter, r *http.Request) { + _ = CreateRole(w, r, manager) + }, + "Create role", + openapi.WithTags("Roles"), + openapi.WithJSONRequest(jsonschema.MustFor[schema.RoleMeta]()), + openapi.WithJSONResponse(http.StatusCreated, jsonschema.MustFor[schema.Role]()), + ), + ), + router.RegisterPath("role/{role}", jsonschema.MustFor[RolePathParams](), httprequest.NewPathItem("Role", "Manage a PostgreSQL role"). + Get( + func(w http.ResponseWriter, r *http.Request) { + _ = GetRole(w, r, manager, r.PathValue("role")) + }, + "Get role", + openapi.WithTags("Roles"), + openapi.WithJSONResponse(http.StatusOK, jsonschema.MustFor[schema.Role]()), + openapi.WithErrorResponse(http.StatusNotFound, "Role not found"), + ). + Delete( + func(w http.ResponseWriter, r *http.Request) { + _ = DeleteRole(w, r, manager, r.PathValue("role")) + }, + "Delete role", + openapi.WithTags("Roles"), + openapi.WithJSONResponse(http.StatusOK, jsonschema.MustFor[schema.Role]()), + openapi.WithErrorResponse(http.StatusNotFound, "Role not found"), + ). + Patch( + func(w http.ResponseWriter, r *http.Request) { + _ = UpdateRole(w, r, manager, r.PathValue("role")) + }, + "Update role", + openapi.WithTags("Roles"), + openapi.WithJSONRequest(jsonschema.MustFor[schema.RoleMeta]()), + openapi.WithJSONResponse(http.StatusOK, jsonschema.MustFor[schema.Role]()), + openapi.WithErrorResponse(http.StatusNotFound, "Role not found"), + ), + ), + ) +} + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +func ListRoles(w http.ResponseWriter, r *http.Request, manager *manager.Manager) error { + var req schema.RoleListRequest + if err := httprequest.Query(r.URL.Query(), &req); err != nil { + return httpresponse.Error(w, httpresponse.ErrBadRequest.With(err.Error())) + } + if roles, err := manager.ListRoles(r.Context(), req); err != nil { + return httpresponse.Error(w, pg.HTTPError(err)) + } else { + return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), roles) + } +} + +func CreateRole(w http.ResponseWriter, r *http.Request, manager *manager.Manager) error { + var meta schema.RoleMeta + if err := httprequest.Read(r, &meta); err != nil { + return httpresponse.Error(w, httpresponse.ErrBadRequest.With(err.Error())) + } + if role, err := manager.CreateRole(r.Context(), meta); err != nil { + return httpresponse.Error(w, pg.HTTPError(err), meta.Name) + } else { + return httpresponse.JSON(w, http.StatusCreated, httprequest.Indent(r), role) + } +} + +func GetRole(w http.ResponseWriter, r *http.Request, manager *manager.Manager, name string) error { + if role, err := manager.GetRole(r.Context(), name); err != nil { + return httpresponse.Error(w, pg.HTTPError(err), name) + } else { + return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), role) + } +} + +func DeleteRole(w http.ResponseWriter, r *http.Request, manager *manager.Manager, name string) error { + if role, err := manager.DeleteRole(r.Context(), name); err != nil { + return httpresponse.Error(w, pg.HTTPError(err), name) + } else { + return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), role) + } +} + +func UpdateRole(w http.ResponseWriter, r *http.Request, manager *manager.Manager, name string) error { + var meta schema.RoleMeta + if err := httprequest.Read(r, &meta); err != nil { + return httpresponse.Error(w, httpresponse.ErrBadRequest.With(err.Error())) + } + if role, err := manager.UpdateRole(r.Context(), name, meta); err != nil { + return httpresponse.Error(w, pg.HTTPError(err), name) + } else { + return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), role) + } +} diff --git a/pgmanager/httphandlers/schema.go b/pgmanager/httphandlers/schema.go new file mode 100644 index 0000000..4d7036b --- /dev/null +++ b/pgmanager/httphandlers/schema.go @@ -0,0 +1,165 @@ +package httphandlers + +import ( + "errors" + "net/http" + + // Packages + pg "github.com/mutablelogic/go-pg" + manager "github.com/mutablelogic/go-pg/pgmanager/manager" + schema "github.com/mutablelogic/go-pg/pgmanager/schema" + httprequest "github.com/mutablelogic/go-server/pkg/httprequest" + httpresponse "github.com/mutablelogic/go-server/pkg/httpresponse" + httprouter "github.com/mutablelogic/go-server/pkg/httprouter" + jsonschema "github.com/mutablelogic/go-server/pkg/jsonschema" + openapi "github.com/mutablelogic/go-server/pkg/openapi" + types "github.com/mutablelogic/go-server/pkg/types" +) + +/////////////////////////////////////////////////////////////////////////////// +// TYPES + +type SchemaPathParams struct { + Database string `json:"database"` + Namespace string `json:"namespace"` +} + +/////////////////////////////////////////////////////////////////////////////// +// LIFECYCLE + +func RegisterSchemaHandlers(manager *manager.Manager, router *httprouter.Router) error { + router.Spec().AddTag("Schemas", "Schema Operations") + + return errors.Join( + router.RegisterPath("schema", nil, httprequest.NewPathItem("Schemas", "Manage PostgreSQL schemas"). + Get( + func(w http.ResponseWriter, r *http.Request) { + _ = ListSchemas(w, r, manager, nil) + }, + "List all schemas", + openapi.WithTags("Schemas"), + openapi.WithQuery(jsonschema.MustFor[pg.OffsetLimit]()), + openapi.WithJSONResponse(http.StatusOK, jsonschema.MustFor[schema.SchemaList]()), + ), + ), + router.RegisterPath("schema/{database}", nil, httprequest.NewPathItem("Schemas", "Manage PostgreSQL schemas"). + Get( + func(w http.ResponseWriter, r *http.Request) { + _ = ListSchemas(w, r, manager, types.Ptr(r.PathValue("database"))) + }, + "List schemas in a specific database", + openapi.WithTags("Schemas"), + openapi.WithQuery(jsonschema.MustFor[pg.OffsetLimit]()), + openapi.WithJSONResponse(http.StatusOK, jsonschema.MustFor[schema.SchemaList]()), + ). + Post( + func(w http.ResponseWriter, r *http.Request) { + _ = CreateSchema(w, r, manager, r.PathValue("database")) + }, + "Create a new schema in a specific database", + openapi.WithTags("Schemas"), + openapi.WithJSONRequest(jsonschema.MustFor[schema.SchemaMeta]()), + openapi.WithJSONResponse(http.StatusOK, jsonschema.MustFor[schema.Schema]()), + openapi.WithErrorResponse(http.StatusBadRequest, "Invalid request body"), + openapi.WithErrorResponse(http.StatusNotFound, "Database not found"), + ), + ), + router.RegisterPath("schema/{database}/{namespace}", nil, httprequest.NewPathItem("Schema", "Manage a specific PostgreSQL schema"). + Get( + func(w http.ResponseWriter, r *http.Request) { + _ = GetSchema(w, r, manager, r.PathValue("database"), r.PathValue("namespace")) + }, + "Get a schema in a specific database", + openapi.WithTags("Schemas"), + openapi.WithJSONResponse(http.StatusOK, jsonschema.MustFor[schema.Schema]()), + ). + Delete( + func(w http.ResponseWriter, r *http.Request) { + _ = DeleteSchema(w, r, manager, r.PathValue("database"), r.PathValue("namespace")) + }, + "Delete a schema in a specific database", + openapi.WithTags("Schemas"), + openapi.WithQuery(jsonschema.MustFor[ForceQueryParams]()), + openapi.WithJSONResponse(http.StatusOK, jsonschema.MustFor[schema.Schema]()), + openapi.WithErrorResponse(http.StatusNotFound, "Schema not found"), + openapi.WithErrorResponse(http.StatusBadRequest, "Invalid query parameters"), + openapi.WithErrorResponse(http.StatusConflict, "Schema has dependent objects"), + ). + Patch( + func(w http.ResponseWriter, r *http.Request) { + _ = UpdateSchema(w, r, manager, r.PathValue("database"), r.PathValue("namespace")) + }, + "Update a schema in a specific database", + openapi.WithTags("Schemas"), + openapi.WithJSONRequest(jsonschema.MustFor[schema.SchemaMeta]()), + openapi.WithJSONResponse(http.StatusOK, jsonschema.MustFor[schema.Schema]()), + openapi.WithErrorResponse(http.StatusBadRequest, "Invalid request body"), + openapi.WithErrorResponse(http.StatusNotFound, "Schema not found"), + openapi.WithErrorResponse(http.StatusBadRequest, "Invalid query parameters"), + openapi.WithErrorResponse(http.StatusConflict, "Schema has dependent objects"), + ), + ), + ) +} + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +func ListSchemas(w http.ResponseWriter, r *http.Request, manager *manager.Manager, database *string) error { + var req pg.OffsetLimit + if err := httprequest.Query(r.URL.Query(), &req); err != nil { + return httpresponse.Error(w, httpresponse.ErrBadRequest.With(err.Error())) + } + if schemas, err := manager.ListSchemas(r.Context(), schema.SchemaListRequest{ + OffsetLimit: req, + Database: database, + }); err != nil { + return httpresponse.Error(w, pg.HTTPError(err)) + } else { + return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), schemas) + } +} + +func GetSchema(w http.ResponseWriter, r *http.Request, manager *manager.Manager, database, namespace string) error { + if schema, err := manager.GetSchema(r.Context(), database, namespace); err != nil { + return httpresponse.Error(w, pg.HTTPError(err)) + } else { + return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), schema) + } +} + +func CreateSchema(w http.ResponseWriter, r *http.Request, manager *manager.Manager, database string) error { + var req schema.SchemaMeta + if err := httprequest.Read(r, &req); err != nil { + return httpresponse.Error(w, httpresponse.ErrBadRequest.With(err.Error())) + } + if schema, err := manager.CreateSchema(r.Context(), database, req); err != nil { + return httpresponse.Error(w, pg.HTTPError(err)) + } else { + return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), schema) + } +} + +func DeleteSchema(w http.ResponseWriter, r *http.Request, manager *manager.Manager, database, namespace string) error { + var query ForceQueryParams + if err := httprequest.Query(r.URL.Query(), &query); err != nil { + return httpresponse.Error(w, httpresponse.ErrBadRequest.With(err.Error())) + } + if schema, err := manager.DeleteSchema(r.Context(), database, namespace, query.Force); err != nil { + return httpresponse.Error(w, pg.HTTPError(err)) + } else { + return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), schema) + } +} + +func UpdateSchema(w http.ResponseWriter, r *http.Request, manager *manager.Manager, database, namespace string) error { + var req schema.SchemaMeta + if err := httprequest.Read(r, &req); err != nil { + return httpresponse.Error(w, httpresponse.ErrBadRequest.With(err.Error())) + } + if schema, err := manager.UpdateSchema(r.Context(), database, namespace, req); err != nil { + return httpresponse.Error(w, pg.HTTPError(err)) + } else { + return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), schema) + } +} diff --git a/pgmanager/httphandlers/setting.go b/pgmanager/httphandlers/setting.go new file mode 100644 index 0000000..b665650 --- /dev/null +++ b/pgmanager/httphandlers/setting.go @@ -0,0 +1,110 @@ +package httphandlers + +import ( + "errors" + "net/http" + + // Packages + pg "github.com/mutablelogic/go-pg" + manager "github.com/mutablelogic/go-pg/pgmanager/manager" + schema "github.com/mutablelogic/go-pg/pgmanager/schema" + httprequest "github.com/mutablelogic/go-server/pkg/httprequest" + httpresponse "github.com/mutablelogic/go-server/pkg/httpresponse" + httprouter "github.com/mutablelogic/go-server/pkg/httprouter" + jsonschema "github.com/mutablelogic/go-server/pkg/jsonschema" + openapi "github.com/mutablelogic/go-server/pkg/openapi" +) + +/////////////////////////////////////////////////////////////////////////////// +// LIFECYCLE + +func RegisterSettingHandlers(manager *manager.Manager, router *httprouter.Router) error { + router.Spec().AddTag("Settings", "Settings Operations") + + return errors.Join( + router.RegisterPath("setting", nil, httprequest.NewPathItem("Settings", "Manage PostgreSQL settings"). + Get( + func(w http.ResponseWriter, r *http.Request) { + _ = ListSettings(w, r, manager) + }, + "List settings", + openapi.WithTags("Settings"), + openapi.WithQuery(jsonschema.MustFor[schema.SettingListRequest]()), + openapi.WithJSONResponse(http.StatusOK, jsonschema.MustFor[schema.SettingList]()), + ), + ), + router.RegisterPath("setting/category", nil, httprequest.NewPathItem("Settings", "Manage PostgreSQL setting categories"). + Get( + func(w http.ResponseWriter, r *http.Request) { + _ = ListSettingCategories(w, r, manager) + }, + "List setting categories", + openapi.WithTags("Settings"), + openapi.WithQuery(jsonschema.MustFor[schema.SettingCategoryListRequest]()), + openapi.WithJSONResponse(http.StatusOK, jsonschema.MustFor[schema.SettingCategoryList]()), + ), + ), + router.RegisterPath("setting/{setting}", nil, httprequest.NewPathItem("Settings", "Manage a PostgreSQL setting"). + Get( + func(w http.ResponseWriter, r *http.Request) { + _ = GetSetting(w, r, manager, r.PathValue("setting")) + }, + "Get a setting by name", + openapi.WithTags("Settings"), + openapi.WithJSONResponse(http.StatusOK, jsonschema.MustFor[schema.Setting]()), + ). + Patch( + func(w http.ResponseWriter, r *http.Request) { + _ = UpdateSetting(w, r, manager, r.PathValue("setting")) + }, + "Update a setting by name", + openapi.WithTags("Settings"), + openapi.WithJSONRequest(jsonschema.MustFor[schema.SettingMeta]()), + openapi.WithJSONResponse(http.StatusOK, jsonschema.MustFor[schema.Setting]()), + ), + ), + ) +} + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +func ListSettings(w http.ResponseWriter, r *http.Request, manager *manager.Manager) error { + var req schema.SettingListRequest + if err := httprequest.Query(r.URL.Query(), &req); err != nil { + return httpresponse.Error(w, httpresponse.ErrBadRequest.With(err.Error())) + } + if settings, err := manager.ListSettings(r.Context(), req); err != nil { + return httpresponse.Error(w, pg.HTTPError(err)) + } else { + return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), settings) + } +} + +func ListSettingCategories(w http.ResponseWriter, r *http.Request, manager *manager.Manager) error { + if categories, err := manager.ListSettingCategories(r.Context(), schema.SettingCategoryListRequest{}); err != nil { + return httpresponse.Error(w, pg.HTTPError(err)) + } else { + return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), categories) + } +} + +func GetSetting(w http.ResponseWriter, r *http.Request, manager *manager.Manager, setting string) error { + if result, err := manager.GetSetting(r.Context(), setting); err != nil { + return httpresponse.Error(w, pg.HTTPError(err)) + } else { + return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), result) + } +} + +func UpdateSetting(w http.ResponseWriter, r *http.Request, manager *manager.Manager, setting string) error { + var req schema.SettingMeta + if err := httprequest.Read(r, &req); err != nil { + return httpresponse.Error(w, httpresponse.ErrBadRequest.With(err.Error())) + } + if result, err := manager.UpdateSetting(r.Context(), setting, req); err != nil { + return httpresponse.Error(w, pg.HTTPError(err)) + } else { + return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), result) + } +} diff --git a/pgmanager/httphandlers/statement.go b/pgmanager/httphandlers/statement.go new file mode 100644 index 0000000..619c305 --- /dev/null +++ b/pgmanager/httphandlers/statement.go @@ -0,0 +1,67 @@ +package httphandlers + +import ( + "errors" + "net/http" + + // Packages + pg "github.com/mutablelogic/go-pg" + manager "github.com/mutablelogic/go-pg/pgmanager/manager" + schema "github.com/mutablelogic/go-pg/pgmanager/schema" + httprequest "github.com/mutablelogic/go-server/pkg/httprequest" + httpresponse "github.com/mutablelogic/go-server/pkg/httpresponse" + httprouter "github.com/mutablelogic/go-server/pkg/httprouter" + jsonschema "github.com/mutablelogic/go-server/pkg/jsonschema" + openapi "github.com/mutablelogic/go-server/pkg/openapi" +) + +/////////////////////////////////////////////////////////////////////////////// +// LIFECYCLE + +func RegisterStatementHandlers(manager *manager.Manager, router *httprouter.Router) error { + router.Spec().AddTag("Statements", "Observe PostgreSQL statement execution statistics") + + return errors.Join( + router.RegisterPath("statement", nil, httprequest.NewPathItem("Statements", "Observe PostgreSQL statement execution statistics"). + Get( + func(w http.ResponseWriter, r *http.Request) { + _ = ListStatements(w, r, manager) + }, + "List statements", + openapi.WithTags("Statements"), + openapi.WithQuery(jsonschema.MustFor[schema.StatementListRequest]()), + openapi.WithJSONResponse(http.StatusOK, jsonschema.MustFor[schema.StatementList]()), + ). + Delete( + func(w http.ResponseWriter, r *http.Request) { + _ = ResetStatementStats(w, r, manager) + }, + "Reset statement statistics", + openapi.WithTags("Statements"), + ), + ), + ) +} + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +func ListStatements(w http.ResponseWriter, r *http.Request, manager *manager.Manager) error { + var req schema.StatementListRequest + if err := httprequest.Query(r.URL.Query(), &req); err != nil { + return httpresponse.Error(w, httpresponse.ErrBadRequest.With(err.Error())) + } + if statements, err := manager.ListStatements(r.Context(), req); err != nil { + return httpresponse.Error(w, pg.HTTPError(err)) + } else { + return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), statements) + } +} + +func ResetStatementStats(w http.ResponseWriter, r *http.Request, manager *manager.Manager) error { + if err := manager.ResetStatements(r.Context()); err != nil { + return httpresponse.Error(w, pg.HTTPError(err)) + } else { + return httpresponse.Empty(w, http.StatusNoContent) + } +} diff --git a/pgmanager/httphandlers/status.go b/pgmanager/httphandlers/status.go new file mode 100644 index 0000000..901637a --- /dev/null +++ b/pgmanager/httphandlers/status.go @@ -0,0 +1,45 @@ +package httphandlers + +import ( + "errors" + "net/http" + + // Packages + pg "github.com/mutablelogic/go-pg" + manager "github.com/mutablelogic/go-pg/pgmanager/manager" + httprequest "github.com/mutablelogic/go-server/pkg/httprequest" + httpresponse "github.com/mutablelogic/go-server/pkg/httpresponse" + httprouter "github.com/mutablelogic/go-server/pkg/httprouter" + openapi "github.com/mutablelogic/go-server/pkg/openapi" +) + +/////////////////////////////////////////////////////////////////////////////// +// LIFECYCLE + +func RegisterStatusHandlers(manager *manager.Manager, router *httprouter.Router) error { + router.Spec().AddTag("Status", "Cluster Status Operations") + + return errors.Join( + // Register Ping Handler + router.RegisterPath("health", nil, httprequest.NewPathItem("Health", "Determine the health of the PostgreSQL server"). + Get( + func(w http.ResponseWriter, r *http.Request) { + _ = Ping(w, r, manager) + }, + "Ping the postgresql server", + openapi.WithTags("Status"), + openapi.WithNoContentResponse(http.StatusNoContent, "PostgreSQL server is healthy"), + ), + ), + ) +} + +/////////////////////////////////////////////////////////////////////////////// +// LIFECYCLE + +func Ping(w http.ResponseWriter, r *http.Request, manager *manager.Manager) error { + if err := manager.Ping(r.Context()); err != nil { + return httpresponse.Error(w, pg.HTTPError(err)) + } + return httpresponse.Empty(w, http.StatusNoContent) +} diff --git a/pgmanager/httphandlers/tablespace.go b/pgmanager/httphandlers/tablespace.go new file mode 100644 index 0000000..e9d8066 --- /dev/null +++ b/pgmanager/httphandlers/tablespace.go @@ -0,0 +1,143 @@ +package httphandlers + +import ( + "errors" + "net/http" + + // Packages + pg "github.com/mutablelogic/go-pg" + manager "github.com/mutablelogic/go-pg/pgmanager/manager" + schema "github.com/mutablelogic/go-pg/pgmanager/schema" + httprequest "github.com/mutablelogic/go-server/pkg/httprequest" + httpresponse "github.com/mutablelogic/go-server/pkg/httpresponse" + httprouter "github.com/mutablelogic/go-server/pkg/httprouter" + jsonschema "github.com/mutablelogic/go-server/pkg/jsonschema" + openapi "github.com/mutablelogic/go-server/pkg/openapi" +) + +/////////////////////////////////////////////////////////////////////////////// +// TYPES + +type TablespacePathParams struct { + Name string `json:"name" path:"name" validate:"required"` +} + +type TablespaceCreateMeta struct { + schema.TablespaceMeta + Location string `json:"location" validate:"required" help:"Location for the tablespace"` +} + +/////////////////////////////////////////////////////////////////////////////// +// LIFECYCLE + +func RegisterTablespaceHandlers(manager *manager.Manager, router *httprouter.Router) error { + router.Spec().AddTag("Tablespaces", "Cluster Tablespace Operations") + + return errors.Join( + router.RegisterPath("tablespace", nil, httprequest.NewPathItem("Tablespaces", "Manage PostgreSQL tablespaces"). + Get( + func(w http.ResponseWriter, r *http.Request) { + _ = ListTablespaces(w, r, manager) + }, + "List tablespaces", + openapi.WithTags("Tablespaces"), + openapi.WithQuery(jsonschema.MustFor[schema.TablespaceListRequest]()), + openapi.WithJSONResponse(http.StatusOK, jsonschema.MustFor[schema.TablespaceList]()), + ). + Post( + func(w http.ResponseWriter, r *http.Request) { + _ = CreateTablespace(w, r, manager) + }, + "Create tablespace", + openapi.WithTags("Tablespaces"), + openapi.WithJSONRequest(jsonschema.MustFor[TablespaceCreateMeta]()), + openapi.WithJSONResponse(http.StatusCreated, jsonschema.MustFor[schema.Tablespace]()), + ), + ), + router.RegisterPath("tablespace/{name}", jsonschema.MustFor[TablespacePathParams](), httprequest.NewPathItem("Tablespace", "Manage a PostgreSQL tablespace"). + Get( + func(w http.ResponseWriter, r *http.Request) { + _ = GetTablespace(w, r, manager, r.PathValue("name")) + }, + "Get tablespace", + openapi.WithTags("Tablespaces"), + openapi.WithJSONResponse(http.StatusOK, jsonschema.MustFor[schema.Tablespace]()), + openapi.WithErrorResponse(http.StatusNotFound, "Tablespace not found"), + ). + Delete( + func(w http.ResponseWriter, r *http.Request) { + _ = DeleteTablespace(w, r, manager, r.PathValue("name")) + }, + "Drop a tablespace", + openapi.WithTags("Tablespaces"), + openapi.WithJSONResponse(http.StatusOK, jsonschema.MustFor[schema.Tablespace]()), + openapi.WithErrorResponse(http.StatusNotFound, "Tablespace not found"), + ). + Patch( + func(w http.ResponseWriter, r *http.Request) { + _ = UpdateTablespace(w, r, manager, r.PathValue("name")) + }, + "Update a tablespace", + openapi.WithTags("Tablespaces"), + openapi.WithJSONRequest(jsonschema.MustFor[schema.TablespaceMeta]()), + openapi.WithJSONResponse(http.StatusOK, jsonschema.MustFor[schema.Tablespace]()), + openapi.WithErrorResponse(http.StatusNotFound, "Tablespace not found"), + ), + ), + ) +} + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +func ListTablespaces(w http.ResponseWriter, r *http.Request, manager *manager.Manager) error { + var req schema.TablespaceListRequest + if err := httprequest.Query(r.URL.Query(), &req); err != nil { + return httpresponse.Error(w, httpresponse.ErrBadRequest.With(err.Error())) + } + if tablespaces, err := manager.ListTablespaces(r.Context(), req); err != nil { + return httpresponse.Error(w, pg.HTTPError(err)) + } else { + return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), tablespaces) + } +} + +func CreateTablespace(w http.ResponseWriter, r *http.Request, manager *manager.Manager) error { + var req TablespaceCreateMeta + if err := httprequest.Read(r, &req); err != nil { + return httpresponse.Error(w, httpresponse.ErrBadRequest.With(err.Error())) + } + if tablespace, err := manager.CreateTablespace(r.Context(), req.TablespaceMeta, req.Location); err != nil { + return httpresponse.Error(w, pg.HTTPError(err)) + } else { + return httpresponse.JSON(w, http.StatusCreated, httprequest.Indent(r), tablespace) + } +} + +func GetTablespace(w http.ResponseWriter, r *http.Request, manager *manager.Manager, name string) error { + if tablespace, err := manager.GetTablespace(r.Context(), name); err != nil { + return httpresponse.Error(w, pg.HTTPError(err), name) + } else { + return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), tablespace) + } +} + +func DeleteTablespace(w http.ResponseWriter, r *http.Request, manager *manager.Manager, name string) error { + if tablespace, err := manager.DeleteTablespace(r.Context(), name); err != nil { + return httpresponse.Error(w, pg.HTTPError(err), name) + } else { + return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), tablespace) + } +} + +func UpdateTablespace(w http.ResponseWriter, r *http.Request, manager *manager.Manager, name string) error { + var req schema.TablespaceMeta + if err := httprequest.Read(r, &req); err != nil { + return httpresponse.Error(w, httpresponse.ErrBadRequest.With(err.Error()), name) + } + if tablespace, err := manager.UpdateTablespace(r.Context(), name, req); err != nil { + return httpresponse.Error(w, pg.HTTPError(err), name) + } else { + return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), tablespace) + } +} diff --git a/pgmanager/manager/connection.go b/pgmanager/manager/connection.go new file mode 100644 index 0000000..9b50d4e --- /dev/null +++ b/pgmanager/manager/connection.go @@ -0,0 +1,87 @@ +package manager + +import ( + "context" + + // Packages + otel "github.com/mutablelogic/go-client/pkg/otel" + pg "github.com/mutablelogic/go-pg" + schema "github.com/mutablelogic/go-pg/pgmanager/schema" + types "github.com/mutablelogic/go-server/pkg/types" + attribute "go.opentelemetry.io/otel/attribute" +) + +//////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS - CONNECTION + +// ListConnections returns a list of active database connections matching the request criteria. +// It supports filtering by database, role, and state, as well as pagination. +func (manager *Manager) ListConnections(ctx context.Context, req schema.ConnectionListRequest) (_ *schema.ConnectionList, err error) { + // Otel span + ctx, endSpan := otel.StartSpan(manager.tracer, ctx, "ListConnections", + attribute.String("req", types.Stringify(req)), + ) + defer func() { endSpan(err) }() + + // List connections + var result schema.ConnectionList + if err := manager.conn.List(ctx, &result, &req); err != nil { + return nil, err + } + + // Set the offset and limit in the result to reflect the actual count of items returned + // which may be less than the requested limit if there are not enough items in the database. + result.ConnectionListRequest = req + result.OffsetLimit.Clamp(result.Count) + + // Return success + return &result, nil +} + +// GetConnection retrieves a single connection by process ID. +// Returns an error if the pid is zero or the connection is not found. +func (manager *Manager) GetConnection(ctx context.Context, pid uint64) (_ *schema.Connection, err error) { + // Otel span + ctx, endSpan := otel.StartSpan(manager.tracer, ctx, "GetConnection", + attribute.Int64("pid", int64(pid)), + ) + defer func() { endSpan(err) }() + + // Validate input + if pid == 0 { + return nil, pg.ErrBadParameter.With("pid is zero") + } + + // Get the connection + var response schema.Connection + if err := manager.conn.Get(ctx, &response, schema.ConnectionPid(pid)); err != nil { + return nil, err + } + + // Return success + return &response, nil +} + +// DeleteConnection terminates a connection by process ID and returns the terminated connection. +// Returns an error if the pid is zero or the connection is not found. +func (manager *Manager) DeleteConnection(ctx context.Context, pid uint64) (_ *schema.Connection, err error) { + // Otel span + ctx, endSpan := otel.StartSpan(manager.tracer, ctx, "DeleteConnection", + attribute.Int64("pid", int64(pid)), + ) + defer func() { endSpan(err) }() + + // Validate input + if pid == 0 { + return nil, pg.ErrBadParameter.With("pid is zero") + } + + // Delete the connection + var connection schema.Connection + if err := manager.conn.Delete(ctx, &connection, schema.ConnectionPid(pid)); err != nil { + return nil, err + } + + // Return success + return &connection, nil +} diff --git a/pkg/manager/database.go b/pgmanager/manager/database.go similarity index 74% rename from pkg/manager/database.go rename to pgmanager/manager/database.go index 10aaaa0..a94f208 100644 --- a/pkg/manager/database.go +++ b/pgmanager/manager/database.go @@ -6,8 +6,11 @@ import ( "slices" // Packages + otel "github.com/mutablelogic/go-client/pkg/otel" pg "github.com/mutablelogic/go-pg" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" + schema "github.com/mutablelogic/go-pg/pgmanager/schema" + types "github.com/mutablelogic/go-server/pkg/types" + attribute "go.opentelemetry.io/otel/attribute" ) //////////////////////////////////////////////////////////////////////////////// @@ -15,21 +18,43 @@ import ( // ListDatabases returns a list of databases matching the request criteria. // It supports pagination through the OffsetLimit fields in the request. -func (manager *Manager) ListDatabases(ctx context.Context, req schema.DatabaseListRequest) (*schema.DatabaseList, error) { - var list schema.DatabaseList - if err := manager.conn.List(ctx, &list, req); err != nil { +func (manager *Manager) ListDatabases(ctx context.Context, req schema.DatabaseListRequest) (_ *schema.DatabaseList, err error) { + // Otel span + ctx, endSpan := otel.StartSpan(manager.tracer, ctx, "ListDatabases", + attribute.String("req", types.Stringify(req)), + ) + defer func() { endSpan(err) }() + + // List databases + var result schema.DatabaseList + if err := manager.conn.List(ctx, &result, &req); err != nil { return nil, err - } else { - return &list, nil } + + // Set the offset and limit in the result to reflect the actual count of items returned + // which may be less than the requested limit if there are not enough items in the database. + result.DatabaseListRequest = req + result.OffsetLimit.Clamp(result.Count) + + // Return success + return &result, nil } // GetDatabase retrieves a single database by name. // Returns an error if the name is empty or the database is not found. -func (manager *Manager) GetDatabase(ctx context.Context, name string) (*schema.Database, error) { +func (manager *Manager) GetDatabase(ctx context.Context, name string) (_ *schema.Database, err error) { + // Otel span + ctx, endSpan := otel.StartSpan(manager.tracer, ctx, "GetDatabase", + attribute.String("name", name), + ) + defer func() { endSpan(err) }() + + // Validate input if name == "" { return nil, pg.ErrBadParameter.With("name is empty") } + + // Get the database var database schema.Database if err := manager.conn.Get(ctx, &database, schema.DatabaseName(name)); err != nil { return nil, err @@ -41,8 +66,12 @@ func (manager *Manager) GetDatabase(ctx context.Context, name string) (*schema.D // The database creation cannot be done in a transaction, but ACL grants are // applied within a transaction. If ACL grants fail, the database is deleted // to maintain consistency. -func (manager *Manager) CreateDatabase(ctx context.Context, meta schema.DatabaseMeta) (*schema.Database, error) { - var database schema.Database +func (manager *Manager) CreateDatabase(ctx context.Context, meta schema.DatabaseMeta) (_ *schema.Database, err error) { + // Otel span + ctx, endSpan := otel.StartSpan(manager.tracer, ctx, "CreateDatabase", + attribute.String("meta", types.Stringify(meta)), + ) + defer func() { endSpan(err) }() // Validate metadata if err := meta.Validate(); err != nil { @@ -50,6 +79,7 @@ func (manager *Manager) CreateDatabase(ctx context.Context, meta schema.Database } // Create the database - cannot be done in a transaction + var database schema.Database if err := manager.conn.Insert(ctx, nil, meta); err != nil { return nil, err } @@ -64,8 +94,7 @@ func (manager *Manager) CreateDatabase(ctx context.Context, meta schema.Database return nil }); err != nil { // Delete the database if there is an issue with ACL's - deleteErr := manager.conn.Delete(ctx, nil, schema.DatabaseName(meta.Name)) - return nil, errors.Join(err, deleteErr) + return nil, errors.Join(err, manager.conn.Delete(ctx, nil, schema.DatabaseName(meta.Name))) } // Get the database @@ -79,16 +108,28 @@ func (manager *Manager) CreateDatabase(ctx context.Context, meta schema.Database // DeleteDatabase drops a database by name and returns its metadata before deletion. // If force is true, the database is dropped even if there are active connections. -func (manager *Manager) DeleteDatabase(ctx context.Context, name string, force bool) (*schema.Database, error) { +func (manager *Manager) DeleteDatabase(ctx context.Context, name string, force bool) (_ *schema.Database, err error) { + // Otel span + ctx, endSpan := otel.StartSpan(manager.tracer, ctx, "DeleteDatabase", + attribute.String("name", name), + attribute.Bool("force", force), + ) + defer func() { endSpan(err) }() + + // Validate input if name == "" { return nil, pg.ErrBadParameter.With("name is empty") } + + // Get the database and delete it var database schema.Database if err := manager.conn.Get(ctx, &database, schema.DatabaseName(name)); err != nil { return nil, err } else if err := manager.conn.With("force", force).Delete(ctx, nil, schema.DatabaseName(name)); err != nil { return nil, err } + + // Return success return &database, nil } @@ -96,12 +137,18 @@ func (manager *Manager) DeleteDatabase(ctx context.Context, name string, force b // All changes are applied within a transaction to ensure atomicity. // If meta.Name is provided and differs from name, the database is renamed. // ACL changes are synchronized by revoking removed privileges and granting new ones. -func (manager *Manager) UpdateDatabase(ctx context.Context, name string, meta schema.DatabaseMeta) (*schema.Database, error) { +func (manager *Manager) UpdateDatabase(ctx context.Context, name string, meta schema.DatabaseMeta) (_ *schema.Database, err error) { + // Otel span + ctx, endSpan := otel.StartSpan(manager.tracer, ctx, "UpdateDatabase", + attribute.String("name", name), + attribute.String("meta", types.Stringify(meta)), + ) + defer func() { endSpan(err) }() + + // Validate if name == "" { return nil, pg.ErrBadParameter.With("name is empty") } - - // Validate new name if provided if meta.Name != "" { if err := (schema.DatabaseMeta{Name: meta.Name, Owner: meta.Owner}).Validate(); err != nil { return nil, err @@ -113,8 +160,8 @@ func (manager *Manager) UpdateDatabase(ctx context.Context, name string, meta sc } } + // Update the database and ACL's in a transaction var database schema.Database - if err := manager.conn.Tx(ctx, func(conn pg.Conn) error { // Get the database and ACL's if err := conn.Get(ctx, &database, schema.DatabaseName(name)); err != nil { diff --git a/pkg/manager/extension.go b/pgmanager/manager/extension.go similarity index 60% rename from pkg/manager/extension.go rename to pgmanager/manager/extension.go index 93374f4..c51917c 100644 --- a/pkg/manager/extension.go +++ b/pgmanager/manager/extension.go @@ -2,12 +2,15 @@ package manager import ( "context" + "errors" "strings" // Packages + otel "github.com/mutablelogic/go-client/pkg/otel" pg "github.com/mutablelogic/go-pg" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" + schema "github.com/mutablelogic/go-pg/pgmanager/schema" types "github.com/mutablelogic/go-server/pkg/types" + attribute "go.opentelemetry.io/otel/attribute" ) //////////////////////////////////////////////////////////////////////////////// @@ -17,33 +20,49 @@ import ( // If Database is specified, shows extensions for that specific database (with installed status). // If Database is not specified, shows available extensions cluster-wide from the current connection. // Use the Installed filter to show only installed, only not-installed, or all extensions. -func (manager *Manager) ListExtensions(ctx context.Context, req schema.ExtensionListRequest) (*schema.ExtensionList, error) { - var list schema.ExtensionList +func (manager *Manager) ListExtensions(ctx context.Context, req schema.ExtensionListRequest) (_ *schema.ExtensionList, err error) { + // Otel span + ctx, endSpan := otel.StartSpan(manager.tracer, ctx, "ListExtensions", + attribute.String("req", types.Stringify(req)), + ) + defer func() { endSpan(err) }() // Determine which database to query - database := strings.TrimSpace(types.PtrString(req.Database)) - if database != "" { + var result schema.ExtensionList + if database := strings.TrimSpace(types.Value(req.Database)); database != "" { // Query specific database via Remote - if err := manager.conn.Remote(database).With("as", schema.ExtensionDef).List(ctx, &list, req); err != nil { + if err := manager.conn.Remote(database).With("as", schema.ExtensionDef).List(ctx, &result, &req); err != nil { return nil, err } // Set the database on each extension - for i := range list.Body { - list.Body[i].Database = database + for i := range result.Body { + result.Body[i].Database = database } } else { // No database specified - query directly for cluster-wide available extensions - if err := manager.conn.List(ctx, &list, req); err != nil { + if err := manager.conn.List(ctx, &result, &req); err != nil { return nil, err } } + // Set the offset and limit in the result to reflect the actual count of items returned + // which may be less than the requested limit if there are not enough items in the database. + result.ExtensionListRequest = req + result.OffsetLimit.Clamp(result.Count) + // Return success - return &list, nil + return &result, nil } -func (manager *Manager) GetExtension(ctx context.Context, name string) (*schema.Extension, error) { +func (manager *Manager) GetExtension(ctx context.Context, name string) (_ *schema.Extension, err error) { + // Otel span + ctx, endSpan := otel.StartSpan(manager.tracer, ctx, "GetExtension", + attribute.String("name", name), + ) + defer func() { endSpan(err) }() + + // Validate input name = strings.TrimSpace(name) if name == "" { return nil, pg.ErrBadParameter.With("name is empty") @@ -59,10 +78,47 @@ func (manager *Manager) GetExtension(ctx context.Context, name string) (*schema. return &ext, nil } +func (manager *Manager) GetInstalledExtension(ctx context.Context, name, database string) (_ *schema.Extension, err error) { + // Otel span + ctx, endSpan := otel.StartSpan(manager.tracer, ctx, "GetInstalledExtension", + attribute.String("name", name), + attribute.String("database", database), + ) + defer func() { endSpan(err) }() + + // Validate input + name = strings.TrimSpace(name) + if name == "" { + return nil, pg.ErrBadParameter.With("name is empty") + } + database = strings.TrimSpace(database) + if database == "" { + return nil, pg.ErrBadParameter.With("database is empty") + } + + // Query the specific database for the extension + var ext schema.Extension + if err := manager.conn.Remote(database).With("as", schema.ExtensionDef).Get(ctx, &ext, schema.ExtensionName(name)); err != nil { + return nil, err + } else { + ext.Database = database + } + + // Return success + return &ext, nil +} + // CreateExtension installs an extension in a database. // The Database field in meta specifies which database to install into. // If cascade is true, dependent extensions are also installed. -func (manager *Manager) CreateExtension(ctx context.Context, meta schema.ExtensionMeta, cascade bool) (*schema.Extension, error) { +func (manager *Manager) CreateExtension(ctx context.Context, meta schema.ExtensionMeta, cascade bool) (_ *schema.Extension, err error) { + // Otel span + ctx, endSpan := otel.StartSpan(manager.tracer, ctx, "CreateExtension", + attribute.String("meta", types.Stringify(meta)), + attribute.Bool("cascade", cascade), + ) + defer func() { endSpan(err) }() + // Check parameters database := strings.TrimSpace(meta.Database) if database == "" { @@ -73,14 +129,19 @@ func (manager *Manager) CreateExtension(ctx context.Context, meta schema.Extensi return nil, pg.ErrBadParameter.With("name is empty") } - // Create the extension + // Check for extension existence first to avoid creating an extension that already exists + var ext schema.Extension conn := manager.conn.Remote(database).With("cascade", cascade) + if err := conn.With("as", schema.ExtensionDef).Get(ctx, &ext, schema.ExtensionName(name)); errors.Is(err, pg.ErrNotFound) == false { + return nil, pg.ErrConflict.Withf("extension %q already exists in database %q", name, database) + } + + // Create the extension if err := conn.Insert(ctx, nil, meta); err != nil { return nil, err } // Get the extension - var ext schema.Extension if err := conn.With("as", schema.ExtensionDef).Get(ctx, &ext, schema.ExtensionName(name)); err != nil { return nil, err } else { @@ -96,7 +157,14 @@ func (manager *Manager) CreateExtension(ctx context.Context, meta schema.Extensi // The Version field specifies the target version (empty means latest). // The Schema field specifies a new schema to move the extension to (only for relocatable extensions). // Note: Name and Owner cannot be changed for extensions in PostgreSQL. -func (manager *Manager) UpdateExtension(ctx context.Context, name string, meta schema.ExtensionMeta) (*schema.Extension, error) { +func (manager *Manager) UpdateExtension(ctx context.Context, name string, meta schema.ExtensionMeta) (_ *schema.Extension, err error) { + // Otel span + ctx, endSpan := otel.StartSpan(manager.tracer, ctx, "UpdateExtension", + attribute.String("name", name), + attribute.String("meta", types.Stringify(meta)), + ) + defer func() { endSpan(err) }() + // Check parameters database := strings.TrimSpace(meta.Database) if database == "" { @@ -115,9 +183,8 @@ func (manager *Manager) UpdateExtension(ctx context.Context, name string, meta s return nil, pg.ErrBadParameter.With("owner cannot be changed") } - conn := manager.conn.Remote(database) - // If schema change is requested, first check if the extension is relocatable + conn := manager.conn.Remote(database) if meta.Schema != "" { var ext schema.Extension if err := conn.With("as", schema.ExtensionDef).Get(ctx, &ext, schema.ExtensionName(name)); err != nil { @@ -156,7 +223,15 @@ func (manager *Manager) UpdateExtension(ctx context.Context, name string, meta s return &ext, nil } -func (manager *Manager) DeleteExtension(ctx context.Context, database, name string, cascade bool) error { +func (manager *Manager) DeleteExtension(ctx context.Context, database, name string, cascade bool) (err error) { + // Otel span + ctx, endSpan := otel.StartSpan(manager.tracer, ctx, "DeleteExtension", + attribute.String("database", database), + attribute.String("name", name), + attribute.Bool("cascade", cascade), + ) + defer func() { endSpan(err) }() + // Check parameters database = strings.TrimSpace(database) if database == "" { diff --git a/pkg/manager/manager.go b/pgmanager/manager/manager.go similarity index 58% rename from pkg/manager/manager.go rename to pgmanager/manager/manager.go index 38bf755..e4acd7a 100644 --- a/pkg/manager/manager.go +++ b/pgmanager/manager/manager.go @@ -2,55 +2,83 @@ package manager import ( "context" + "errors" + "fmt" // Packages pg "github.com/mutablelogic/go-pg" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" + schema "github.com/mutablelogic/go-pg/pgmanager/schema" types "github.com/mutablelogic/go-server/pkg/types" ) -//////////////////////////////////////////////////////////////////////////////// +/////////////////////////////////////////////////////////////////////////////// // TYPES type Manager struct { - conn pg.PoolConn - - // Feature flags - statStatementsAvailable bool + opts + conn pg.PoolConn + pgStatementsAvailable bool } -//////////////////////////////////////////////////////////////////////////////// +/////////////////////////////////////////////////////////////////////////////// // LIFECYCLE -// New creates a new database manager. -func New(ctx context.Context, conn pg.PoolConn) (*Manager, error) { - if conn == nil { - return nil, pg.ErrBadParameter.With("connection is nil") - } +func New(conn pg.PoolConn, opt ...Opt) (*Manager, error) { self := new(Manager) + + // Set the schema self.conn = conn.With("schema", schema.CatalogSchema).(pg.PoolConn) - // Bootstrap extensions - result, err := schema.Bootstrap(ctx, self.conn) - if err != nil { + // Set default options + self.opts = opts{} + + // Get the cluster name for metrics + var cluster schema.Cluster + if err := self.conn.Get(context.Background(), &cluster, cluster); err != nil { + return nil, fmt.Errorf("get cluster name: %w", err) + } else { + self.cluster = cluster.Name + } + + // Apply options + if err := self.opts.apply(opt...); err != nil { return nil, err } - self.statStatementsAvailable = result.StatStatementsAvailable + + // Register metrics + if self.metrics != nil { + err := errors.Join( + self.RegisterDatabaseMetrics("database"), + self.RegisterSchemaMetrics("schema"), + self.RegisterConnectionMetrics("connection"), + self.RegisterTablespaceMetrics("tablespace"), + self.RegisterReplicationSlotMetrics("replication_slot"), + ) + if err != nil { + return nil, fmt.Errorf("register metrics: %w", err) + } + } + + // Add required extensions and check for optional ones + bootstrapResult, err := schema.Bootstrap(context.Background(), self.conn) + if err != nil { + return nil, fmt.Errorf("bootstrap schema: %w", err) + } else { + self.pgStatementsAvailable = bootstrapResult.StatStatementsAvailable + } // Return success return self, nil } -// StatStatementsAvailable returns true if pg_stat_statements extension is available -func (manager *Manager) StatStatementsAvailable() bool { - return manager.statStatementsAvailable -} +/////////////////////////////////////////////////////////////////////////////// +// PRIVATE METHODS // Iterate through all the databases func (manager *Manager) withDatabases(ctx context.Context, fn func(database *schema.Database) error) (uint64, error) { var req schema.DatabaseListRequest req.Offset = 0 - req.Limit = types.Uint64Ptr(schema.DatabaseListLimit) + req.Limit = types.Ptr(uint64(schema.DatabaseListLimit)) for { list, err := manager.ListDatabases(ctx, req) @@ -64,7 +92,7 @@ func (manager *Manager) withDatabases(ctx context.Context, fn func(database *sch } // Determine if the next page is over the count - next := req.Offset + types.PtrUint64(req.Limit) + next := req.Offset + types.Value(req.Limit) if next >= list.Count { return list.Count, nil } else { @@ -77,7 +105,7 @@ func (manager *Manager) withDatabases(ctx context.Context, fn func(database *sch func (manager *Manager) withSchemas(ctx context.Context, database string, fn func(schema *schema.Schema) error) (uint64, error) { var req schema.SchemaListRequest req.Offset = 0 - req.Limit = types.Uint64Ptr(schema.SchemaListLimit) + req.Limit = types.Ptr(uint64(schema.SchemaListLimit)) for { var list schema.SchemaList @@ -92,7 +120,7 @@ func (manager *Manager) withSchemas(ctx context.Context, database string, fn fun } // Determine if the next page is over the count - next := req.Offset + types.PtrUint64(req.Limit) + next := req.Offset + types.Value(req.Limit) if next >= list.Count { return list.Count, nil } else { @@ -104,7 +132,7 @@ func (manager *Manager) withSchemas(ctx context.Context, database string, fn fun // Iterate through all the objects for a database - requires object.go to be ported from go-server func (manager *Manager) withObjects(ctx context.Context, database string, req schema.ObjectListRequest, fn func(schema *schema.Object) error) (uint64, error) { req.Offset = 0 - req.Limit = types.Uint64Ptr(schema.ObjectListLimit) + req.Limit = types.Ptr(uint64(schema.ObjectListLimit)) for { var list schema.ObjectList @@ -119,7 +147,7 @@ func (manager *Manager) withObjects(ctx context.Context, database string, req sc } // Determine if the next page is over the count - next := req.Offset + types.PtrUint64(req.Limit) + next := req.Offset + types.Value(req.Limit) if next >= list.Count { return list.Count, nil } else { diff --git a/pgmanager/manager/metrics.go b/pgmanager/manager/metrics.go new file mode 100644 index 0000000..98d3345 --- /dev/null +++ b/pgmanager/manager/metrics.go @@ -0,0 +1,234 @@ +package manager + +import ( + "context" + "strings" + + // Packages + + otel "github.com/mutablelogic/go-client/pkg/otel" + "github.com/mutablelogic/go-pg" + schema "github.com/mutablelogic/go-pg/pgmanager/schema" + types "github.com/mutablelogic/go-server/pkg/types" + attribute "go.opentelemetry.io/otel/attribute" + metric "go.opentelemetry.io/otel/metric" +) + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +func (manager *Manager) RegisterDatabaseMetrics(name string) (err error) { + // Register a gauge for database size + if guage, err := manager.metrics.Int64ObservableGauge( + name, metric.WithDescription("Size of database in bytes"), + ); err != nil { + return pg.ErrInternalServerError.Withf("RegisterDatabaseMetrics: %v", err) + } else if _, err := manager.metrics.RegisterCallback(func(parent context.Context, observer metric.Observer) error { + // Otel span + ctx, endSpan := otel.StartSpan(manager.tracer, parent, "ObserveDatabaseMetrics", + attribute.String("name", name), + ) + defer func() { endSpan(err) }() + + // TODO: Paginate through databases + databases, err := manager.ListDatabases(ctx, schema.DatabaseListRequest{}) + if err != nil { + return pg.ErrInternalServerError.Withf("RegisterDatabaseMetrics: %v", err) + } + for _, database := range databases.Body { + observer.ObserveInt64(guage, int64(database.Size), metric.WithAttributes( + attribute.String("cluster", manager.cluster), + attribute.String("database", database.Name), + attribute.Int64("oid", int64(database.Oid)), + )) + } + return nil + }, guage); err != nil { + return pg.ErrInternalServerError.Withf("RegisterDatabaseMetrics: %v", err) + } + + // Return success + return nil +} + +func (manager *Manager) RegisterSchemaMetrics(name string) error { + if guage, err := manager.metrics.Int64ObservableGauge( + name, metric.WithDescription("Size of schema in bytes"), + ); err != nil { + return pg.ErrInternalServerError.Withf("RegisterSchemaMetrics: %v", err) + } else if _, err := manager.metrics.RegisterCallback(func(parent context.Context, observer metric.Observer) error { + // Otel span + ctx, endSpan := otel.StartSpan(manager.tracer, parent, "ObserveSchemaMetrics", + attribute.String("name", name), + ) + defer func() { endSpan(err) }() + + // TODO: Paginate through schemas + schemas, err := manager.ListSchemas(ctx, schema.SchemaListRequest{}) + if err != nil { + return pg.ErrInternalServerError.Withf("RegisterSchemaMetrics: %v", err) + } + for _, schema := range schemas.Body { + observer.ObserveInt64(guage, int64(schema.Size), metric.WithAttributes( + attribute.String("cluster", manager.cluster), + attribute.String("database", schema.Database), + attribute.String("schema", schema.Name), + attribute.Int64("oid", int64(schema.Oid)), + )) + } + return nil + }, guage); err != nil { + return pg.ErrInternalServerError.Withf("RegisterSchemaMetrics: %v", err) + } + + // Return success + return nil +} + +func (manager *Manager) RegisterConnectionMetrics(name string) error { + if guage, err := manager.metrics.Int64ObservableGauge( + name, metric.WithDescription("Number of active connections"), + ); err != nil { + return pg.ErrInternalServerError.Withf("RegisterConnectionMetrics: %v", err) + } else if _, err := manager.metrics.RegisterCallback(func(parent context.Context, observer metric.Observer) error { + // Otel span + ctx, endSpan := otel.StartSpan(manager.tracer, parent, "ObserveConnectionMetrics", + attribute.String("name", name), + ) + defer func() { endSpan(err) }() + + // TODO: Paginate through connections + connections, err := manager.ListConnections(ctx, schema.ConnectionListRequest{}) + if err != nil { + return pg.ErrInternalServerError.Withf("RegisterConnectionMetrics: %v", err) + } + + type key struct { + database string + role string + state string + } + counts := make(map[key]int64) + for _, connection := range connections.Body { + k := key{ + database: strings.TrimSpace(connection.Database), + role: strings.TrimSpace(connection.Role), + state: strings.TrimSpace(connection.State), + } + if k.database == "" { + k.database = "unknown" + } + if k.role == "" { + k.role = "unknown" + } + if k.state == "" { + k.state = "unknown" + } + counts[k]++ + } + + for k, count := range counts { + observer.ObserveInt64(guage, count, metric.WithAttributes( + attribute.String("cluster", manager.cluster), + attribute.String("database", k.database), + attribute.String("role", k.role), + attribute.String("state", k.state), + )) + } + return nil + }, guage); err != nil { + return pg.ErrInternalServerError.Withf("RegisterConnectionMetrics: %v", err) + } + + // Return success + return nil +} + +func (manager *Manager) RegisterTablespaceMetrics(name string) error { + if guage, err := manager.metrics.Int64ObservableGauge( + name, metric.WithDescription("Size of tablespace in bytes"), + ); err != nil { + return pg.ErrInternalServerError.Withf("RegisterTablespaceMetrics: %v", err) + } else if _, err := manager.metrics.RegisterCallback(func(parent context.Context, observer metric.Observer) error { + // Otel span + ctx, endSpan := otel.StartSpan(manager.tracer, parent, "ObserveTablespaceMetrics", + attribute.String("name", name), + ) + defer func() { endSpan(err) }() + + // TODO: Paginate through tablespaces + tablespaces, err := manager.ListTablespaces(ctx, schema.TablespaceListRequest{}) + if err != nil { + return pg.ErrInternalServerError.Withf("RegisterTablespaceMetrics: %v", err) + } + for _, tablespace := range tablespaces.Body { + observer.ObserveInt64(guage, int64(tablespace.Size), metric.WithAttributes( + attribute.String("cluster", manager.cluster), + attribute.String("tablespace", tablespace.Name), + attribute.Int64("oid", int64(tablespace.Oid)), + attribute.String("location", tablespace.Location), + )) + } + return nil + }, guage); err != nil { + return pg.ErrInternalServerError.Withf("RegisterTablespaceMetrics: %v", err) + } + + // Return success + return nil +} + +func (manager *Manager) RegisterReplicationSlotMetrics(name string) error { + lag_bytes, err := manager.metrics.Int64ObservableGauge( + name+"_bytes", metric.WithDescription("Lag of replication slot in bytes"), + ) + if err != nil { + return pg.ErrInternalServerError.Withf("RegisterReplicationSlotMetrics: %v", err) + } + lag_ms, err := manager.metrics.Float64ObservableGauge( + name+"_ms", metric.WithDescription("Lag of replication slot in milliseconds"), + ) + if err != nil { + return pg.ErrInternalServerError.Withf("RegisterReplicationSlotMetrics: %v", err) + } + + if _, err := manager.metrics.RegisterCallback(func(parent context.Context, observer metric.Observer) error { + // Otel span + ctx, endSpan := otel.StartSpan(manager.tracer, parent, "ObserveReplicationSlotMetrics", + attribute.String("name", name), + ) + defer func() { endSpan(err) }() + + // TODO: Paginate through replication slots + replicationslots, err := manager.ListReplicationSlots(ctx, schema.ReplicationSlotListRequest{}) + if err != nil { + return pg.ErrInternalServerError.Withf("RegisterReplicationSlotMetrics: %v", err) + } + for _, slot := range replicationslots.Body { + if slot.LagBytes != nil { + observer.ObserveInt64(lag_bytes, types.Value(slot.LagBytes), metric.WithAttributes( + attribute.String("cluster", manager.cluster), + attribute.String("slot", slot.Name), + attribute.String("database", slot.Database), + attribute.String("type", slot.Type), + attribute.String("status", slot.Status), + )) + } + if slot.LagMs != nil { + observer.ObserveFloat64(lag_ms, types.Value(slot.LagMs), metric.WithAttributes( + attribute.String("cluster", manager.cluster), + attribute.String("slot", slot.Name), + attribute.String("database", slot.Database), + attribute.String("type", slot.Type), + attribute.String("status", slot.Status), + )) + } + } + return nil + }, lag_bytes, lag_ms); err != nil { + return pg.ErrInternalServerError.Withf("RegisterReplicationSlotMetrics: %v", err) + } + + // Return success + return nil +} diff --git a/pkg/manager/object.go b/pgmanager/manager/object.go similarity index 65% rename from pkg/manager/object.go rename to pgmanager/manager/object.go index 85b8ea9..e737fb8 100644 --- a/pkg/manager/object.go +++ b/pgmanager/manager/object.go @@ -5,31 +5,38 @@ import ( "strings" // Packages + otel "github.com/mutablelogic/go-client/pkg/otel" pg "github.com/mutablelogic/go-pg" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" + schema "github.com/mutablelogic/go-pg/pgmanager/schema" types "github.com/mutablelogic/go-server/pkg/types" + attribute "go.opentelemetry.io/otel/attribute" ) //////////////////////////////////////////////////////////////////////////////// // PUBLIC METHODS - OBJECT -func (manager *Manager) ListObjects(ctx context.Context, req schema.ObjectListRequest) (*schema.ObjectList, error) { - var list schema.ObjectList - var offset, limit uint64 +func (manager *Manager) ListObjects(ctx context.Context, req schema.ObjectListRequest) (_ *schema.ObjectList, err error) { + // Otel span + ctx, endSpan := otel.StartSpan(manager.tracer, ctx, "ListObjects", + attribute.String("req", types.Stringify(req)), + ) + defer func() { endSpan(err) }() // Set limit lower if request limit is lower + var offset, limit uint64 limit = schema.ObjectListLimit - if req.Limit != nil && types.PtrUint64(req.Limit) < limit { - limit = types.PtrUint64(req.Limit) + if req.Limit != nil && types.Value(req.Limit) < limit { + limit = types.Value(req.Limit) } // Allocate the body with capacity + var list schema.ObjectList list.Body = make([]schema.Object, 0, limit) // Iterate through all the databases if _, err := manager.withDatabases(ctx, func(database *schema.Database) error { // Filter by database - if name := strings.TrimSpace(types.PtrString(req.Database)); name != "" && name != database.Name { + if name := strings.TrimSpace(types.Value(req.Database)); name != "" && name != database.Name { return nil } @@ -58,7 +65,16 @@ func (manager *Manager) ListObjects(ctx context.Context, req schema.ObjectListRe return &list, nil } -func (manager *Manager) GetObject(ctx context.Context, database, namespace, name string) (*schema.Object, error) { +func (manager *Manager) GetObject(ctx context.Context, database, namespace, name string) (_ *schema.Object, err error) { + // Otel span + ctx, endSpan := otel.StartSpan(manager.tracer, ctx, "GetObject", + attribute.String("database", database), + attribute.String("namespace", namespace), + attribute.String("name", name), + ) + defer func() { endSpan(err) }() + + // Validate input if database == "" { return nil, pg.ErrBadParameter.With("database is empty") } @@ -68,6 +84,8 @@ func (manager *Manager) GetObject(ctx context.Context, database, namespace, name if name == "" { return nil, pg.ErrBadParameter.With("name is empty") } + + // Get the object var response schema.Object if err := manager.conn.Remote(database).With("as", schema.ObjectDef).Get(ctx, &response, schema.ObjectName{Schema: namespace, Name: name}); err != nil { return nil, err diff --git a/pgmanager/manager/opt.go b/pgmanager/manager/opt.go new file mode 100644 index 0000000..e773d93 --- /dev/null +++ b/pgmanager/manager/opt.go @@ -0,0 +1,69 @@ +package manager + +import ( + // Packages + "errors" + "strings" + + metric "go.opentelemetry.io/otel/metric" + trace "go.opentelemetry.io/otel/trace" +) + +/////////////////////////////////////////////////////////////////////////////// +// TYPES + +// Opt configures a Manager during construction. +type Opt func(*opts) error + +// combines all configuration options for Manager. +type opts struct { + tracer trace.Tracer + metrics metric.Meter + cluster string +} + +/////////////////////////////////////////////////////////////////////////////// +// LIFECYCLE + +func (o *opts) apply(opts ...Opt) error { + for _, opt := range opts { + if opt == nil { + continue + } + if err := opt(o); err != nil { + return err + } + } + return nil +} + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +// WithTracer sets the OpenTelemetry tracer used for manager spans. +func WithTracer(tracer trace.Tracer) Opt { + return func(o *opts) error { + o.tracer = tracer + return nil + } +} + +// WithMeter sets the OpenTelemetry meter used for manager metrics. +func WithMeter(meter metric.Meter) Opt { + return func(o *opts) error { + o.metrics = meter + return nil + } +} + +// WithClusterName sets the name to be used as an attribute on all metrics and spans. +func WithClusterName(cluster string) Opt { + return func(o *opts) error { + if cluster := strings.TrimSpace(cluster); cluster != "" { + o.cluster = cluster + } else { + return errors.New("cluster name cannot be empty") + } + return nil + } +} diff --git a/pkg/manager/replicationslot.go b/pgmanager/manager/replicationslot.go similarity index 55% rename from pkg/manager/replicationslot.go rename to pgmanager/manager/replicationslot.go index c75d877..37bb4c4 100644 --- a/pkg/manager/replicationslot.go +++ b/pgmanager/manager/replicationslot.go @@ -4,8 +4,11 @@ import ( "context" // Packages + otel "github.com/mutablelogic/go-client/pkg/otel" pg "github.com/mutablelogic/go-pg" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" + schema "github.com/mutablelogic/go-pg/pgmanager/schema" + types "github.com/mutablelogic/go-server/pkg/types" + attribute "go.opentelemetry.io/otel/attribute" ) /////////////////////////////////////////////////////////////////////////////// @@ -13,20 +16,41 @@ import ( // ListReplicationSlots returns a list of replication slots with their status. // Includes lag information for connected replicas. -func (manager *Manager) ListReplicationSlots(ctx context.Context, req schema.ReplicationSlotListRequest) (*schema.ReplicationSlotList, error) { - var list schema.ReplicationSlotList - if err := manager.conn.List(ctx, &list, req); err != nil { +func (manager *Manager) ListReplicationSlots(ctx context.Context, req schema.ReplicationSlotListRequest) (_ *schema.ReplicationSlotList, err error) { + // Otel span + ctx, endSpan := otel.StartSpan(manager.tracer, ctx, "ListReplicationSlots", + attribute.String("req", types.Stringify(req)), + ) + defer func() { endSpan(err) }() + + var result schema.ReplicationSlotList + if err := manager.conn.List(ctx, &result, &req); err != nil { return nil, err } - return &list, nil + + // Set the offset and limit in the result to reflect the actual count of items returned + // which may be less than the requested limit if there are not enough items. + result.ReplicationSlotListRequest = req + result.OffsetLimit.Clamp(result.Count) + + return &result, nil } // GetReplicationSlot retrieves a single replication slot by name. // Returns an error if the name is empty or the slot is not found. -func (manager *Manager) GetReplicationSlot(ctx context.Context, name string) (*schema.ReplicationSlot, error) { +func (manager *Manager) GetReplicationSlot(ctx context.Context, name string) (_ *schema.ReplicationSlot, err error) { + // Otel span + ctx, endSpan := otel.StartSpan(manager.tracer, ctx, "GetReplicationSlot", + attribute.String("name", name), + ) + defer func() { endSpan(err) }() + + // Validate input if name == "" { return nil, pg.ErrBadParameter.With("name is empty") } + + // Get the slot var slot schema.ReplicationSlot if err := manager.conn.Get(ctx, &slot, schema.ReplicationSlotName(name)); err != nil { return nil, err @@ -36,7 +60,14 @@ func (manager *Manager) GetReplicationSlot(ctx context.Context, name string) (*s // CreateReplicationSlot creates a new replication slot with the specified metadata. // Type must be "physical" or "logical". Logical slots require a plugin name. -func (manager *Manager) CreateReplicationSlot(ctx context.Context, meta schema.ReplicationSlotMeta) (*schema.ReplicationSlot, error) { +func (manager *Manager) CreateReplicationSlot(ctx context.Context, meta schema.ReplicationSlotMeta) (_ *schema.ReplicationSlot, err error) { + // Otel span + ctx, endSpan := otel.StartSpan(manager.tracer, ctx, "CreateReplicationSlot", + attribute.String("meta", types.Stringify(meta)), + ) + defer func() { endSpan(err) }() + + // Validate input if err := meta.Validate(); err != nil { return nil, err } @@ -57,7 +88,14 @@ func (manager *Manager) CreateReplicationSlot(ctx context.Context, meta schema.R // DeleteReplicationSlot drops a replication slot by name. // Returns the slot metadata before deletion. -func (manager *Manager) DeleteReplicationSlot(ctx context.Context, name string) (*schema.ReplicationSlot, error) { +func (manager *Manager) DeleteReplicationSlot(ctx context.Context, name string) (_ *schema.ReplicationSlot, err error) { + // Otel span + ctx, endSpan := otel.StartSpan(manager.tracer, ctx, "DeleteReplicationSlot", + attribute.String("name", name), + ) + defer func() { endSpan(err) }() + + // Validate input if name == "" { return nil, pg.ErrBadParameter.With("name is empty") } diff --git a/pkg/manager/role.go b/pgmanager/manager/role.go similarity index 68% rename from pkg/manager/role.go rename to pgmanager/manager/role.go index b887b04..cbe6bb4 100644 --- a/pkg/manager/role.go +++ b/pgmanager/manager/role.go @@ -5,8 +5,11 @@ import ( "slices" // Packages + otel "github.com/mutablelogic/go-client/pkg/otel" pg "github.com/mutablelogic/go-pg" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" + schema "github.com/mutablelogic/go-pg/pgmanager/schema" + types "github.com/mutablelogic/go-server/pkg/types" + attribute "go.opentelemetry.io/otel/attribute" ) //////////////////////////////////////////////////////////////////////////////// @@ -14,21 +17,41 @@ import ( // ListRoles returns a list of roles matching the request criteria. // It supports pagination through the OffsetLimit fields in the request. -func (manager *Manager) ListRoles(ctx context.Context, req schema.RoleListRequest) (*schema.RoleList, error) { - var list schema.RoleList - if err := manager.conn.List(ctx, &list, req); err != nil { +func (manager *Manager) ListRoles(ctx context.Context, req schema.RoleListRequest) (_ *schema.RoleList, err error) { + // Otel span + ctx, endSpan := otel.StartSpan(manager.tracer, ctx, "ListRoles", + attribute.String("req", types.Stringify(req)), + ) + defer func() { endSpan(err) }() + + var result schema.RoleList + if err := manager.conn.List(ctx, &result, &req); err != nil { return nil, err - } else { - return &list, nil } + + // Set the offset and limit in the result to reflect the actual count of items returned + // which may be less than the requested limit if there are not enough items. + result.RoleListRequest = req + result.OffsetLimit.Clamp(result.Count) + + return &result, nil } // GetRole retrieves a single role by name. // Returns an error if the name is empty or the role is not found. -func (manager *Manager) GetRole(ctx context.Context, name string) (*schema.Role, error) { +func (manager *Manager) GetRole(ctx context.Context, name string) (_ *schema.Role, err error) { + // Otel span + ctx, endSpan := otel.StartSpan(manager.tracer, ctx, "GetRole", + attribute.String("name", name), + ) + defer func() { endSpan(err) }() + + // Validate input if name == "" { return nil, pg.ErrBadParameter.With("name is empty") } + + // Get the role var role schema.Role if err := manager.conn.Get(ctx, &role, schema.RoleName(name)); err != nil { return nil, err @@ -38,38 +61,66 @@ func (manager *Manager) GetRole(ctx context.Context, name string) (*schema.Role, // CreateRole creates a new role with the specified metadata. // The name must be a valid identifier and cannot have the reserved "pg_" prefix. -func (manager *Manager) CreateRole(ctx context.Context, meta schema.RoleMeta) (*schema.Role, error) { +func (manager *Manager) CreateRole(ctx context.Context, meta schema.RoleMeta) (_ *schema.Role, err error) { + // Otel span + ctx, endSpan := otel.StartSpan(manager.tracer, ctx, "CreateRole", + attribute.String("meta", types.Stringify(meta)), + ) + defer func() { endSpan(err) }() + + // Validate input if err := meta.Validate(); err != nil { return nil, err } + + // Insert the role and return it var role schema.Role if err := manager.conn.Insert(ctx, nil, meta); err != nil { return nil, err } else if err := manager.conn.Get(ctx, &role, schema.RoleName(meta.Name)); err != nil { return nil, err } + return &role, nil } // DeleteRole deletes a role by name and returns the deleted role. // Returns an error if the name is empty, has a reserved prefix, or the role is not found. -func (manager *Manager) DeleteRole(ctx context.Context, name string) (*schema.Role, error) { +func (manager *Manager) DeleteRole(ctx context.Context, name string) (_ *schema.Role, err error) { + // Otel span + ctx, endSpan := otel.StartSpan(manager.tracer, ctx, "DeleteRole", + attribute.String("name", name), + ) + defer func() { endSpan(err) }() + + // Validate input if name == "" { return nil, pg.ErrBadParameter.With("name is empty") } + + // Get the role var role schema.Role if err := manager.conn.Get(ctx, &role, schema.RoleName(name)); err != nil { return nil, err } else if err := manager.conn.Delete(ctx, nil, schema.RoleName(name)); err != nil { return nil, err } + return &role, nil } // UpdateRole updates an existing role with the specified metadata. // If meta.Name is set and different from the current name, the role is renamed. // If meta.Groups is set (even if empty), the group memberships are updated. -func (manager *Manager) UpdateRole(ctx context.Context, name string, meta schema.RoleMeta) (*schema.Role, error) { +func (manager *Manager) UpdateRole(ctx context.Context, name string, meta schema.RoleMeta) (_ *schema.Role, err error) { + // Otel span + ctx, endSpan := otel.StartSpan(manager.tracer, ctx, "UpdateRole", + attribute.String("name", name), + attribute.String("meta", types.Stringify(meta)), + ) + defer func() { endSpan(err) }() + + // Validate input if name == "" { return nil, pg.ErrBadParameter.With("name is empty") } diff --git a/pkg/manager/schema.go b/pgmanager/manager/schema.go similarity index 66% rename from pkg/manager/schema.go rename to pgmanager/manager/schema.go index edd41f8..fc4ea9f 100644 --- a/pkg/manager/schema.go +++ b/pgmanager/manager/schema.go @@ -6,9 +6,11 @@ import ( "slices" // Packages + otel "github.com/mutablelogic/go-client/pkg/otel" pg "github.com/mutablelogic/go-pg" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" + schema "github.com/mutablelogic/go-pg/pgmanager/schema" types "github.com/mutablelogic/go-server/pkg/types" + attribute "go.opentelemetry.io/otel/attribute" ) //////////////////////////////////////////////////////////////////////////////// @@ -17,30 +19,33 @@ import ( // ListSchemas returns a list of schemas across all databases matching the request criteria. // It supports pagination through the OffsetLimit fields in the request. // If Database is specified in the request, only schemas from that database are returned. -func (manager *Manager) ListSchemas(ctx context.Context, req schema.SchemaListRequest) (*schema.SchemaList, error) { - var list schema.SchemaList - var offset, limit uint64 +func (manager *Manager) ListSchemas(ctx context.Context, req schema.SchemaListRequest) (_ *schema.SchemaList, err error) { + // Otel span + ctx, endSpan := otel.StartSpan(manager.tracer, ctx, "ListSchemas", + attribute.String("req", types.Stringify(req)), + ) + defer func() { endSpan(err) }() // Set limit lower if request limit is lower + var offset, limit uint64 limit = schema.SchemaListLimit - if req.Limit != nil && types.PtrUint64(req.Limit) < limit { - limit = types.PtrUint64(req.Limit) + if req.Limit != nil && types.Value(req.Limit) < limit { + limit = types.Value(req.Limit) } - // Allocate the body with capacity - list.Body = make([]schema.Schema, 0, limit) - // Iterate through all the databases + var result schema.SchemaList + result.Body = make([]schema.Schema, 0, limit) if _, err := manager.withDatabases(ctx, func(database *schema.Database) error { // Filter by database - if name := types.PtrString(req.Database); name != "" && name != database.Name { + if name := types.Value(req.Database); name != "" && name != database.Name { return nil } // Iterate through all the schemas count, err := manager.withSchemas(ctx, database.Name, func(s *schema.Schema) error { - if offset >= req.Offset && uint64(len(list.Body)) < limit { - list.Body = append(list.Body, *s) + if offset >= req.Offset && uint64(len(result.Body)) < limit { + result.Body = append(result.Body, *s) } offset++ return nil @@ -50,7 +55,7 @@ func (manager *Manager) ListSchemas(ctx context.Context, req schema.SchemaListRe } // Increment the count - list.Count += count + result.Count += count // Return success return nil @@ -58,19 +63,34 @@ func (manager *Manager) ListSchemas(ctx context.Context, req schema.SchemaListRe return nil, err } + // Set the offset and limit in the result to reflect the actual count of items returned + // which may be less than the requested limit if there are not enough items + result.SchemaListRequest = req + result.OffsetLimit.Clamp(result.Count) + // Return success - return &list, nil + return &result, nil } // GetSchema retrieves a single schema by database and namespace name. // Returns an error if the database or namespace is empty or the schema is not found. -func (manager *Manager) GetSchema(ctx context.Context, database, namespace string) (*schema.Schema, error) { +func (manager *Manager) GetSchema(ctx context.Context, database, namespace string) (_ *schema.Schema, err error) { + // Otel span + ctx, endSpan := otel.StartSpan(manager.tracer, ctx, "GetSchema", + attribute.String("database", database), + attribute.String("namespace", namespace), + ) + defer func() { endSpan(err) }() + + // Validate input if database == "" { return nil, pg.ErrBadParameter.With("database is empty") } if namespace == "" { return nil, pg.ErrBadParameter.With("namespace is empty") } + + // Get the schema var s schema.Schema if err := manager.conn.Remote(database).With("as", schema.SchemaDef).Get(ctx, &s, schema.SchemaName(namespace)); err != nil { return nil, err @@ -81,15 +101,21 @@ func (manager *Manager) GetSchema(ctx context.Context, database, namespace strin // CreateSchema creates a new schema in the specified database with the given metadata. // ACL grants are applied after schema creation. If ACL grants fail, the schema is deleted // to maintain consistency. -func (manager *Manager) CreateSchema(ctx context.Context, database string, meta schema.SchemaMeta) (*schema.Schema, error) { +func (manager *Manager) CreateSchema(ctx context.Context, database string, meta schema.SchemaMeta) (_ *schema.Schema, err error) { + // Otel span + ctx, endSpan := otel.StartSpan(manager.tracer, ctx, "CreateSchema", + attribute.String("database", database), + attribute.String("meta", types.Stringify(meta)), + ) + defer func() { endSpan(err) }() + + // Validate input if database == "" { return nil, pg.ErrBadParameter.With("database is empty") } - var s schema.Schema - conn := manager.conn.Remote(database) - // Create the schema + conn := manager.conn.Remote(database) if err := conn.Insert(ctx, nil, meta); err != nil { return nil, err } @@ -104,22 +130,31 @@ func (manager *Manager) CreateSchema(ctx context.Context, database string, meta return nil }); err != nil { // Delete the schema if there is an issue with ACL's - deleteErr := conn.With("force", true).Delete(ctx, nil, schema.SchemaName(meta.Name)) - return nil, errors.Join(err, deleteErr) + return nil, errors.Join(err, conn.With("force", true).Delete(ctx, nil, schema.SchemaName(meta.Name))) } // Get the schema - if err := conn.With("as", schema.SchemaDef).Get(ctx, &s, schema.SchemaName(meta.Name)); err != nil { + var result schema.Schema + if err := conn.With("as", schema.SchemaDef).Get(ctx, &result, schema.SchemaName(meta.Name)); err != nil { return nil, err } // Return success - return &s, nil + return &result, nil } // DeleteSchema drops a schema by database and namespace name, returning its metadata before deletion. // If force is true, the schema is dropped with CASCADE even if there are dependent objects. -func (manager *Manager) DeleteSchema(ctx context.Context, database, namespace string, force bool) (*schema.Schema, error) { +func (manager *Manager) DeleteSchema(ctx context.Context, database, namespace string, force bool) (_ *schema.Schema, err error) { + // Otel span + ctx, endSpan := otel.StartSpan(manager.tracer, ctx, "DeleteSchema", + attribute.String("database", database), + attribute.String("namespace", namespace), + attribute.Bool("force", force), + ) + defer func() { endSpan(err) }() + + // Validate input if database == "" { return nil, pg.ErrBadParameter.With("database is empty") } @@ -127,11 +162,10 @@ func (manager *Manager) DeleteSchema(ctx context.Context, database, namespace st return nil, pg.ErrBadParameter.With("namespace is empty") } - var s schema.Schema - conn := manager.conn.Remote(database) - // Get the schema - if err := conn.With("as", schema.SchemaDef).Get(ctx, &s, schema.SchemaName(namespace)); err != nil { + conn := manager.conn.Remote(database) + var result schema.Schema + if err := conn.With("as", schema.SchemaDef).Get(ctx, &result, schema.SchemaName(namespace)); err != nil { return nil, err } @@ -141,13 +175,22 @@ func (manager *Manager) DeleteSchema(ctx context.Context, database, namespace st } // Return success - return &s, nil + return &result, nil } // UpdateSchema modifies an existing schema's metadata including name, owner, and ACLs. // If meta.Name is provided and differs from namespace, the schema is renamed. // ACL changes are synchronized by revoking removed privileges and granting new ones. -func (manager *Manager) UpdateSchema(ctx context.Context, database, namespace string, meta schema.SchemaMeta) (*schema.Schema, error) { +func (manager *Manager) UpdateSchema(ctx context.Context, database, namespace string, meta schema.SchemaMeta) (_ *schema.Schema, err error) { + // Otel span + ctx, endSpan := otel.StartSpan(manager.tracer, ctx, "UpdateSchema", + attribute.String("database", database), + attribute.String("namespace", namespace), + attribute.String("meta", types.Stringify(meta)), + ) + defer func() { endSpan(err) }() + + // Validate input if database == "" { return nil, pg.ErrBadParameter.With("database is empty") } @@ -155,11 +198,10 @@ func (manager *Manager) UpdateSchema(ctx context.Context, database, namespace st return nil, pg.ErrBadParameter.With("namespace is empty") } - var s schema.Schema - conn := manager.conn.Remote(database) - // Get the schema - if err := conn.With("as", schema.SchemaDef).Get(ctx, &s, schema.SchemaName(namespace)); err != nil { + var result schema.Schema + conn := manager.conn.Remote(database) + if err := conn.With("as", schema.SchemaDef).Get(ctx, &result, schema.SchemaName(namespace)); err != nil { return nil, err } @@ -173,7 +215,7 @@ func (manager *Manager) UpdateSchema(ctx context.Context, database, namespace st } // Update the owner if provided and different - if meta.Owner != "" && s.Owner != meta.Owner { + if meta.Owner != "" && result.Owner != meta.Owner { if err := conn.Update(ctx, nil, meta, meta); err != nil { return nil, err } @@ -181,18 +223,18 @@ func (manager *Manager) UpdateSchema(ctx context.Context, database, namespace st // Update ACL's if meta.Acl != nil { - if err := manager.updateSchemaACLs(ctx, conn, meta.Name, s.Acl, meta.Acl); err != nil { + if err := manager.updateSchemaACLs(ctx, conn, meta.Name, result.Acl, meta.Acl); err != nil { return nil, err } } // Get the updated schema - if err := conn.With("as", schema.SchemaDef).Get(ctx, &s, schema.SchemaName(meta.Name)); err != nil { + if err := conn.With("as", schema.SchemaDef).Get(ctx, &result, schema.SchemaName(meta.Name)); err != nil { return nil, err } // Return success - return &s, nil + return &result, nil } //////////////////////////////////////////////////////////////////////////////// diff --git a/pgmanager/manager/setting.go b/pgmanager/manager/setting.go new file mode 100644 index 0000000..8c910f5 --- /dev/null +++ b/pgmanager/manager/setting.go @@ -0,0 +1,119 @@ +package manager + +import ( + "context" + + // Packages + otel "github.com/mutablelogic/go-client/pkg/otel" + pg "github.com/mutablelogic/go-pg" + schema "github.com/mutablelogic/go-pg/pgmanager/schema" + types "github.com/mutablelogic/go-server/pkg/types" + attribute "go.opentelemetry.io/otel/attribute" +) + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +// ListSettings returns all server settings, optionally filtered by category. +func (manager *Manager) ListSettings(ctx context.Context, req schema.SettingListRequest) (_ *schema.SettingList, err error) { + // Otel span + ctx, endSpan := otel.StartSpan(manager.tracer, ctx, "ListSettings", + attribute.String("req", types.Stringify(req)), + ) + defer func() { endSpan(err) }() + + var result schema.SettingList + if err := manager.conn.List(ctx, &result, &req); err != nil { + return nil, err + } + + // Set the offset and limit in the result to reflect the actual count of items returned + // which may be less than the requested limit if there are not enough items + result.SettingListRequest = req + result.OffsetLimit.Clamp(result.Count) + + // Return the result + return &result, nil +} + +// ListSettingCategories returns all distinct setting categories. +func (manager *Manager) ListSettingCategories(ctx context.Context, req schema.SettingCategoryListRequest) (_ *schema.SettingCategoryList, err error) { + // Otel span + ctx, endSpan := otel.StartSpan(manager.tracer, ctx, "ListSettingCategories", + attribute.String("req", types.Stringify(req)), + ) + defer func() { endSpan(err) }() + + var result schema.SettingCategoryList + if err := manager.conn.List(ctx, &result, req); err != nil { + return nil, err + } + return &result, nil +} + +// GetSetting returns a single setting by name. +func (manager *Manager) GetSetting(ctx context.Context, name string) (_ *schema.Setting, err error) { + // Otel span + ctx, endSpan := otel.StartSpan(manager.tracer, ctx, "GetSetting", + attribute.String("name", name), + ) + defer func() { endSpan(err) }() + + var result schema.Setting + if err := manager.conn.Get(ctx, &result, schema.SettingName(name)); err != nil { + return nil, err + } + return &result, nil +} + +// UpdateSetting updates a setting value. If meta.Value is nil, the setting is reset to default. +// Returns the updated setting. Check the Context field to determine if ReloadConfig() or a +// server restart is needed for the change to take effect. +// Returns an error for settings with 'internal' context (cannot be changed) or +// 'postmaster' context (requires server restart, not supported via API). +func (manager *Manager) UpdateSetting(ctx context.Context, name string, meta schema.SettingMeta) (_ *schema.Setting, err error) { + // Otel span + ctx, endSpan := otel.StartSpan(manager.tracer, ctx, "UpdateSetting", + attribute.String("name", name), + attribute.String("meta", types.Stringify(meta)), + ) + defer func() { endSpan(err) }() + + // First get the current setting to check its context + current, err := manager.GetSetting(ctx, name) + if err != nil { + return nil, err + } + + // Reject updates for settings that cannot be changed dynamically + switch current.Context { + case "internal": + return nil, pg.ErrBadParameter.Withf("setting %q cannot be changed (internal)", name) + case "postmaster": + return nil, pg.ErrBadParameter.Withf("setting %q requires server restart (postmaster context)", name) + } + + // Update the setting (ALTER SYSTEM doesn't return rows, so pass nil reader) + if err := manager.conn.Update(ctx, nil, schema.SettingName(name), meta); err != nil { + return nil, err + } + + // Perform a reload + if err := manager.ReloadConfig(ctx); err != nil { + return nil, err + } + + // Get and return the updated setting + return manager.GetSetting(ctx, name) +} + +// ReloadConfig calls pg_reload_conf() to reload server configuration. +// This applies changes to settings with 'sighup' context without requiring a restart. +func (manager *Manager) ReloadConfig(ctx context.Context) (err error) { + // Otel span + ctx, endSpan := otel.StartSpan(manager.tracer, ctx, "ReloadConfig") + defer func() { endSpan(err) }() + + // Execute the reload command + return manager.conn.Exec(ctx, "SELECT pg_reload_conf()") +} diff --git a/pkg/manager/statement.go b/pgmanager/manager/statement.go similarity index 69% rename from pkg/manager/statement.go rename to pgmanager/manager/statement.go index 3c06104..a741b5d 100644 --- a/pkg/manager/statement.go +++ b/pgmanager/manager/statement.go @@ -5,7 +5,7 @@ import ( // Packages pg "github.com/mutablelogic/go-pg" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" + schema "github.com/mutablelogic/go-pg/pgmanager/schema" ) /////////////////////////////////////////////////////////////////////////////// @@ -15,23 +15,28 @@ import ( // The request can filter by database, user, and order by various fields. // Returns ErrNotAvailable if pg_stat_statements is not installed. func (manager *Manager) ListStatements(ctx context.Context, req schema.StatementListRequest) (*schema.StatementList, error) { - if !manager.statStatementsAvailable { + if !manager.pgStatementsAvailable { return nil, pg.ErrNotAvailable.With("pg_stat_statements") } // Execute query - var list schema.StatementList - if err := manager.conn.List(ctx, &list, &req); err != nil { + var result schema.StatementList + if err := manager.conn.List(ctx, &result, &req); err != nil { return nil, err } - return &list, nil + // Set the offset and limit in the result to reflect the actual count of items returned + // which may be less than the requested limit if there are not enough items in the database. + result.StatementListRequest = req + result.OffsetLimit.Clamp(result.Count) + + return &result, nil } // ResetStatements resets the statistics for all statements. // Returns ErrNotAvailable if pg_stat_statements is not installed. func (manager *Manager) ResetStatements(ctx context.Context) error { - if !manager.statStatementsAvailable { + if !manager.pgStatementsAvailable { return pg.ErrNotAvailable.With("pg_stat_statements") } diff --git a/pgmanager/manager/status.go b/pgmanager/manager/status.go new file mode 100644 index 0000000..ae06cbe --- /dev/null +++ b/pgmanager/manager/status.go @@ -0,0 +1,20 @@ +package manager + +import ( + "context" + + "github.com/mutablelogic/go-client/pkg/otel" +) + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +// Ping the database connection - returns an error if the connection is not healthy. +func (manager *Manager) Ping(ctx context.Context) (err error) { + // Otel span + ctx, endSpan := otel.StartSpan(manager.tracer, ctx, "Ping") + defer func() { endSpan(err) }() + + // Perform the ping + return manager.conn.Ping(ctx) +} diff --git a/pkg/manager/tablespace.go b/pgmanager/manager/tablespace.go similarity index 74% rename from pkg/manager/tablespace.go rename to pgmanager/manager/tablespace.go index be0372e..2891fa7 100644 --- a/pkg/manager/tablespace.go +++ b/pgmanager/manager/tablespace.go @@ -7,8 +7,11 @@ import ( "strings" // Packages + otel "github.com/mutablelogic/go-client/pkg/otel" pg "github.com/mutablelogic/go-pg" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" + schema "github.com/mutablelogic/go-pg/pgmanager/schema" + types "github.com/mutablelogic/go-server/pkg/types" + attribute "go.opentelemetry.io/otel/attribute" ) //////////////////////////////////////////////////////////////////////////////// @@ -16,21 +19,41 @@ import ( // ListTablespaces returns a list of tablespaces matching the request criteria. // It supports pagination through the OffsetLimit fields in the request. -func (manager *Manager) ListTablespaces(ctx context.Context, req schema.TablespaceListRequest) (*schema.TablespaceList, error) { - var list schema.TablespaceList - if err := manager.conn.List(ctx, &list, req); err != nil { +func (manager *Manager) ListTablespaces(ctx context.Context, req schema.TablespaceListRequest) (_ *schema.TablespaceList, err error) { + // Otel span + ctx, endSpan := otel.StartSpan(manager.tracer, ctx, "ListTablespaces", + attribute.String("req", types.Stringify(req)), + ) + defer func() { endSpan(err) }() + + var result schema.TablespaceList + if err := manager.conn.List(ctx, &result, &req); err != nil { return nil, err - } else { - return &list, nil } + + // Set the offset and limit in the result to reflect the actual count of items returned + // which may be less than the requested limit if there are not enough items. + result.TablespaceListRequest = req + result.OffsetLimit.Clamp(result.Count) + + return &result, nil } // GetTablespace retrieves a single tablespace by name. // Returns an error if the name is empty or the tablespace is not found. -func (manager *Manager) GetTablespace(ctx context.Context, name string) (*schema.Tablespace, error) { +func (manager *Manager) GetTablespace(ctx context.Context, name string) (_ *schema.Tablespace, err error) { + // Otel span + ctx, endSpan := otel.StartSpan(manager.tracer, ctx, "GetTablespace", + attribute.String("name", name), + ) + defer func() { endSpan(err) }() + + // Validate input if name == "" { return nil, pg.ErrBadParameter.With("name is empty") } + + // Get the tablespace var response schema.Tablespace if err := manager.conn.Get(ctx, &response, schema.TablespaceName(name)); err != nil { return nil, err @@ -42,10 +65,16 @@ func (manager *Manager) GetTablespace(ctx context.Context, name string) (*schema // The tablespace creation cannot be done in a transaction, but ACL grants are // applied within a transaction. If ACL grants fail, the tablespace is deleted // to maintain consistency. -func (manager *Manager) CreateTablespace(ctx context.Context, meta schema.TablespaceMeta, location string) (*schema.Tablespace, error) { - var response schema.Tablespace +func (manager *Manager) CreateTablespace(ctx context.Context, meta schema.TablespaceMeta, location string) (_ *schema.Tablespace, err error) { + // Otel span + ctx, endSpan := otel.StartSpan(manager.tracer, ctx, "CreateTablespace", + attribute.String("meta", types.Stringify(meta)), + attribute.String("location", location), + ) + defer func() { endSpan(err) }() // Create the tablespace (outside a transaction) + var response schema.Tablespace if err := manager.conn.With("location", location).Insert(ctx, nil, meta); err != nil { return nil, err } @@ -75,7 +104,14 @@ func (manager *Manager) CreateTablespace(ctx context.Context, meta schema.Tables // DeleteTablespace drops a tablespace by name and returns its metadata before deletion. // Returns an error if the name is empty or the tablespace is not found. -func (manager *Manager) DeleteTablespace(ctx context.Context, name string) (*schema.Tablespace, error) { +func (manager *Manager) DeleteTablespace(ctx context.Context, name string) (_ *schema.Tablespace, err error) { + // Otel span + ctx, endSpan := otel.StartSpan(manager.tracer, ctx, "DeleteTablespace", + attribute.String("name", name), + ) + defer func() { endSpan(err) }() + + // Validate input if name == "" { return nil, pg.ErrBadParameter.With("name is empty") } @@ -99,13 +135,21 @@ func (manager *Manager) DeleteTablespace(ctx context.Context, name string) (*sch // All changes are applied within a transaction to ensure atomicity. // If meta.Name is provided and differs from name, the tablespace is renamed. // ACL changes are synchronized by revoking removed privileges and granting new ones. -func (manager *Manager) UpdateTablespace(ctx context.Context, name string, meta schema.TablespaceMeta) (*schema.Tablespace, error) { +func (manager *Manager) UpdateTablespace(ctx context.Context, name string, meta schema.TablespaceMeta) (_ *schema.Tablespace, err error) { + // Otel span + ctx, endSpan := otel.StartSpan(manager.tracer, ctx, "UpdateTablespace", + attribute.String("name", name), + attribute.String("meta", types.Stringify(meta)), + ) + defer func() { endSpan(err) }() + + // Validate input if name == "" { return nil, pg.ErrBadParameter.With("name is empty") } - var response schema.Tablespace // Get the tablespace + var response schema.Tablespace if err := manager.conn.Get(ctx, &response, schema.TablespaceName(name)); err != nil { return nil, err } diff --git a/pkg/manager/schema/acl.go b/pgmanager/schema/acl.go similarity index 100% rename from pkg/manager/schema/acl.go rename to pgmanager/schema/acl.go diff --git a/pkg/manager/schema/globals.go b/pgmanager/schema/bootstrap.go similarity index 68% rename from pkg/manager/schema/globals.go rename to pgmanager/schema/bootstrap.go index 90a68ef..0a939be 100644 --- a/pkg/manager/schema/globals.go +++ b/pgmanager/schema/bootstrap.go @@ -7,36 +7,6 @@ import ( pg "github.com/mutablelogic/go-pg" ) -//////////////////////////////////////////////////////////////////////////////// -// GLOBALS - -const ( - CatalogSchema = "pg_catalog" - APIPrefix = "/pg/v1" - DefaultAclRole = "PUBLIC" -) - -const ( - // Maximum number of items to return in a list query, for each type - RoleListLimit = 100 - DatabaseListLimit = 100 - SchemaListLimit = 100 - ObjectListLimit = 100 - ConnectionListLimit = 100 - TablespaceListLimit = 100 - ExtensionListLimit = 100 - SettingListLimit = 500 - StatementListLimit = 100 - ReplicationSlotListLimit = 100 -) - -const ( - pgTimestampFormat = "2006-01-02 15:04:05" - pgObfuscatedPassword = "********" - defaultSchema = "public" - reservedPrefix = "pg_" -) - //////////////////////////////////////////////////////////////////////////////// // BOOTSTRAP diff --git a/pgmanager/schema/cluster.go b/pgmanager/schema/cluster.go new file mode 100644 index 0000000..874392d --- /dev/null +++ b/pgmanager/schema/cluster.go @@ -0,0 +1,48 @@ +package schema + +import ( + pg "github.com/mutablelogic/go-pg" +) + +//////////////////////////////////////////////////////////////////////////////// +// TYPES + +type Cluster struct { + Name string +} + +//////////////////////////////////////////////////////////////////////////////// +// SELECT + +func (d Cluster) Select(bind *pg.Bind, op pg.Op) (string, error) { + // Return query + switch op { + case pg.Get: + return sqlClusterName, nil + default: + return "", pg.ErrNotImplemented.Withf("unsupported Cluster operation %q", op) + } +} + +//////////////////////////////////////////////////////////////////////////////// +// READER + +func (c *Cluster) Scan(row pg.Row) error { + return row.Scan(&c.Name) +} + +//////////////////////////////////////////////////////////////////////////////// +// SQL + +const ( + sqlClusterName = ` + SELECT COALESCE( + NULLIF(current_setting('cluster_name', true), ''), + CONCAT_WS( + ':', + host(inet_server_addr()), + inet_server_port()::text + ) + ) AS cluster_name + ` +) diff --git a/pkg/manager/schema/connection.go b/pgmanager/schema/connection.go similarity index 71% rename from pkg/manager/schema/connection.go rename to pgmanager/schema/connection.go index 2c6f7e9..4ccf572 100644 --- a/pkg/manager/schema/connection.go +++ b/pgmanager/schema/connection.go @@ -1,12 +1,14 @@ package schema import ( - "encoding/json" + "fmt" + "net/url" "strings" "time" // Packages pg "github.com/mutablelogic/go-pg" + types "github.com/mutablelogic/go-server/pkg/types" ) //////////////////////////////////////////////////////////////////////////////// @@ -35,6 +37,7 @@ type ConnectionListRequest struct { } type ConnectionList struct { + ConnectionListRequest Count uint64 `json:"count"` Body []Connection `json:"body,omitempty"` } @@ -43,43 +46,92 @@ type ConnectionList struct { // STRINGIFY func (c Connection) String() string { - data, err := json.MarshalIndent(c, "", " ") - if err != nil { - return err.Error() - } - return string(data) + return types.Stringify(c) } func (c ConnectionListRequest) String() string { - data, err := json.MarshalIndent(c, "", " ") - if err != nil { - return err.Error() - } - return string(data) + return types.Stringify(c) } func (c ConnectionList) String() string { - data, err := json.MarshalIndent(c, "", " ") - if err != nil { - return err.Error() + return types.Stringify(c) +} + +//////////////////////////////////////////////////////////////////////////////// +// QUERY + +func (q ConnectionListRequest) Query() url.Values { + values := url.Values{} + if q.Offset > 0 { + values.Set("offset", fmt.Sprint(q.Offset)) + } + if q.Limit != nil { + values.Set("limit", fmt.Sprint(types.Value(q.Limit))) + } + if q.Database != nil { + values.Set("database", types.Value(q.Database)) + } + if q.Role != nil { + values.Set("role", types.Value(q.Role)) + } + if q.State != nil { + values.Set("state", types.Value(q.State)) + } + return values +} + +//////////////////////////////////////////////////////////////////////////////// +// TABLE + +func (r Connection) Header() []string { + return []string{"Pid", "Database", "Role", "Application", "Client Addr", "Client Port", "Conn Start", "Query Start", "Query", "State"} +} + +func (r Connection) Width(col int) int { + return 0 +} + +func (r Connection) Cell(col int) string { + switch col { + case 0: + return fmt.Sprint(r.Pid) + case 1: + return r.Database + case 2: + return r.Role + case 3: + return types.Value(r.Application) + case 4: + return r.ClientAddr + case 5: + return fmt.Sprint(r.ClientPort) + case 6: + return r.ConnStart.Format(time.RFC3339) + case 7: + return r.QueryStart.Format(time.RFC3339) + case 8: + return r.Query + case 9: + return r.State + default: + return "" } - return string(data) } //////////////////////////////////////////////////////////////////////////////// // SELECT -func (c ConnectionListRequest) Select(bind *pg.Bind, op pg.Op) (string, error) { +func (c *ConnectionListRequest) Select(bind *pg.Bind, op pg.Op) (string, error) { // Where bind.Del("where") if c.Database != nil { - bind.Append("where", `"database" = `+bind.Set("database", strings.TrimSpace(*c.Database))) + bind.Append("where", `"database" = `+bind.Set("database", strings.TrimSpace(types.Value(c.Database)))) } if c.Role != nil { - bind.Append("where", `"role" = `+bind.Set("role", strings.TrimSpace(*c.Role))) + bind.Append("where", `"role" = `+bind.Set("role", strings.TrimSpace(types.Value(c.Role)))) } if c.State != nil { - bind.Append("where", `"state" = `+bind.Set("state", strings.TrimSpace(*c.State))) + bind.Append("where", `"state" = `+bind.Set("state", strings.TrimSpace(types.Value(c.State)))) } if where := bind.Join("where", " AND "); where != "" { bind.Set("where", `WHERE `+where) diff --git a/pkg/manager/schema/database.go b/pgmanager/schema/database.go similarity index 84% rename from pkg/manager/schema/database.go rename to pgmanager/schema/database.go index 7230348..60b6a8e 100644 --- a/pkg/manager/schema/database.go +++ b/pgmanager/schema/database.go @@ -1,12 +1,13 @@ package schema import ( - "encoding/json" + "fmt" + "net/url" "strings" // Packages pg "github.com/mutablelogic/go-pg" - types "github.com/mutablelogic/go-pg/pkg/types" + types "github.com/mutablelogic/go-server/pkg/types" ) //////////////////////////////////////////////////////////////////////////////// @@ -21,7 +22,7 @@ type Database struct { } type DatabaseMeta struct { - Name string `json:"name,omitempty" arg:"" help:"Name"` + Name string `json:"name,omitempty" arg:"" help:"Name of the database"` Owner string `json:"owner,omitempty" help:"Owner"` Acl ACLList `json:"acl,omitempty" help:"Access privileges"` } @@ -31,6 +32,7 @@ type DatabaseListRequest struct { } type DatabaseList struct { + DatabaseListRequest Count uint64 `json:"count"` Body []Database `json:"body,omitempty"` } @@ -39,41 +41,70 @@ type DatabaseList struct { // STRINGIFY func (d Database) String() string { - data, err := json.MarshalIndent(d, "", " ") - if err != nil { - return err.Error() - } - return string(data) + return types.Stringify(d) } func (d DatabaseMeta) String() string { - data, err := json.MarshalIndent(d, "", " ") - if err != nil { - return err.Error() - } - return string(data) + return types.Stringify(d) } func (d DatabaseListRequest) String() string { - data, err := json.MarshalIndent(d, "", " ") - if err != nil { - return err.Error() - } - return string(data) + return types.Stringify(d) } func (d DatabaseList) String() string { - data, err := json.MarshalIndent(d, "", " ") - if err != nil { - return err.Error() + return types.Stringify(d) +} + +//////////////////////////////////////////////////////////////////////////////// +// TABLE + +func (r Database) Header() []string { + return []string{"Oid", "Name", "Owner", "Acl", "Size"} +} + +func (r Database) Width(col int) int { + return 0 +} + +func (r Database) Cell(col int) string { + switch col { + case 0: + return fmt.Sprint(r.Oid) + case 1: + return r.Name + case 2: + return r.Owner + case 3: + if len(r.Acl) == 0 { + return "" + } + return fmt.Sprint(r.Acl) + case 4: + return fmt.Sprint(r.Size) + default: + return "" + } +} + +//////////////////////////////////////////////////////////////////////////////// +// QUERY + +func (d DatabaseListRequest) Query() url.Values { + q := url.Values{} + if d.Offset > 0 { + q.Set("offset", fmt.Sprint(d.Offset)) + } + if d.Limit != nil { + q.Set("limit", fmt.Sprint(types.Value(d.Limit))) } - return string(data) + return q } //////////////////////////////////////////////////////////////////////////////// // SELECT -func (d DatabaseListRequest) Select(bind *pg.Bind, op pg.Op) (string, error) { +func (d *DatabaseListRequest) Select(bind *pg.Bind, op pg.Op) (string, error) { // Set empty where bind.Set("where", "") bind.Set("orderby", "ORDER BY name ASC") diff --git a/pkg/manager/schema/extension.go b/pgmanager/schema/extension.go similarity index 78% rename from pkg/manager/schema/extension.go rename to pgmanager/schema/extension.go index 9f3d39c..9d2ffae 100644 --- a/pkg/manager/schema/extension.go +++ b/pgmanager/schema/extension.go @@ -1,12 +1,13 @@ package schema import ( - "encoding/json" + "fmt" + "net/url" "strings" // Packages pg "github.com/mutablelogic/go-pg" - types "github.com/mutablelogic/go-pg/pkg/types" + types "github.com/mutablelogic/go-server/pkg/types" ) //////////////////////////////////////////////////////////////////////////////// @@ -19,7 +20,7 @@ type ExtensionMeta struct { Database string `json:"database,omitempty" help:"Database to install extension into"` Schema string `json:"schema,omitempty" help:"Schema to install extension into"` Owner string `json:"owner,omitempty"` - Version string `json:"version,omitempty" help:"Extension version"` + Version string `json:"version,omitempty" name:"ver" help:"Extension version"` } type Extension struct { @@ -39,6 +40,7 @@ type ExtensionListRequest struct { } type ExtensionList struct { + ExtensionListRequest Count uint64 `json:"count"` Body []Extension `json:"body,omitempty"` } @@ -47,41 +49,86 @@ type ExtensionList struct { // STRINGIFY func (e Extension) String() string { - data, err := json.MarshalIndent(e, "", " ") - if err != nil { - return err.Error() - } - return string(data) + return types.Stringify(e) } func (e ExtensionMeta) String() string { - data, err := json.MarshalIndent(e, "", " ") - if err != nil { - return err.Error() - } - return string(data) + return types.Stringify(e) } func (e ExtensionList) String() string { - data, err := json.MarshalIndent(e, "", " ") - if err != nil { - return err.Error() - } - return string(data) + return types.Stringify(e) } func (e ExtensionListRequest) String() string { - data, err := json.MarshalIndent(e, "", " ") - if err != nil { - return err.Error() + return types.Stringify(e) +} + +//////////////////////////////////////////////////////////////////////////////// +// QUERY + +func (q ExtensionListRequest) Query() url.Values { + values := url.Values{} + if q.Offset > 0 { + values.Set("offset", fmt.Sprint(q.Offset)) + } + if q.Limit != nil { + values.Set("limit", fmt.Sprint(types.Value(q.Limit))) + } + if q.Database != nil { + values.Set("database", types.Value(q.Database)) + } + if q.Installed != nil { + values.Set("installed", fmt.Sprint(types.Value(q.Installed))) + } + return values +} + +//////////////////////////////////////////////////////////////////////////////// +// TABLE + +func (r Extension) Header() []string { + return []string{"Oid", "Name", "Database", "Owner", "Schema", "Default Version", "Installed Version", "Relocatable", "Requires", "Comment"} +} + +func (r Extension) Width(col int) int { + return 0 +} + +func (r Extension) Cell(col int) string { + switch col { + case 0: + if r.Oid != nil { + return fmt.Sprint(types.Value(r.Oid)) + } + return "" + case 1: + return r.Name + case 2: + return r.Database + case 3: + return r.Owner + case 4: + return r.Schema + case 5: + return r.DefaultVersion + case 6: + return types.Value(r.InstalledVersion) + case 7: + return fmt.Sprint(types.Value(r.Relocatable)) + case 8: + return strings.Join(r.Requires, ", ") + case 9: + return r.Comment + default: + return "" } - return string(data) } //////////////////////////////////////////////////////////////////////////////// // SELECT -func (e ExtensionListRequest) Select(bind *pg.Bind, op pg.Op) (string, error) { +func (e *ExtensionListRequest) Select(bind *pg.Bind, op pg.Op) (string, error) { if op != pg.List { return "", pg.ErrNotImplemented.Withf("operation %q", op) } @@ -208,8 +255,7 @@ func (e ExtensionMeta) UpdateQuery(bind *pg.Bind) (string, error) { // SCAN func (e *Extension) Scan(row pg.Row) error { - return row.Scan(&e.Oid, &e.Name, &e.Owner, &e.Schema, - &e.DefaultVersion, &e.InstalledVersion, &e.Relocatable, &e.Requires, &e.Comment) + return row.Scan(&e.Oid, &e.Name, &e.Owner, &e.Schema, &e.DefaultVersion, &e.InstalledVersion, &e.Relocatable, &e.Requires, &e.Comment) } func (e *ExtensionList) Scan(row pg.Row) error { diff --git a/pgmanager/schema/globals.go b/pgmanager/schema/globals.go new file mode 100644 index 0000000..3cc88cf --- /dev/null +++ b/pgmanager/schema/globals.go @@ -0,0 +1,29 @@ +package schema + +//////////////////////////////////////////////////////////////////////////////// +// GLOBALS + +const ( + DatabaseListLimit = 50 + ConnectionListLimit = 50 + ExtensionListLimit = 50 + SchemaListLimit = 20 + ObjectListLimit = 20 + SettingListLimit = 100 + RoleListLimit = 50 + TablespaceListLimit = 50 + ReplicationSlotListLimit = 50 + StatementListLimit = 50 +) + +const ( + CatalogSchema = "pg_catalog" + DefaultAclRole = "PUBLIC" +) + +const ( + defaultSchema = "public" + reservedPrefix = "pg_" + pgTimestampFormat = "2006-01-02 15:04:05" + pgObfuscatedPassword = "********" +) diff --git a/pkg/manager/schema/object.go b/pgmanager/schema/object.go similarity index 80% rename from pkg/manager/schema/object.go rename to pgmanager/schema/object.go index e10a88c..0432df5 100644 --- a/pkg/manager/schema/object.go +++ b/pgmanager/schema/object.go @@ -1,12 +1,13 @@ package schema import ( - "encoding/json" + "fmt" + "net/url" "strings" // Packages pg "github.com/mutablelogic/go-pg" - types "github.com/mutablelogic/go-pg/pkg/types" + types "github.com/mutablelogic/go-server/pkg/types" ) //////////////////////////////////////////////////////////////////////////////// @@ -48,6 +49,7 @@ type ObjectListRequest struct { } type ObjectList struct { + ObjectListRequest Count uint64 `json:"count"` Body []Object `json:"body,omitempty"` } @@ -56,51 +58,102 @@ type ObjectList struct { // STRINGIFY func (o ObjectMeta) String() string { - data, err := json.MarshalIndent(o, "", " ") - if err != nil { - return err.Error() - } - return string(data) + return types.Stringify(o) } func (t TableMeta) String() string { - data, err := json.MarshalIndent(t, "", " ") - if err != nil { - return err.Error() - } - return string(data) + return types.Stringify(t) } func (o Object) String() string { - data, err := json.MarshalIndent(o, "", " ") - if err != nil { - return err.Error() - } - return string(data) + return types.Stringify(o) } func (o ObjectListRequest) String() string { - data, err := json.MarshalIndent(o, "", " ") - if err != nil { - return err.Error() - } - return string(data) + return types.Stringify(o) } func (o ObjectList) String() string { - data, err := json.MarshalIndent(o, "", " ") - if err != nil { - return err.Error() - } - return string(data) + return types.Stringify(o) } func (o ObjectName) String() string { - data, err := json.MarshalIndent(o, "", " ") - if err != nil { - return err.Error() + return types.Stringify(o) +} + +//////////////////////////////////////////////////////////////////////////////// +// TABLE + +func (r Object) Header() []string { + return []string{"Oid", "Database", "Schema", "Name", "Type", "Owner", "Acl", "Tablespace", "Size", "LiveTuples", "DeadTuples"} +} + +func (r Object) Width(col int) int { + return 0 +} + +func (r Object) Cell(col int) string { + switch col { + case 0: + return fmt.Sprint(r.Oid) + case 1: + return r.Database + case 2: + return r.Schema + case 3: + return r.Name + case 4: + return r.Type + case 5: + return r.Owner + case 6: + if len(r.Acl) == 0 { + return "" + } + return fmt.Sprint(r.Acl) + case 7: + if r.Tablespace == nil { + return "" + } + return *r.Tablespace + case 8: + return fmt.Sprint(r.Size) + case 9: + if r.Table == nil || r.Table.LiveTuples == nil { + return "" + } + return fmt.Sprint(*r.Table.LiveTuples) + case 10: + if r.Table == nil || r.Table.DeadTuples == nil { + return "" + } + return fmt.Sprint(*r.Table.DeadTuples) + default: + return "" + } +} + +//////////////////////////////////////////////////////////////////////////////// +// QUERY + +func (d ObjectListRequest) Query() url.Values { + q := url.Values{} + if d.Offset > 0 { + q.Set("offset", fmt.Sprint(d.Offset)) + } + if d.Limit != nil { + q.Set("limit", fmt.Sprint(types.Value(d.Limit))) + } + if d.Database != nil { + q.Set("database", types.Value(d.Database)) + } + if d.Schema != nil { + q.Set("schema", types.Value(d.Schema)) + } + if d.Type != nil { + q.Set("type", types.Value(d.Type)) } - return string(data) + return q } //////////////////////////////////////////////////////////////////////////////// diff --git a/pkg/manager/schema/replicationslot.go b/pgmanager/schema/replicationslot.go similarity index 74% rename from pkg/manager/schema/replicationslot.go rename to pgmanager/schema/replicationslot.go index 7934cdb..ce6af2e 100644 --- a/pkg/manager/schema/replicationslot.go +++ b/pgmanager/schema/replicationslot.go @@ -1,11 +1,13 @@ package schema import ( - "encoding/json" + "fmt" + "net/url" "strings" // Packages pg "github.com/mutablelogic/go-pg" + "github.com/mutablelogic/go-server/pkg/types" ) /////////////////////////////////////////////////////////////////////////////// @@ -16,12 +18,12 @@ type ReplicationSlotName string // ReplicationSlotMeta contains parameters for creating a replication slot type ReplicationSlotMeta struct { - Name string `json:"name"` - Type string `json:"type"` // physical, logical - Plugin string `json:"plugin,omitempty"` // required for logical (e.g., pgoutput) - Database string `json:"database,omitempty"` // required for logical - Temporary bool `json:"temporary,omitempty"` - TwoPhase bool `json:"two_phase,omitempty"` // PG14+ + Name string `json:"name" arg:"" help:"Name of the replication slot"` + Type string `json:"type" enum:"physical,logical" required:"" help:"Type of the replication slot"` + Plugin string `json:"plugin,omitempty" help:"Plugin for logical replication slots (e.g., pgoutput)"` + Database string `json:"database,omitempty" help:"Database for logical replication slots"` + Temporary bool `json:"temporary,omitempty" negatable:"" help:"If true, slot will be dropped on disconnect; logical slots only"` + TwoPhase bool `json:"two_phase,omitempty" negatable:"" help:"Logical slots only, allows PREPARE/COMMIT semantics for streaming transactions"` } // ReplicationSlot represents a replication slot with its status @@ -34,7 +36,7 @@ type ReplicationSlot struct { // Connected replica info (when streaming/catchup) ClientAddr string `json:"client_addr,omitempty"` LagBytes *int64 `json:"lag_bytes,omitempty"` - LagMs *float64 `json:"lag_ms"` + LagMs *float64 `json:"lag_ms,omitempty"` } // ReplicationSlotListRequest contains parameters for listing replication slots @@ -44,6 +46,7 @@ type ReplicationSlotListRequest struct { // ReplicationSlotList is a list of replication slots with a total count type ReplicationSlotList struct { + ReplicationSlotListRequest Count uint64 `json:"count"` Body []ReplicationSlot `json:"body,omitempty"` } @@ -52,27 +55,73 @@ type ReplicationSlotList struct { // STRINGIFY func (s ReplicationSlot) String() string { - data, err := json.MarshalIndent(s, "", " ") - if err != nil { - return err.Error() - } - return string(data) + return types.Stringify(s) } func (s ReplicationSlotMeta) String() string { - data, err := json.MarshalIndent(s, "", " ") - if err != nil { - return err.Error() - } - return string(data) + return types.Stringify(s) } func (l ReplicationSlotList) String() string { - data, err := json.MarshalIndent(l, "", " ") - if err != nil { - return err.Error() + return types.Stringify(l) +} + +//////////////////////////////////////////////////////////////////////////////// +// TABLE + +func (r ReplicationSlot) Header() []string { + return []string{"Name", "Type", "Plugin", "Database", "Temporary", "TwoPhase", "Status", "ClientAddr", "LagBytes", "LagMs"} +} + +func (r ReplicationSlot) Width(col int) int { + return 0 +} + +func (r ReplicationSlot) Cell(col int) string { + switch col { + case 0: + return r.Name + case 1: + return r.Type + case 2: + return r.Plugin + case 3: + return r.Database + case 4: + return fmt.Sprint(r.Temporary) + case 5: + return fmt.Sprint(r.TwoPhase) + case 6: + return r.Status + case 7: + return r.ClientAddr + case 8: + if r.LagBytes != nil { + return fmt.Sprint(*r.LagBytes) + } + return "" + case 9: + if r.LagMs != nil { + return fmt.Sprintf("%.2f", *r.LagMs) + } + return "" + default: + return "" + } +} + +//////////////////////////////////////////////////////////////////////////////// +// QUERY + +func (d ReplicationSlotListRequest) Query() url.Values { + q := url.Values{} + if d.Offset > 0 { + q.Set("offset", fmt.Sprint(d.Offset)) + } + if d.Limit != nil { + q.Set("limit", fmt.Sprint(types.Value(d.Limit))) } - return string(data) + return q } /////////////////////////////////////////////////////////////////////////////// @@ -132,7 +181,7 @@ func (n ReplicationSlotName) Select(bind *pg.Bind, op pg.Op) (string, error) { } } -func (r ReplicationSlotListRequest) Select(bind *pg.Bind, op pg.Op) (string, error) { +func (r *ReplicationSlotListRequest) Select(bind *pg.Bind, op pg.Op) (string, error) { bind.Set("where", "") bind.Set("orderby", "ORDER BY name ASC") diff --git a/pkg/manager/schema/role.go b/pgmanager/schema/role.go similarity index 75% rename from pkg/manager/schema/role.go rename to pgmanager/schema/role.go index ac34ffe..f2bace1 100644 --- a/pkg/manager/schema/role.go +++ b/pgmanager/schema/role.go @@ -2,8 +2,8 @@ package schema import ( "context" - "encoding/json" "fmt" + "net/url" "strings" "time" @@ -19,14 +19,14 @@ type RoleName string type RoleMeta struct { Name string `json:"name,omitempty" arg:"" help:"Role name"` - Superuser *bool `json:"super,omitempty" help:"Superuser permission"` - Inherit *bool `json:"inherit,omitempty" help:"Inherit permissions"` - CreateRoles *bool `json:"createrole,omitempty" help:"Create roles permission"` - CreateDatabases *bool `json:"createdb,omitempty" help:"Create databases permission"` - Replication *bool `json:"replication,omitempty" help:"Replication permission"` + Superuser *bool `json:"super,omitempty" name:"super" help:"Superuser permission" negatable:""` + Inherit *bool `json:"inherit,omitempty" help:"Inherit permissions" negatable:""` + CreateRoles *bool `json:"createrole,omitempty" help:"Create roles permission" negatable:""` + CreateDatabases *bool `json:"createdb,omitempty" help:"Create databases permission" negatable:""` + Replication *bool `json:"replication,omitempty" help:"Replication permission" negatable:""` ConnectionLimit *uint64 `json:"conlimit,omitempty" help:"Connection limit"` - BypassRowLevelSecurity *bool `json:"bypassrls,omitempty" help:"Bypass row-level security"` - Login *bool `json:"login,omitempty" help:"Login permission"` + BypassRowLevelSecurity *bool `json:"bypassrls,omitempty" help:"Bypass row-level security" negatable:""` + Login *bool `json:"login,omitempty" help:"Login permission" negatable:""` Password *string `json:"password,omitempty" help:"Password"` Expires *time.Time `json:"expires,omitzero" help:"Password expiration"` Groups []string `json:"memberof,omitempty" help:"Group memberships"` @@ -42,6 +42,7 @@ type RoleListRequest struct { } type RoleList struct { + RoleListRequest Count uint64 `json:"count"` Body []Role `json:"body,omitempty"` } @@ -50,35 +51,91 @@ type RoleList struct { // STRINGIFY func (r Role) String() string { - data, err := json.MarshalIndent(r, "", " ") - if err != nil { - return err.Error() - } - return string(data) + return types.Stringify(r) } func (r RoleMeta) String() string { - data, err := json.MarshalIndent(r, "", " ") - if err != nil { - return err.Error() - } - return string(data) + return types.Stringify(r) } func (r RoleList) String() string { - data, err := json.MarshalIndent(r, "", " ") - if err != nil { - return err.Error() - } - return string(data) + return types.Stringify(r) } func (r RoleListRequest) String() string { - data, err := json.MarshalIndent(r, "", " ") - if err != nil { - return err.Error() + return types.Stringify(r) +} + +//////////////////////////////////////////////////////////////////////////////// +// TABLE + +func (r Role) Header() []string { + return []string{"Oid", "Name", "Attributes", "ConnectionLimit", "Expires", "Groups"} +} + +func (r Role) Width(col int) int { + return 0 +} + +func (r Role) Cell(col int) string { + switch col { + case 0: + return fmt.Sprint(r.Oid) + case 1: + return r.Name + case 2: + var attr []string + if r.Superuser != nil && *r.Superuser { + attr = append(attr, "SUPERUSER") + } + if r.Inherit != nil && *r.Inherit { + attr = append(attr, "INHERIT") + } + if r.CreateRoles != nil && *r.CreateRoles { + attr = append(attr, "CREATEROLE") + } + if r.CreateDatabases != nil && *r.CreateDatabases { + attr = append(attr, "CREATEDB") + } + if r.Replication != nil && *r.Replication { + attr = append(attr, "REPLICATION") + } + if r.BypassRowLevelSecurity != nil && *r.BypassRowLevelSecurity { + attr = append(attr, "BYPASSRLS") + } + if r.Login != nil && *r.Login { + attr = append(attr, "LOGIN") + } + return strings.Join(attr, " ") + case 3: + if r.ConnectionLimit == nil { + return "" + } + return fmt.Sprint(*r.ConnectionLimit) + case 4: + if r.Expires == nil { + return "" + } + return r.Expires.Format(time.RFC3339) + case 5: + return strings.Join(r.Groups, ", ") + default: + return "" + } +} + +//////////////////////////////////////////////////////////////////////////////// +// QUERY + +func (d RoleListRequest) Query() url.Values { + q := url.Values{} + if d.Offset > 0 { + q.Set("offset", fmt.Sprint(d.Offset)) + } + if d.Limit != nil { + q.Set("limit", fmt.Sprint(types.Value(d.Limit))) } - return string(data) + return q } //////////////////////////////////////////////////////////////////////////////// @@ -143,7 +200,7 @@ func (r RoleMeta) name() (string, error) { //////////////////////////////////////////////////////////////////////////////// // SELECT -func (r RoleListRequest) Select(bind *pg.Bind, op pg.Op) (string, error) { +func (r *RoleListRequest) Select(bind *pg.Bind, op pg.Op) (string, error) { // Set empty where bind.Set("where", "") @@ -288,31 +345,31 @@ func (r RoleMeta) with(insert bool) string { return "NO" + v } if r.Superuser != nil { - with = append(with, opt("SUPERUSER", types.PtrBool(r.Superuser))) + with = append(with, opt("SUPERUSER", types.Value(r.Superuser))) } if r.CreateDatabases != nil { - with = append(with, opt("CREATEDB", types.PtrBool(r.CreateDatabases))) + with = append(with, opt("CREATEDB", types.Value(r.CreateDatabases))) } if r.CreateRoles != nil { - with = append(with, opt("CREATEROLE", types.PtrBool(r.CreateRoles))) + with = append(with, opt("CREATEROLE", types.Value(r.CreateRoles))) } if r.Replication != nil { - with = append(with, opt("REPLICATION", types.PtrBool(r.Replication))) + with = append(with, opt("REPLICATION", types.Value(r.Replication))) } if r.Inherit != nil { - with = append(with, opt("INHERIT", types.PtrBool(r.Inherit))) + with = append(with, opt("INHERIT", types.Value(r.Inherit))) } if r.Login != nil { - with = append(with, opt("LOGIN", types.PtrBool(r.Login))) + with = append(with, opt("LOGIN", types.Value(r.Login))) } if r.BypassRowLevelSecurity != nil { - with = append(with, opt("BYPASSRLS", types.PtrBool(r.BypassRowLevelSecurity))) + with = append(with, opt("BYPASSRLS", types.Value(r.BypassRowLevelSecurity))) } if r.ConnectionLimit != nil { - with = append(with, fmt.Sprintf("CONNECTION LIMIT %v", types.PtrUint64(r.ConnectionLimit))) + with = append(with, fmt.Sprintf("CONNECTION LIMIT %v", types.Value(r.ConnectionLimit))) } if r.Password != nil { - if password := types.PtrString(r.Password); password == pgObfuscatedPassword { + if password := types.Value(r.Password); password == pgObfuscatedPassword { // Do nothing } else if password == "" { with = append(with, "PASSWORD NULL") @@ -320,7 +377,7 @@ func (r RoleMeta) with(insert bool) string { with = append(with, fmt.Sprintf("PASSWORD %v", types.Quote(password))) } } - if expires := types.PtrTime(r.Expires).UTC(); !expires.IsZero() { + if expires := types.Value(r.Expires).UTC(); !expires.IsZero() { with = append(with, fmt.Sprintf("VALID UNTIL %v", types.Quote(expires.Format(pgTimestampFormat)))) } if len(r.Groups) > 0 && insert { diff --git a/pkg/manager/schema/schema.go b/pgmanager/schema/schema.go similarity index 83% rename from pkg/manager/schema/schema.go rename to pgmanager/schema/schema.go index c771d65..f91a9c5 100644 --- a/pkg/manager/schema/schema.go +++ b/pgmanager/schema/schema.go @@ -1,7 +1,8 @@ package schema import ( - "encoding/json" + "fmt" + "net/url" "strings" // Packages @@ -15,7 +16,7 @@ import ( type SchemaName string type SchemaMeta struct { - Name string `json:"name,omitempty" arg:"" help:"Name"` + Name string `json:"schema,omitempty" arg:"" help:"Schema name"` Owner string `json:"owner,omitempty" help:"Owner"` Acl ACLList `json:"acl,omitempty" help:"Access privileges"` } @@ -28,11 +29,12 @@ type Schema struct { } type SchemaListRequest struct { - Database *string `json:"database,omitempty" help:"Database"` + Database *string `json:"database,omitempty" arg:"" optional:"" help:"Database"` pg.OffsetLimit } type SchemaList struct { + SchemaListRequest Count uint64 `json:"count"` Body []Schema `json:"body,omitempty"` } @@ -41,41 +43,72 @@ type SchemaList struct { // STRINGIFY func (s SchemaMeta) String() string { - data, err := json.MarshalIndent(s, "", " ") - if err != nil { - return err.Error() - } - return string(data) + return types.Stringify(s) } func (s Schema) String() string { - data, err := json.MarshalIndent(s, "", " ") - if err != nil { - return err.Error() - } - return string(data) + return types.Stringify(s) } func (s SchemaListRequest) String() string { - data, err := json.MarshalIndent(s, "", " ") - if err != nil { - return err.Error() - } - return string(data) + return types.Stringify(s) } func (s SchemaList) String() string { - data, err := json.MarshalIndent(s, "", " ") - if err != nil { - return err.Error() + return types.Stringify(s) +} + +//////////////////////////////////////////////////////////////////////////////// +// TABLE + +func (r Schema) Header() []string { + return []string{"Oid", "Database", "Schema", "Owner", "Acl", "Size"} +} + +func (r Schema) Width(col int) int { + return 0 +} + +func (r Schema) Cell(col int) string { + switch col { + case 0: + return fmt.Sprint(r.Oid) + case 1: + return r.Database + case 2: + return r.Name + case 3: + return r.Owner + case 4: + if len(r.Acl) == 0 { + return "" + } + return fmt.Sprint(r.Acl) + case 5: + return fmt.Sprint(r.Size) + default: + return "" + } +} + +//////////////////////////////////////////////////////////////////////////////// +// QUERY + +func (s SchemaListRequest) Query() url.Values { + q := url.Values{} + if s.Offset > 0 { + q.Set("offset", fmt.Sprint(s.Offset)) + } + if s.Limit != nil { + q.Set("limit", fmt.Sprint(types.Value(s.Limit))) } - return string(data) + return q } //////////////////////////////////////////////////////////////////////////////// // SELECT -func (d SchemaListRequest) Select(bind *pg.Bind, op pg.Op) (string, error) { +func (d *SchemaListRequest) Select(bind *pg.Bind, op pg.Op) (string, error) { // Order bind.Set("orderby", `ORDER BY name ASC`) diff --git a/pkg/manager/schema/setting.go b/pgmanager/schema/setting.go similarity index 73% rename from pkg/manager/schema/setting.go rename to pgmanager/schema/setting.go index d92defa..3c006d3 100644 --- a/pkg/manager/schema/setting.go +++ b/pgmanager/schema/setting.go @@ -1,10 +1,13 @@ package schema import ( - "encoding/json" // Packages + "fmt" + "net/url" + pg "github.com/mutablelogic/go-pg" + types "github.com/mutablelogic/go-server/pkg/types" ) /////////////////////////////////////////////////////////////////////////////// @@ -13,6 +16,9 @@ import ( // SettingName is a setting name identifier type SettingName string +// CategoryName is a setting category identifier +type CategoryName string + // SettingMeta represents the mutable parts of a setting type SettingMeta struct { Value *string `json:"value"` @@ -37,6 +43,7 @@ type SettingListRequest struct { // SettingList contains the list of settings type SettingList struct { + SettingListRequest Count uint64 `json:"count"` Body []Setting `json:"body,omitempty"` } @@ -54,33 +61,97 @@ type SettingCategoryList struct { // STRINGIFY func (s Setting) String() string { - data, err := json.MarshalIndent(s, "", " ") - if err != nil { - return err.Error() - } - return string(data) + return types.Stringify(s) } func (s SettingList) String() string { - data, err := json.MarshalIndent(s, "", " ") - if err != nil { - return err.Error() - } - return string(data) + return types.Stringify(s) } func (s SettingCategoryList) String() string { - data, err := json.MarshalIndent(s, "", " ") - if err != nil { - return err.Error() + return types.Stringify(s) +} + +//////////////////////////////////////////////////////////////////////////////// +// TABLE + +func (r Setting) Header() []string { + return []string{"Name", "Value", "Unit", "Category", "Context", "Description", "ExtraDesc"} +} + +func (r Setting) Width(col int) int { + return 0 +} + +func (r Setting) Cell(col int) string { + switch col { + case 0: + return r.Name + case 1: + if r.Value == nil { + return "" + } + return *r.Value + case 2: + if r.Unit == nil { + return "" + } + return *r.Unit + case 3: + return r.Category + case 4: + return r.Context + case 5: + return r.Description + case 6: + return r.ExtraDesc + default: + return "" + } +} + +func (r CategoryName) Header() []string { + return []string{"Category"} +} + +func (r CategoryName) Width(col int) int { + return 0 +} + +func (r CategoryName) Cell(col int) string { + switch col { + case 0: + return string(r) + default: + return "" } - return string(data) +} + +//////////////////////////////////////////////////////////////////////////////// +// QUERY + +func (d SettingListRequest) Query() url.Values { + q := url.Values{} + if d.Offset > 0 { + q.Set("offset", fmt.Sprint(d.Offset)) + } + if d.Limit != nil { + q.Set("limit", fmt.Sprint(types.Value(d.Limit))) + } + if d.Category != nil { + q.Set("category", *d.Category) + } + return q +} + +func (d SettingCategoryListRequest) Query() url.Values { + return url.Values{} } /////////////////////////////////////////////////////////////////////////////// // SELECT -func (r SettingListRequest) Select(bind *pg.Bind, op pg.Op) (string, error) { +func (r *SettingListRequest) Select(bind *pg.Bind, op pg.Op) (string, error) { // Set empty where bind.Set("where", "") bind.Set("orderby", "ORDER BY category, name") @@ -141,9 +212,8 @@ func (m SettingMeta) Insert(_ *pg.Bind) (string, error) { } func (m SettingMeta) Update(bind *pg.Bind) error { - // Set value (nil means reset) if m.Value != nil { - bind.Set("value", *m.Value) + bind.Set("value", types.Value(m.Value)) } return nil } diff --git a/pkg/manager/schema/statement.go b/pgmanager/schema/statement.go similarity index 65% rename from pkg/manager/schema/statement.go rename to pgmanager/schema/statement.go index 1cb6730..3b834a1 100644 --- a/pkg/manager/schema/statement.go +++ b/pgmanager/schema/statement.go @@ -1,11 +1,13 @@ package schema import ( - "encoding/json" + "fmt" + "net/url" "strings" // Packages pg "github.com/mutablelogic/go-pg" + types "github.com/mutablelogic/go-server/pkg/types" ) /////////////////////////////////////////////////////////////////////////////// @@ -15,7 +17,7 @@ import ( type Statement struct { Role string `json:"role,omitempty"` // Name of the role who executed the statement Database string `json:"database,omitempty"` // Name of the database in which the statement was executed - QueryID int64 `json:"query_id"` // Hash code to identify identical normalized queries + QueryID uint64 `json:"query_id"` // Hash code to identify identical normalized queries Query string `json:"query"` // Text of a representative statement Calls int64 `json:"calls"` // Number of times the statement was executed Rows int64 `json:"rows"` // Total number of rows retrieved or affected by the statement @@ -27,6 +29,7 @@ type Statement struct { // StatementList is a list of statements with a total count type StatementList struct { + StatementListRequest Count uint64 `json:"count"` Body []Statement `json:"body"` } @@ -43,32 +46,89 @@ type StatementListRequest struct { // Sort by field (calls, rows, total_ms, min_ms, max_ms, mean_ms) // All sort DESC except min_ms which sorts ASC - Sort string `json:"sort,omitempty"` + Sort string `json:"sort,omitempty" enum:"calls,rows,total_ms,min_ms,max_ms,mean_ms" default:"max_ms"` } /////////////////////////////////////////////////////////////////////////////// // STRINGIFY func (s Statement) String() string { - data, err := json.MarshalIndent(s, "", " ") - if err != nil { - return err.Error() - } - return string(data) + return types.Stringify(s) } func (l StatementList) String() string { - data, err := json.MarshalIndent(l, "", " ") - if err != nil { - return err.Error() + return types.Stringify(l) +} + +func (l StatementListRequest) String() string { + return types.Stringify(l) +} + +//////////////////////////////////////////////////////////////////////////////// +// TABLE + +func (r Statement) Header() []string { + return []string{"Role", "Database", "QueryID", "Query", "Calls", "Rows", "Total", "Min", "Max", "Mean"} +} + +func (r Statement) Width(col int) int { + return 0 +} + +func (r Statement) Cell(col int) string { + switch col { + case 0: + return r.Role + case 1: + return r.Database + case 2: + return fmt.Sprintf("0x%016x", r.QueryID) + case 3: + return r.Query + case 4: + return fmt.Sprint(r.Calls) + case 5: + return fmt.Sprint(r.Rows) + case 6: + return fmt.Sprintf("%.2f", r.Total) + case 7: + return fmt.Sprintf("%.2f", r.Min) + case 8: + return fmt.Sprintf("%.2f", r.Max) + case 9: + return fmt.Sprintf("%.2f", r.Mean) + default: + return "" } - return string(data) +} + +//////////////////////////////////////////////////////////////////////////////// +// QUERY + +func (s StatementListRequest) Query() url.Values { + q := url.Values{} + if s.Offset > 0 { + q.Set("offset", fmt.Sprint(s.Offset)) + } + if s.Limit != nil { + q.Set("limit", fmt.Sprint(types.Value(s.Limit))) + } + if s.Database != nil { + q.Set("database", types.Value(s.Database)) + } + if s.Role != nil { + q.Set("role", types.Value(s.Role)) + } + if s.Sort != "" { + q.Set("sort", s.Sort) + } + return q } /////////////////////////////////////////////////////////////////////////////// // SELECTOR -func (r StatementListRequest) Select(bind *pg.Bind, op pg.Op) (string, error) { +func (r *StatementListRequest) Select(bind *pg.Bind, op pg.Op) (string, error) { // Build WHERE clause var where []string if r.Database != nil && *r.Database != "" { @@ -90,23 +150,23 @@ func (r StatementListRequest) Select(bind *pg.Bind, op pg.Op) (string, error) { var sortClause string switch strings.ToLower(r.Sort) { case "": - // No additional sort + sortClause = "queryid ASC" case "calls": - sortClause = ", calls DESC" + sortClause = "calls DESC" case "rows": - sortClause = ", rows DESC" + sortClause = "rows DESC" case "total_ms": - sortClause = ", total_exec_time DESC" + sortClause = "total_exec_time DESC" case "min_ms": - sortClause = ", min_exec_time ASC" + sortClause = "min_exec_time ASC" case "max_ms": - sortClause = ", max_exec_time DESC" + sortClause = "max_exec_time DESC" case "mean_ms": - sortClause = ", mean_exec_time DESC" + sortClause = "mean_exec_time DESC" default: return "", pg.ErrBadParameter.Withf("invalid sort parameter %q", r.Sort) } - bind.Set("orderby", "ORDER BY database ASC, queryid ASC"+sortClause) + bind.Set("orderby", "ORDER BY "+sortClause) // Set offset/limit r.OffsetLimit.Bind(bind, StatementListLimit) @@ -124,11 +184,16 @@ func (r StatementListRequest) Select(bind *pg.Bind, op pg.Op) (string, error) { // READER func (s *Statement) Scan(row pg.Row) error { - return row.Scan( - &s.Role, &s.Database, &s.QueryID, &s.Query, + var queryID int64 + if err := row.Scan( + &s.Role, &s.Database, &queryID, &s.Query, &s.Calls, &s.Rows, &s.Total, &s.Min, &s.Max, &s.Mean, - ) + ); err != nil { + return err + } + s.QueryID = uint64(queryID) + return nil } func (l *StatementList) Scan(row pg.Row) error { diff --git a/pkg/manager/schema/tablespace.go b/pgmanager/schema/tablespace.go similarity index 84% rename from pkg/manager/schema/tablespace.go rename to pgmanager/schema/tablespace.go index 70919c8..47c770a 100644 --- a/pkg/manager/schema/tablespace.go +++ b/pgmanager/schema/tablespace.go @@ -1,13 +1,14 @@ package schema import ( - "encoding/json" + "fmt" + "net/url" "path/filepath" "strings" // Packages pg "github.com/mutablelogic/go-pg" - types "github.com/mutablelogic/go-pg/pkg/types" + types "github.com/mutablelogic/go-server/pkg/types" ) //////////////////////////////////////////////////////////////////////////////// @@ -34,6 +35,7 @@ type TablespaceListRequest struct { } type TablespaceList struct { + TablespaceListRequest Count uint64 `json:"count"` Body []Tablespace `json:"body,omitempty"` } @@ -42,41 +44,77 @@ type TablespaceList struct { // STRINGIFY func (t TablespaceMeta) String() string { - data, err := json.MarshalIndent(t, "", " ") - if err != nil { - return err.Error() - } - return string(data) + return types.Stringify(t) } func (t Tablespace) String() string { - data, err := json.MarshalIndent(t, "", " ") - if err != nil { - return err.Error() - } - return string(data) + return types.Stringify(t) } func (t TablespaceListRequest) String() string { - data, err := json.MarshalIndent(t, "", " ") - if err != nil { - return err.Error() - } - return string(data) + return types.Stringify(t) } func (t TablespaceList) String() string { - data, err := json.MarshalIndent(t, "", " ") - if err != nil { - return err.Error() + return types.Stringify(t) +} + +//////////////////////////////////////////////////////////////////////////////// +// TABLE + +func (r Tablespace) Header() []string { + return []string{"Oid", "Name", "Owner", "Acl", "Location", "Options", "Size"} +} + +func (r Tablespace) Width(col int) int { + return 0 +} + +func (r Tablespace) Cell(col int) string { + switch col { + case 0: + return fmt.Sprint(r.Oid) + case 1: + return r.Name + case 2: + return r.Owner + case 3: + if len(r.Acl) == 0 { + return "" + } + return fmt.Sprint(r.Acl) + case 4: + return r.Location + case 5: + if len(r.Options) == 0 { + return "" + } + return fmt.Sprint(r.Options) + case 6: + return fmt.Sprint(r.Size) + default: + return "" + } +} + +//////////////////////////////////////////////////////////////////////////////// +// QUERY + +func (d TablespaceListRequest) Query() url.Values { + q := url.Values{} + if d.Offset > 0 { + q.Set("offset", fmt.Sprint(d.Offset)) + } + if d.Limit != nil { + q.Set("limit", fmt.Sprint(types.Value(d.Limit))) } - return string(data) + return q } //////////////////////////////////////////////////////////////////////////////// // SELECT -func (t TablespaceListRequest) Select(bind *pg.Bind, op pg.Op) (string, error) { +func (t *TablespaceListRequest) Select(bind *pg.Bind, op pg.Op) (string, error) { // Order bind.Set("orderby", `ORDER BY name ASC`) diff --git a/wasm/pgmanager/main.go b/pgmanager/wasm/pgmanager/main.go similarity index 100% rename from wasm/pgmanager/main.go rename to pgmanager/wasm/pgmanager/main.go diff --git a/wasm/pgmanager/wasmbuild.yaml b/pgmanager/wasm/pgmanager/wasmbuild.yaml similarity index 100% rename from wasm/pgmanager/wasmbuild.yaml rename to pgmanager/wasm/pgmanager/wasmbuild.yaml diff --git a/pkg/queue/README.md b/pkg/_queue/README.md similarity index 100% rename from pkg/queue/README.md rename to pkg/_queue/README.md diff --git a/pkg/queue/doc.go b/pkg/_queue/doc.go similarity index 100% rename from pkg/queue/doc.go rename to pkg/_queue/doc.go diff --git a/pkg/queue/handler.go b/pkg/_queue/handler.go similarity index 100% rename from pkg/queue/handler.go rename to pkg/_queue/handler.go diff --git a/pkg/queue/httpclient/client.go b/pkg/_queue/httpclient/client.go similarity index 100% rename from pkg/queue/httpclient/client.go rename to pkg/_queue/httpclient/client.go diff --git a/pkg/queue/httpclient/client_test.go b/pkg/_queue/httpclient/client_test.go similarity index 100% rename from pkg/queue/httpclient/client_test.go rename to pkg/_queue/httpclient/client_test.go diff --git a/pkg/queue/httpclient/doc.go b/pkg/_queue/httpclient/doc.go similarity index 100% rename from pkg/queue/httpclient/doc.go rename to pkg/_queue/httpclient/doc.go diff --git a/pkg/queue/httpclient/namespace.go b/pkg/_queue/httpclient/namespace.go similarity index 100% rename from pkg/queue/httpclient/namespace.go rename to pkg/_queue/httpclient/namespace.go diff --git a/pkg/queue/httpclient/namespace_test.go b/pkg/_queue/httpclient/namespace_test.go similarity index 100% rename from pkg/queue/httpclient/namespace_test.go rename to pkg/_queue/httpclient/namespace_test.go diff --git a/pkg/queue/httpclient/opts.go b/pkg/_queue/httpclient/opts.go similarity index 100% rename from pkg/queue/httpclient/opts.go rename to pkg/_queue/httpclient/opts.go diff --git a/pkg/queue/httpclient/opts_test.go b/pkg/_queue/httpclient/opts_test.go similarity index 100% rename from pkg/queue/httpclient/opts_test.go rename to pkg/_queue/httpclient/opts_test.go diff --git a/pkg/queue/httpclient/queue.go b/pkg/_queue/httpclient/queue.go similarity index 100% rename from pkg/queue/httpclient/queue.go rename to pkg/_queue/httpclient/queue.go diff --git a/pkg/queue/httpclient/queue_test.go b/pkg/_queue/httpclient/queue_test.go similarity index 100% rename from pkg/queue/httpclient/queue_test.go rename to pkg/_queue/httpclient/queue_test.go diff --git a/pkg/queue/httpclient/task.go b/pkg/_queue/httpclient/task.go similarity index 100% rename from pkg/queue/httpclient/task.go rename to pkg/_queue/httpclient/task.go diff --git a/pkg/queue/httpclient/task_test.go b/pkg/_queue/httpclient/task_test.go similarity index 100% rename from pkg/queue/httpclient/task_test.go rename to pkg/_queue/httpclient/task_test.go diff --git a/pkg/queue/httpclient/ticker.go b/pkg/_queue/httpclient/ticker.go similarity index 100% rename from pkg/queue/httpclient/ticker.go rename to pkg/_queue/httpclient/ticker.go diff --git a/pkg/queue/httpclient/ticker_test.go b/pkg/_queue/httpclient/ticker_test.go similarity index 100% rename from pkg/queue/httpclient/ticker_test.go rename to pkg/_queue/httpclient/ticker_test.go diff --git a/pkg/queue/httphandler/doc.go b/pkg/_queue/httphandler/doc.go similarity index 100% rename from pkg/queue/httphandler/doc.go rename to pkg/_queue/httphandler/doc.go diff --git a/pkg/queue/httphandler/frontend_excluded.go b/pkg/_queue/httphandler/frontend_excluded.go similarity index 100% rename from pkg/queue/httphandler/frontend_excluded.go rename to pkg/_queue/httphandler/frontend_excluded.go diff --git a/pkg/queue/httphandler/httphandler.go b/pkg/_queue/httphandler/httphandler.go similarity index 100% rename from pkg/queue/httphandler/httphandler.go rename to pkg/_queue/httphandler/httphandler.go diff --git a/pkg/queue/httphandler/metrics.go b/pkg/_queue/httphandler/metrics.go similarity index 100% rename from pkg/queue/httphandler/metrics.go rename to pkg/_queue/httphandler/metrics.go diff --git a/pkg/queue/httphandler/metrics_test.go b/pkg/_queue/httphandler/metrics_test.go similarity index 100% rename from pkg/queue/httphandler/metrics_test.go rename to pkg/_queue/httphandler/metrics_test.go diff --git a/pkg/queue/httphandler/namespace.go b/pkg/_queue/httphandler/namespace.go similarity index 100% rename from pkg/queue/httphandler/namespace.go rename to pkg/_queue/httphandler/namespace.go diff --git a/pkg/queue/httphandler/namespace_test.go b/pkg/_queue/httphandler/namespace_test.go similarity index 100% rename from pkg/queue/httphandler/namespace_test.go rename to pkg/_queue/httphandler/namespace_test.go diff --git a/pkg/queue/httphandler/queue.go b/pkg/_queue/httphandler/queue.go similarity index 100% rename from pkg/queue/httphandler/queue.go rename to pkg/_queue/httphandler/queue.go diff --git a/pkg/queue/httphandler/queue_test.go b/pkg/_queue/httphandler/queue_test.go similarity index 100% rename from pkg/queue/httphandler/queue_test.go rename to pkg/_queue/httphandler/queue_test.go diff --git a/pkg/queue/httphandler/task.go b/pkg/_queue/httphandler/task.go similarity index 100% rename from pkg/queue/httphandler/task.go rename to pkg/_queue/httphandler/task.go diff --git a/pkg/queue/httphandler/task_test.go b/pkg/_queue/httphandler/task_test.go similarity index 100% rename from pkg/queue/httphandler/task_test.go rename to pkg/_queue/httphandler/task_test.go diff --git a/pkg/queue/httphandler/ticker.go b/pkg/_queue/httphandler/ticker.go similarity index 100% rename from pkg/queue/httphandler/ticker.go rename to pkg/_queue/httphandler/ticker.go diff --git a/pkg/queue/httphandler/ticker_test.go b/pkg/_queue/httphandler/ticker_test.go similarity index 100% rename from pkg/queue/httphandler/ticker_test.go rename to pkg/_queue/httphandler/ticker_test.go diff --git a/pkg/queue/manager.go b/pkg/_queue/manager.go similarity index 100% rename from pkg/queue/manager.go rename to pkg/_queue/manager.go diff --git a/pkg/queue/manager_test.go b/pkg/_queue/manager_test.go similarity index 100% rename from pkg/queue/manager_test.go rename to pkg/_queue/manager_test.go diff --git a/pkg/queue/namespace.go b/pkg/_queue/namespace.go similarity index 100% rename from pkg/queue/namespace.go rename to pkg/_queue/namespace.go diff --git a/pkg/queue/namespace_test.go b/pkg/_queue/namespace_test.go similarity index 100% rename from pkg/queue/namespace_test.go rename to pkg/_queue/namespace_test.go diff --git a/pkg/queue/opts.go b/pkg/_queue/opts.go similarity index 100% rename from pkg/queue/opts.go rename to pkg/_queue/opts.go diff --git a/pkg/queue/queue.go b/pkg/_queue/queue.go similarity index 100% rename from pkg/queue/queue.go rename to pkg/_queue/queue.go diff --git a/pkg/queue/queue_test.go b/pkg/_queue/queue_test.go similarity index 100% rename from pkg/queue/queue_test.go rename to pkg/_queue/queue_test.go diff --git a/pkg/queue/run.go b/pkg/_queue/run.go similarity index 100% rename from pkg/queue/run.go rename to pkg/_queue/run.go diff --git a/pkg/queue/schema/globals.go b/pkg/_queue/schema/globals.go similarity index 100% rename from pkg/queue/schema/globals.go rename to pkg/_queue/schema/globals.go diff --git a/pkg/queue/schema/namespace.go b/pkg/_queue/schema/namespace.go similarity index 100% rename from pkg/queue/schema/namespace.go rename to pkg/_queue/schema/namespace.go diff --git a/pkg/queue/schema/queue.go b/pkg/_queue/schema/queue.go similarity index 100% rename from pkg/queue/schema/queue.go rename to pkg/_queue/schema/queue.go diff --git a/pkg/queue/schema/task.go b/pkg/_queue/schema/task.go similarity index 100% rename from pkg/queue/schema/task.go rename to pkg/_queue/schema/task.go diff --git a/pkg/queue/schema/ticker.go b/pkg/_queue/schema/ticker.go similarity index 100% rename from pkg/queue/schema/ticker.go rename to pkg/_queue/schema/ticker.go diff --git a/pkg/queue/sql/objects.sql b/pkg/_queue/sql/objects.sql similarity index 100% rename from pkg/queue/sql/objects.sql rename to pkg/_queue/sql/objects.sql diff --git a/pkg/queue/sql/queries.sql b/pkg/_queue/sql/queries.sql similarity index 100% rename from pkg/queue/sql/queries.sql rename to pkg/_queue/sql/queries.sql diff --git a/pkg/queue/sql/sql.go b/pkg/_queue/sql/sql.go similarity index 100% rename from pkg/queue/sql/sql.go rename to pkg/_queue/sql/sql.go diff --git a/pkg/queue/task.go b/pkg/_queue/task.go similarity index 100% rename from pkg/queue/task.go rename to pkg/_queue/task.go diff --git a/pkg/queue/task_test.go b/pkg/_queue/task_test.go similarity index 100% rename from pkg/queue/task_test.go rename to pkg/_queue/task_test.go diff --git a/pkg/queue/ticker.go b/pkg/_queue/ticker.go similarity index 100% rename from pkg/queue/ticker.go rename to pkg/_queue/ticker.go diff --git a/pkg/queue/ticker_test.go b/pkg/_queue/ticker_test.go similarity index 100% rename from pkg/queue/ticker_test.go rename to pkg/_queue/ticker_test.go diff --git a/pkg/manager/README.md b/pkg/manager/README.md deleted file mode 100644 index 598d28b..0000000 --- a/pkg/manager/README.md +++ /dev/null @@ -1,153 +0,0 @@ -# PostgreSQL Manager - -The `manager` package provides an API for managing PostgreSQL server resources. It enables introspection and management of databases, roles, schemas, tables, and other PostgreSQL objects through both a direct Go API and HTTP interfaces. -There are unit and integration tests included. - -To test the package: - -```bash -git clone github.com/mutablelogic/go-pg -make tests -``` - -You'll need to have docker installed in order to run the integration tests, which will create a PostgreSQL server in a container. There is a command line client included for testing: - -```bash -git clone github.com/mutablelogic/go-pg -make cmd/pgmanager -``` - -This places a binary in the `build` folder which you can use as a server or client. To run the server -on localhost, port 8080: - -```bash -build/pgmanager run postgres://localhost:5432/postgres -``` - -To use the client: - -```bash -build/pgmanager databases -``` - -Run `build/pgmanager --help` for more information. - -## Architecture - -The package is organized into four main components: - -### Manager (this package folder) - -The core component that provides direct access to PostgreSQL management functions. It wraps a connection pool and exposes methods for querying and managing server resources. - -```go -import "github.com/mutablelogic/go-pg/pkg/manager" - -// Create a manager from an existing connection pool -mgr, err := manager.New(ctx, conn) -``` - -Documentation for all manager methods can be found [here](https://pkg.go.dev/github.com/mutablelogic/go-pg/pkg/manager). - -### Schema (`schema/`) - -Defines all data types, request/response structures, and SQL queries for PostgreSQL resources. Each resource type has its own file containing: - -- **Structs** - Go types representing PostgreSQL objects (e.g., `Role`, `Database`, `Schema`) -- **List requests** - Parameters for filtering and pagination -- **SQL generation** - Methods that produce parameterized SQL queries - -### HTTP Handler (`httphandler/`) - -Provides REST API endpoints for all management operations. Register handlers with an `http.ServeMux`: - -```go -import "github.com/mutablelogic/go-pg/pkg/manager/httphandler" - -httphandler.RegisterHandlers(mux, "/api/v1", mgr) -``` - -Includes a Prometheus metrics endpoint at `/api/v1/metrics` exposing: - -- Connection counts by database and state -- Database and tablespace sizes -- Table and index sizes -- Dead tuple ratios for vacuum monitoring -- Replication slot status and lag - -### HTTP Client (`httpclient/`) - -A typed client for consuming the REST API from Go applications: - -```go -import "github.com/mutablelogic/go-pg/pkg/manager/httpclient" - -client, err := httpclient.New("http://localhost:8080/api/v1") -roles, err := client.ListRoles(ctx) -``` - -## Managed Resources - -| Resource | Description | -|----------|-------------| -| **Roles** | Database users and groups with their attributes and memberships | -| **Databases** | Database instances with size, owner, encoding, and connection settings | -| **Schemas** | Namespaces within databases containing tables and other objects | -| **Objects** | Tables, views, indexes, sequences, and other database objects | -| **Tablespaces** | Storage locations for database files | -| **Extensions** | PostgreSQL extensions installed on the server | -| **Connections** | Active database connections with state and query information | -| **Settings** | Server configuration parameters | -| **Statements** | Query statistics from `pg_stat_statements` (when available) | -| **Replication Slots** | Logical and physical replication slots with lag metrics | - -## API Patterns - -All list operations follow a consistent pattern: - -```go -// Request with optional filtering and pagination -req := schema.RoleListRequest{ - OffsetLimit: pg.OffsetLimit{ - Offset: 0, - Limit: types.Uint64Ptr(100), - }, - Name: types.StringPtr("admin%"), // LIKE pattern -} - -// Response includes total count and body -list, err := mgr.ListRoles(ctx, req) -fmt.Printf("Total: %d, Returned: %d\n", list.Count, len(list.Body)) -``` - -## REST API Endpoints - -All endpoints are prefixed with the configured path (e.g., `/api/v1`): - -| Method | Path | Description | -|--------|------|-------------| -| GET | `/roles` | List roles | -| GET | `/roles/{name}` | Get role by name | -| GET | `/databases` | List databases | -| GET | `/databases/{name}` | Get database by name | -| GET | `/schemas` | List schemas | -| GET | `/objects` | List objects (tables, views, indexes, etc.) | -| GET | `/tablespaces` | List tablespaces | -| GET | `/extensions` | List extensions | -| GET | `/connections` | List active connections | -| GET | `/settings` | List server settings | -| GET | `/statements` | List statement statistics | -| GET | `/replicationslots` | List replication slots | -| GET | `/metrics` | Prometheus metrics | - -Query parameters support filtering and pagination: - -- `offset` - Skip N results -- `limit` - Maximum results to return -- Resource-specific filters (e.g., `database`, `schema`, `type`) - -## Dependencies - -- `github.com/mutablelogic/go-pg` - PostgreSQL connection pool -- `github.com/mutablelogic/go-server` - HTTP utilities -- `github.com/prometheus/client_golang` - Prometheus metrics diff --git a/pkg/manager/connection.go b/pkg/manager/connection.go deleted file mode 100644 index f15415c..0000000 --- a/pkg/manager/connection.go +++ /dev/null @@ -1,49 +0,0 @@ -package manager - -import ( - "context" - - // Packages - pg "github.com/mutablelogic/go-pg" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" -) - -//////////////////////////////////////////////////////////////////////////////// -// PUBLIC METHODS - CONNECTION - -// ListConnections returns a list of active database connections matching the request criteria. -// It supports filtering by database, role, and state, as well as pagination. -func (manager *Manager) ListConnections(ctx context.Context, req schema.ConnectionListRequest) (*schema.ConnectionList, error) { - var list schema.ConnectionList - if err := manager.conn.List(ctx, &list, req); err != nil { - return nil, err - } else { - return &list, nil - } -} - -// GetConnection retrieves a single connection by process ID. -// Returns an error if the pid is zero or the connection is not found. -func (manager *Manager) GetConnection(ctx context.Context, pid uint64) (*schema.Connection, error) { - if pid == 0 { - return nil, pg.ErrBadParameter.With("pid is zero") - } - var response schema.Connection - if err := manager.conn.Get(ctx, &response, schema.ConnectionPid(pid)); err != nil { - return nil, err - } - return &response, nil -} - -// DeleteConnection terminates a connection by process ID and returns the terminated connection. -// Returns an error if the pid is zero or the connection is not found. -func (manager *Manager) DeleteConnection(ctx context.Context, pid uint64) (*schema.Connection, error) { - if pid == 0 { - return nil, pg.ErrBadParameter.With("pid is zero") - } - var connection schema.Connection - if err := manager.conn.Delete(ctx, &connection, schema.ConnectionPid(pid)); err != nil { - return nil, err - } - return &connection, nil -} diff --git a/pkg/manager/connection_test.go b/pkg/manager/connection_test.go deleted file mode 100644 index 2a6b955..0000000 --- a/pkg/manager/connection_test.go +++ /dev/null @@ -1,176 +0,0 @@ -package manager_test - -import ( - "context" - "testing" - - // Packages - pg "github.com/mutablelogic/go-pg" - manager "github.com/mutablelogic/go-pg/pkg/manager" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" - assert "github.com/stretchr/testify/assert" -) - -//////////////////////////////////////////////////////////////////////////////// -// LIST CONNECTIONS TESTS - -func Test_Manager_ListConnections(t *testing.T) { - assert := assert.New(t) - conn := conn.Begin(t) - defer conn.Close() - - mgr, err := manager.New(context.TODO(), conn) - if !assert.NoError(err) { - t.FailNow() - } - - t.Run("ListAll", func(t *testing.T) { - connections, err := mgr.ListConnections(context.TODO(), schema.ConnectionListRequest{}) - assert.NoError(err) - assert.NotNil(connections) - assert.Equal(len(connections.Body), int(connections.Count)) - // Should have at least one connection (our own) - assert.GreaterOrEqual(connections.Count, uint64(1)) - }) - - t.Run("ListWithPagination", func(t *testing.T) { - limit := uint64(1) - connections, err := mgr.ListConnections(context.TODO(), schema.ConnectionListRequest{ - OffsetLimit: pg.OffsetLimit{Limit: &limit}, - }) - assert.NoError(err) - assert.NotNil(connections) - assert.LessOrEqual(len(connections.Body), 1) - }) - - t.Run("ListByDatabase", func(t *testing.T) { - dbName := "postgres" - connections, err := mgr.ListConnections(context.TODO(), schema.ConnectionListRequest{ - Database: &dbName, - }) - assert.NoError(err) - assert.NotNil(connections) - // All connections should be from postgres database - for _, conn := range connections.Body { - assert.Equal(dbName, conn.Database) - } - }) - - t.Run("ListByState", func(t *testing.T) { - state := "active" - connections, err := mgr.ListConnections(context.TODO(), schema.ConnectionListRequest{ - State: &state, - }) - assert.NoError(err) - assert.NotNil(connections) - // All connections should have active state - for _, conn := range connections.Body { - assert.Equal(state, conn.State) - } - }) - - t.Run("ListWithMultipleFilters", func(t *testing.T) { - dbName := "postgres" - state := "active" - connections, err := mgr.ListConnections(context.TODO(), schema.ConnectionListRequest{ - Database: &dbName, - State: &state, - }) - assert.NoError(err) - assert.NotNil(connections) - // All connections should match both filters - for _, conn := range connections.Body { - assert.Equal(dbName, conn.Database) - assert.Equal(state, conn.State) - } - }) - - t.Run("ListWithOffset", func(t *testing.T) { - // First get all connections - allConnections, err := mgr.ListConnections(context.TODO(), schema.ConnectionListRequest{}) - assert.NoError(err) - - if allConnections.Count < 2 { - t.Skip("Not enough connections to test offset") - } - - // Get with offset - limit := uint64(10) - connections, err := mgr.ListConnections(context.TODO(), schema.ConnectionListRequest{ - OffsetLimit: pg.OffsetLimit{ - Limit: &limit, - Offset: 1, - }, - }) - assert.NoError(err) - assert.NotNil(connections) - // Should have fewer connections than total - assert.Less(len(connections.Body), int(allConnections.Count)) - }) -} - -//////////////////////////////////////////////////////////////////////////////// -// GET CONNECTION TESTS - -func Test_Manager_GetConnection(t *testing.T) { - assert := assert.New(t) - conn := conn.Begin(t) - defer conn.Close() - - mgr, err := manager.New(context.TODO(), conn) - if !assert.NoError(err) { - t.FailNow() - } - - t.Run("GetExisting", func(t *testing.T) { - // First list connections to get a valid PID - connections, err := mgr.ListConnections(context.TODO(), schema.ConnectionListRequest{}) - if !assert.NoError(err) || connections.Count == 0 { - t.Skip("No connections available to test") - } - - // Get the first connection's PID - pid := uint64(connections.Body[0].Pid) - connection, err := mgr.GetConnection(context.TODO(), pid) - assert.NoError(err) - assert.NotNil(connection) - assert.Equal(uint32(pid), connection.Pid) - }) - - t.Run("GetNonExistent", func(t *testing.T) { - // Use a very high PID that should not exist - _, err := mgr.GetConnection(context.TODO(), 999999999) - assert.Error(err) - assert.ErrorIs(err, pg.ErrNotFound) - }) - - t.Run("GetZeroPid", func(t *testing.T) { - _, err := mgr.GetConnection(context.TODO(), 0) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) -} - -//////////////////////////////////////////////////////////////////////////////// -// DELETE CONNECTION TESTS - -func Test_Manager_DeleteConnection(t *testing.T) { - assert := assert.New(t) - conn := conn.Begin(t) - defer conn.Close() - - mgr, err := manager.New(context.TODO(), conn) - if !assert.NoError(err) { - t.FailNow() - } - - t.Run("DeleteZeroPid", func(t *testing.T) { - _, err := mgr.DeleteConnection(context.TODO(), 0) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - // Note: We don't test DeleteConnection with a valid PID here because - // terminating connections during tests could be disruptive. - // The schema-level tests verify the SQL generation is correct. -} diff --git a/pkg/manager/database_test.go b/pkg/manager/database_test.go deleted file mode 100644 index 360fc3a..0000000 --- a/pkg/manager/database_test.go +++ /dev/null @@ -1,370 +0,0 @@ -package manager_test - -import ( - "context" - "testing" - - // Packages - pg "github.com/mutablelogic/go-pg" - manager "github.com/mutablelogic/go-pg/pkg/manager" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" - test "github.com/mutablelogic/go-pg/pkg/test" - assert "github.com/stretchr/testify/assert" -) - -// Global connection variable -var conn test.Conn - -// Start up a container and test the pool -func TestMain(m *testing.M) { - test.Main(m, func(pool pg.PoolConn) (func(), error) { - conn = test.Conn{PoolConn: pool} - return nil, nil - }) -} - -//////////////////////////////////////////////////////////////////////////////// -// MANAGER LIFECYCLE TESTS - -func Test_Manager_New(t *testing.T) { - assert := assert.New(t) - conn := conn.Begin(t) - defer conn.Close() - - t.Run("ValidConnection", func(t *testing.T) { - mgr, err := manager.New(context.TODO(), conn) - assert.NoError(err) - assert.NotNil(mgr) - }) - - t.Run("NilConnection", func(t *testing.T) { - _, err := manager.New(context.TODO(), nil) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) -} - -//////////////////////////////////////////////////////////////////////////////// -// LIST DATABASES TESTS - -func Test_Manager_ListDatabases(t *testing.T) { - assert := assert.New(t) - conn := conn.Begin(t) - defer conn.Close() - - mgr, err := manager.New(context.TODO(), conn) - if !assert.NoError(err) { - t.FailNow() - } - - t.Run("ListAll", func(t *testing.T) { - databases, err := mgr.ListDatabases(context.TODO(), schema.DatabaseListRequest{}) - assert.NoError(err) - assert.NotNil(databases) - assert.Equal(len(databases.Body), int(databases.Count)) - // Should have at least the default postgres database - assert.GreaterOrEqual(databases.Count, uint64(1)) - }) - - t.Run("ListWithPagination", func(t *testing.T) { - limit := uint64(1) - databases, err := mgr.ListDatabases(context.TODO(), schema.DatabaseListRequest{ - OffsetLimit: pg.OffsetLimit{Limit: &limit}, - }) - assert.NoError(err) - assert.NotNil(databases) - assert.LessOrEqual(len(databases.Body), 1) - }) -} - -//////////////////////////////////////////////////////////////////////////////// -// GET DATABASE TESTS - -func Test_Manager_GetDatabase(t *testing.T) { - assert := assert.New(t) - conn := conn.Begin(t) - defer conn.Close() - - mgr, err := manager.New(context.TODO(), conn) - if !assert.NoError(err) { - t.FailNow() - } - - t.Run("GetExisting", func(t *testing.T) { - // postgres database should always exist - database, err := mgr.GetDatabase(context.TODO(), "postgres") - assert.NoError(err) - assert.NotNil(database) - assert.Equal("postgres", database.Name) - }) - - t.Run("GetNonExistent", func(t *testing.T) { - _, err := mgr.GetDatabase(context.TODO(), "non_existing_database_xyz") - assert.Error(err) - assert.ErrorIs(err, pg.ErrNotFound) - }) - - t.Run("GetEmptyName", func(t *testing.T) { - _, err := mgr.GetDatabase(context.TODO(), "") - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) -} - -//////////////////////////////////////////////////////////////////////////////// -// CREATE DATABASE TESTS - -func Test_Manager_CreateDatabase(t *testing.T) { - assert := assert.New(t) - conn := conn.Begin(t) - defer conn.Close() - - mgr, err := manager.New(context.TODO(), conn) - if !assert.NoError(err) { - t.FailNow() - } - - t.Run("CreateSimple", func(t *testing.T) { - dbName := "test_create_simple" - t.Cleanup(func() { - mgr.DeleteDatabase(context.TODO(), dbName, true) - }) - - database, err := mgr.CreateDatabase(context.TODO(), schema.DatabaseMeta{ - Name: dbName, - }) - assert.NoError(err) - assert.NotNil(database) - assert.Equal(dbName, database.Name) - assert.NotEmpty(database.Owner) - }) - - t.Run("CreateWithACL", func(t *testing.T) { - dbName := "test_create_with_acl" - t.Cleanup(func() { - mgr.DeleteDatabase(context.TODO(), dbName, true) - }) - - database, err := mgr.CreateDatabase(context.TODO(), schema.DatabaseMeta{ - Name: dbName, - Acl: schema.ACLList{ - {Role: "PUBLIC", Priv: []string{"CONNECT"}}, - }, - }) - assert.NoError(err) - assert.NotNil(database) - - publicACL := database.Acl.Find("PUBLIC") - assert.NotNil(publicACL) - }) - - t.Run("CreateEmptyName", func(t *testing.T) { - _, err := mgr.CreateDatabase(context.TODO(), schema.DatabaseMeta{ - Name: "", - }) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("CreateReservedPrefix", func(t *testing.T) { - _, err := mgr.CreateDatabase(context.TODO(), schema.DatabaseMeta{ - Name: "pg_reserved_test", - }) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("CreateDuplicate", func(t *testing.T) { - dbName := "test_create_duplicate" - t.Cleanup(func() { - mgr.DeleteDatabase(context.TODO(), dbName, true) - }) - - _, err := mgr.CreateDatabase(context.TODO(), schema.DatabaseMeta{ - Name: dbName, - }) - assert.NoError(err) - - // Try to create again - _, err = mgr.CreateDatabase(context.TODO(), schema.DatabaseMeta{ - Name: dbName, - }) - assert.Error(err) - }) -} - -//////////////////////////////////////////////////////////////////////////////// -// DELETE DATABASE TESTS - -func Test_Manager_DeleteDatabase(t *testing.T) { - assert := assert.New(t) - conn := conn.Begin(t) - defer conn.Close() - - mgr, err := manager.New(context.TODO(), conn) - if !assert.NoError(err) { - t.FailNow() - } - - t.Run("DeleteExisting", func(t *testing.T) { - dbName := "test_delete_existing" - - // Create first - _, err := mgr.CreateDatabase(context.TODO(), schema.DatabaseMeta{ - Name: dbName, - }) - if !assert.NoError(err) { - t.FailNow() - } - - // Delete - database, err := mgr.DeleteDatabase(context.TODO(), dbName, false) - assert.NoError(err) - assert.NotNil(database) - assert.Equal(dbName, database.Name) - - // Verify it's gone - _, err = mgr.GetDatabase(context.TODO(), dbName) - assert.ErrorIs(err, pg.ErrNotFound) - }) - - t.Run("DeleteNonExistent", func(t *testing.T) { - _, err := mgr.DeleteDatabase(context.TODO(), "non_existing_db_xyz", false) - assert.Error(err) - assert.ErrorIs(err, pg.ErrNotFound) - }) - - t.Run("DeleteEmptyName", func(t *testing.T) { - _, err := mgr.DeleteDatabase(context.TODO(), "", false) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("DeleteWithForce", func(t *testing.T) { - dbName := "test_delete_force" - - // Create first - _, err := mgr.CreateDatabase(context.TODO(), schema.DatabaseMeta{ - Name: dbName, - }) - if !assert.NoError(err) { - t.FailNow() - } - - // Delete with force - database, err := mgr.DeleteDatabase(context.TODO(), dbName, true) - assert.NoError(err) - assert.NotNil(database) - }) -} - -//////////////////////////////////////////////////////////////////////////////// -// UPDATE DATABASE TESTS - -func Test_Manager_UpdateDatabase(t *testing.T) { - assert := assert.New(t) - conn := conn.Begin(t) - defer conn.Close() - - mgr, err := manager.New(context.TODO(), conn) - if !assert.NoError(err) { - t.FailNow() - } - - t.Run("RenameDatabase", func(t *testing.T) { - oldName := "test_rename_old" - newName := "test_rename_new" - t.Cleanup(func() { - mgr.DeleteDatabase(context.TODO(), oldName, true) - mgr.DeleteDatabase(context.TODO(), newName, true) - }) - - // Create - _, err := mgr.CreateDatabase(context.TODO(), schema.DatabaseMeta{ - Name: oldName, - }) - if !assert.NoError(err) { - t.FailNow() - } - - // Rename - database, err := mgr.UpdateDatabase(context.TODO(), oldName, schema.DatabaseMeta{ - Name: newName, - }) - assert.NoError(err) - assert.NotNil(database) - assert.Equal(newName, database.Name) - - // Old name should not exist - _, err = mgr.GetDatabase(context.TODO(), oldName) - assert.ErrorIs(err, pg.ErrNotFound) - - // New name should exist - _, err = mgr.GetDatabase(context.TODO(), newName) - assert.NoError(err) - }) - - t.Run("UpdateEmptyName", func(t *testing.T) { - _, err := mgr.UpdateDatabase(context.TODO(), "", schema.DatabaseMeta{ - Name: "newname", - }) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("UpdateToReservedPrefix", func(t *testing.T) { - dbName := "test_update_reserved" - t.Cleanup(func() { - mgr.DeleteDatabase(context.TODO(), dbName, true) - }) - - // Create - _, err := mgr.CreateDatabase(context.TODO(), schema.DatabaseMeta{ - Name: dbName, - }) - if !assert.NoError(err) { - t.FailNow() - } - - // Try to rename to reserved prefix - _, err = mgr.UpdateDatabase(context.TODO(), dbName, schema.DatabaseMeta{ - Name: "pg_reserved", - }) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("UpdateNonExistent", func(t *testing.T) { - _, err := mgr.UpdateDatabase(context.TODO(), "non_existing_db_xyz", schema.DatabaseMeta{ - Name: "newname", - }) - assert.Error(err) - }) - - t.Run("UpdateAddACL", func(t *testing.T) { - dbName := "test_update_add_acl" - t.Cleanup(func() { - mgr.DeleteDatabase(context.TODO(), dbName, true) - }) - - // Create without ACL - _, err := mgr.CreateDatabase(context.TODO(), schema.DatabaseMeta{ - Name: dbName, - }) - if !assert.NoError(err) { - t.FailNow() - } - - // Add ACL - database, err := mgr.UpdateDatabase(context.TODO(), dbName, schema.DatabaseMeta{ - Acl: schema.ACLList{ - {Role: "PUBLIC", Priv: []string{"CONNECT"}}, - }, - }) - assert.NoError(err) - assert.NotNil(database) - - publicACL := database.Acl.Find("PUBLIC") - assert.NotNil(publicACL) - }) -} diff --git a/pkg/manager/doc.go b/pkg/manager/doc.go deleted file mode 100644 index 30c3ce4..0000000 --- a/pkg/manager/doc.go +++ /dev/null @@ -1,36 +0,0 @@ -// Package manager provides a comprehensive API for managing PostgreSQL server -// resources including roles, databases, schemas, tables, connections, and more. -// -// It wraps a connection pool and exposes methods for querying and managing -// PostgreSQL system catalogs. The package supports introspection of database -// objects and server configuration. -// -// # Creating a Manager -// -// mgr, err := manager.New(ctx, pool) -// if err != nil { -// panic(err) -// } -// -// # Listing Resources -// -// All list operations follow a consistent pattern with filtering and pagination: -// -// roles, err := mgr.ListRoles(ctx, schema.RoleListRequest{ -// OffsetLimit: pg.OffsetLimit{Limit: ptr(100)}, -// }) -// -// # Managed Resources -// -// The manager provides access to: -// - Roles (users and groups) -// - Databases -// - Schemas -// - Objects (tables, views, indexes, sequences) -// - Tablespaces -// - Extensions -// - Connections -// - Settings -// - Statements (pg_stat_statements) -// - Replication slots -package manager diff --git a/pkg/manager/extension_test.go b/pkg/manager/extension_test.go deleted file mode 100644 index 0778dd3..0000000 --- a/pkg/manager/extension_test.go +++ /dev/null @@ -1,366 +0,0 @@ -package manager_test - -import ( - "context" - "testing" - - // Packages - pg "github.com/mutablelogic/go-pg" - manager "github.com/mutablelogic/go-pg/pkg/manager" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" - assert "github.com/stretchr/testify/assert" -) - -//////////////////////////////////////////////////////////////////////////////// -// LIST EXTENSIONS TESTS - -func Test_Manager_ListExtensions(t *testing.T) { - assert := assert.New(t) - conn := conn.Begin(t) - defer conn.Close() - - mgr, err := manager.New(context.TODO(), conn) - if !assert.NoError(err) { - t.FailNow() - } - - t.Run("ListAll", func(t *testing.T) { - extensions, err := mgr.ListExtensions(context.TODO(), schema.ExtensionListRequest{}) - assert.NoError(err) - assert.NotNil(extensions) - // Should have at least some available extensions (plpgsql is always available) - assert.GreaterOrEqual(extensions.Count, uint64(1)) - }) - - t.Run("ListWithPagination", func(t *testing.T) { - limit := uint64(1) - extensions, err := mgr.ListExtensions(context.TODO(), schema.ExtensionListRequest{ - OffsetLimit: pg.OffsetLimit{Limit: &limit}, - }) - assert.NoError(err) - assert.NotNil(extensions) - assert.LessOrEqual(len(extensions.Body), 1) - }) - - t.Run("ListWithOffset", func(t *testing.T) { - // First get all extensions - allExtensions, err := mgr.ListExtensions(context.TODO(), schema.ExtensionListRequest{}) - assert.NoError(err) - - if allExtensions.Count < 2 { - t.Skip("Not enough extensions to test offset") - } - - // Get with offset - limit := uint64(100) - extensions, err := mgr.ListExtensions(context.TODO(), schema.ExtensionListRequest{ - OffsetLimit: pg.OffsetLimit{Offset: 1, Limit: &limit}, - }) - assert.NoError(err) - assert.NotNil(extensions) - assert.Less(len(extensions.Body), int(allExtensions.Count)) - }) - - t.Run("ListInstalledOnly", func(t *testing.T) { - installed := true - extensions, err := mgr.ListExtensions(context.TODO(), schema.ExtensionListRequest{ - Installed: &installed, - }) - assert.NoError(err) - assert.NotNil(extensions) - // All returned extensions should have InstalledVersion set - for _, ext := range extensions.Body { - assert.NotNil(ext.InstalledVersion, "Extension %s should have InstalledVersion", ext.Name) - } - }) - - t.Run("ListNotInstalledOnly", func(t *testing.T) { - installed := false - extensions, err := mgr.ListExtensions(context.TODO(), schema.ExtensionListRequest{ - Installed: &installed, - }) - assert.NoError(err) - assert.NotNil(extensions) - // All returned extensions should NOT have InstalledVersion set - for _, ext := range extensions.Body { - assert.Nil(ext.InstalledVersion, "Extension %s should not have InstalledVersion", ext.Name) - } - }) - - t.Run("ListByDatabase", func(t *testing.T) { - database := "postgres" - extensions, err := mgr.ListExtensions(context.TODO(), schema.ExtensionListRequest{ - Database: &database, - }) - assert.NoError(err) - assert.NotNil(extensions) - // All returned extensions should be from the postgres database - for _, ext := range extensions.Body { - assert.Equal("postgres", ext.Database) - } - }) - - t.Run("ListByNonExistentDatabase", func(t *testing.T) { - database := "non_existent_database_xyz" - _, err := mgr.ListExtensions(context.TODO(), schema.ExtensionListRequest{ - Database: &database, - }) - // Should return an error for non-existent database - assert.Error(err) - }) - - t.Run("ExtensionHasDatabaseWhenSpecified", func(t *testing.T) { - // When querying a specific database, extensions should have Database set - database := "postgres" - extensions, err := mgr.ListExtensions(context.TODO(), schema.ExtensionListRequest{ - Database: &database, - }) - assert.NoError(err) - assert.NotNil(extensions) - for _, ext := range extensions.Body { - assert.Equal(database, ext.Database, "Extension %s should have Database set", ext.Name) - } - }) - - t.Run("ExtensionNoDatabaseWhenClusterWide", func(t *testing.T) { - // When no database specified, extensions should NOT have Database set (cluster-wide) - extensions, err := mgr.ListExtensions(context.TODO(), schema.ExtensionListRequest{}) - assert.NoError(err) - assert.NotNil(extensions) - for _, ext := range extensions.Body { - assert.Empty(ext.Database, "Extension %s should not have Database set for cluster-wide query", ext.Name) - } - }) -} - -//////////////////////////////////////////////////////////////////////////////// -// GET EXTENSION TESTS - -func Test_Manager_GetExtension(t *testing.T) { - assert := assert.New(t) - conn := conn.Begin(t) - defer conn.Close() - - mgr, err := manager.New(context.TODO(), conn) - if !assert.NoError(err) { - t.FailNow() - } - - t.Run("GetExistingExtension", func(t *testing.T) { - // plpgsql is always available - ext, err := mgr.GetExtension(context.TODO(), "plpgsql") - assert.NoError(err) - assert.NotNil(ext) - assert.Equal("plpgsql", ext.Name) - // Database field is not set for cluster-wide queries - assert.Empty(ext.Database) - }) - - t.Run("GetNonExistentExtension", func(t *testing.T) { - _, err := mgr.GetExtension(context.TODO(), "nonexistent_extension_xyz") - assert.Error(err) - assert.ErrorIs(err, pg.ErrNotFound) - }) - - t.Run("GetEmptyName", func(t *testing.T) { - _, err := mgr.GetExtension(context.TODO(), "") - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("GetWhitespaceName", func(t *testing.T) { - _, err := mgr.GetExtension(context.TODO(), " ") - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) -} - -//////////////////////////////////////////////////////////////////////////////// -// CREATE EXTENSION TESTS - -func Test_Manager_CreateExtension(t *testing.T) { - assert := assert.New(t) - conn := conn.Begin(t) - defer conn.Close() - - mgr, err := manager.New(context.TODO(), conn) - if !assert.NoError(err) { - t.FailNow() - } - - t.Run("MissingDatabase", func(t *testing.T) { - _, err := mgr.CreateExtension(context.TODO(), schema.ExtensionMeta{ - Name: "hstore", - }, false) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("MissingName", func(t *testing.T) { - _, err := mgr.CreateExtension(context.TODO(), schema.ExtensionMeta{ - Database: "postgres", - }, false) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("CreateDblink", func(t *testing.T) { - // dblink should be available since we use it for cross-database queries - ext, err := mgr.CreateExtension(context.TODO(), schema.ExtensionMeta{ - Name: "dblink", - Database: "postgres", - }, false) - if !assert.NoError(err) { - t.FailNow() - } - assert.Equal("dblink", ext.Name) - assert.NotNil(ext.InstalledVersion) - }) - - t.Run("CreateWithCascade", func(t *testing.T) { - // file_fdw is a simpler extension that should be available - ext, err := mgr.CreateExtension(context.TODO(), schema.ExtensionMeta{ - Name: "file_fdw", - Database: "postgres", - }, true) - if !assert.NoError(err) { - t.FailNow() - } - assert.Equal("file_fdw", ext.Name) - assert.NotNil(ext.InstalledVersion) - }) -} - -//////////////////////////////////////////////////////////////////////////////// -// UPDATE EXTENSION TESTS - -func Test_Manager_UpdateExtension(t *testing.T) { - assert := assert.New(t) - conn := conn.Begin(t) - defer conn.Close() - - mgr, err := manager.New(context.TODO(), conn) - if !assert.NoError(err) { - t.FailNow() - } - - t.Run("MissingDatabase", func(t *testing.T) { - _, err := mgr.UpdateExtension(context.TODO(), "plpgsql", schema.ExtensionMeta{ - Version: "1.0", - }) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("MissingName", func(t *testing.T) { - _, err := mgr.UpdateExtension(context.TODO(), "", schema.ExtensionMeta{ - Database: "postgres", - Version: "1.0", - }) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("NameCannotBeChanged", func(t *testing.T) { - _, err := mgr.UpdateExtension(context.TODO(), "plpgsql", schema.ExtensionMeta{ - Database: "postgres", - Name: "newname", - }) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - assert.Contains(err.Error(), "name cannot be changed") - }) - - t.Run("OwnerCannotBeChanged", func(t *testing.T) { - _, err := mgr.UpdateExtension(context.TODO(), "plpgsql", schema.ExtensionMeta{ - Database: "postgres", - Owner: "newowner", - }) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - assert.Contains(err.Error(), "owner cannot be changed") - }) - - t.Run("UpdateToLatest", func(t *testing.T) { - // Update plpgsql (always installed) to latest version - ext, err := mgr.UpdateExtension(context.TODO(), "plpgsql", schema.ExtensionMeta{ - Database: "postgres", - }) - if !assert.NoError(err) { - t.FailNow() - } - assert.Equal("plpgsql", ext.Name) - assert.Equal("postgres", ext.Database) - }) -} - -//////////////////////////////////////////////////////////////////////////////// -// DELETE EXTENSION TESTS - -func Test_Manager_DeleteExtension(t *testing.T) { - assert := assert.New(t) - conn := conn.Begin(t) - defer conn.Close() - - mgr, err := manager.New(context.TODO(), conn) - if !assert.NoError(err) { - t.FailNow() - } - - t.Run("MissingDatabase", func(t *testing.T) { - err := mgr.DeleteExtension(context.TODO(), "", "dblink", false) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("MissingName", func(t *testing.T) { - err := mgr.DeleteExtension(context.TODO(), "postgres", "", false) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("DeleteNonExistent", func(t *testing.T) { - // Deleting a non-existent extension should fail - err := mgr.DeleteExtension(context.TODO(), "postgres", "nonexistent_ext_xyz", false) - assert.Error(err) - }) - - t.Run("DeleteAndRecreate", func(t *testing.T) { - // First create an extension - ext, err := mgr.CreateExtension(context.TODO(), schema.ExtensionMeta{ - Name: "file_fdw", - Database: "postgres", - }, false) - if !assert.NoError(err) { - t.FailNow() - } - assert.Equal("file_fdw", ext.Name) - assert.NotNil(ext.InstalledVersion) - - // Delete it - err = mgr.DeleteExtension(context.TODO(), "postgres", "file_fdw", false) - assert.NoError(err) - - // Verify it's not installed anymore - ext2, err := mgr.GetExtension(context.TODO(), "file_fdw") - if !assert.NoError(err) { - t.FailNow() - } - assert.Nil(ext2.InstalledVersion) - }) - - t.Run("DeleteWithCascade", func(t *testing.T) { - // Create an extension first - _, err := mgr.CreateExtension(context.TODO(), schema.ExtensionMeta{ - Name: "file_fdw", - Database: "postgres", - }, false) - if !assert.NoError(err) { - t.FailNow() - } - - // Delete with cascade - err = mgr.DeleteExtension(context.TODO(), "postgres", "file_fdw", true) - assert.NoError(err) - }) -} diff --git a/pkg/manager/httpclient/doc.go b/pkg/manager/httpclient/doc.go deleted file mode 100644 index 3bbe333..0000000 --- a/pkg/manager/httpclient/doc.go +++ /dev/null @@ -1,15 +0,0 @@ -// Package httpclient provides a typed Go client for consuming the PostgreSQL -// management REST API. -// -// Create a client with: -// -// client, err := httpclient.New("http://localhost:8080/api/v1") -// if err != nil { -// panic(err) -// } -// -// Then use the client to query resources: -// -// roles, err := client.ListRoles(ctx) -// databases, err := client.ListDatabases(ctx, httpclient.WithDatabase("mydb")) -package httpclient diff --git a/pkg/manager/httpclient/extension.go b/pkg/manager/httpclient/extension.go deleted file mode 100644 index 5bd65d7..0000000 --- a/pkg/manager/httpclient/extension.go +++ /dev/null @@ -1,90 +0,0 @@ -package httpclient - -import ( - "context" - "net/http" - - // Packages - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" - client "github.com/mutablelogic/go-client" -) - -/////////////////////////////////////////////////////////////////////////////// -// PUBLIC METHODS - -func (c *Client) ListExtensions(ctx context.Context, opts ...Opt) (*schema.ExtensionList, error) { - req := client.NewRequest() - - // Apply options - opt, err := applyOpts(opts...) - if err != nil { - return nil, err - } - - // Perform request - var response schema.ExtensionList - if err := c.DoWithContext(ctx, req, &response, client.OptPath("extension"), client.OptQuery(opt.Values)); err != nil { - return nil, err - } - - // Return the responses - return &response, nil -} - -func (c *Client) GetExtension(ctx context.Context, name string) (*schema.Extension, error) { - req := client.NewRequest() - - // Perform request - var response schema.Extension - if err := c.DoWithContext(ctx, req, &response, client.OptPath("extension", name)); err != nil { - return nil, err - } - - // Return the responses - return &response, nil -} - -func (c *Client) CreateExtension(ctx context.Context, meta schema.ExtensionMeta, opts ...Opt) (*schema.Extension, error) { - opt, err := applyOpts(opts...) - if err != nil { - return nil, err - } - - req, err := client.NewJSONRequest(meta) - if err != nil { - return nil, err - } - - // Perform request - var response schema.Extension - if err := c.DoWithContext(ctx, req, &response, client.OptPath("extension"), client.OptQuery(opt.Values)); err != nil { - return nil, err - } - - // Return the responses - return &response, nil -} - -func (c *Client) DeleteExtension(ctx context.Context, name string, opts ...Opt) error { - opt, err := applyOpts(opts...) - if err != nil { - return err - } - return c.DoWithContext(ctx, client.MethodDelete, nil, client.OptPath("extension", name), client.OptQuery(opt.Values)) -} - -func (c *Client) UpdateExtension(ctx context.Context, name string, meta schema.ExtensionMeta) (*schema.Extension, error) { - req, err := client.NewJSONRequestEx(http.MethodPatch, meta, "") - if err != nil { - return nil, err - } - - // Perform request - var response schema.Extension - if err := c.DoWithContext(ctx, req, &response, client.OptPath("extension", name)); err != nil { - return nil, err - } - - // Return the responses - return &response, nil -} diff --git a/pkg/manager/httpclient/object.go b/pkg/manager/httpclient/object.go deleted file mode 100644 index e4f0582..0000000 --- a/pkg/manager/httpclient/object.go +++ /dev/null @@ -1,59 +0,0 @@ -package httpclient - -import ( - "context" - - // Packages - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" - client "github.com/mutablelogic/go-client" -) - -/////////////////////////////////////////////////////////////////////////////// -// PUBLIC METHODS - -// ListObjects returns a list of objects. If database is non-empty, -// only objects from that database are returned. If namespace is also non-empty, -// objects are further filtered by schema. -func (c *Client) ListObjects(ctx context.Context, database, namespace string, opts ...Opt) (*schema.ObjectList, error) { - req := client.NewRequest() - - // Apply options - opt, err := applyOpts(opts...) - if err != nil { - return nil, err - } - - // Build path based on whether database/namespace is specified - var pathOpt client.RequestOpt - switch { - case database != "" && namespace != "": - pathOpt = client.OptPath("object", database, namespace) - case database != "": - pathOpt = client.OptPath("object", database) - default: - pathOpt = client.OptPath("object") - } - - // Perform request - var response schema.ObjectList - if err := c.DoWithContext(ctx, req, &response, pathOpt, client.OptQuery(opt.Values)); err != nil { - return nil, err - } - - // Return the responses - return &response, nil -} - -// GetObject returns an object by database, namespace (schema), and name. -func (c *Client) GetObject(ctx context.Context, database, namespace, name string) (*schema.Object, error) { - req := client.NewRequest() - - // Perform request - var response schema.Object - if err := c.DoWithContext(ctx, req, &response, client.OptPath("object", database, namespace, name)); err != nil { - return nil, err - } - - // Return the responses - return &response, nil -} diff --git a/pkg/manager/httpclient/opts.go b/pkg/manager/httpclient/opts.go deleted file mode 100644 index ab1fc06..0000000 --- a/pkg/manager/httpclient/opts.go +++ /dev/null @@ -1,143 +0,0 @@ -package httpclient - -import ( - "fmt" - "net/url" - - // Packages - types "github.com/mutablelogic/go-server/pkg/types" -) - -//////////////////////////////////////////////////////////////////////////////// -// TYPES - -type opt struct { - url.Values -} - -// Opt is an option to set on the client request. -type Opt func(*opt) error - -//////////////////////////////////////////////////////////////////////////////// -// LIFECYCLE - -func applyOpts(opts ...Opt) (*opt, error) { - o := new(opt) - o.Values = make(url.Values) - for _, opt := range opts { - if err := opt(o); err != nil { - return nil, err - } - } - return o, nil -} - -//////////////////////////////////////////////////////////////////////////////// -// OPTIONS - -// WithOffsetLimit sets offset and limit query parameters. -func WithOffsetLimit(offset uint64, limit *uint64) Opt { - return func(o *opt) error { - if offset > 0 { - o.Set("offset", fmt.Sprint(offset)) - } - if limit != nil { - o.Set("limit", fmt.Sprint(*limit)) - } - return nil - } -} - -func WithForce(v bool) Opt { - if v { - return OptSet("force", fmt.Sprint(v)) - } else { - return OptSet("force", "") - } -} - -func WithDatabase(v *string) Opt { - return OptSet("database", types.PtrString(v)) -} - -func WithRole(v *string) Opt { - return OptSet("role", types.PtrString(v)) -} - -func WithState(v *string) Opt { - return OptSet("state", types.PtrString(v)) -} - -func WithSchema(v *string) Opt { - return OptSet("schema", types.PtrString(v)) -} - -func WithCategory(v *string) Opt { - return OptSet("category", types.PtrString(v)) -} - -func WithReload(v bool) Opt { - if v { - return OptSet("reload", "true") - } - return OptSet("reload", "") -} - -func WithType(v *string) Opt { - return OptSet("type", types.PtrString(v)) -} - -func WithInstalled(v *bool) Opt { - return func(o *opt) error { - if v == nil { - o.Del("installed") - } else if *v { - o.Set("installed", "true") - } else { - o.Set("installed", "false") - } - return nil - } -} - -func OptDatabase(v string) Opt { - return OptSet("database", v) -} - -func OptRole(v string) Opt { - return OptSet("role", v) -} - -func OptState(v string) Opt { - return OptSet("state", v) -} - -func OptCascade(v bool) Opt { - if v { - return OptSet("cascade", "true") - } - return OptSet("cascade", "") -} - -func WithOrderBy(v string) Opt { - return OptSet("order_by", v) -} - -func WithOrderDir(v string) Opt { - return OptSet("order_dir", v) -} - -func WithSort(v string) Opt { - return OptSet("sort", v) -} - -func OptSet(k, v string) Opt { - return func(o *opt) error { - if v == "" { - o.Del(k) - } else { - o.Set(k, v) - } - return nil - } -} diff --git a/pkg/manager/httpclient/setting.go b/pkg/manager/httpclient/setting.go deleted file mode 100644 index 69c8bb5..0000000 --- a/pkg/manager/httpclient/setting.go +++ /dev/null @@ -1,80 +0,0 @@ -package httpclient - -import ( - "context" - "net/http" - - // Packages - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" - client "github.com/mutablelogic/go-client" -) - -/////////////////////////////////////////////////////////////////////////////// -// PUBLIC METHODS - -func (c *Client) ListSettings(ctx context.Context, opts ...Opt) (*schema.SettingList, error) { - req := client.NewRequest() - - // Apply options - opt, err := applyOpts(opts...) - if err != nil { - return nil, err - } - - // Perform request - var response schema.SettingList - if err := c.DoWithContext(ctx, req, &response, client.OptPath("setting"), client.OptQuery(opt.Values)); err != nil { - return nil, err - } - - // Return the responses - return &response, nil -} - -func (c *Client) ListSettingCategories(ctx context.Context) (*schema.SettingCategoryList, error) { - req := client.NewRequest() - - // Perform request - var response schema.SettingCategoryList - if err := c.DoWithContext(ctx, req, &response, client.OptPath("setting", "category")); err != nil { - return nil, err - } - - // Return the responses - return &response, nil -} - -func (c *Client) GetSetting(ctx context.Context, name string) (*schema.Setting, error) { - req := client.NewRequest() - - // Perform request - var response schema.Setting - if err := c.DoWithContext(ctx, req, &response, client.OptPath("setting", name)); err != nil { - return nil, err - } - - // Return the responses - return &response, nil -} - -func (c *Client) UpdateSetting(ctx context.Context, name string, meta schema.SettingMeta, opts ...Opt) (*schema.Setting, error) { - req, err := client.NewJSONRequestEx(http.MethodPatch, meta, "") - if err != nil { - return nil, err - } - - // Apply options - opt, err := applyOpts(opts...) - if err != nil { - return nil, err - } - - // Perform request - var response schema.Setting - if err := c.DoWithContext(ctx, req, &response, client.OptPath("setting", name), client.OptQuery(opt.Values)); err != nil { - return nil, err - } - - // Return the responses - return &response, nil -} diff --git a/pkg/manager/httpclient/statement.go b/pkg/manager/httpclient/statement.go deleted file mode 100644 index 2498957..0000000 --- a/pkg/manager/httpclient/statement.go +++ /dev/null @@ -1,38 +0,0 @@ -package httpclient - -import ( - "context" - - // Packages - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" - client "github.com/mutablelogic/go-client" -) - -/////////////////////////////////////////////////////////////////////////////// -// PUBLIC METHODS - -// ListStatements returns statement statistics from pg_stat_statements. -// Supports filtering by database, user, and ordering. -func (c *Client) ListStatements(ctx context.Context, opts ...Opt) (*schema.StatementList, error) { - req := client.NewRequest() - - // Apply options - opt, err := applyOpts(opts...) - if err != nil { - return nil, err - } - - // Perform request - var response schema.StatementList - if err := c.DoWithContext(ctx, req, &response, client.OptPath("statement"), client.OptQuery(opt.Values)); err != nil { - return nil, err - } - - // Return the responses - return &response, nil -} - -// ResetStatements resets all statement statistics. -func (c *Client) ResetStatements(ctx context.Context) error { - return c.DoWithContext(ctx, client.MethodDelete, nil, client.OptPath("statement")) -} diff --git a/pkg/manager/httphandler/connection.go b/pkg/manager/httphandler/connection.go deleted file mode 100644 index 3cdee71..0000000 --- a/pkg/manager/httphandler/connection.go +++ /dev/null @@ -1,88 +0,0 @@ -package httphandler - -import ( - "net/http" - "strconv" - - // Packages - manager "github.com/mutablelogic/go-pg/pkg/manager" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" - httprequest "github.com/mutablelogic/go-server/pkg/httprequest" - httpresponse "github.com/mutablelogic/go-server/pkg/httpresponse" -) - -/////////////////////////////////////////////////////////////////////////////// -// PUBLIC METHODS - -// RegisterConnectionHandlers registers HTTP handlers for connection operations -// on the provided router with the given path prefix. The manager must be non-nil. -func RegisterConnectionHandlers(router *http.ServeMux, prefix string, manager *manager.Manager) { - if manager == nil { - panic("manager is nil") - } - router.HandleFunc(joinPath(prefix, "connection"), func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - _ = connectionList(w, r, manager) - default: - _ = httpresponse.Error(w, httpresponse.Err(http.StatusMethodNotAllowed), r.Method) - } - }) - - router.HandleFunc(joinPath(prefix, "connection/{pid}"), func(w http.ResponseWriter, r *http.Request) { - pid, err := strconv.ParseUint(r.PathValue("pid"), 10, 64) - if err != nil { - _ = httpresponse.Error(w, httpresponse.ErrBadRequest.With("missing or invalid pid")) - return - } - - switch r.Method { - case http.MethodGet: - _ = connectionGet(w, r, manager, pid) - case http.MethodDelete: - _ = connectionDelete(w, r, manager, pid) - default: - _ = httpresponse.Error(w, httpresponse.Err(http.StatusMethodNotAllowed), r.Method) - } - }) -} - -/////////////////////////////////////////////////////////////////////////////// -// PRIVATE METHODS - -func connectionList(w http.ResponseWriter, r *http.Request, manager *manager.Manager) error { - // Parse request - var req schema.ConnectionListRequest - if err := httprequest.Query(r.URL.Query(), &req); err != nil { - return httpresponse.Error(w, err) - } - - // List the connections - response, err := manager.ListConnections(r.Context(), req) - if err != nil { - return httpresponse.Error(w, httperr(err)) - } - - // Return success - return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), response) -} - -func connectionGet(w http.ResponseWriter, r *http.Request, manager *manager.Manager, pid uint64) error { - connection, err := manager.GetConnection(r.Context(), pid) - if err != nil { - return httpresponse.Error(w, httperr(err)) - } - - // Return success - return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), connection) -} - -func connectionDelete(w http.ResponseWriter, r *http.Request, manager *manager.Manager, pid uint64) error { - _, err := manager.DeleteConnection(r.Context(), pid) - if err != nil { - return httpresponse.Error(w, httperr(err)) - } - - // Return success - return httpresponse.Empty(w, http.StatusOK) -} diff --git a/pkg/manager/httphandler/connection_test.go b/pkg/manager/httphandler/connection_test.go deleted file mode 100644 index 2f8d4ce..0000000 --- a/pkg/manager/httphandler/connection_test.go +++ /dev/null @@ -1,240 +0,0 @@ -package httphandler_test - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "strconv" - "testing" - - // Packages - httprequest "github.com/mutablelogic/go-pg/pkg/manager/httphandler" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" - test "github.com/mutablelogic/go-pg/pkg/test" - assert "github.com/stretchr/testify/assert" -) - -/////////////////////////////////////////////////////////////////////////////// -// TESTS - -func Test_Connection_RegisterHandlers(t *testing.T) { - assert := assert.New(t) - - // Create manager with test container - manager := test.NewManager(t) - t.Cleanup(func() { - manager.Close() - }) - - t.Run("PanicOnNilManager", func(t *testing.T) { - router := http.NewServeMux() - assert.Panics(func() { - httprequest.RegisterConnectionHandlers(router, "/api", nil) - }) - }) - - t.Run("RegisterSuccess", func(t *testing.T) { - router := http.NewServeMux() - assert.NotPanics(func() { - httprequest.RegisterConnectionHandlers(router, "/api", manager.Manager) - }) - }) -} - -func Test_Connection_List(t *testing.T) { - assert := assert.New(t) - - // Create manager with test container - manager := test.NewManager(t) - t.Cleanup(func() { - manager.Close() - }) - - router := http.NewServeMux() - httprequest.RegisterConnectionHandlers(router, "/api", manager.Manager) - - t.Run("ListConnections", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/connection", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusOK, w.Code) - assert.Contains(w.Header().Get("Content-Type"), "application/json") - - var resp schema.ConnectionList - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(err) - assert.GreaterOrEqual(int(resp.Count), 1) // At least our own connection - }) - - t.Run("ListConnectionsWithPagination", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/connection?limit=1", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusOK, w.Code) - - var resp schema.ConnectionList - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(err) - assert.LessOrEqual(len(resp.Body), 1) - }) - - t.Run("ListConnectionsByDatabase", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/connection?database=postgres", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusOK, w.Code) - - var resp schema.ConnectionList - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(err) - - // All connections should be from the postgres database - for _, conn := range resp.Body { - assert.Equal("postgres", conn.Database) - } - }) - - t.Run("ListConnectionsByState", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/connection?state=active", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusOK, w.Code) - - var resp schema.ConnectionList - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(err) - - // All connections should have active state - for _, conn := range resp.Body { - assert.Equal("active", conn.State) - } - }) - - t.Run("MethodNotAllowed", func(t *testing.T) { - req := httptest.NewRequest(http.MethodPost, "/api/connection", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusMethodNotAllowed, w.Code) - }) -} - -func Test_Connection_Get(t *testing.T) { - assert := assert.New(t) - - // Create manager with test container - manager := test.NewManager(t) - t.Cleanup(func() { - manager.Close() - }) - - router := http.NewServeMux() - httprequest.RegisterConnectionHandlers(router, "/api", manager.Manager) - - t.Run("GetExisting", func(t *testing.T) { - // First list connections to get a valid PID - listReq := httptest.NewRequest(http.MethodGet, "/api/connection", nil) - listW := httptest.NewRecorder() - router.ServeHTTP(listW, listReq) - - var listResp schema.ConnectionList - err := json.Unmarshal(listW.Body.Bytes(), &listResp) - if !assert.NoError(err) || listResp.Count == 0 { - t.Skip("No connections available to test") - } - - pid := listResp.Body[0].Pid - req := httptest.NewRequest(http.MethodGet, "/api/connection/"+strconv.Itoa(int(pid)), nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusOK, w.Code) - assert.Contains(w.Header().Get("Content-Type"), "application/json") - - var resp schema.Connection - err = json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(err) - assert.Equal(pid, resp.Pid) - }) - - t.Run("GetNotFound", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/connection/999999999", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusNotFound, w.Code) - }) - - t.Run("GetInvalidPid", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/connection/invalid", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusBadRequest, w.Code) - }) - - t.Run("GetZeroPid", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/connection/0", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusBadRequest, w.Code) - }) - - t.Run("MethodNotAllowed", func(t *testing.T) { - req := httptest.NewRequest(http.MethodPost, "/api/connection/123", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusMethodNotAllowed, w.Code) - }) -} - -func Test_Connection_Delete(t *testing.T) { - assert := assert.New(t) - - // Create manager with test container - manager := test.NewManager(t) - t.Cleanup(func() { - manager.Close() - }) - - router := http.NewServeMux() - httprequest.RegisterConnectionHandlers(router, "/api", manager.Manager) - - t.Run("DeleteInvalidPid", func(t *testing.T) { - req := httptest.NewRequest(http.MethodDelete, "/api/connection/invalid", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusBadRequest, w.Code) - }) - - t.Run("DeleteZeroPid", func(t *testing.T) { - req := httptest.NewRequest(http.MethodDelete, "/api/connection/0", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusBadRequest, w.Code) - }) - - // Note: We don't test DeleteConnection with a valid PID here because - // terminating connections during tests could be disruptive. - // The manager-level tests verify the functionality. -} diff --git a/pkg/manager/httphandler/database.go b/pkg/manager/httphandler/database.go deleted file mode 100644 index 46a02f8..0000000 --- a/pkg/manager/httphandler/database.go +++ /dev/null @@ -1,139 +0,0 @@ -package httphandler - -import ( - "net/http" - "strings" - - // Packages - manager "github.com/mutablelogic/go-pg/pkg/manager" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" - httprequest "github.com/mutablelogic/go-server/pkg/httprequest" - httpresponse "github.com/mutablelogic/go-server/pkg/httpresponse" -) - -/////////////////////////////////////////////////////////////////////////////// -// PUBLIC METHODS - -// RegisterDatabaseHandlers registers HTTP handlers for database CRUD operations -// on the provided router with the given path prefix. The manager must be non-nil. -func RegisterDatabaseHandlers(router *http.ServeMux, prefix string, manager *manager.Manager) { - if manager == nil { - panic("manager is nil") - } - router.HandleFunc(joinPath(prefix, "database"), func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - _ = databaseList(w, r, manager) - case http.MethodPost: - _ = databaseCreate(w, r, manager) - default: - _ = httpresponse.Error(w, httpresponse.Err(http.StatusMethodNotAllowed), r.Method) - } - }) - - router.HandleFunc(joinPath(prefix, "database/{name}"), func(w http.ResponseWriter, r *http.Request) { - name := r.PathValue("name") - if name == "" { - _ = httpresponse.Error(w, httpresponse.ErrBadRequest.With("missing or invalid database name")) - return - } - if strings.HasPrefix(name, "pg_") { - _ = httpresponse.Error(w, httpresponse.ErrBadRequest.With("database name cannot start with reserved prefix 'pg_'")) - return - } - - switch r.Method { - case http.MethodGet: - _ = databaseGet(w, r, manager, name) - case http.MethodPatch: - _ = databaseUpdate(w, r, manager, name) - case http.MethodDelete: - _ = databaseDelete(w, r, manager, name) - default: - _ = httpresponse.Error(w, httpresponse.Err(http.StatusMethodNotAllowed), r.Method) - } - }) -} - -/////////////////////////////////////////////////////////////////////////////// -// PRIVATE METHODS - -func databaseGet(w http.ResponseWriter, r *http.Request, manager *manager.Manager, name string) error { - database, err := manager.GetDatabase(r.Context(), name) - if err != nil { - return httpresponse.Error(w, httperr(err)) - } - - // Return success - return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), database) -} - -func databaseList(w http.ResponseWriter, r *http.Request, manager *manager.Manager) error { - // Parse request - var req schema.DatabaseListRequest - if err := httprequest.Query(r.URL.Query(), &req); err != nil { - return httpresponse.Error(w, err) - } - - // List the databases - response, err := manager.ListDatabases(r.Context(), req) - if err != nil { - return httpresponse.Error(w, httperr(err)) - } - - // Return success - return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), response) -} - -func databaseCreate(w http.ResponseWriter, r *http.Request, manager *manager.Manager) error { - // Parse request - var req schema.DatabaseMeta - if err := httprequest.Read(r, &req); err != nil { - return httpresponse.Error(w, err) - } - - // Create the database - response, err := manager.CreateDatabase(r.Context(), req) - if err != nil { - return httpresponse.Error(w, httperr(err)) - } - - // Return success - return httpresponse.JSON(w, http.StatusCreated, httprequest.Indent(r), response) -} - -func databaseDelete(w http.ResponseWriter, r *http.Request, manager *manager.Manager, name string) error { - // Parse the query - var req struct { - Force bool `json:"force,omitempty" help:"Force delete"` - } - if err := httprequest.Query(r.URL.Query(), &req); err != nil { - return httpresponse.Error(w, err) - } - - // Delete the database - _, err := manager.DeleteDatabase(r.Context(), name, req.Force) - if err != nil { - return httpresponse.Error(w, httperr(err)) - } - - // Return success - return httpresponse.Empty(w, http.StatusOK) -} - -func databaseUpdate(w http.ResponseWriter, r *http.Request, manager *manager.Manager, name string) error { - // Parse request - var req schema.DatabaseMeta - if err := httprequest.Read(r, &req); err != nil { - return httpresponse.Error(w, err) - } - - // Perform update - database, err := manager.UpdateDatabase(r.Context(), name, req) - if err != nil { - return httpresponse.Error(w, httperr(err)) - } - - // Return success - return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), database) -} diff --git a/pkg/manager/httphandler/database_test.go b/pkg/manager/httphandler/database_test.go deleted file mode 100644 index ed36924..0000000 --- a/pkg/manager/httphandler/database_test.go +++ /dev/null @@ -1,429 +0,0 @@ -package httphandler_test - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - // Packages - httprequest "github.com/mutablelogic/go-pg/pkg/manager/httphandler" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" - test "github.com/mutablelogic/go-pg/pkg/test" - assert "github.com/stretchr/testify/assert" -) - -/////////////////////////////////////////////////////////////////////////////// -// TESTS - -func Test_Database_RegisterHandlers(t *testing.T) { - assert := assert.New(t) - - // Create manager with test container - manager := test.NewManager(t) - t.Cleanup(func() { - manager.Close() - }) - - t.Run("PanicOnNilManager", func(t *testing.T) { - router := http.NewServeMux() - assert.Panics(func() { - httprequest.RegisterDatabaseHandlers(router, "/api", nil) - }) - }) - - t.Run("RegisterSuccess", func(t *testing.T) { - router := http.NewServeMux() - assert.NotPanics(func() { - httprequest.RegisterDatabaseHandlers(router, "/api", manager.Manager) - }) - }) -} - -func Test_Database_List(t *testing.T) { - assert := assert.New(t) - - // Create manager with test container - manager := test.NewManager(t) - t.Cleanup(func() { - manager.Close() - }) - - router := http.NewServeMux() - httprequest.RegisterDatabaseHandlers(router, "/api", manager.Manager) - - t.Run("ListDatabases", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/database", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusOK, w.Code) - assert.Contains(w.Header().Get("Content-Type"), "application/json") - - var resp schema.DatabaseList - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(err) - assert.GreaterOrEqual(int(resp.Count), 1) // At least the default database - }) - - t.Run("MethodNotAllowed", func(t *testing.T) { - req := httptest.NewRequest(http.MethodPut, "/api/database", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusMethodNotAllowed, w.Code) - }) -} - -func Test_Database_Get(t *testing.T) { - assert := assert.New(t) - - // Create manager with test container - manager := test.NewManager(t) - t.Cleanup(func() { - manager.Close() - }) - - router := http.NewServeMux() - httprequest.RegisterDatabaseHandlers(router, "/api", manager.Manager) - - t.Run("GetExisting", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/database/postgres", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusOK, w.Code) - assert.Contains(w.Header().Get("Content-Type"), "application/json") - - var resp schema.Database - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(err) - assert.Equal("postgres", resp.Name) - }) - - t.Run("GetNotFound", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/database/nonexistent_db_xyz", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusNotFound, w.Code) - }) - - t.Run("GetReservedPrefix", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/database/pg_reserved", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusBadRequest, w.Code) - }) - - t.Run("MethodNotAllowed", func(t *testing.T) { - req := httptest.NewRequest(http.MethodPost, "/api/database/postgres", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusMethodNotAllowed, w.Code) - }) -} - -func Test_Database_Create(t *testing.T) { - assert := assert.New(t) - - // Create manager with test container - manager := test.NewManager(t) - t.Cleanup(func() { - manager.Close() - }) - - router := http.NewServeMux() - httprequest.RegisterDatabaseHandlers(router, "/api", manager.Manager) - - t.Run("CreateSuccess", func(t *testing.T) { - body := `{"name": "test_http_create"}` - req := httptest.NewRequest(http.MethodPost, "/api/database", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusCreated, w.Code) - - var resp schema.Database - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(err) - assert.Equal("test_http_create", resp.Name) - - // Cleanup - t.Cleanup(func() { - _, _ = manager.DeleteDatabase(req.Context(), "test_http_create", true) - }) - }) - - t.Run("CreateEmptyName", func(t *testing.T) { - body := `{"name": ""}` - req := httptest.NewRequest(http.MethodPost, "/api/database", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusBadRequest, w.Code) - }) - - t.Run("CreateReservedPrefix", func(t *testing.T) { - body := `{"name": "pg_reserved_test"}` - req := httptest.NewRequest(http.MethodPost, "/api/database", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusBadRequest, w.Code) - }) - - t.Run("CreateInvalidJSON", func(t *testing.T) { - body := `{invalid json}` - req := httptest.NewRequest(http.MethodPost, "/api/database", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusBadRequest, w.Code) - }) - - t.Run("CreateDuplicate", func(t *testing.T) { - // First create - body := `{"name": "test_http_duplicate"}` - req := httptest.NewRequest(http.MethodPost, "/api/database", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - assert.Equal(http.StatusCreated, w.Code) - - // Second create should fail - req2 := httptest.NewRequest(http.MethodPost, "/api/database", bytes.NewBufferString(body)) - req2.Header.Set("Content-Type", "application/json") - w2 := httptest.NewRecorder() - - router.ServeHTTP(w2, req2) - // Duplicate database returns 500 (PostgreSQL error) - assert.Equal(http.StatusInternalServerError, w2.Code) - - // Cleanup - t.Cleanup(func() { - _, _ = manager.DeleteDatabase(req.Context(), "test_http_duplicate", true) - }) - }) -} - -func Test_Database_Delete(t *testing.T) { - assert := assert.New(t) - - // Create manager with test container - manager := test.NewManager(t) - t.Cleanup(func() { - manager.Close() - }) - - router := http.NewServeMux() - httprequest.RegisterDatabaseHandlers(router, "/api", manager.Manager) - - t.Run("DeleteExisting", func(t *testing.T) { - // First create a database - body := `{"name": "test_http_delete"}` - createReq := httptest.NewRequest(http.MethodPost, "/api/database", bytes.NewBufferString(body)) - createReq.Header.Set("Content-Type", "application/json") - createW := httptest.NewRecorder() - router.ServeHTTP(createW, createReq) - assert.Equal(http.StatusCreated, createW.Code) - - // Delete the database - req := httptest.NewRequest(http.MethodDelete, "/api/database/test_http_delete", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusOK, w.Code) - }) - - t.Run("DeleteWithForce", func(t *testing.T) { - // First create a database - body := `{"name": "test_http_delete_force"}` - createReq := httptest.NewRequest(http.MethodPost, "/api/database", bytes.NewBufferString(body)) - createReq.Header.Set("Content-Type", "application/json") - createW := httptest.NewRecorder() - router.ServeHTTP(createW, createReq) - assert.Equal(http.StatusCreated, createW.Code) - - // Delete with force - req := httptest.NewRequest(http.MethodDelete, "/api/database/test_http_delete_force?force=true", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusOK, w.Code) - }) - - t.Run("DeleteNotFound", func(t *testing.T) { - req := httptest.NewRequest(http.MethodDelete, "/api/database/nonexistent_db_xyz", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusNotFound, w.Code) - }) - - t.Run("DeleteReservedPrefix", func(t *testing.T) { - req := httptest.NewRequest(http.MethodDelete, "/api/database/pg_reserved", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusBadRequest, w.Code) - }) -} - -func Test_Database_Update(t *testing.T) { - assert := assert.New(t) - - // Create manager with test container - manager := test.NewManager(t) - t.Cleanup(func() { - manager.Close() - }) - - router := http.NewServeMux() - httprequest.RegisterDatabaseHandlers(router, "/api", manager.Manager) - - t.Run("UpdateRename", func(t *testing.T) { - // First create a database - body := `{"name": "test_http_update_old"}` - createReq := httptest.NewRequest(http.MethodPost, "/api/database", bytes.NewBufferString(body)) - createReq.Header.Set("Content-Type", "application/json") - createW := httptest.NewRecorder() - router.ServeHTTP(createW, createReq) - assert.Equal(http.StatusCreated, createW.Code) - - // Update (rename) the database - updateBody := `{"name": "test_http_update_new"}` - req := httptest.NewRequest(http.MethodPatch, "/api/database/test_http_update_old", bytes.NewBufferString(updateBody)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusOK, w.Code) - - var resp schema.Database - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(err) - assert.Equal("test_http_update_new", resp.Name) - - // Cleanup - t.Cleanup(func() { - _, _ = manager.DeleteDatabase(req.Context(), "test_http_update_new", true) - }) - }) - - t.Run("UpdateNotFound", func(t *testing.T) { - body := `{"name": "new_name"}` - req := httptest.NewRequest(http.MethodPatch, "/api/database/nonexistent_db_xyz", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusNotFound, w.Code) - }) - - t.Run("UpdateToReservedPrefix", func(t *testing.T) { - // First create a database - body := `{"name": "test_http_update_reserved"}` - createReq := httptest.NewRequest(http.MethodPost, "/api/database", bytes.NewBufferString(body)) - createReq.Header.Set("Content-Type", "application/json") - createW := httptest.NewRecorder() - router.ServeHTTP(createW, createReq) - assert.Equal(http.StatusCreated, createW.Code) - - // Try to rename to reserved prefix - updateBody := `{"name": "pg_reserved_name"}` - req := httptest.NewRequest(http.MethodPatch, "/api/database/test_http_update_reserved", bytes.NewBufferString(updateBody)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusBadRequest, w.Code) - - // Cleanup - t.Cleanup(func() { - _, _ = manager.DeleteDatabase(req.Context(), "test_http_update_reserved", true) - }) - }) - - t.Run("UpdateReservedPrefixSource", func(t *testing.T) { - body := `{"name": "new_name"}` - req := httptest.NewRequest(http.MethodPatch, "/api/database/pg_reserved", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusBadRequest, w.Code) - }) - - t.Run("UpdateInvalidJSON", func(t *testing.T) { - body := `{invalid json}` - req := httptest.NewRequest(http.MethodPatch, "/api/database/postgres", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusBadRequest, w.Code) - }) -} - -func Test_httperr(t *testing.T) { - assert := assert.New(t) - - // Create manager with test container - manager := test.NewManager(t) - t.Cleanup(func() { - manager.Close() - }) - - router := http.NewServeMux() - httprequest.RegisterDatabaseHandlers(router, "/api", manager.Manager) - - // Test error mapping through actual HTTP calls - t.Run("NotFoundMapsTo404", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/database/this_db_does_not_exist_xyz", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusNotFound, w.Code) - }) - - t.Run("BadParameterMapsTo400", func(t *testing.T) { - // Empty name in create - body := `{"name": ""}` - req := httptest.NewRequest(http.MethodPost, "/api/database", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusBadRequest, w.Code) - }) -} diff --git a/pkg/manager/httphandler/doc.go b/pkg/manager/httphandler/doc.go deleted file mode 100644 index 2299798..0000000 --- a/pkg/manager/httphandler/doc.go +++ /dev/null @@ -1,11 +0,0 @@ -// Package httphandler provides REST API endpoints for PostgreSQL management -// operations. -// -// Register all handlers with an http.ServeMux: -// -// httphandler.RegisterBackendHandlers(mux, "/api/v1", mgr) -// -// This registers endpoints for roles, databases, schemas, objects, tablespaces, -// extensions, connections, settings, statements, replication slots, and -// Prometheus metrics. -package httphandler diff --git a/pkg/manager/httphandler/extension.go b/pkg/manager/httphandler/extension.go deleted file mode 100644 index 6d4a764..0000000 --- a/pkg/manager/httphandler/extension.go +++ /dev/null @@ -1,139 +0,0 @@ -package httphandler - -import ( - "net/http" - - // Packages - manager "github.com/mutablelogic/go-pg/pkg/manager" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" - httprequest "github.com/mutablelogic/go-server/pkg/httprequest" - httpresponse "github.com/mutablelogic/go-server/pkg/httpresponse" -) - -/////////////////////////////////////////////////////////////////////////////// -// PUBLIC METHODS - -// RegisterExtensionHandlers registers HTTP handlers for extension CRUD operations -// on the provided router with the given path prefix. The manager must be non-nil. -func RegisterExtensionHandlers(router *http.ServeMux, prefix string, manager *manager.Manager) { - if manager == nil { - panic("manager is nil") - } - router.HandleFunc(joinPath(prefix, "extension"), func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - _ = extensionList(w, r, manager) - case http.MethodPost: - _ = extensionCreate(w, r, manager) - default: - _ = httpresponse.Error(w, httpresponse.Err(http.StatusMethodNotAllowed), r.Method) - } - }) - - router.HandleFunc(joinPath(prefix, "extension/{name}"), func(w http.ResponseWriter, r *http.Request) { - name := r.PathValue("name") - if name == "" { - _ = httpresponse.Error(w, httpresponse.ErrBadRequest.With("missing or invalid extension name")) - return - } - - switch r.Method { - case http.MethodGet: - _ = extensionGet(w, r, manager, name) - case http.MethodPatch: - _ = extensionUpdate(w, r, manager, name) - case http.MethodDelete: - _ = extensionDelete(w, r, manager, name) - default: - _ = httpresponse.Error(w, httpresponse.Err(http.StatusMethodNotAllowed), r.Method) - } - }) -} - -/////////////////////////////////////////////////////////////////////////////// -// PRIVATE METHODS - -func extensionList(w http.ResponseWriter, r *http.Request, manager *manager.Manager) error { - // Parse request - var req schema.ExtensionListRequest - if err := httprequest.Query(r.URL.Query(), &req); err != nil { - return httpresponse.Error(w, err) - } - - // List the extensions - response, err := manager.ListExtensions(r.Context(), req) - if err != nil { - return httpresponse.Error(w, httperr(err)) - } - - // Return success - return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), response) -} - -func extensionCreate(w http.ResponseWriter, r *http.Request, manager *manager.Manager) error { - // Parse request - var req schema.ExtensionMeta - if err := httprequest.Read(r, &req); err != nil { - return httpresponse.Error(w, err) - } - - // Parse cascade from query params - cascade := r.URL.Query().Get("cascade") == "true" - - // Create the extension - response, err := manager.CreateExtension(r.Context(), req, cascade) - if err != nil { - return httpresponse.Error(w, httperr(err)) - } - - // Return success - return httpresponse.JSON(w, http.StatusCreated, httprequest.Indent(r), response) -} - -func extensionGet(w http.ResponseWriter, r *http.Request, manager *manager.Manager, name string) error { - // Get the extension - response, err := manager.GetExtension(r.Context(), name) - if err != nil { - return httpresponse.Error(w, httperr(err)) - } - - // Return success - return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), response) -} - -func extensionDelete(w http.ResponseWriter, r *http.Request, manager *manager.Manager, name string) error { - // Parse the query - var req struct { - Database string `json:"database" help:"Database to delete extension from"` - Cascade bool `json:"cascade,omitempty" help:"Cascade delete to dependent objects"` - } - if err := httprequest.Query(r.URL.Query(), &req); err != nil { - return httpresponse.Error(w, err) - } - - // Delete the extension - err := manager.DeleteExtension(r.Context(), req.Database, name, req.Cascade) - if err != nil { - return httpresponse.Error(w, httperr(err)) - } - - // Return success - return httpresponse.Empty(w, http.StatusOK) -} - -func extensionUpdate(w http.ResponseWriter, r *http.Request, manager *manager.Manager, name string) error { - // Parse request - var req schema.ExtensionMeta - if err := httprequest.Read(r, &req); err != nil { - return httpresponse.Error(w, err) - } - - // Update the extension - response, err := manager.UpdateExtension(r.Context(), name, req) - if err != nil { - return httpresponse.Error(w, httperr(err)) - } - - // Return success - return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), response) -} diff --git a/pkg/manager/httphandler/extension_test.go b/pkg/manager/httphandler/extension_test.go deleted file mode 100644 index d626196..0000000 --- a/pkg/manager/httphandler/extension_test.go +++ /dev/null @@ -1,300 +0,0 @@ -package httphandler_test - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - // Packages - httphandler "github.com/mutablelogic/go-pg/pkg/manager/httphandler" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" - test "github.com/mutablelogic/go-pg/pkg/test" - assert "github.com/stretchr/testify/assert" -) - -/////////////////////////////////////////////////////////////////////////////// -// TESTS - -func Test_Extension_RegisterHandlers(t *testing.T) { - assert := assert.New(t) - - // Create manager with test container - manager := test.NewManager(t) - t.Cleanup(func() { - manager.Close() - }) - - t.Run("PanicOnNilManager", func(t *testing.T) { - router := http.NewServeMux() - assert.Panics(func() { - httphandler.RegisterExtensionHandlers(router, "/api", nil) - }) - }) - - t.Run("RegisterSuccess", func(t *testing.T) { - router := http.NewServeMux() - assert.NotPanics(func() { - httphandler.RegisterExtensionHandlers(router, "/api", manager.Manager) - }) - }) -} - -func Test_Extension_List(t *testing.T) { - assert := assert.New(t) - - // Create manager with test container - manager := test.NewManager(t) - t.Cleanup(func() { - manager.Close() - }) - - router := http.NewServeMux() - httphandler.RegisterExtensionHandlers(router, "/api", manager.Manager) - - t.Run("ListExtensions", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/extension", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusOK, w.Code) - assert.Contains(w.Header().Get("Content-Type"), "application/json") - - var resp schema.ExtensionList - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(err) - assert.GreaterOrEqual(int(resp.Count), 1) // At least some available extensions - }) - - t.Run("ListExtensionsWithPagination", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/extension?limit=1", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusOK, w.Code) - - var resp schema.ExtensionList - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(err) - assert.LessOrEqual(len(resp.Body), 1) - }) - - t.Run("ListInstalledOnly", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/extension?installed=true", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusOK, w.Code) - - var resp schema.ExtensionList - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(err) - // All returned extensions should have InstalledVersion set - for _, ext := range resp.Body { - assert.NotNil(ext.InstalledVersion, "Extension %s should have InstalledVersion", ext.Name) - } - }) - - t.Run("ListNotInstalledOnly", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/extension?installed=false", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusOK, w.Code) - - var resp schema.ExtensionList - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(err) - // All returned extensions should NOT have InstalledVersion set - for _, ext := range resp.Body { - assert.Nil(ext.InstalledVersion, "Extension %s should not have InstalledVersion", ext.Name) - } - }) - - t.Run("ListByDatabase", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/extension?database=postgres", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusOK, w.Code) - - var resp schema.ExtensionList - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(err) - // All returned extensions should be from the postgres database - for _, ext := range resp.Body { - assert.Equal("postgres", ext.Database) - } - }) - - t.Run("MethodNotAllowed", func(t *testing.T) { - req := httptest.NewRequest(http.MethodPut, "/api/extension", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusMethodNotAllowed, w.Code) - }) -} - -func Test_Extension_Get(t *testing.T) { - assert := assert.New(t) - - // Create manager with test container - manager := test.NewManager(t) - t.Cleanup(func() { - manager.Close() - }) - - router := http.NewServeMux() - httphandler.RegisterExtensionHandlers(router, "/api", manager.Manager) - - t.Run("GetExisting", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/extension/plpgsql", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusOK, w.Code) - - // Parse response - var ext schema.Extension - err := json.Unmarshal(w.Body.Bytes(), &ext) - assert.NoError(err) - assert.Equal("plpgsql", ext.Name) - }) - - t.Run("MethodNotAllowed", func(t *testing.T) { - req := httptest.NewRequest(http.MethodPost, "/api/extension/plpgsql", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusMethodNotAllowed, w.Code) - }) -} - -func Test_Extension_Create(t *testing.T) { - assert := assert.New(t) - - // Create manager with test container - manager := test.NewManager(t) - t.Cleanup(func() { - manager.Close() - }) - - router := http.NewServeMux() - httphandler.RegisterExtensionHandlers(router, "/api", manager.Manager) - - t.Run("CreateMissingDatabase", func(t *testing.T) { - body := `{"name": "hstore"}` - req := httptest.NewRequest(http.MethodPost, "/api/extension", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - // Create requires database field - assert.Equal(http.StatusBadRequest, w.Code) - }) - - t.Run("CreateEmptyName", func(t *testing.T) { - body := `{"name": "", "database": "postgres"}` - req := httptest.NewRequest(http.MethodPost, "/api/extension", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - // Returns bad request for empty name - assert.Equal(http.StatusBadRequest, w.Code) - }) - - t.Run("CreateWithSchemaMissingDatabase", func(t *testing.T) { - body := `{"name": "hstore", "schema": "public"}` - req := httptest.NewRequest(http.MethodPost, "/api/extension", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - // Create requires database field - assert.Equal(http.StatusBadRequest, w.Code) - }) - - t.Run("CreateWithVersionMissingDatabase", func(t *testing.T) { - body := `{"name": "hstore", "version": "1.8"}` - req := httptest.NewRequest(http.MethodPost, "/api/extension", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - // Create requires database field - assert.Equal(http.StatusBadRequest, w.Code) - }) -} - -func Test_Extension_Update(t *testing.T) { - assert := assert.New(t) - - // Create manager with test container - manager := test.NewManager(t) - t.Cleanup(func() { - manager.Close() - }) - - router := http.NewServeMux() - httphandler.RegisterExtensionHandlers(router, "/api", manager.Manager) - - t.Run("UpdateMissingDatabase", func(t *testing.T) { - body := `{"version": "1.0"}` - req := httptest.NewRequest(http.MethodPatch, "/api/extension/plpgsql", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - // Update requires database field - assert.Equal(http.StatusBadRequest, w.Code) - }) -} - -func Test_Extension_Delete(t *testing.T) { - assert := assert.New(t) - - // Create manager with test container - manager := test.NewManager(t) - t.Cleanup(func() { - manager.Close() - }) - - router := http.NewServeMux() - httphandler.RegisterExtensionHandlers(router, "/api", manager.Manager) - - t.Run("DeleteMissingDatabase", func(t *testing.T) { - req := httptest.NewRequest(http.MethodDelete, "/api/extension/hstore", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - // Delete requires database query parameter - assert.Equal(http.StatusBadRequest, w.Code) - }) - - t.Run("DeleteWithForceMissingDatabase", func(t *testing.T) { - req := httptest.NewRequest(http.MethodDelete, "/api/extension/hstore?force=true", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - // Delete requires database query parameter - assert.Equal(http.StatusBadRequest, w.Code) - }) -} diff --git a/pkg/manager/httphandler/frontend_excluded.go b/pkg/manager/httphandler/frontend_excluded.go deleted file mode 100644 index a012a77..0000000 --- a/pkg/manager/httphandler/frontend_excluded.go +++ /dev/null @@ -1,18 +0,0 @@ -//go:build !frontend - -package httphandler - -import ( - "net/http" - - // Packages - "github.com/mutablelogic/go-server/pkg/httpresponse" -) - -// RegisterFrontendHandler registers a fallback handler when frontend is not included -func RegisterFrontendHandler(router *http.ServeMux, prefix string, enabled bool) { - // Catch all handler returns a "not found" error - router.HandleFunc(joinPath(prefix, "/"), func(w http.ResponseWriter, r *http.Request) { - _ = httpresponse.Error(w, httpresponse.ErrNotFound, r.URL.String()) - }) -} diff --git a/pkg/manager/httphandler/frontend_included.go b/pkg/manager/httphandler/frontend_included.go deleted file mode 100644 index c8ca420..0000000 --- a/pkg/manager/httphandler/frontend_included.go +++ /dev/null @@ -1,38 +0,0 @@ -//go:build frontend - -//go:generate sh -c "wasmbuild build -o frontend ../../../wasm/pgmanager && mv frontend/wasm_exec.html frontend/index.html" - -package httphandler - -import ( - "embed" - "io/fs" - "net/http" - - // Packages - "github.com/mutablelogic/go-server/pkg/httpresponse" -) - -//go:embed frontend/* -var frontendFS embed.FS - -// RegisterFrontendHandler registers the frontend static file handler if enabled, -// otherwise registers a fallback handler that returns a not found error. -func RegisterFrontendHandler(router *http.ServeMux, prefix string, enabled bool) { - if !enabled { - // Fallback handler returns a "not found" error - router.HandleFunc(joinPath(prefix, "/"), func(w http.ResponseWriter, r *http.Request) { - _ = httpresponse.Error(w, httpresponse.ErrNotFound, r.URL.String()) - }) - return - } - - // Get the subdirectory to strip the "frontend" prefix - subFS, err := fs.Sub(frontendFS, "frontend") - if err != nil { - panic(err) - } - - // Serve static files from the embedded frontend folder - router.Handle(joinPath(prefix, "/"), http.StripPrefix(prefix, http.FileServer(http.FS(subFS)))) -} diff --git a/pkg/manager/httphandler/httphandler.go b/pkg/manager/httphandler/httphandler.go deleted file mode 100644 index 74d8841..0000000 --- a/pkg/manager/httphandler/httphandler.go +++ /dev/null @@ -1,65 +0,0 @@ -package httphandler - -import ( - "errors" - "net/http" - - // Packages - pg "github.com/mutablelogic/go-pg" - manager "github.com/mutablelogic/go-pg/pkg/manager" - httpresponse "github.com/mutablelogic/go-server/pkg/httpresponse" - types "github.com/mutablelogic/go-server/pkg/types" -) - -/////////////////////////////////////////////////////////////////////////////// -// PUBLIC METHODS - -func RegisterBackendHandlers(router *http.ServeMux, prefix string, manager *manager.Manager) { - RegisterConnectionHandlers(router, prefix, manager) - RegisterDatabaseHandlers(router, prefix, manager) - RegisterExtensionHandlers(router, prefix, manager) - RegisterMetricsHandler(router, prefix, manager) - RegisterObjectHandlers(router, prefix, manager) - RegisterReplicationSlotHandlers(router, prefix, manager) - RegisterRoleHandlers(router, prefix, manager) - RegisterSchemaHandlers(router, prefix, manager) - RegisterSettingHandlers(router, prefix, manager) - RegisterStatementHandlers(router, prefix, manager) - RegisterTablespaceHandlers(router, prefix, manager) -} - -/////////////////////////////////////////////////////////////////////////////// -// PRIVATE METHODS - -func joinPath(prefix, path string) string { - return types.JoinPath(prefix, path) -} - -// httperr converts pg errors to appropriate HTTP errors. -// Returns the original error if it's already an httpresponse.Err, -// otherwise maps pg errors to their HTTP equivalents. -func httperr(err error) error { - if err == nil { - return nil - } - - // If already an HTTP error, return as-is - var httpErr httpresponse.Err - if errors.As(err, &httpErr) { - return err - } - - // Map pg errors to HTTP errors - switch { - case errors.Is(err, pg.ErrNotFound): - return httpresponse.ErrNotFound.With(err.Error()) - case errors.Is(err, pg.ErrBadParameter): - return httpresponse.ErrBadRequest.With(err.Error()) - case errors.Is(err, pg.ErrNotImplemented): - return httpresponse.ErrNotImplemented.With(err.Error()) - case errors.Is(err, pg.ErrNotAvailable): - return httpresponse.ErrNotImplemented.With(err.Error()) - default: - return httpresponse.ErrInternalError.With(err.Error()) - } -} diff --git a/pkg/manager/httphandler/metrics.go b/pkg/manager/httphandler/metrics.go deleted file mode 100644 index 5e0e61c..0000000 --- a/pkg/manager/httphandler/metrics.go +++ /dev/null @@ -1,374 +0,0 @@ -package httphandler - -import ( - "context" - "net/http" - "sync" - "time" - - // Packages - pg "github.com/mutablelogic/go-pg" - manager "github.com/mutablelogic/go-pg/pkg/manager" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" - httpresponse "github.com/mutablelogic/go-server/pkg/httpresponse" - prometheus "github.com/prometheus/client_golang/prometheus" - promhttp "github.com/prometheus/client_golang/prometheus/promhttp" -) - -/////////////////////////////////////////////////////////////////////////////// -// CONSTANTS - -const ( - metricsTimeout = 30 * time.Second -) - -/////////////////////////////////////////////////////////////////////////////// -// PUBLIC METHODS - -type metrics struct { - manager *manager.Manager - connections *prometheus.Desc - databaseSize *prometheus.Desc - tablespaceSize *prometheus.Desc - tableSize *prometheus.Desc - indexSize *prometheus.Desc - deadTupleRatio *prometheus.Desc - replicationSlots *prometheus.Desc - replicationLagBytes *prometheus.Desc - replicationLagMs *prometheus.Desc -} - -// RegisterMetricsHandler registers a HTTP handler for prometheus metrics -// on the provided router with the given path prefix. The manager must be non-nil. -func RegisterMetricsHandler(router *http.ServeMux, prefix string, manager *manager.Manager) { - if manager == nil { - panic("manager is nil") - } - - // Create a prometheus registry - registry := prometheus.NewRegistry() - registry.MustRegister(&metrics{ - manager: manager, - connections: prometheus.NewDesc( - "pg_connections", - "Number of connections to the database server", - []string{"database", "state"}, nil, - ), - databaseSize: prometheus.NewDesc( - "pg_database_size_bytes", - "Size of database in bytes", - []string{"database"}, nil, - ), - tablespaceSize: prometheus.NewDesc( - "pg_tablespace_size_bytes", - "Size of tablespace in bytes", - []string{"tablespace"}, nil, - ), - tableSize: prometheus.NewDesc( - "pg_table_size_bytes", - "Size of table in bytes", - []string{"database", "schema", "table"}, nil, - ), - indexSize: prometheus.NewDesc( - "pg_index_size_bytes", - "Size of index in bytes", - []string{"database", "schema", "index"}, nil, - ), - deadTupleRatio: prometheus.NewDesc( - "pg_table_dead_tuple_ratio", - "Ratio of dead tuples to total tuples (0.0-1.0)", - []string{"database", "schema", "table"}, nil, - ), - replicationSlots: prometheus.NewDesc( - "pg_replication_slots", - "Number of replication slots by status", - []string{"status"}, nil, - ), - replicationLagBytes: prometheus.NewDesc( - "pg_replication_lag_bytes", - "Replication lag in bytes", - []string{"slot", "type"}, nil, - ), - replicationLagMs: prometheus.NewDesc( - "pg_replication_lag_ms", - "Replication lag in milliseconds", - []string{"slot", "type"}, nil, - ), - }) - handler := promhttp.HandlerFor(registry, promhttp.HandlerOpts{}) - - // Create a handler for metrics - router.HandleFunc(joinPath(prefix, "metrics"), func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - handler.ServeHTTP(w, r) - default: - _ = httpresponse.Error(w, httpresponse.Err(http.StatusMethodNotAllowed), r.Method) - } - }) -} - -/////////////////////////////////////////////////////////////////////////////// -// PUBLIC METHODS - COLLECTOR - -// Describe sends metric descriptors to the channel -func (m *metrics) Describe(ch chan<- *prometheus.Desc) { - ch <- m.connections - ch <- m.databaseSize - ch <- m.tablespaceSize - ch <- m.tableSize - ch <- m.indexSize - ch <- m.deadTupleRatio - ch <- m.replicationSlots - ch <- m.replicationLagBytes - ch <- m.replicationLagMs -} - -// Collect fetches metrics from the database and sends them to the channel -func (m *metrics) Collect(ch chan<- prometheus.Metric) { - ctx, cancel := context.WithTimeout(context.Background(), metricsTimeout) - defer cancel() - - var wg sync.WaitGroup - - wg.Add(1) - go func() { - defer wg.Done() - if err := m.collectConnections(ctx, ch); err != nil { - ch <- prometheus.NewInvalidMetric(m.connections, err) - } - }() - - wg.Add(1) - go func() { - defer wg.Done() - if err := m.collectDatabaseSize(ctx, ch); err != nil { - ch <- prometheus.NewInvalidMetric(m.databaseSize, err) - } - }() - - wg.Add(1) - go func() { - defer wg.Done() - if err := m.collectTablespaceSize(ctx, ch); err != nil { - ch <- prometheus.NewInvalidMetric(m.tablespaceSize, err) - } - }() - - wg.Add(1) - go func() { - defer wg.Done() - if err := m.collectObjectSize(ctx, ch); err != nil { - ch <- prometheus.NewInvalidMetric(m.tableSize, err) - ch <- prometheus.NewInvalidMetric(m.indexSize, err) - ch <- prometheus.NewInvalidMetric(m.deadTupleRatio, err) - } - }() - - wg.Add(1) - go func() { - defer wg.Done() - if err := m.collectReplicationSlots(ctx, ch); err != nil { - ch <- prometheus.NewInvalidMetric(m.replicationSlots, err) - ch <- prometheus.NewInvalidMetric(m.replicationLagBytes, err) - ch <- prometheus.NewInvalidMetric(m.replicationLagMs, err) - } - }() - - wg.Wait() -} - -/////////////////////////////////////////////////////////////////////////////// -// PRIVATE METHODS - -func (m *metrics) collectConnections(ctx context.Context, ch chan<- prometheus.Metric) error { - // Count connections by database and state - counts := make(map[string]map[string]float64) - - // Paginate through all connections - var offset uint64 - for { - req := schema.ConnectionListRequest{ - OffsetLimit: pg.OffsetLimit{ - Offset: offset, - }, - } - - // Get connections - list, err := m.manager.ListConnections(ctx, req) - if err != nil { - return err - } - - // Increment counts - for _, conn := range list.Body { - if counts[conn.Database] == nil { - counts[conn.Database] = make(map[string]float64) - } - counts[conn.Database][conn.State]++ - } - - // Check if we've fetched all connections - offset += uint64(len(list.Body)) - if offset >= list.Count || len(list.Body) == 0 { - break - } - } - - // Send metrics for each database/state combination - for database, states := range counts { - for state, count := range states { - ch <- prometheus.MustNewConstMetric(m.connections, prometheus.GaugeValue, count, database, state) - } - } - - return nil -} - -func (m *metrics) collectDatabaseSize(ctx context.Context, ch chan<- prometheus.Metric) error { - // Paginate through all databases - var offset uint64 - for { - req := schema.DatabaseListRequest{ - OffsetLimit: pg.OffsetLimit{ - Offset: offset, - }, - } - - list, err := m.manager.ListDatabases(ctx, req) - if err != nil { - return err - } - - for _, db := range list.Body { - ch <- prometheus.MustNewConstMetric(m.databaseSize, prometheus.GaugeValue, float64(db.Size), db.Name) - } - - // Check if we've fetched all databases - offset += uint64(len(list.Body)) - if offset >= list.Count || len(list.Body) == 0 { - break - } - } - - return nil -} - -func (m *metrics) collectTablespaceSize(ctx context.Context, ch chan<- prometheus.Metric) error { - // Paginate through all tablespaces - var offset uint64 - for { - req := schema.TablespaceListRequest{ - OffsetLimit: pg.OffsetLimit{ - Offset: offset, - }, - } - - list, err := m.manager.ListTablespaces(ctx, req) - if err != nil { - return err - } - - for _, ts := range list.Body { - ch <- prometheus.MustNewConstMetric(m.tablespaceSize, prometheus.GaugeValue, float64(ts.Size), ts.Name) - } - - // Check if we've fetched all tablespaces - offset += uint64(len(list.Body)) - if offset >= list.Count || len(list.Body) == 0 { - break - } - } - - return nil -} - -func (m *metrics) collectObjectSize(ctx context.Context, ch chan<- prometheus.Metric) error { - // Paginate through all objects - var offset uint64 - for { - req := schema.ObjectListRequest{ - OffsetLimit: pg.OffsetLimit{ - Offset: offset, - }, - } - - list, err := m.manager.ListObjects(ctx, req) - if err != nil { - return err - } - - for _, obj := range list.Body { - switch obj.Type { - case "TABLE", "PARTITIONED TABLE": - ch <- prometheus.MustNewConstMetric(m.tableSize, prometheus.GaugeValue, float64(obj.Size), obj.Database, obj.Schema, obj.Name) - // Calculate dead tuple ratio if we have tuple data - if obj.Table != nil && obj.Table.LiveTuples != nil && obj.Table.DeadTuples != nil { - live := *obj.Table.LiveTuples - dead := *obj.Table.DeadTuples - total := live + dead - if total > 0 { - ratio := float64(dead) / float64(total) - ch <- prometheus.MustNewConstMetric(m.deadTupleRatio, prometheus.GaugeValue, ratio, obj.Database, obj.Schema, obj.Name) - } - } - case "INDEX", "PARTITIONED INDEX": - ch <- prometheus.MustNewConstMetric(m.indexSize, prometheus.GaugeValue, float64(obj.Size), obj.Database, obj.Schema, obj.Name) - } - } - - // Check if we've fetched all objects - offset += uint64(len(list.Body)) - if offset >= list.Count || len(list.Body) == 0 { - break - } - } - - return nil -} - -func (m *metrics) collectReplicationSlots(ctx context.Context, ch chan<- prometheus.Metric) error { - // Count slots by status - statusCounts := make(map[string]float64) - - // Paginate through all replication slots - var offset uint64 - for { - req := schema.ReplicationSlotListRequest{ - OffsetLimit: pg.OffsetLimit{ - Offset: offset, - }, - } - - list, err := m.manager.ListReplicationSlots(ctx, req) - if err != nil { - return err - } - - for _, slot := range list.Body { - // Count by status - statusCounts[slot.Status]++ - - // Report lag metrics for active slots - if slot.LagBytes != nil { - ch <- prometheus.MustNewConstMetric(m.replicationLagBytes, prometheus.GaugeValue, float64(*slot.LagBytes), slot.Name, slot.Type) - } - if slot.LagMs != nil { - ch <- prometheus.MustNewConstMetric(m.replicationLagMs, prometheus.GaugeValue, *slot.LagMs, slot.Name, slot.Type) - } - } - - // Check if we've fetched all slots - offset += uint64(len(list.Body)) - if offset >= list.Count || len(list.Body) == 0 { - break - } - } - - // Send slot count metrics by status - for status, count := range statusCounts { - ch <- prometheus.MustNewConstMetric(m.replicationSlots, prometheus.GaugeValue, count, status) - } - - return nil -} diff --git a/pkg/manager/httphandler/object.go b/pkg/manager/httphandler/object.go deleted file mode 100644 index 5834fb6..0000000 --- a/pkg/manager/httphandler/object.go +++ /dev/null @@ -1,137 +0,0 @@ -package httphandler - -import ( - "net/http" - - // Packages - manager "github.com/mutablelogic/go-pg/pkg/manager" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" - httprequest "github.com/mutablelogic/go-server/pkg/httprequest" - httpresponse "github.com/mutablelogic/go-server/pkg/httpresponse" -) - -/////////////////////////////////////////////////////////////////////////////// -// PUBLIC METHODS - -// RegisterObjectHandlers registers HTTP handlers for object listing and retrieval -// on the provided router with the given path prefix. The manager must be non-nil. -func RegisterObjectHandlers(router *http.ServeMux, prefix string, manager *manager.Manager) { - if manager == nil { - panic("manager is nil") - } - - // List objects across all databases - router.HandleFunc(joinPath(prefix, "object"), func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - _ = objectList(w, r, manager, nil, nil, nil) - default: - _ = httpresponse.Error(w, httpresponse.Err(http.StatusMethodNotAllowed), r.Method) - } - }) - - // List objects in a specific database - router.HandleFunc(joinPath(prefix, "object/{database}"), func(w http.ResponseWriter, r *http.Request) { - database := r.PathValue("database") - if database == "" { - _ = httpresponse.Error(w, httpresponse.ErrBadRequest.With("missing or invalid database name")) - return - } - - switch r.Method { - case http.MethodGet: - _ = objectList(w, r, manager, &database, nil, nil) - default: - _ = httpresponse.Error(w, httpresponse.Err(http.StatusMethodNotAllowed), r.Method) - } - }) - - // List objects in a specific database and schema - router.HandleFunc(joinPath(prefix, "object/{database}/{schema}"), func(w http.ResponseWriter, r *http.Request) { - database := r.PathValue("database") - if database == "" { - _ = httpresponse.Error(w, httpresponse.ErrBadRequest.With("missing or invalid database name")) - return - } - namespace := r.PathValue("schema") - if namespace == "" { - _ = httpresponse.Error(w, httpresponse.ErrBadRequest.With("missing or invalid schema name")) - return - } - - switch r.Method { - case http.MethodGet: - _ = objectList(w, r, manager, &database, &namespace, nil) - default: - _ = httpresponse.Error(w, httpresponse.Err(http.StatusMethodNotAllowed), r.Method) - } - }) - - // Get a specific object - router.HandleFunc(joinPath(prefix, "object/{database}/{schema}/{name}"), func(w http.ResponseWriter, r *http.Request) { - database := r.PathValue("database") - if database == "" { - _ = httpresponse.Error(w, httpresponse.ErrBadRequest.With("missing or invalid database name")) - return - } - namespace := r.PathValue("schema") - if namespace == "" { - _ = httpresponse.Error(w, httpresponse.ErrBadRequest.With("missing or invalid schema name")) - return - } - name := r.PathValue("name") - if name == "" { - _ = httpresponse.Error(w, httpresponse.ErrBadRequest.With("missing or invalid object name")) - return - } - - switch r.Method { - case http.MethodGet: - _ = objectGet(w, r, manager, database, namespace, name) - default: - _ = httpresponse.Error(w, httpresponse.Err(http.StatusMethodNotAllowed), r.Method) - } - }) -} - -/////////////////////////////////////////////////////////////////////////////// -// PRIVATE METHODS - -func objectList(w http.ResponseWriter, r *http.Request, manager *manager.Manager, database, namespace, objectType *string) error { - // Parse request - var req schema.ObjectListRequest - if err := httprequest.Query(r.URL.Query(), &req); err != nil { - return httpresponse.Error(w, err) - } - - // Apply path filters - if database != nil { - req.Database = database - } - if namespace != nil { - req.Schema = namespace - } - if objectType != nil { - req.Type = objectType - } - - // List the objects - response, err := manager.ListObjects(r.Context(), req) - if err != nil { - return httpresponse.Error(w, httperr(err)) - } - - // Return success - return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), response) -} - -func objectGet(w http.ResponseWriter, r *http.Request, manager *manager.Manager, database, namespace, name string) error { - // Get the object - response, err := manager.GetObject(r.Context(), database, namespace, name) - if err != nil { - return httpresponse.Error(w, httperr(err)) - } - - // Return success - return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), response) -} diff --git a/pkg/manager/httphandler/object_test.go b/pkg/manager/httphandler/object_test.go deleted file mode 100644 index 1fe8da4..0000000 --- a/pkg/manager/httphandler/object_test.go +++ /dev/null @@ -1,262 +0,0 @@ -package httphandler_test - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - // Packages - httprequest "github.com/mutablelogic/go-pg/pkg/manager/httphandler" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" - test "github.com/mutablelogic/go-pg/pkg/test" - assert "github.com/stretchr/testify/assert" -) - -/////////////////////////////////////////////////////////////////////////////// -// TESTS - -func Test_Object_RegisterHandlers(t *testing.T) { - assert := assert.New(t) - - // Create manager with test container - manager := test.NewManager(t) - t.Cleanup(func() { - manager.Close() - }) - - t.Run("PanicOnNilManager", func(t *testing.T) { - router := http.NewServeMux() - assert.Panics(func() { - httprequest.RegisterObjectHandlers(router, "/api", nil) - }) - }) - - t.Run("RegisterSuccess", func(t *testing.T) { - router := http.NewServeMux() - assert.NotPanics(func() { - httprequest.RegisterObjectHandlers(router, "/api", manager.Manager) - }) - }) -} - -func Test_Object_List(t *testing.T) { - assert := assert.New(t) - - // Create manager with test container - manager := test.NewManager(t) - t.Cleanup(func() { - manager.Close() - }) - - router := http.NewServeMux() - httprequest.RegisterObjectHandlers(router, "/api", manager.Manager) - - t.Run("ListAllObjects", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/object", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusOK, w.Code) - assert.Contains(w.Header().Get("Content-Type"), "application/json") - - var resp schema.ObjectList - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(err) - // Count should match or exceed body length - assert.LessOrEqual(len(resp.Body), int(resp.Count)) - }) - - t.Run("ListObjectsByDatabase", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/object/postgres", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusOK, w.Code) - assert.Contains(w.Header().Get("Content-Type"), "application/json") - - var resp schema.ObjectList - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(err) - - // All objects should be from the postgres database - for _, obj := range resp.Body { - assert.Equal("postgres", obj.Database) - } - }) - - t.Run("ListObjectsByDatabaseAndSchema", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/object/postgres/public", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusOK, w.Code) - assert.Contains(w.Header().Get("Content-Type"), "application/json") - - var resp schema.ObjectList - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(err) - - // All objects should be from postgres.public - for _, obj := range resp.Body { - assert.Equal("postgres", obj.Database) - assert.Equal("public", obj.Schema) - } - }) - - t.Run("ListObjectsWithPagination", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/object?limit=1", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusOK, w.Code) - - var resp schema.ObjectList - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(err) - assert.LessOrEqual(len(resp.Body), 1) - }) - - t.Run("ListObjectsWithTypeFilter", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/object?type=TABLE", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusOK, w.Code) - - var resp schema.ObjectList - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(err) - - // All returned objects should be tables - for _, obj := range resp.Body { - assert.Equal("TABLE", obj.Type) - } - }) - - t.Run("ListFromNonExistentDatabase", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/object/nonexistent_db_xyz", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusOK, w.Code) - - var resp schema.ObjectList - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(err) - // Should return empty list - assert.Equal(uint64(0), resp.Count) - assert.Empty(resp.Body) - }) - - t.Run("MethodNotAllowedOnList", func(t *testing.T) { - req := httptest.NewRequest(http.MethodPost, "/api/object", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusMethodNotAllowed, w.Code) - }) - - t.Run("MethodNotAllowedOnDatabaseList", func(t *testing.T) { - req := httptest.NewRequest(http.MethodDelete, "/api/object/postgres", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusMethodNotAllowed, w.Code) - }) - - t.Run("MethodNotAllowedOnSchemaList", func(t *testing.T) { - req := httptest.NewRequest(http.MethodPut, "/api/object/postgres/public", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusMethodNotAllowed, w.Code) - }) -} - -func Test_Object_Get(t *testing.T) { - assert := assert.New(t) - - // Create manager with test container - manager := test.NewManager(t) - t.Cleanup(func() { - manager.Close() - }) - - router := http.NewServeMux() - httprequest.RegisterObjectHandlers(router, "/api", manager.Manager) - - t.Run("GetNotFound", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/object/postgres/public/nonexistent_object_xyz", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusNotFound, w.Code) - }) - - t.Run("GetFromNonExistentSchema", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/object/postgres/nonexistent_schema/test", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusNotFound, w.Code) - }) - - t.Run("GetExistingObject", func(t *testing.T) { - // First, list objects to find one that exists - listReq := httptest.NewRequest(http.MethodGet, "/api/object/postgres/public?limit=1", nil) - listW := httptest.NewRecorder() - router.ServeHTTP(listW, listReq) - - if listW.Code != http.StatusOK { - t.Skip("Could not list objects") - } - - var listResp schema.ObjectList - if err := json.Unmarshal(listW.Body.Bytes(), &listResp); err != nil { - t.Skip("Could not parse object list") - } - - if len(listResp.Body) == 0 { - t.Skip("No objects found in postgres.public") - } - - // Get the first object - obj := listResp.Body[0] - getReq := httptest.NewRequest(http.MethodGet, "/api/object/"+obj.Database+"/"+obj.Schema+"/"+obj.Name, nil) - getW := httptest.NewRecorder() - router.ServeHTTP(getW, getReq) - - assert.Equal(http.StatusOK, getW.Code) - assert.Contains(getW.Header().Get("Content-Type"), "application/json") - - var resp schema.Object - err := json.Unmarshal(getW.Body.Bytes(), &resp) - assert.NoError(err) - assert.Equal(obj.Name, resp.Name) - assert.Equal(obj.Schema, resp.Schema) - assert.Equal(obj.Database, resp.Database) - assert.Equal(obj.Type, resp.Type) - assert.Equal(obj.Owner, resp.Owner) - }) - - t.Run("MethodNotAllowed", func(t *testing.T) { - req := httptest.NewRequest(http.MethodDelete, "/api/object/postgres/public/test", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusMethodNotAllowed, w.Code) - }) -} diff --git a/pkg/manager/httphandler/replicationslot.go b/pkg/manager/httphandler/replicationslot.go deleted file mode 100644 index fc7a8da..0000000 --- a/pkg/manager/httphandler/replicationslot.go +++ /dev/null @@ -1,112 +0,0 @@ -package httphandler - -import ( - "net/http" - "strings" - - // Packages - manager "github.com/mutablelogic/go-pg/pkg/manager" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" - httprequest "github.com/mutablelogic/go-server/pkg/httprequest" - httpresponse "github.com/mutablelogic/go-server/pkg/httpresponse" -) - -/////////////////////////////////////////////////////////////////////////////// -// PUBLIC METHODS - -// RegisterReplicationSlotHandlers registers HTTP handlers for replication slot -// CRUD operations on the provided router with the given path prefix. -func RegisterReplicationSlotHandlers(router *http.ServeMux, prefix string, manager *manager.Manager) { - if manager == nil { - panic("manager is nil") - } - router.HandleFunc(joinPath(prefix, "replicationslot"), func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - _ = replicationSlotList(w, r, manager) - case http.MethodPost: - _ = replicationSlotCreate(w, r, manager) - default: - _ = httpresponse.Error(w, httpresponse.Err(http.StatusMethodNotAllowed), r.Method) - } - }) - - router.HandleFunc(joinPath(prefix, "replicationslot/{name}"), func(w http.ResponseWriter, r *http.Request) { - name := r.PathValue("name") - if name == "" { - _ = httpresponse.Error(w, httpresponse.ErrBadRequest.With("missing or invalid slot name")) - return - } - if strings.HasPrefix(name, "pg_") { - _ = httpresponse.Error(w, httpresponse.ErrBadRequest.With("slot name cannot start with reserved prefix 'pg_'")) - return - } - - switch r.Method { - case http.MethodGet: - _ = replicationSlotGet(w, r, manager, name) - case http.MethodDelete: - _ = replicationSlotDelete(w, r, manager, name) - default: - _ = httpresponse.Error(w, httpresponse.Err(http.StatusMethodNotAllowed), r.Method) - } - }) -} - -/////////////////////////////////////////////////////////////////////////////// -// PRIVATE METHODS - -func replicationSlotGet(w http.ResponseWriter, r *http.Request, manager *manager.Manager, name string) error { - slot, err := manager.GetReplicationSlot(r.Context(), name) - if err != nil { - return httpresponse.Error(w, httperr(err)) - } - - // Return success - return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), slot) -} - -func replicationSlotList(w http.ResponseWriter, r *http.Request, manager *manager.Manager) error { - // Parse request - var req schema.ReplicationSlotListRequest - if err := httprequest.Query(r.URL.Query(), &req); err != nil { - return httpresponse.Error(w, err) - } - - // List the slots - response, err := manager.ListReplicationSlots(r.Context(), req) - if err != nil { - return httpresponse.Error(w, httperr(err)) - } - - // Return success - return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), response) -} - -func replicationSlotCreate(w http.ResponseWriter, r *http.Request, manager *manager.Manager) error { - // Parse request - var req schema.ReplicationSlotMeta - if err := httprequest.Read(r, &req); err != nil { - return httpresponse.Error(w, err) - } - - // Create the slot - response, err := manager.CreateReplicationSlot(r.Context(), req) - if err != nil { - return httpresponse.Error(w, httperr(err)) - } - - // Return success - return httpresponse.JSON(w, http.StatusCreated, httprequest.Indent(r), response) -} - -func replicationSlotDelete(w http.ResponseWriter, r *http.Request, manager *manager.Manager, name string) error { - // Delete the slot - _, err := manager.DeleteReplicationSlot(r.Context(), name) - if err != nil { - return httpresponse.Error(w, httperr(err)) - } - - // Return success - return httpresponse.Empty(w, http.StatusOK) -} diff --git a/pkg/manager/httphandler/replicationslot_test.go b/pkg/manager/httphandler/replicationslot_test.go deleted file mode 100644 index dbb670c..0000000 --- a/pkg/manager/httphandler/replicationslot_test.go +++ /dev/null @@ -1,383 +0,0 @@ -package httphandler_test - -import ( - "bytes" - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - // Packages - httphandler "github.com/mutablelogic/go-pg/pkg/manager/httphandler" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" - test "github.com/mutablelogic/go-pg/pkg/test" - assert "github.com/stretchr/testify/assert" -) - -/////////////////////////////////////////////////////////////////////////////// -// TESTS - -func Test_ReplicationSlot_RegisterHandlers(t *testing.T) { - assert := assert.New(t) - - // Create manager with test container - manager := test.NewManager(t) - t.Cleanup(func() { - manager.Close() - }) - - t.Run("PanicOnNilManager", func(t *testing.T) { - router := http.NewServeMux() - assert.Panics(func() { - httphandler.RegisterReplicationSlotHandlers(router, "/api", nil) - }) - }) - - t.Run("RegisterSuccess", func(t *testing.T) { - router := http.NewServeMux() - assert.NotPanics(func() { - httphandler.RegisterReplicationSlotHandlers(router, "/api", manager.Manager) - }) - }) -} - -func Test_ReplicationSlot_List(t *testing.T) { - assert := assert.New(t) - - // Create manager with test container - manager := test.NewManager(t) - t.Cleanup(func() { - manager.Close() - }) - - router := http.NewServeMux() - httphandler.RegisterReplicationSlotHandlers(router, "/api", manager.Manager) - - t.Run("ListSlots", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/replicationslot", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusOK, w.Code) - assert.Contains(w.Header().Get("Content-Type"), "application/json") - - var resp schema.ReplicationSlotList - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(err) - // Count should match body length (may be 0 initially) - assert.Equal(len(resp.Body), int(resp.Count)) - }) - - t.Run("MethodNotAllowed", func(t *testing.T) { - req := httptest.NewRequest(http.MethodPut, "/api/replicationslot", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusMethodNotAllowed, w.Code) - }) -} - -func Test_ReplicationSlot_Get(t *testing.T) { - assert := assert.New(t) - - // Create manager with test container - manager := test.NewManager(t) - t.Cleanup(func() { - manager.Close() - }) - - router := http.NewServeMux() - httphandler.RegisterReplicationSlotHandlers(router, "/api", manager.Manager) - - t.Run("GetNotFound", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/replicationslot/nonexistent_slot_xyz", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusNotFound, w.Code) - }) - - t.Run("GetReservedPrefix", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/replicationslot/pg_reserved", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusBadRequest, w.Code) - }) - - t.Run("MethodNotAllowed", func(t *testing.T) { - req := httptest.NewRequest(http.MethodPatch, "/api/replicationslot/test_slot", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusMethodNotAllowed, w.Code) - }) -} - -func Test_ReplicationSlot_Create(t *testing.T) { - assert := assert.New(t) - - // Create manager with test container - manager := test.NewManager(t) - t.Cleanup(func() { - manager.Close() - }) - - router := http.NewServeMux() - httphandler.RegisterReplicationSlotHandlers(router, "/api", manager.Manager) - - t.Run("CreatePhysicalSuccess", func(t *testing.T) { - body := `{"name": "test_http_physical", "type": "physical"}` - req := httptest.NewRequest(http.MethodPost, "/api/replicationslot", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusCreated, w.Code) - - var resp schema.ReplicationSlot - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(err) - assert.Equal("test_http_physical", resp.Name) - assert.Equal("physical", resp.Type) - assert.Equal("inactive", resp.Status) - - // Cleanup - t.Cleanup(func() { - _, _ = manager.DeleteReplicationSlot(context.TODO(), "test_http_physical") - }) - }) - - t.Run("CreateLogicalSuccess", func(t *testing.T) { - body := `{"name": "test_http_logical", "type": "logical", "plugin": "pgoutput"}` - req := httptest.NewRequest(http.MethodPost, "/api/replicationslot", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusCreated, w.Code) - - var resp schema.ReplicationSlot - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(err) - assert.Equal("test_http_logical", resp.Name) - assert.Equal("logical", resp.Type) - assert.Equal("pgoutput", resp.Plugin) - - // Cleanup - t.Cleanup(func() { - _, _ = manager.DeleteReplicationSlot(context.TODO(), "test_http_logical") - }) - }) - - t.Run("CreateEmptyName", func(t *testing.T) { - body := `{"name": "", "type": "physical"}` - req := httptest.NewRequest(http.MethodPost, "/api/replicationslot", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusBadRequest, w.Code) - }) - - t.Run("CreateInvalidType", func(t *testing.T) { - body := `{"name": "test_invalid", "type": "invalid"}` - req := httptest.NewRequest(http.MethodPost, "/api/replicationslot", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusBadRequest, w.Code) - }) - - t.Run("CreateLogicalWithoutPlugin", func(t *testing.T) { - body := `{"name": "test_no_plugin", "type": "logical"}` - req := httptest.NewRequest(http.MethodPost, "/api/replicationslot", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusBadRequest, w.Code) - }) - - t.Run("CreateReservedPrefix", func(t *testing.T) { - body := `{"name": "pg_reserved_slot", "type": "physical"}` - req := httptest.NewRequest(http.MethodPost, "/api/replicationslot", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusBadRequest, w.Code) - }) - - t.Run("CreateInvalidJSON", func(t *testing.T) { - body := `{invalid json}` - req := httptest.NewRequest(http.MethodPost, "/api/replicationslot", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusBadRequest, w.Code) - }) - - t.Run("CreateDuplicate", func(t *testing.T) { - // First create - body := `{"name": "test_http_duplicate", "type": "physical"}` - req := httptest.NewRequest(http.MethodPost, "/api/replicationslot", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - assert.Equal(http.StatusCreated, w.Code) - - // Second create should fail - req2 := httptest.NewRequest(http.MethodPost, "/api/replicationslot", bytes.NewBufferString(body)) - req2.Header.Set("Content-Type", "application/json") - w2 := httptest.NewRecorder() - - router.ServeHTTP(w2, req2) - // Duplicate slot returns 500 (PostgreSQL error) - assert.Equal(http.StatusInternalServerError, w2.Code) - - // Cleanup - t.Cleanup(func() { - _, _ = manager.DeleteReplicationSlot(context.TODO(), "test_http_duplicate") - }) - }) -} - -func Test_ReplicationSlot_Delete(t *testing.T) { - assert := assert.New(t) - - // Create manager with test container - manager := test.NewManager(t) - t.Cleanup(func() { - manager.Close() - }) - - router := http.NewServeMux() - httphandler.RegisterReplicationSlotHandlers(router, "/api", manager.Manager) - - t.Run("DeleteExisting", func(t *testing.T) { - // First create a slot - body := `{"name": "test_http_delete", "type": "physical"}` - createReq := httptest.NewRequest(http.MethodPost, "/api/replicationslot", bytes.NewBufferString(body)) - createReq.Header.Set("Content-Type", "application/json") - createW := httptest.NewRecorder() - router.ServeHTTP(createW, createReq) - assert.Equal(http.StatusCreated, createW.Code) - - // Delete the slot - req := httptest.NewRequest(http.MethodDelete, "/api/replicationslot/test_http_delete", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusOK, w.Code) - - // Verify it's deleted - getReq := httptest.NewRequest(http.MethodGet, "/api/replicationslot/test_http_delete", nil) - getW := httptest.NewRecorder() - router.ServeHTTP(getW, getReq) - assert.Equal(http.StatusNotFound, getW.Code) - }) - - t.Run("DeleteNotFound", func(t *testing.T) { - req := httptest.NewRequest(http.MethodDelete, "/api/replicationslot/nonexistent_slot_xyz", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusNotFound, w.Code) - }) - - t.Run("DeleteReservedPrefix", func(t *testing.T) { - req := httptest.NewRequest(http.MethodDelete, "/api/replicationslot/pg_reserved", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusBadRequest, w.Code) - }) -} - -func Test_ReplicationSlot_RoundTrip(t *testing.T) { - assert := assert.New(t) - - // Create manager with test container - manager := test.NewManager(t) - t.Cleanup(func() { - manager.Close() - }) - - router := http.NewServeMux() - httphandler.RegisterReplicationSlotHandlers(router, "/api", manager.Manager) - - t.Run("CreateGetListDelete", func(t *testing.T) { - slotName := "test_http_roundtrip" - - // Create - createBody := `{"name": "` + slotName + `", "type": "physical"}` - createReq := httptest.NewRequest(http.MethodPost, "/api/replicationslot", bytes.NewBufferString(createBody)) - createReq.Header.Set("Content-Type", "application/json") - createW := httptest.NewRecorder() - router.ServeHTTP(createW, createReq) - assert.Equal(http.StatusCreated, createW.Code) - - // Get - getReq := httptest.NewRequest(http.MethodGet, "/api/replicationslot/"+slotName, nil) - getW := httptest.NewRecorder() - router.ServeHTTP(getW, getReq) - assert.Equal(http.StatusOK, getW.Code) - - var slot schema.ReplicationSlot - err := json.Unmarshal(getW.Body.Bytes(), &slot) - assert.NoError(err) - assert.Equal(slotName, slot.Name) - assert.Equal("physical", slot.Type) - assert.Equal("inactive", slot.Status) - - // List should include it - listReq := httptest.NewRequest(http.MethodGet, "/api/replicationslot", nil) - listW := httptest.NewRecorder() - router.ServeHTTP(listW, listReq) - assert.Equal(http.StatusOK, listW.Code) - - var list schema.ReplicationSlotList - err = json.Unmarshal(listW.Body.Bytes(), &list) - assert.NoError(err) - found := false - for _, s := range list.Body { - if s.Name == slotName { - found = true - break - } - } - assert.True(found, "slot should appear in list") - - // Delete - deleteReq := httptest.NewRequest(http.MethodDelete, "/api/replicationslot/"+slotName, nil) - deleteW := httptest.NewRecorder() - router.ServeHTTP(deleteW, deleteReq) - assert.Equal(http.StatusOK, deleteW.Code) - - // Get should now return 404 - getReq2 := httptest.NewRequest(http.MethodGet, "/api/replicationslot/"+slotName, nil) - getW2 := httptest.NewRecorder() - router.ServeHTTP(getW2, getReq2) - assert.Equal(http.StatusNotFound, getW2.Code) - }) -} diff --git a/pkg/manager/httphandler/role.go b/pkg/manager/httphandler/role.go deleted file mode 100644 index 67a25f1..0000000 --- a/pkg/manager/httphandler/role.go +++ /dev/null @@ -1,130 +0,0 @@ -package httphandler - -import ( - "net/http" - "strings" - - // Packages - manager "github.com/mutablelogic/go-pg/pkg/manager" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" - httprequest "github.com/mutablelogic/go-server/pkg/httprequest" - httpresponse "github.com/mutablelogic/go-server/pkg/httpresponse" -) - -/////////////////////////////////////////////////////////////////////////////// -// PUBLIC METHODS - -// RegisterRoleHandlers registers HTTP handlers for role CRUD operations -// on the provided router with the given path prefix. The manager must be non-nil. -func RegisterRoleHandlers(router *http.ServeMux, prefix string, manager *manager.Manager) { - if manager == nil { - panic("manager is nil") - } - router.HandleFunc(joinPath(prefix, "role"), func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - _ = roleList(w, r, manager) - case http.MethodPost: - _ = roleCreate(w, r, manager) - default: - _ = httpresponse.Error(w, httpresponse.Err(http.StatusMethodNotAllowed), r.Method) - } - }) - - router.HandleFunc(joinPath(prefix, "role/{name}"), func(w http.ResponseWriter, r *http.Request) { - name := r.PathValue("name") - if name == "" { - _ = httpresponse.Error(w, httpresponse.ErrBadRequest.With("missing or invalid role name")) - return - } - if strings.HasPrefix(name, "pg_") { - _ = httpresponse.Error(w, httpresponse.ErrBadRequest.With("role name cannot start with reserved prefix 'pg_'")) - return - } - - switch r.Method { - case http.MethodGet: - _ = roleGet(w, r, manager, name) - case http.MethodPatch: - _ = roleUpdate(w, r, manager, name) - case http.MethodDelete: - _ = roleDelete(w, r, manager, name) - default: - _ = httpresponse.Error(w, httpresponse.Err(http.StatusMethodNotAllowed), r.Method) - } - }) -} - -/////////////////////////////////////////////////////////////////////////////// -// PRIVATE METHODS - -func roleGet(w http.ResponseWriter, r *http.Request, manager *manager.Manager, name string) error { - role, err := manager.GetRole(r.Context(), name) - if err != nil { - return httpresponse.Error(w, httperr(err)) - } - - // Return success - return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), role) -} - -func roleList(w http.ResponseWriter, r *http.Request, manager *manager.Manager) error { - // Parse request - var req schema.RoleListRequest - if err := httprequest.Query(r.URL.Query(), &req); err != nil { - return httpresponse.Error(w, err) - } - - // List the roles - response, err := manager.ListRoles(r.Context(), req) - if err != nil { - return httpresponse.Error(w, httperr(err)) - } - - // Return success - return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), response) -} - -func roleCreate(w http.ResponseWriter, r *http.Request, manager *manager.Manager) error { - // Parse request - var req schema.RoleMeta - if err := httprequest.Read(r, &req); err != nil { - return httpresponse.Error(w, err) - } - - // Create the role - response, err := manager.CreateRole(r.Context(), req) - if err != nil { - return httpresponse.Error(w, httperr(err)) - } - - // Return success - return httpresponse.JSON(w, http.StatusCreated, httprequest.Indent(r), response) -} - -func roleDelete(w http.ResponseWriter, r *http.Request, manager *manager.Manager, name string) error { - _, err := manager.DeleteRole(r.Context(), name) - if err != nil { - return httpresponse.Error(w, httperr(err)) - } - - // Return success - return httpresponse.Empty(w, http.StatusOK) -} - -func roleUpdate(w http.ResponseWriter, r *http.Request, manager *manager.Manager, name string) error { - // Parse request - var req schema.RoleMeta - if err := httprequest.Read(r, &req); err != nil { - return httpresponse.Error(w, err) - } - - // Perform update - role, err := manager.UpdateRole(r.Context(), name, req) - if err != nil { - return httpresponse.Error(w, httperr(err)) - } - - // Return success - return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), role) -} diff --git a/pkg/manager/httphandler/role_test.go b/pkg/manager/httphandler/role_test.go deleted file mode 100644 index b736cfa..0000000 --- a/pkg/manager/httphandler/role_test.go +++ /dev/null @@ -1,376 +0,0 @@ -package httphandler_test - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - // Packages - httprequest "github.com/mutablelogic/go-pg/pkg/manager/httphandler" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" - test "github.com/mutablelogic/go-pg/pkg/test" - assert "github.com/stretchr/testify/assert" -) - -/////////////////////////////////////////////////////////////////////////////// -// TESTS - -func Test_Role_RegisterHandlers(t *testing.T) { - assert := assert.New(t) - - // Create manager with test container - manager := test.NewManager(t) - t.Cleanup(func() { - manager.Close() - }) - - t.Run("PanicOnNilManager", func(t *testing.T) { - router := http.NewServeMux() - assert.Panics(func() { - httprequest.RegisterRoleHandlers(router, "/api", nil) - }) - }) - - t.Run("RegisterSuccess", func(t *testing.T) { - router := http.NewServeMux() - assert.NotPanics(func() { - httprequest.RegisterRoleHandlers(router, "/api", manager.Manager) - }) - }) -} - -func Test_Role_List(t *testing.T) { - assert := assert.New(t) - - // Create manager with test container - manager := test.NewManager(t) - t.Cleanup(func() { - manager.Close() - }) - - router := http.NewServeMux() - httprequest.RegisterRoleHandlers(router, "/api", manager.Manager) - - t.Run("ListRoles", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/role", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusOK, w.Code) - assert.Contains(w.Header().Get("Content-Type"), "application/json") - - var resp schema.RoleList - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(err) - assert.GreaterOrEqual(int(resp.Count), 1) // At least the default postgres role - }) - - t.Run("MethodNotAllowed", func(t *testing.T) { - req := httptest.NewRequest(http.MethodPut, "/api/role", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusMethodNotAllowed, w.Code) - }) -} - -func Test_Role_Get(t *testing.T) { - assert := assert.New(t) - - // Create manager with test container - manager := test.NewManager(t) - t.Cleanup(func() { - manager.Close() - }) - - router := http.NewServeMux() - httprequest.RegisterRoleHandlers(router, "/api", manager.Manager) - - t.Run("GetExisting", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/role/postgres", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusOK, w.Code) - assert.Contains(w.Header().Get("Content-Type"), "application/json") - - var resp schema.Role - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(err) - assert.Equal("postgres", resp.Name) - }) - - t.Run("GetNotFound", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/role/nonexistent_role_xyz", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusNotFound, w.Code) - }) - - t.Run("GetReservedPrefix", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/role/pg_reserved", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusBadRequest, w.Code) - }) - - t.Run("MethodNotAllowed", func(t *testing.T) { - req := httptest.NewRequest(http.MethodPost, "/api/role/postgres", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusMethodNotAllowed, w.Code) - }) -} - -func Test_Role_Create(t *testing.T) { - assert := assert.New(t) - - // Create manager with test container - manager := test.NewManager(t) - t.Cleanup(func() { - manager.Close() - }) - - router := http.NewServeMux() - httprequest.RegisterRoleHandlers(router, "/api", manager.Manager) - - t.Run("CreateSuccess", func(t *testing.T) { - body := `{"name": "test_http_create_role"}` - req := httptest.NewRequest(http.MethodPost, "/api/role", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusCreated, w.Code) - - var resp schema.Role - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(err) - assert.Equal("test_http_create_role", resp.Name) - - // Cleanup - t.Cleanup(func() { - _, _ = manager.DeleteRole(req.Context(), "test_http_create_role") - }) - }) - - t.Run("CreateEmptyName", func(t *testing.T) { - body := `{"name": ""}` - req := httptest.NewRequest(http.MethodPost, "/api/role", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusBadRequest, w.Code) - }) - - t.Run("CreateReservedPrefix", func(t *testing.T) { - body := `{"name": "pg_reserved_test"}` - req := httptest.NewRequest(http.MethodPost, "/api/role", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusBadRequest, w.Code) - }) - - t.Run("CreateInvalidJSON", func(t *testing.T) { - body := `{invalid json}` - req := httptest.NewRequest(http.MethodPost, "/api/role", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusBadRequest, w.Code) - }) - - t.Run("CreateDuplicate", func(t *testing.T) { - // First create - body := `{"name": "test_http_duplicate_role"}` - req := httptest.NewRequest(http.MethodPost, "/api/role", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - assert.Equal(http.StatusCreated, w.Code) - - // Second create should fail - req2 := httptest.NewRequest(http.MethodPost, "/api/role", bytes.NewBufferString(body)) - req2.Header.Set("Content-Type", "application/json") - w2 := httptest.NewRecorder() - - router.ServeHTTP(w2, req2) - // Duplicate role returns 500 (PostgreSQL error) - assert.Equal(http.StatusInternalServerError, w2.Code) - - // Cleanup - t.Cleanup(func() { - _, _ = manager.DeleteRole(req.Context(), "test_http_duplicate_role") - }) - }) -} - -func Test_Role_Delete(t *testing.T) { - assert := assert.New(t) - - // Create manager with test container - manager := test.NewManager(t) - t.Cleanup(func() { - manager.Close() - }) - - router := http.NewServeMux() - httprequest.RegisterRoleHandlers(router, "/api", manager.Manager) - - t.Run("DeleteExisting", func(t *testing.T) { - // First create a role - body := `{"name": "test_http_delete_role"}` - createReq := httptest.NewRequest(http.MethodPost, "/api/role", bytes.NewBufferString(body)) - createReq.Header.Set("Content-Type", "application/json") - createW := httptest.NewRecorder() - router.ServeHTTP(createW, createReq) - assert.Equal(http.StatusCreated, createW.Code) - - // Delete the role - req := httptest.NewRequest(http.MethodDelete, "/api/role/test_http_delete_role", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusOK, w.Code) - }) - - t.Run("DeleteNotFound", func(t *testing.T) { - req := httptest.NewRequest(http.MethodDelete, "/api/role/nonexistent_role_xyz", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusNotFound, w.Code) - }) - - t.Run("DeleteReservedPrefix", func(t *testing.T) { - req := httptest.NewRequest(http.MethodDelete, "/api/role/pg_reserved", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusBadRequest, w.Code) - }) -} - -func Test_Role_Update(t *testing.T) { - assert := assert.New(t) - - // Create manager with test container - manager := test.NewManager(t) - t.Cleanup(func() { - manager.Close() - }) - - router := http.NewServeMux() - httprequest.RegisterRoleHandlers(router, "/api", manager.Manager) - - t.Run("UpdateRename", func(t *testing.T) { - // First create a role - body := `{"name": "test_http_update_role_old"}` - createReq := httptest.NewRequest(http.MethodPost, "/api/role", bytes.NewBufferString(body)) - createReq.Header.Set("Content-Type", "application/json") - createW := httptest.NewRecorder() - router.ServeHTTP(createW, createReq) - assert.Equal(http.StatusCreated, createW.Code) - - // Update (rename) the role - updateBody := `{"name": "test_http_update_role_new"}` - req := httptest.NewRequest(http.MethodPatch, "/api/role/test_http_update_role_old", bytes.NewBufferString(updateBody)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusOK, w.Code) - - var resp schema.Role - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(err) - assert.Equal("test_http_update_role_new", resp.Name) - - // Cleanup - t.Cleanup(func() { - _, _ = manager.DeleteRole(req.Context(), "test_http_update_role_new") - }) - }) - - t.Run("UpdateNotFound", func(t *testing.T) { - body := `{"name": "new_name"}` - req := httptest.NewRequest(http.MethodPatch, "/api/role/nonexistent_role_xyz", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusNotFound, w.Code) - }) - - t.Run("UpdateToReservedPrefix", func(t *testing.T) { - // First create a role - body := `{"name": "test_http_update_reserved_role"}` - createReq := httptest.NewRequest(http.MethodPost, "/api/role", bytes.NewBufferString(body)) - createReq.Header.Set("Content-Type", "application/json") - createW := httptest.NewRecorder() - router.ServeHTTP(createW, createReq) - assert.Equal(http.StatusCreated, createW.Code) - - // Try to rename to reserved prefix - updateBody := `{"name": "pg_reserved_name"}` - req := httptest.NewRequest(http.MethodPatch, "/api/role/test_http_update_reserved_role", bytes.NewBufferString(updateBody)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusBadRequest, w.Code) - - // Cleanup - t.Cleanup(func() { - _, _ = manager.DeleteRole(req.Context(), "test_http_update_reserved_role") - }) - }) - - t.Run("UpdateReservedPrefixSource", func(t *testing.T) { - body := `{"name": "new_name"}` - req := httptest.NewRequest(http.MethodPatch, "/api/role/pg_reserved", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusBadRequest, w.Code) - }) - - t.Run("UpdateInvalidJSON", func(t *testing.T) { - body := `{invalid json}` - req := httptest.NewRequest(http.MethodPatch, "/api/role/postgres", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusBadRequest, w.Code) - }) -} diff --git a/pkg/manager/httphandler/schema.go b/pkg/manager/httphandler/schema.go deleted file mode 100644 index 783f288..0000000 --- a/pkg/manager/httphandler/schema.go +++ /dev/null @@ -1,164 +0,0 @@ -package httphandler - -import ( - "net/http" - "strings" - - // Packages - manager "github.com/mutablelogic/go-pg/pkg/manager" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" - httprequest "github.com/mutablelogic/go-server/pkg/httprequest" - httpresponse "github.com/mutablelogic/go-server/pkg/httpresponse" -) - -/////////////////////////////////////////////////////////////////////////////// -// PUBLIC METHODS - -// RegisterSchemaHandlers registers HTTP handlers for schema CRUD operations -// on the provided router with the given path prefix. The manager must be non-nil. -func RegisterSchemaHandlers(router *http.ServeMux, prefix string, manager *manager.Manager) { - if manager == nil { - panic("manager is nil") - } - - // List schemas across all databases - router.HandleFunc(joinPath(prefix, "schema"), func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - _ = schemaList(w, r, manager, nil) - default: - _ = httpresponse.Error(w, httpresponse.Err(http.StatusMethodNotAllowed), r.Method) - } - }) - - // List schemas in a specific database, or create a new schema - router.HandleFunc(joinPath(prefix, "schema/{database}"), func(w http.ResponseWriter, r *http.Request) { - database := r.PathValue("database") - if database == "" { - _ = httpresponse.Error(w, httpresponse.ErrBadRequest.With("missing or invalid database name")) - return - } - - switch r.Method { - case http.MethodGet: - _ = schemaList(w, r, manager, &database) - case http.MethodPost: - _ = schemaCreate(w, r, manager, database) - default: - _ = httpresponse.Error(w, httpresponse.Err(http.StatusMethodNotAllowed), r.Method) - } - }) - - // Get, update, or delete a specific schema - router.HandleFunc(joinPath(prefix, "schema/{database}/{namespace}"), func(w http.ResponseWriter, r *http.Request) { - database := r.PathValue("database") - if database == "" { - _ = httpresponse.Error(w, httpresponse.ErrBadRequest.With("missing or invalid database name")) - return - } - namespace := r.PathValue("namespace") - if namespace == "" { - _ = httpresponse.Error(w, httpresponse.ErrBadRequest.With("missing or invalid schema name")) - return - } - if strings.HasPrefix(namespace, "pg_") { - _ = httpresponse.Error(w, httpresponse.ErrBadRequest.With("schema name cannot start with reserved prefix 'pg_'")) - return - } - - switch r.Method { - case http.MethodGet: - _ = schemaGet(w, r, manager, database, namespace) - case http.MethodPatch: - _ = schemaUpdate(w, r, manager, database, namespace) - case http.MethodDelete: - _ = schemaDelete(w, r, manager, database, namespace) - default: - _ = httpresponse.Error(w, httpresponse.Err(http.StatusMethodNotAllowed), r.Method) - } - }) -} - -/////////////////////////////////////////////////////////////////////////////// -// PRIVATE METHODS - -func schemaGet(w http.ResponseWriter, r *http.Request, manager *manager.Manager, database, namespace string) error { - s, err := manager.GetSchema(r.Context(), database, namespace) - if err != nil { - return httpresponse.Error(w, httperr(err)) - } - - // Return success - return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), s) -} - -func schemaList(w http.ResponseWriter, r *http.Request, manager *manager.Manager, database *string) error { - // Parse request - var req schema.SchemaListRequest - if err := httprequest.Query(r.URL.Query(), &req); err != nil { - return httpresponse.Error(w, err) - } - req.Database = database - - // List the schemas - response, err := manager.ListSchemas(r.Context(), req) - if err != nil { - return httpresponse.Error(w, httperr(err)) - } - - // Return success - return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), response) -} - -func schemaCreate(w http.ResponseWriter, r *http.Request, manager *manager.Manager, database string) error { - // Parse request - var req schema.SchemaMeta - if err := httprequest.Read(r, &req); err != nil { - return httpresponse.Error(w, err) - } - - // Create the schema - response, err := manager.CreateSchema(r.Context(), database, req) - if err != nil { - return httpresponse.Error(w, httperr(err)) - } - - // Return success - return httpresponse.JSON(w, http.StatusCreated, httprequest.Indent(r), response) -} - -func schemaDelete(w http.ResponseWriter, r *http.Request, manager *manager.Manager, database, namespace string) error { - // Parse the query - var req struct { - Force bool `json:"force,omitempty" help:"Force delete with CASCADE"` - } - if err := httprequest.Query(r.URL.Query(), &req); err != nil { - return httpresponse.Error(w, err) - } - - // Delete the schema - _, err := manager.DeleteSchema(r.Context(), database, namespace, req.Force) - if err != nil { - return httpresponse.Error(w, httperr(err)) - } - - // Return success - return httpresponse.Empty(w, http.StatusOK) -} - -func schemaUpdate(w http.ResponseWriter, r *http.Request, manager *manager.Manager, database, namespace string) error { - // Parse request - var req schema.SchemaMeta - if err := httprequest.Read(r, &req); err != nil { - return httpresponse.Error(w, err) - } - - // Perform update - s, err := manager.UpdateSchema(r.Context(), database, namespace, req) - if err != nil { - return httpresponse.Error(w, httperr(err)) - } - - // Return success - return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), s) -} diff --git a/pkg/manager/httphandler/schema_test.go b/pkg/manager/httphandler/schema_test.go deleted file mode 100644 index b3efd04..0000000 --- a/pkg/manager/httphandler/schema_test.go +++ /dev/null @@ -1,494 +0,0 @@ -package httphandler_test - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - // Packages - httprequest "github.com/mutablelogic/go-pg/pkg/manager/httphandler" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" - test "github.com/mutablelogic/go-pg/pkg/test" - assert "github.com/stretchr/testify/assert" -) - -/////////////////////////////////////////////////////////////////////////////// -// TESTS - -func Test_Schema_RegisterHandlers(t *testing.T) { - assert := assert.New(t) - - // Create manager with test container - manager := test.NewManager(t) - t.Cleanup(func() { - manager.Close() - }) - - t.Run("PanicOnNilManager", func(t *testing.T) { - router := http.NewServeMux() - assert.Panics(func() { - httprequest.RegisterSchemaHandlers(router, "/api", nil) - }) - }) - - t.Run("RegisterSuccess", func(t *testing.T) { - router := http.NewServeMux() - assert.NotPanics(func() { - httprequest.RegisterSchemaHandlers(router, "/api", manager.Manager) - }) - }) -} - -func Test_Schema_List(t *testing.T) { - assert := assert.New(t) - - // Create manager with test container - manager := test.NewManager(t) - t.Cleanup(func() { - manager.Close() - }) - - router := http.NewServeMux() - httprequest.RegisterSchemaHandlers(router, "/api", manager.Manager) - - t.Run("ListAllSchemas", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/schema", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusOK, w.Code) - assert.Contains(w.Header().Get("Content-Type"), "application/json") - - var resp schema.SchemaList - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(err) - assert.GreaterOrEqual(int(resp.Count), 1) // At least the public schema - }) - - t.Run("ListSchemasByDatabase", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/schema/postgres", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusOK, w.Code) - assert.Contains(w.Header().Get("Content-Type"), "application/json") - - var resp schema.SchemaList - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(err) - assert.GreaterOrEqual(int(resp.Count), 1) // At least the public schema - - // All schemas should be from the postgres database - for _, s := range resp.Body { - assert.Equal("postgres", s.Database) - } - }) - - t.Run("MethodNotAllowed", func(t *testing.T) { - req := httptest.NewRequest(http.MethodPut, "/api/schema", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusMethodNotAllowed, w.Code) - }) -} - -func Test_Schema_Get(t *testing.T) { - assert := assert.New(t) - - // Create manager with test container - manager := test.NewManager(t) - t.Cleanup(func() { - manager.Close() - }) - - router := http.NewServeMux() - httprequest.RegisterSchemaHandlers(router, "/api", manager.Manager) - - t.Run("GetExisting", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/schema/postgres/public", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusOK, w.Code) - assert.Contains(w.Header().Get("Content-Type"), "application/json") - - var resp schema.Schema - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(err) - assert.Equal("public", resp.Name) - assert.Equal("postgres", resp.Database) - }) - - t.Run("GetNotFound", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/schema/postgres/nonexistent_schema_xyz", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusNotFound, w.Code) - }) - - t.Run("GetReservedPrefix", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/schema/postgres/pg_reserved", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusBadRequest, w.Code) - }) - - t.Run("MethodNotAllowed", func(t *testing.T) { - req := httptest.NewRequest(http.MethodPut, "/api/schema/postgres/public", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusMethodNotAllowed, w.Code) - }) -} - -func Test_Schema_Create(t *testing.T) { - assert := assert.New(t) - - // Create manager with test container - manager := test.NewManager(t) - t.Cleanup(func() { - manager.Close() - }) - - router := http.NewServeMux() - httprequest.RegisterSchemaHandlers(router, "/api", manager.Manager) - - t.Run("CreateSuccess", func(t *testing.T) { - body := `{"name": "test_http_schema", "owner": "postgres"}` - req := httptest.NewRequest(http.MethodPost, "/api/schema/postgres", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusCreated, w.Code) - assert.Contains(w.Header().Get("Content-Type"), "application/json") - - var resp schema.Schema - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(err) - assert.Equal("test_http_schema", resp.Name) - assert.Equal("postgres", resp.Database) - - // Cleanup - t.Cleanup(func() { - _, _ = manager.DeleteSchema(req.Context(), "postgres", "test_http_schema", true) - }) - }) - - t.Run("CreateWithDefaultOwner", func(t *testing.T) { - body := `{"name": "test_http_schema_noowner"}` - req := httptest.NewRequest(http.MethodPost, "/api/schema/postgres", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - // Without owner, should succeed and default to postgres user - assert.Equal(http.StatusCreated, w.Code) - - var resp schema.Schema - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(err) - assert.Equal("test_http_schema_noowner", resp.Name) - // Owner defaults to the current connection user - assert.NotEmpty(resp.Owner) - - // Cleanup - t.Cleanup(func() { - _, _ = manager.DeleteSchema(req.Context(), "postgres", "test_http_schema_noowner", true) - }) - }) - - t.Run("CreateWithOwner", func(t *testing.T) { - body := `{"name": "test_http_schema_owner", "owner": "postgres"}` - req := httptest.NewRequest(http.MethodPost, "/api/schema/postgres", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusCreated, w.Code) - - var resp schema.Schema - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(err) - assert.Equal("test_http_schema_owner", resp.Name) - assert.Equal("postgres", resp.Owner) - - // Cleanup - t.Cleanup(func() { - _, _ = manager.DeleteSchema(req.Context(), "postgres", "test_http_schema_owner", true) - }) - }) - - t.Run("CreateEmptyName", func(t *testing.T) { - body := `{"name": ""}` - req := httptest.NewRequest(http.MethodPost, "/api/schema/postgres", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusBadRequest, w.Code) - }) - - t.Run("CreateReservedPrefix", func(t *testing.T) { - body := `{"name": "pg_reserved_schema"}` - req := httptest.NewRequest(http.MethodPost, "/api/schema/postgres", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusBadRequest, w.Code) - }) - - t.Run("CreateInvalidJSON", func(t *testing.T) { - body := `{invalid json}` - req := httptest.NewRequest(http.MethodPost, "/api/schema/postgres", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusBadRequest, w.Code) - }) - - t.Run("CreateDuplicate", func(t *testing.T) { - // First create - body := `{"name": "test_http_duplicate_schema", "owner": "postgres"}` - req := httptest.NewRequest(http.MethodPost, "/api/schema/postgres", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - assert.Equal(http.StatusCreated, w.Code) - - // Second create should fail - req2 := httptest.NewRequest(http.MethodPost, "/api/schema/postgres", bytes.NewBufferString(body)) - req2.Header.Set("Content-Type", "application/json") - w2 := httptest.NewRecorder() - - router.ServeHTTP(w2, req2) - // Duplicate schema returns 500 (PostgreSQL error) - assert.Equal(http.StatusInternalServerError, w2.Code) - - // Cleanup - t.Cleanup(func() { - _, _ = manager.DeleteSchema(req.Context(), "postgres", "test_http_duplicate_schema", true) - }) - }) -} - -func Test_Schema_Delete(t *testing.T) { - assert := assert.New(t) - - // Create manager with test container - manager := test.NewManager(t) - t.Cleanup(func() { - manager.Close() - }) - - router := http.NewServeMux() - httprequest.RegisterSchemaHandlers(router, "/api", manager.Manager) - - t.Run("DeleteExisting", func(t *testing.T) { - // First create a schema - body := `{"name": "test_http_delete_schema", "owner": "postgres"}` - createReq := httptest.NewRequest(http.MethodPost, "/api/schema/postgres", bytes.NewBufferString(body)) - createReq.Header.Set("Content-Type", "application/json") - createW := httptest.NewRecorder() - router.ServeHTTP(createW, createReq) - assert.Equal(http.StatusCreated, createW.Code) - - // Delete the schema - req := httptest.NewRequest(http.MethodDelete, "/api/schema/postgres/test_http_delete_schema", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusOK, w.Code) - }) - - t.Run("DeleteWithForce", func(t *testing.T) { - // First create a schema - body := `{"name": "test_http_delete_force_schema", "owner": "postgres"}` - createReq := httptest.NewRequest(http.MethodPost, "/api/schema/postgres", bytes.NewBufferString(body)) - createReq.Header.Set("Content-Type", "application/json") - createW := httptest.NewRecorder() - router.ServeHTTP(createW, createReq) - assert.Equal(http.StatusCreated, createW.Code) - - // Delete with force - req := httptest.NewRequest(http.MethodDelete, "/api/schema/postgres/test_http_delete_force_schema?force=true", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusOK, w.Code) - }) - - t.Run("DeleteNotFound", func(t *testing.T) { - req := httptest.NewRequest(http.MethodDelete, "/api/schema/postgres/nonexistent_schema_xyz", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusNotFound, w.Code) - }) - - t.Run("DeleteReservedPrefix", func(t *testing.T) { - req := httptest.NewRequest(http.MethodDelete, "/api/schema/postgres/pg_reserved", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusBadRequest, w.Code) - }) -} - -func Test_Schema_Update(t *testing.T) { - assert := assert.New(t) - - // Create manager with test container - manager := test.NewManager(t) - t.Cleanup(func() { - manager.Close() - }) - - router := http.NewServeMux() - httprequest.RegisterSchemaHandlers(router, "/api", manager.Manager) - - t.Run("UpdateRename", func(t *testing.T) { - // First create a schema - body := `{"name": "test_http_update_schema_old", "owner": "postgres"}` - createReq := httptest.NewRequest(http.MethodPost, "/api/schema/postgres", bytes.NewBufferString(body)) - createReq.Header.Set("Content-Type", "application/json") - createW := httptest.NewRecorder() - router.ServeHTTP(createW, createReq) - assert.Equal(http.StatusCreated, createW.Code) - - // Update (rename) the schema - updateBody := `{"name": "test_http_update_schema_new"}` - req := httptest.NewRequest(http.MethodPatch, "/api/schema/postgres/test_http_update_schema_old", bytes.NewBufferString(updateBody)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusOK, w.Code) - - var resp schema.Schema - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(err) - assert.Equal("test_http_update_schema_new", resp.Name) - - // Cleanup - t.Cleanup(func() { - _, _ = manager.DeleteSchema(req.Context(), "postgres", "test_http_update_schema_new", true) - }) - }) - - t.Run("UpdateOwner", func(t *testing.T) { - // First create a schema - body := `{"name": "test_http_update_owner_schema", "owner": "postgres"}` - createReq := httptest.NewRequest(http.MethodPost, "/api/schema/postgres", bytes.NewBufferString(body)) - createReq.Header.Set("Content-Type", "application/json") - createW := httptest.NewRecorder() - router.ServeHTTP(createW, createReq) - assert.Equal(http.StatusCreated, createW.Code) - - // Update owner - updateBody := `{"owner": "postgres"}` - req := httptest.NewRequest(http.MethodPatch, "/api/schema/postgres/test_http_update_owner_schema", bytes.NewBufferString(updateBody)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusOK, w.Code) - - var resp schema.Schema - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(err) - assert.Equal("postgres", resp.Owner) - - // Cleanup - t.Cleanup(func() { - _, _ = manager.DeleteSchema(req.Context(), "postgres", "test_http_update_owner_schema", true) - }) - }) - - t.Run("UpdateNotFound", func(t *testing.T) { - body := `{"name": "new_name"}` - req := httptest.NewRequest(http.MethodPatch, "/api/schema/postgres/nonexistent_schema_xyz", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusNotFound, w.Code) - }) - - t.Run("UpdateToReservedPrefix", func(t *testing.T) { - // First create a schema - body := `{"name": "test_http_update_reserved_schema", "owner": "postgres"}` - createReq := httptest.NewRequest(http.MethodPost, "/api/schema/postgres", bytes.NewBufferString(body)) - createReq.Header.Set("Content-Type", "application/json") - createW := httptest.NewRecorder() - router.ServeHTTP(createW, createReq) - assert.Equal(http.StatusCreated, createW.Code) - - // Try to rename to reserved prefix - PostgreSQL rejects this - updateBody := `{"name": "pg_reserved_name"}` - req := httptest.NewRequest(http.MethodPatch, "/api/schema/postgres/test_http_update_reserved_schema", bytes.NewBufferString(updateBody)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - // PostgreSQL returns an error for reserved prefix names - assert.Equal(http.StatusInternalServerError, w.Code) - - // Cleanup - t.Cleanup(func() { - _, _ = manager.DeleteSchema(req.Context(), "postgres", "test_http_update_reserved_schema", true) - }) - }) - - t.Run("UpdateReservedPrefixSource", func(t *testing.T) { - body := `{"name": "new_name"}` - req := httptest.NewRequest(http.MethodPatch, "/api/schema/postgres/pg_reserved", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusBadRequest, w.Code) - }) - - t.Run("UpdateInvalidJSON", func(t *testing.T) { - body := `{invalid json}` - req := httptest.NewRequest(http.MethodPatch, "/api/schema/postgres/public", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusBadRequest, w.Code) - }) -} diff --git a/pkg/manager/httphandler/setting.go b/pkg/manager/httphandler/setting.go deleted file mode 100644 index aef9d45..0000000 --- a/pkg/manager/httphandler/setting.go +++ /dev/null @@ -1,133 +0,0 @@ -package httphandler - -import ( - "net/http" - - // Packages - manager "github.com/mutablelogic/go-pg/pkg/manager" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" - httprequest "github.com/mutablelogic/go-server/pkg/httprequest" - httpresponse "github.com/mutablelogic/go-server/pkg/httpresponse" -) - -/////////////////////////////////////////////////////////////////////////////// -// PUBLIC METHODS - -// RegisterSettingHandlers registers HTTP handlers for server setting operations -// on the provided router with the given path prefix. The manager must be non-nil. -func RegisterSettingHandlers(router *http.ServeMux, prefix string, manager *manager.Manager) { - if manager == nil { - panic("manager is nil") - } - - // List settings - router.HandleFunc(joinPath(prefix, "setting"), func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - _ = settingList(w, r, manager) - default: - _ = httpresponse.Error(w, httpresponse.Err(http.StatusMethodNotAllowed), r.Method) - } - }) - - // List setting categories - router.HandleFunc(joinPath(prefix, "setting/category"), func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - _ = settingCategoryList(w, r, manager) - default: - _ = httpresponse.Error(w, httpresponse.Err(http.StatusMethodNotAllowed), r.Method) - } - }) - - // Get or update a specific setting - router.HandleFunc(joinPath(prefix, "setting/{name}"), func(w http.ResponseWriter, r *http.Request) { - name := r.PathValue("name") - if name == "" { - _ = httpresponse.Error(w, httpresponse.ErrBadRequest.With("missing or invalid setting name")) - return - } - - switch r.Method { - case http.MethodGet: - _ = settingGet(w, r, manager, name) - case http.MethodPatch: - _ = settingUpdate(w, r, manager, name) - default: - _ = httpresponse.Error(w, httpresponse.Err(http.StatusMethodNotAllowed), r.Method) - } - }) -} - -/////////////////////////////////////////////////////////////////////////////// -// PRIVATE METHODS - -func settingGet(w http.ResponseWriter, r *http.Request, manager *manager.Manager, name string) error { - setting, err := manager.GetSetting(r.Context(), name) - if err != nil { - return httpresponse.Error(w, httperr(err)) - } - - // Return success - return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), setting) -} - -func settingList(w http.ResponseWriter, r *http.Request, manager *manager.Manager) error { - // Parse request - var req schema.SettingListRequest - if err := httprequest.Query(r.URL.Query(), &req); err != nil { - return httpresponse.Error(w, err) - } - - // List the settings - response, err := manager.ListSettings(r.Context(), req) - if err != nil { - return httpresponse.Error(w, httperr(err)) - } - - // Return success - return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), response) -} - -func settingCategoryList(w http.ResponseWriter, r *http.Request, manager *manager.Manager) error { - // List the setting categories - response, err := manager.ListSettingCategories(r.Context()) - if err != nil { - return httpresponse.Error(w, httperr(err)) - } - - // Return success - return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), response) -} - -func settingUpdate(w http.ResponseWriter, r *http.Request, manager *manager.Manager, name string) error { - // Parse query for reload option - var opts struct { - Reload bool `json:"reload,omitempty" help:"Reload config after update"` - } - if err := httprequest.Query(r.URL.Query(), &opts); err != nil { - return httpresponse.Error(w, err) - } - - // Parse request body - var req schema.SettingMeta - if err := httprequest.Read(r, &req); err != nil { - return httpresponse.Error(w, err) - } - - // Update the setting - response, err := manager.UpdateSetting(r.Context(), name, req) - if err != nil { - return httpresponse.Error(w, httperr(err)) - } - - // Reload config if requested and the setting context supports it - if opts.Reload && response.Context == "sighup" { - if err := manager.ReloadConfig(r.Context()); err != nil { - return httpresponse.Error(w, httperr(err)) - } - } - - // Return success - return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), response) -} diff --git a/pkg/manager/httphandler/setting_test.go b/pkg/manager/httphandler/setting_test.go deleted file mode 100644 index 6426fae..0000000 --- a/pkg/manager/httphandler/setting_test.go +++ /dev/null @@ -1,285 +0,0 @@ -package httphandler_test - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "net/url" - "testing" - - // Packages - httphandler "github.com/mutablelogic/go-pg/pkg/manager/httphandler" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" - test "github.com/mutablelogic/go-pg/pkg/test" - assert "github.com/stretchr/testify/assert" -) - -/////////////////////////////////////////////////////////////////////////////// -// TESTS - -func Test_Setting_RegisterHandlers(t *testing.T) { - assert := assert.New(t) - - // Create manager with test container - manager := test.NewManager(t) - t.Cleanup(func() { - manager.Close() - }) - - t.Run("PanicOnNilManager", func(t *testing.T) { - router := http.NewServeMux() - assert.Panics(func() { - httphandler.RegisterSettingHandlers(router, "/api", nil) - }) - }) - - t.Run("RegisterSuccess", func(t *testing.T) { - router := http.NewServeMux() - assert.NotPanics(func() { - httphandler.RegisterSettingHandlers(router, "/api", manager.Manager) - }) - }) -} - -func Test_Setting_List(t *testing.T) { - assert := assert.New(t) - - // Create manager with test container - manager := test.NewManager(t) - t.Cleanup(func() { - manager.Close() - }) - - router := http.NewServeMux() - httphandler.RegisterSettingHandlers(router, "/api", manager.Manager) - - t.Run("ListSettings", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/setting", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusOK, w.Code) - assert.Contains(w.Header().Get("Content-Type"), "application/json") - - var resp schema.SettingList - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(err) - assert.Greater(int(resp.Count), 100) // PostgreSQL has 300+ settings - }) - - t.Run("ListSettingsWithCategory", func(t *testing.T) { - // First get a valid category - req := httptest.NewRequest(http.MethodGet, "/api/setting/category", nil) - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - var categories schema.SettingCategoryList - err := json.Unmarshal(w.Body.Bytes(), &categories) - assert.NoError(err) - assert.Greater(len(categories.Body), 0) - - // Use first category to filter (URL-encode the category name) - category := categories.Body[0] - req = httptest.NewRequest(http.MethodGet, "/api/setting?category="+url.QueryEscape(category), nil) - w = httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusOK, w.Code) - - var resp schema.SettingList - err = json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(err) - assert.Greater(int(resp.Count), 0) - - // All settings should be in the requested category - for _, s := range resp.Body { - assert.Equal(category, s.Category) - } - }) - - t.Run("ListSettingsWithPagination", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/setting?limit=10", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusOK, w.Code) - - var resp schema.SettingList - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(err) - assert.LessOrEqual(len(resp.Body), 10) - assert.Greater(int(resp.Count), 10) // Total count should be > limit - }) - - t.Run("MethodNotAllowed", func(t *testing.T) { - req := httptest.NewRequest(http.MethodPut, "/api/setting", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusMethodNotAllowed, w.Code) - }) - - t.Run("PostNotAllowed", func(t *testing.T) { - req := httptest.NewRequest(http.MethodPost, "/api/setting", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusMethodNotAllowed, w.Code) - }) -} - -func Test_Setting_CategoryList(t *testing.T) { - assert := assert.New(t) - - // Create manager with test container - manager := test.NewManager(t) - t.Cleanup(func() { - manager.Close() - }) - - router := http.NewServeMux() - httphandler.RegisterSettingHandlers(router, "/api", manager.Manager) - - t.Run("ListCategories", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/setting/category", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusOK, w.Code) - assert.Contains(w.Header().Get("Content-Type"), "application/json") - - var resp schema.SettingCategoryList - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(err) - assert.Greater(int(resp.Count), 10) // Should have 10+ categories - assert.Equal(int(resp.Count), len(resp.Body)) - }) - - t.Run("MethodNotAllowed", func(t *testing.T) { - req := httptest.NewRequest(http.MethodPost, "/api/setting/category", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusMethodNotAllowed, w.Code) - }) -} - -func Test_Setting_Get(t *testing.T) { - assert := assert.New(t) - - // Create manager with test container - manager := test.NewManager(t) - t.Cleanup(func() { - manager.Close() - }) - - router := http.NewServeMux() - httphandler.RegisterSettingHandlers(router, "/api", manager.Manager) - - t.Run("GetExisting", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/setting/max_connections", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusOK, w.Code) - - // Parse response - var setting schema.Setting - err := json.Unmarshal(w.Body.Bytes(), &setting) - assert.NoError(err) - assert.Equal("max_connections", setting.Name) - assert.NotNil(setting.Value) - }) - - t.Run("MethodNotAllowed", func(t *testing.T) { - req := httptest.NewRequest(http.MethodDelete, "/api/setting/max_connections", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusMethodNotAllowed, w.Code) - }) -} - -func Test_Setting_Update(t *testing.T) { - assert := assert.New(t) - - // Create manager with test container - manager := test.NewManager(t) - t.Cleanup(func() { - manager.Close() - }) - - router := http.NewServeMux() - httphandler.RegisterSettingHandlers(router, "/api", manager.Manager) - - t.Run("UpdateSetting", func(t *testing.T) { - body := map[string]interface{}{ - "value": "120", - } - bodyBytes, _ := json.Marshal(body) - req := httptest.NewRequest(http.MethodPatch, "/api/setting/log_min_duration_statement", bytes.NewReader(bodyBytes)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusOK, w.Code) - - // Parse response - var setting schema.Setting - err := json.Unmarshal(w.Body.Bytes(), &setting) - assert.NoError(err) - assert.Equal("log_min_duration_statement", setting.Name) - }) - - t.Run("ResetSetting", func(t *testing.T) { - // value: null means reset - body := map[string]interface{}{ - "value": nil, - } - bodyBytes, _ := json.Marshal(body) - req := httptest.NewRequest(http.MethodPatch, "/api/setting/log_min_duration_statement", bytes.NewReader(bodyBytes)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusOK, w.Code) - - // Parse response - var setting schema.Setting - err := json.Unmarshal(w.Body.Bytes(), &setting) - assert.NoError(err) - assert.Equal("log_min_duration_statement", setting.Name) - }) - - t.Run("UpdateWithReload", func(t *testing.T) { - body := map[string]interface{}{ - "value": "120", - } - bodyBytes, _ := json.Marshal(body) - req := httptest.NewRequest(http.MethodPatch, "/api/setting/log_min_duration_statement?reload=true", bytes.NewReader(bodyBytes)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusOK, w.Code) - - // Parse response - var setting schema.Setting - err := json.Unmarshal(w.Body.Bytes(), &setting) - assert.NoError(err) - assert.Equal("log_min_duration_statement", setting.Name) - }) -} diff --git a/pkg/manager/httphandler/statement.go b/pkg/manager/httphandler/statement.go deleted file mode 100644 index 119fce5..0000000 --- a/pkg/manager/httphandler/statement.go +++ /dev/null @@ -1,64 +0,0 @@ -package httphandler - -import ( - "net/http" - - // Packages - manager "github.com/mutablelogic/go-pg/pkg/manager" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" - httprequest "github.com/mutablelogic/go-server/pkg/httprequest" - httpresponse "github.com/mutablelogic/go-server/pkg/httpresponse" -) - -/////////////////////////////////////////////////////////////////////////////// -// PUBLIC METHODS - -// RegisterStatementHandlers registers HTTP handlers for pg_stat_statements operations -// on the provided router with the given path prefix. The manager must be non-nil. -func RegisterStatementHandlers(router *http.ServeMux, prefix string, manager *manager.Manager) { - if manager == nil { - panic("manager is nil") - } - - // List statements or reset statistics - router.HandleFunc(joinPath(prefix, "statement"), func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - _ = statementList(w, r, manager) - case http.MethodDelete: - _ = statementReset(w, r, manager) - default: - _ = httpresponse.Error(w, httpresponse.Err(http.StatusMethodNotAllowed), r.Method) - } - }) -} - -/////////////////////////////////////////////////////////////////////////////// -// PRIVATE METHODS - -func statementList(w http.ResponseWriter, r *http.Request, manager *manager.Manager) error { - // Parse request - var req schema.StatementListRequest - if err := httprequest.Query(r.URL.Query(), &req); err != nil { - return httpresponse.Error(w, err) - } - - // List the statements - response, err := manager.ListStatements(r.Context(), req) - if err != nil { - return httpresponse.Error(w, httperr(err)) - } - - // Return success - return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), response) -} - -func statementReset(w http.ResponseWriter, r *http.Request, manager *manager.Manager) error { - // Reset the statements - if err := manager.ResetStatements(r.Context()); err != nil { - return httpresponse.Error(w, httperr(err)) - } - - // Return success (no content) - return httpresponse.Empty(w, http.StatusNoContent) -} diff --git a/pkg/manager/httphandler/tablespace.go b/pkg/manager/httphandler/tablespace.go deleted file mode 100644 index e25fccf..0000000 --- a/pkg/manager/httphandler/tablespace.go +++ /dev/null @@ -1,135 +0,0 @@ -package httphandler - -import ( - "net/http" - "strings" - - // Packages - manager "github.com/mutablelogic/go-pg/pkg/manager" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" - httprequest "github.com/mutablelogic/go-server/pkg/httprequest" - httpresponse "github.com/mutablelogic/go-server/pkg/httpresponse" -) - -/////////////////////////////////////////////////////////////////////////////// -// PUBLIC METHODS - -// RegisterTablespaceHandlers registers HTTP handlers for tablespace CRUD operations -// on the provided router with the given path prefix. The manager must be non-nil. -func RegisterTablespaceHandlers(router *http.ServeMux, prefix string, manager *manager.Manager) { - if manager == nil { - panic("manager is nil") - } - router.HandleFunc(joinPath(prefix, "tablespace"), func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - _ = tablespaceList(w, r, manager) - case http.MethodPost: - _ = tablespaceCreate(w, r, manager) - default: - _ = httpresponse.Error(w, httpresponse.Err(http.StatusMethodNotAllowed), r.Method) - } - }) - - router.HandleFunc(joinPath(prefix, "tablespace/{name}"), func(w http.ResponseWriter, r *http.Request) { - name := r.PathValue("name") - if name == "" { - _ = httpresponse.Error(w, httpresponse.ErrBadRequest.With("missing or invalid tablespace name")) - return - } - if strings.HasPrefix(name, "pg_") { - _ = httpresponse.Error(w, httpresponse.ErrBadRequest.With("tablespace name cannot start with reserved prefix 'pg_'")) - return - } - - switch r.Method { - case http.MethodGet: - _ = tablespaceGet(w, r, manager, name) - case http.MethodPatch: - _ = tablespaceUpdate(w, r, manager, name) - case http.MethodDelete: - _ = tablespaceDelete(w, r, manager, name) - default: - _ = httpresponse.Error(w, httpresponse.Err(http.StatusMethodNotAllowed), r.Method) - } - }) -} - -/////////////////////////////////////////////////////////////////////////////// -// PRIVATE METHODS - -func tablespaceList(w http.ResponseWriter, r *http.Request, manager *manager.Manager) error { - // Parse request - var req schema.TablespaceListRequest - if err := httprequest.Query(r.URL.Query(), &req); err != nil { - return httpresponse.Error(w, err) - } - - // List the tablespaces - response, err := manager.ListTablespaces(r.Context(), req) - if err != nil { - return httpresponse.Error(w, httperr(err)) - } - - // Return success - return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), response) -} - -func tablespaceCreate(w http.ResponseWriter, r *http.Request, manager *manager.Manager) error { - // Parse request - var req struct { - Location string `json:"location"` - schema.TablespaceMeta - } - if err := httprequest.Read(r, &req); err != nil { - return httpresponse.Error(w, err) - } - - // Create the tablespace - response, err := manager.CreateTablespace(r.Context(), req.TablespaceMeta, req.Location) - if err != nil { - return httpresponse.Error(w, httperr(err)) - } - - // Return success - return httpresponse.JSON(w, http.StatusCreated, httprequest.Indent(r), response) -} - -func tablespaceGet(w http.ResponseWriter, r *http.Request, manager *manager.Manager, name string) error { - // Get the tablespace - response, err := manager.GetTablespace(r.Context(), name) - if err != nil { - return httpresponse.Error(w, httperr(err)) - } - - // Return success - return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), response) -} - -func tablespaceDelete(w http.ResponseWriter, r *http.Request, manager *manager.Manager, name string) error { - // Delete the tablespace - _, err := manager.DeleteTablespace(r.Context(), name) - if err != nil { - return httpresponse.Error(w, httperr(err)) - } - - // Return success - return httpresponse.Empty(w, http.StatusOK) -} - -func tablespaceUpdate(w http.ResponseWriter, r *http.Request, manager *manager.Manager, name string) error { - // Parse request - var req schema.TablespaceMeta - if err := httprequest.Read(r, &req); err != nil { - return httpresponse.Error(w, err) - } - - // Update the tablespace - response, err := manager.UpdateTablespace(r.Context(), name, req) - if err != nil { - return httpresponse.Error(w, httperr(err)) - } - - // Return success - return httpresponse.JSON(w, http.StatusOK, httprequest.Indent(r), response) -} diff --git a/pkg/manager/httphandler/tablespace_test.go b/pkg/manager/httphandler/tablespace_test.go deleted file mode 100644 index d85f7ab..0000000 --- a/pkg/manager/httphandler/tablespace_test.go +++ /dev/null @@ -1,288 +0,0 @@ -package httphandler_test - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - // Packages - httprequest "github.com/mutablelogic/go-pg/pkg/manager/httphandler" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" - test "github.com/mutablelogic/go-pg/pkg/test" - assert "github.com/stretchr/testify/assert" -) - -/////////////////////////////////////////////////////////////////////////////// -// TESTS - -func Test_Tablespace_RegisterHandlers(t *testing.T) { - assert := assert.New(t) - - // Create manager with test container - manager := test.NewManager(t) - t.Cleanup(func() { - manager.Close() - }) - - t.Run("PanicOnNilManager", func(t *testing.T) { - router := http.NewServeMux() - assert.Panics(func() { - httprequest.RegisterTablespaceHandlers(router, "/api", nil) - }) - }) - - t.Run("RegisterSuccess", func(t *testing.T) { - router := http.NewServeMux() - assert.NotPanics(func() { - httprequest.RegisterTablespaceHandlers(router, "/api", manager.Manager) - }) - }) -} - -func Test_Tablespace_List(t *testing.T) { - assert := assert.New(t) - - // Create manager with test container - manager := test.NewManager(t) - t.Cleanup(func() { - manager.Close() - }) - - router := http.NewServeMux() - httprequest.RegisterTablespaceHandlers(router, "/api", manager.Manager) - - t.Run("ListTablespaces", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/tablespace", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusOK, w.Code) - assert.Contains(w.Header().Get("Content-Type"), "application/json") - - var resp schema.TablespaceList - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(err) - assert.GreaterOrEqual(int(resp.Count), 2) // At least pg_default and pg_global - }) - - t.Run("ListTablespacesWithPagination", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/tablespace?limit=1", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusOK, w.Code) - - var resp schema.TablespaceList - err := json.Unmarshal(w.Body.Bytes(), &resp) - assert.NoError(err) - assert.LessOrEqual(len(resp.Body), 1) - }) - - t.Run("MethodNotAllowed", func(t *testing.T) { - req := httptest.NewRequest(http.MethodPut, "/api/tablespace", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusMethodNotAllowed, w.Code) - }) -} - -func Test_Tablespace_Get(t *testing.T) { - assert := assert.New(t) - - // Create manager with test container - manager := test.NewManager(t) - t.Cleanup(func() { - manager.Close() - }) - - router := http.NewServeMux() - httprequest.RegisterTablespaceHandlers(router, "/api", manager.Manager) - - t.Run("GetPgDefault", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/tablespace/pg_default", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - // pg_default has pg_ prefix, so it's rejected by the handler - assert.Equal(http.StatusBadRequest, w.Code) - }) - - t.Run("GetNotFound", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/tablespace/nonexistent_ts_xyz", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusNotFound, w.Code) - }) - - t.Run("GetReservedPrefix", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/tablespace/pg_reserved", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusBadRequest, w.Code) - }) - - t.Run("MethodNotAllowed", func(t *testing.T) { - req := httptest.NewRequest(http.MethodPost, "/api/tablespace/mytablespace", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusMethodNotAllowed, w.Code) - }) -} - -func Test_Tablespace_Create(t *testing.T) { - assert := assert.New(t) - - // Create manager with test container - manager := test.NewManager(t) - t.Cleanup(func() { - manager.Close() - }) - - router := http.NewServeMux() - httprequest.RegisterTablespaceHandlers(router, "/api", manager.Manager) - - t.Run("CreateEmptyName", func(t *testing.T) { - body := `{"name": "", "location": "/tmp/test"}` - req := httptest.NewRequest(http.MethodPost, "/api/tablespace", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusBadRequest, w.Code) - }) - - t.Run("CreateReservedPrefix", func(t *testing.T) { - body := `{"name": "pg_reserved_test", "location": "/tmp/test"}` - req := httptest.NewRequest(http.MethodPost, "/api/tablespace", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusBadRequest, w.Code) - }) - - t.Run("CreateEmptyLocation", func(t *testing.T) { - body := `{"name": "test_tablespace", "location": ""}` - req := httptest.NewRequest(http.MethodPost, "/api/tablespace", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusBadRequest, w.Code) - }) - - t.Run("CreateRelativeLocation", func(t *testing.T) { - body := `{"name": "test_tablespace", "location": "relative/path"}` - req := httptest.NewRequest(http.MethodPost, "/api/tablespace", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusBadRequest, w.Code) - }) - - t.Run("CreateInvalidJSON", func(t *testing.T) { - body := `{invalid json}` - req := httptest.NewRequest(http.MethodPost, "/api/tablespace", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusBadRequest, w.Code) - }) -} - -func Test_Tablespace_Delete(t *testing.T) { - assert := assert.New(t) - - // Create manager with test container - manager := test.NewManager(t) - t.Cleanup(func() { - manager.Close() - }) - - router := http.NewServeMux() - httprequest.RegisterTablespaceHandlers(router, "/api", manager.Manager) - - t.Run("DeleteNotFound", func(t *testing.T) { - req := httptest.NewRequest(http.MethodDelete, "/api/tablespace/nonexistent_ts_xyz", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusNotFound, w.Code) - }) - - t.Run("DeleteReservedPrefix", func(t *testing.T) { - req := httptest.NewRequest(http.MethodDelete, "/api/tablespace/pg_default", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusBadRequest, w.Code) - }) -} - -func Test_Tablespace_Update(t *testing.T) { - assert := assert.New(t) - - // Create manager with test container - manager := test.NewManager(t) - t.Cleanup(func() { - manager.Close() - }) - - router := http.NewServeMux() - httprequest.RegisterTablespaceHandlers(router, "/api", manager.Manager) - - t.Run("UpdateNotFound", func(t *testing.T) { - body := `{"name": "new_name"}` - req := httptest.NewRequest(http.MethodPatch, "/api/tablespace/nonexistent_ts_xyz", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusNotFound, w.Code) - }) - - t.Run("UpdateReservedPrefix", func(t *testing.T) { - body := `{"name": "new_name"}` - req := httptest.NewRequest(http.MethodPatch, "/api/tablespace/pg_default", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusBadRequest, w.Code) - }) - - t.Run("UpdateInvalidJSON", func(t *testing.T) { - body := `{invalid json}` - req := httptest.NewRequest(http.MethodPatch, "/api/tablespace/mytablespace", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(http.StatusBadRequest, w.Code) - }) -} diff --git a/pkg/manager/object_test.go b/pkg/manager/object_test.go deleted file mode 100644 index 3440754..0000000 --- a/pkg/manager/object_test.go +++ /dev/null @@ -1,196 +0,0 @@ -package manager_test - -import ( - "context" - "testing" - - // Packages - pg "github.com/mutablelogic/go-pg" - manager "github.com/mutablelogic/go-pg/pkg/manager" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" - assert "github.com/stretchr/testify/assert" -) - -//////////////////////////////////////////////////////////////////////////////// -// LIST OBJECTS TESTS - -func Test_Manager_ListObjects(t *testing.T) { - assert := assert.New(t) - conn := conn.Begin(t) - defer conn.Close() - - mgr, err := manager.New(context.TODO(), conn) - if !assert.NoError(err) { - t.FailNow() - } - - t.Run("ListAll", func(t *testing.T) { - objects, err := mgr.ListObjects(context.TODO(), schema.ObjectListRequest{}) - assert.NoError(err) - assert.NotNil(objects) - // Count should match body length (within limit) - assert.LessOrEqual(len(objects.Body), int(objects.Count)) - }) - - t.Run("ListWithPagination", func(t *testing.T) { - limit := uint64(1) - objects, err := mgr.ListObjects(context.TODO(), schema.ObjectListRequest{ - OffsetLimit: pg.OffsetLimit{Limit: &limit}, - }) - assert.NoError(err) - assert.NotNil(objects) - assert.LessOrEqual(len(objects.Body), 1) - }) - - t.Run("ListByDatabase", func(t *testing.T) { - dbName := "postgres" - objects, err := mgr.ListObjects(context.TODO(), schema.ObjectListRequest{ - Database: &dbName, - }) - assert.NoError(err) - assert.NotNil(objects) - // All objects should be from postgres database - for _, obj := range objects.Body { - assert.Equal(dbName, obj.Database) - } - }) - - t.Run("ListBySchema", func(t *testing.T) { - schemaName := "public" - objects, err := mgr.ListObjects(context.TODO(), schema.ObjectListRequest{ - Schema: &schemaName, - }) - assert.NoError(err) - assert.NotNil(objects) - // All objects should be from public schema - for _, obj := range objects.Body { - assert.Equal(schemaName, obj.Schema) - } - }) - - t.Run("ListWithMultipleFilters", func(t *testing.T) { - dbName := "postgres" - schemaName := "public" - objects, err := mgr.ListObjects(context.TODO(), schema.ObjectListRequest{ - Database: &dbName, - Schema: &schemaName, - }) - assert.NoError(err) - assert.NotNil(objects) - // All objects should match both filters - for _, obj := range objects.Body { - assert.Equal(dbName, obj.Database) - assert.Equal(schemaName, obj.Schema) - } - }) - - t.Run("ListWithOffset", func(t *testing.T) { - // First get all objects - allObjects, err := mgr.ListObjects(context.TODO(), schema.ObjectListRequest{}) - assert.NoError(err) - - if allObjects.Count < 2 { - t.Skip("Not enough objects to test offset") - } - - // Get with offset - limit := uint64(10) - offset := uint64(1) - objects, err := mgr.ListObjects(context.TODO(), schema.ObjectListRequest{ - OffsetLimit: pg.OffsetLimit{ - Offset: offset, - Limit: &limit, - }, - }) - assert.NoError(err) - assert.NotNil(objects) - // Count should be the same, but body should start from offset - assert.Equal(allObjects.Count, objects.Count) - }) - - t.Run("ListFromNonExistentDatabase", func(t *testing.T) { - dbName := "non_existing_database_xyz" - objects, err := mgr.ListObjects(context.TODO(), schema.ObjectListRequest{ - Database: &dbName, - }) - assert.NoError(err) - assert.NotNil(objects) - // Should return empty list for non-existent database - assert.Equal(uint64(0), objects.Count) - assert.Empty(objects.Body) - }) -} - -//////////////////////////////////////////////////////////////////////////////// -// GET OBJECT TESTS - -func Test_Manager_GetObject(t *testing.T) { - assert := assert.New(t) - conn := conn.Begin(t) - defer conn.Close() - - mgr, err := manager.New(context.TODO(), conn) - if !assert.NoError(err) { - t.FailNow() - } - - t.Run("GetNonExistentObject", func(t *testing.T) { - _, err := mgr.GetObject(context.TODO(), "postgres", "public", "non_existing_object_xyz") - assert.Error(err) - assert.ErrorIs(err, pg.ErrNotFound) - }) - - t.Run("GetEmptyDatabase", func(t *testing.T) { - _, err := mgr.GetObject(context.TODO(), "", "public", "test") - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("GetEmptyNamespace", func(t *testing.T) { - _, err := mgr.GetObject(context.TODO(), "postgres", "", "test") - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("GetEmptyName", func(t *testing.T) { - _, err := mgr.GetObject(context.TODO(), "postgres", "public", "") - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("GetFromNonExistentSchema", func(t *testing.T) { - _, err := mgr.GetObject(context.TODO(), "postgres", "non_existing_schema_xyz", "test") - assert.Error(err) - assert.ErrorIs(err, pg.ErrNotFound) - }) - - t.Run("GetExistingObject", func(t *testing.T) { - // First list objects to find one that exists - dbName := "postgres" - schemaName := "public" - limit := uint64(1) - objects, err := mgr.ListObjects(context.TODO(), schema.ObjectListRequest{ - Database: &dbName, - Schema: &schemaName, - OffsetLimit: pg.OffsetLimit{Limit: &limit}, - }) - if !assert.NoError(err) { - t.FailNow() - } - - if len(objects.Body) == 0 { - t.Skip("No objects found in postgres.public schema") - } - - // Get the first object - obj := objects.Body[0] - result, err := mgr.GetObject(context.TODO(), obj.Database, obj.Schema, obj.Name) - assert.NoError(err) - assert.NotNil(result) - assert.Equal(obj.Name, result.Name) - assert.Equal(obj.Schema, result.Schema) - assert.Equal(obj.Database, result.Database) - assert.Equal(obj.Type, result.Type) - assert.Equal(obj.Owner, result.Owner) - }) -} diff --git a/pkg/manager/replicationslot_test.go b/pkg/manager/replicationslot_test.go deleted file mode 100644 index a3dde3a..0000000 --- a/pkg/manager/replicationslot_test.go +++ /dev/null @@ -1,303 +0,0 @@ -package manager_test - -import ( - "context" - "strings" - "testing" - - // Packages - pg "github.com/mutablelogic/go-pg" - manager "github.com/mutablelogic/go-pg/pkg/manager" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" - assert "github.com/stretchr/testify/assert" -) - -//////////////////////////////////////////////////////////////////////////////// -// LIST REPLICATION SLOTS TESTS - -func Test_Manager_ListReplicationSlots(t *testing.T) { - assert := assert.New(t) - conn := conn.Begin(t) - defer conn.Close() - - mgr, err := manager.New(context.TODO(), conn) - if !assert.NoError(err) { - t.FailNow() - } - - t.Run("ListAll", func(t *testing.T) { - slots, err := mgr.ListReplicationSlots(context.TODO(), schema.ReplicationSlotListRequest{}) - assert.NoError(err) - assert.NotNil(slots) - // Count should match body length - assert.Equal(len(slots.Body), int(slots.Count)) - }) - - t.Run("ListWithPagination", func(t *testing.T) { - limit := uint64(1) - slots, err := mgr.ListReplicationSlots(context.TODO(), schema.ReplicationSlotListRequest{ - OffsetLimit: pg.OffsetLimit{Limit: &limit}, - }) - assert.NoError(err) - assert.NotNil(slots) - assert.LessOrEqual(len(slots.Body), 1) - }) -} - -//////////////////////////////////////////////////////////////////////////////// -// GET REPLICATION SLOT TESTS - -func Test_Manager_GetReplicationSlot(t *testing.T) { - assert := assert.New(t) - conn := conn.Begin(t) - defer conn.Close() - - mgr, err := manager.New(context.TODO(), conn) - if !assert.NoError(err) { - t.FailNow() - } - - t.Run("GetNonExistent", func(t *testing.T) { - _, err := mgr.GetReplicationSlot(context.TODO(), "non_existing_slot_xyz") - assert.Error(err) - assert.ErrorIs(err, pg.ErrNotFound) - }) - - t.Run("GetEmptyName", func(t *testing.T) { - _, err := mgr.GetReplicationSlot(context.TODO(), "") - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) -} - -//////////////////////////////////////////////////////////////////////////////// -// CREATE REPLICATION SLOT TESTS - -func Test_Manager_CreateReplicationSlot(t *testing.T) { - assert := assert.New(t) - conn := conn.Begin(t) - defer conn.Close() - - mgr, err := manager.New(context.TODO(), conn) - if !assert.NoError(err) { - t.FailNow() - } - - t.Run("CreatePhysical", func(t *testing.T) { - slotName := "test_physical_slot" - t.Cleanup(func() { - mgr.DeleteReplicationSlot(context.TODO(), slotName) - }) - - slot, err := mgr.CreateReplicationSlot(context.TODO(), schema.ReplicationSlotMeta{ - Name: slotName, - Type: "physical", - }) - assert.NoError(err) - assert.NotNil(slot) - assert.Equal(slotName, slot.Name) - assert.Equal("physical", slot.Type) - assert.Equal("inactive", slot.Status) - }) - - t.Run("CreateLogical", func(t *testing.T) { - slotName := "test_logical_slot" - t.Cleanup(func() { - mgr.DeleteReplicationSlot(context.TODO(), slotName) - }) - - slot, err := mgr.CreateReplicationSlot(context.TODO(), schema.ReplicationSlotMeta{ - Name: slotName, - Type: "logical", - Plugin: "pgoutput", - }) - // Skip if wal_level is not sufficient for logical replication - if err != nil && strings.Contains(err.Error(), "wal_level") { - t.Skip("wal_level not set to logical") - } - assert.NoError(err) - if slot == nil { - t.FailNow() - } - assert.Equal(slotName, slot.Name) - assert.Equal("logical", slot.Type) - assert.Equal("pgoutput", slot.Plugin) - assert.Equal("inactive", slot.Status) - }) - - t.Run("CreateTemporary", func(t *testing.T) { - slotName := "test_temp_slot" - // Temporary slots are deleted on disconnect, but cleanup anyway - t.Cleanup(func() { - mgr.DeleteReplicationSlot(context.TODO(), slotName) - }) - - slot, err := mgr.CreateReplicationSlot(context.TODO(), schema.ReplicationSlotMeta{ - Name: slotName, - Type: "physical", - Temporary: true, - }) - assert.NoError(err) - assert.NotNil(slot) - assert.Equal(slotName, slot.Name) - assert.True(slot.Temporary) - }) - - t.Run("CreateEmptyName", func(t *testing.T) { - _, err := mgr.CreateReplicationSlot(context.TODO(), schema.ReplicationSlotMeta{ - Name: "", - Type: "physical", - }) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("CreateInvalidType", func(t *testing.T) { - _, err := mgr.CreateReplicationSlot(context.TODO(), schema.ReplicationSlotMeta{ - Name: "test_invalid_type", - Type: "invalid", - }) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("CreateLogicalWithoutPlugin", func(t *testing.T) { - _, err := mgr.CreateReplicationSlot(context.TODO(), schema.ReplicationSlotMeta{ - Name: "test_logical_no_plugin", - Type: "logical", - }) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("CreateReservedPrefix", func(t *testing.T) { - _, err := mgr.CreateReplicationSlot(context.TODO(), schema.ReplicationSlotMeta{ - Name: "pg_reserved_slot", - Type: "physical", - }) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("CreateDuplicate", func(t *testing.T) { - slotName := "test_duplicate_slot" - t.Cleanup(func() { - mgr.DeleteReplicationSlot(context.TODO(), slotName) - }) - - _, err := mgr.CreateReplicationSlot(context.TODO(), schema.ReplicationSlotMeta{ - Name: slotName, - Type: "physical", - }) - assert.NoError(err) - - // Try to create again - _, err = mgr.CreateReplicationSlot(context.TODO(), schema.ReplicationSlotMeta{ - Name: slotName, - Type: "physical", - }) - assert.Error(err) - }) -} - -//////////////////////////////////////////////////////////////////////////////// -// DELETE REPLICATION SLOT TESTS - -func Test_Manager_DeleteReplicationSlot(t *testing.T) { - assert := assert.New(t) - conn := conn.Begin(t) - defer conn.Close() - - mgr, err := manager.New(context.TODO(), conn) - if !assert.NoError(err) { - t.FailNow() - } - - t.Run("DeleteExisting", func(t *testing.T) { - slotName := "test_delete_slot" - - // Create first - _, err := mgr.CreateReplicationSlot(context.TODO(), schema.ReplicationSlotMeta{ - Name: slotName, - Type: "physical", - }) - assert.NoError(err) - - // Delete - slot, err := mgr.DeleteReplicationSlot(context.TODO(), slotName) - assert.NoError(err) - assert.NotNil(slot) - assert.Equal(slotName, slot.Name) - - // Verify it's gone - _, err = mgr.GetReplicationSlot(context.TODO(), slotName) - assert.Error(err) - assert.ErrorIs(err, pg.ErrNotFound) - }) - - t.Run("DeleteNonExistent", func(t *testing.T) { - _, err := mgr.DeleteReplicationSlot(context.TODO(), "non_existing_slot_xyz") - assert.Error(err) - assert.ErrorIs(err, pg.ErrNotFound) - }) - - t.Run("DeleteEmptyName", func(t *testing.T) { - _, err := mgr.DeleteReplicationSlot(context.TODO(), "") - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) -} - -//////////////////////////////////////////////////////////////////////////////// -// REPLICATION SLOT ROUND TRIP TESTS - -func Test_Manager_ReplicationSlotRoundTrip(t *testing.T) { - assert := assert.New(t) - conn := conn.Begin(t) - defer conn.Close() - - mgr, err := manager.New(context.TODO(), conn) - if !assert.NoError(err) { - t.FailNow() - } - - t.Run("CreateGetDelete", func(t *testing.T) { - slotName := "test_roundtrip_slot" - - // Create - created, err := mgr.CreateReplicationSlot(context.TODO(), schema.ReplicationSlotMeta{ - Name: slotName, - Type: "physical", - }) - assert.NoError(err) - assert.NotNil(created) - - // Get - got, err := mgr.GetReplicationSlot(context.TODO(), slotName) - assert.NoError(err) - assert.Equal(created.Name, got.Name) - assert.Equal(created.Type, got.Type) - assert.Equal("inactive", got.Status) - - // List should include it - list, err := mgr.ListReplicationSlots(context.TODO(), schema.ReplicationSlotListRequest{}) - assert.NoError(err) - found := false - for _, s := range list.Body { - if s.Name == slotName { - found = true - break - } - } - assert.True(found, "slot should appear in list") - - // Delete - deleted, err := mgr.DeleteReplicationSlot(context.TODO(), slotName) - assert.NoError(err) - assert.Equal(slotName, deleted.Name) - - // Should be gone - _, err = mgr.GetReplicationSlot(context.TODO(), slotName) - assert.ErrorIs(err, pg.ErrNotFound) - }) -} diff --git a/pkg/manager/role_test.go b/pkg/manager/role_test.go deleted file mode 100644 index 7153bb0..0000000 --- a/pkg/manager/role_test.go +++ /dev/null @@ -1,534 +0,0 @@ -package manager_test - -import ( - "context" - "testing" - - // Packages - pg "github.com/mutablelogic/go-pg" - manager "github.com/mutablelogic/go-pg/pkg/manager" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" - types "github.com/mutablelogic/go-server/pkg/types" - assert "github.com/stretchr/testify/assert" -) - -//////////////////////////////////////////////////////////////////////////////// -// LIST ROLES TESTS - -func Test_Manager_ListRoles(t *testing.T) { - assert := assert.New(t) - conn := conn.Begin(t) - defer conn.Close() - - mgr, err := manager.New(context.TODO(), conn) - if !assert.NoError(err) { - t.FailNow() - } - - t.Run("ListAll", func(t *testing.T) { - roles, err := mgr.ListRoles(context.TODO(), schema.RoleListRequest{}) - assert.NoError(err) - assert.NotNil(roles) - assert.Equal(len(roles.Body), int(roles.Count)) - // Should have at least one role (the superuser) - assert.GreaterOrEqual(roles.Count, uint64(1)) - }) - - t.Run("ListWithPagination", func(t *testing.T) { - limit := uint64(1) - roles, err := mgr.ListRoles(context.TODO(), schema.RoleListRequest{ - OffsetLimit: pg.OffsetLimit{Limit: &limit}, - }) - assert.NoError(err) - assert.NotNil(roles) - assert.LessOrEqual(len(roles.Body), 1) - }) -} - -//////////////////////////////////////////////////////////////////////////////// -// GET ROLE TESTS - -func Test_Manager_GetRole(t *testing.T) { - assert := assert.New(t) - conn := conn.Begin(t) - defer conn.Close() - - mgr, err := manager.New(context.TODO(), conn) - if !assert.NoError(err) { - t.FailNow() - } - - t.Run("GetExisting", func(t *testing.T) { - // First create a role to get - roleName := "test_get_existing" - t.Cleanup(func() { - mgr.DeleteRole(context.TODO(), roleName) - }) - - _, err := mgr.CreateRole(context.TODO(), schema.RoleMeta{ - Name: roleName, - }) - if !assert.NoError(err) { - t.FailNow() - } - - role, err := mgr.GetRole(context.TODO(), roleName) - assert.NoError(err) - assert.NotNil(role) - assert.Equal(roleName, role.Name) - }) - - t.Run("GetNonExistent", func(t *testing.T) { - _, err := mgr.GetRole(context.TODO(), "non_existing_role_xyz") - assert.Error(err) - assert.ErrorIs(err, pg.ErrNotFound) - }) - - t.Run("GetEmptyName", func(t *testing.T) { - _, err := mgr.GetRole(context.TODO(), "") - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) -} - -//////////////////////////////////////////////////////////////////////////////// -// CREATE ROLE TESTS - -func Test_Manager_CreateRole(t *testing.T) { - assert := assert.New(t) - conn := conn.Begin(t) - defer conn.Close() - - mgr, err := manager.New(context.TODO(), conn) - if !assert.NoError(err) { - t.FailNow() - } - - t.Run("CreateSimple", func(t *testing.T) { - roleName := "test_create_simple_role" - t.Cleanup(func() { - mgr.DeleteRole(context.TODO(), roleName) - }) - - role, err := mgr.CreateRole(context.TODO(), schema.RoleMeta{ - Name: roleName, - }) - assert.NoError(err) - assert.NotNil(role) - assert.Equal(roleName, role.Name) - }) - - t.Run("CreateWithLogin", func(t *testing.T) { - roleName := "test_create_login_role" - t.Cleanup(func() { - mgr.DeleteRole(context.TODO(), roleName) - }) - - role, err := mgr.CreateRole(context.TODO(), schema.RoleMeta{ - Name: roleName, - Login: types.BoolPtr(true), - }) - assert.NoError(err) - assert.NotNil(role) - assert.Equal(roleName, role.Name) - assert.True(types.PtrBool(role.Login)) - }) - - t.Run("CreateWithPassword", func(t *testing.T) { - roleName := "test_create_password_role" - t.Cleanup(func() { - mgr.DeleteRole(context.TODO(), roleName) - }) - - role, err := mgr.CreateRole(context.TODO(), schema.RoleMeta{ - Name: roleName, - Login: types.BoolPtr(true), - Password: types.StringPtr("secret123"), - }) - assert.NoError(err) - assert.NotNil(role) - assert.Equal(roleName, role.Name) - // Password should be obfuscated in response - assert.NotNil(role.Password) - }) - - t.Run("CreateWithConnectionLimit", func(t *testing.T) { - roleName := "test_create_connlimit_role" - t.Cleanup(func() { - mgr.DeleteRole(context.TODO(), roleName) - }) - - role, err := mgr.CreateRole(context.TODO(), schema.RoleMeta{ - Name: roleName, - ConnectionLimit: types.Uint64Ptr(5), - }) - assert.NoError(err) - assert.NotNil(role) - assert.Equal(uint64(5), types.PtrUint64(role.ConnectionLimit)) - }) - - t.Run("CreateWithPermissions", func(t *testing.T) { - roleName := "test_create_perms_role" - t.Cleanup(func() { - mgr.DeleteRole(context.TODO(), roleName) - }) - - role, err := mgr.CreateRole(context.TODO(), schema.RoleMeta{ - Name: roleName, - CreateDatabases: types.BoolPtr(true), - CreateRoles: types.BoolPtr(true), - }) - assert.NoError(err) - assert.NotNil(role) - assert.True(types.PtrBool(role.CreateDatabases)) - assert.True(types.PtrBool(role.CreateRoles)) - }) - - t.Run("CreateEmptyName", func(t *testing.T) { - _, err := mgr.CreateRole(context.TODO(), schema.RoleMeta{ - Name: "", - }) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("CreateReservedPrefix", func(t *testing.T) { - _, err := mgr.CreateRole(context.TODO(), schema.RoleMeta{ - Name: "pg_reserved_role", - }) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("CreateDuplicate", func(t *testing.T) { - roleName := "test_create_duplicate_role" - t.Cleanup(func() { - mgr.DeleteRole(context.TODO(), roleName) - }) - - _, err := mgr.CreateRole(context.TODO(), schema.RoleMeta{ - Name: roleName, - }) - assert.NoError(err) - - // Try to create again - _, err = mgr.CreateRole(context.TODO(), schema.RoleMeta{ - Name: roleName, - }) - assert.Error(err) - }) - - t.Run("CreateWithGroups", func(t *testing.T) { - groupName := "test_group_parent" - roleName := "test_create_with_groups" - t.Cleanup(func() { - mgr.DeleteRole(context.TODO(), roleName) - mgr.DeleteRole(context.TODO(), groupName) - }) - - // Create the group first - _, err := mgr.CreateRole(context.TODO(), schema.RoleMeta{ - Name: groupName, - }) - if !assert.NoError(err) { - t.FailNow() - } - - // Create role as member of the group - role, err := mgr.CreateRole(context.TODO(), schema.RoleMeta{ - Name: roleName, - Groups: []string{groupName}, - }) - assert.NoError(err) - assert.NotNil(role) - assert.Contains(role.Groups, groupName) - }) -} - -//////////////////////////////////////////////////////////////////////////////// -// DELETE ROLE TESTS - -func Test_Manager_DeleteRole(t *testing.T) { - assert := assert.New(t) - conn := conn.Begin(t) - defer conn.Close() - - mgr, err := manager.New(context.TODO(), conn) - if !assert.NoError(err) { - t.FailNow() - } - - t.Run("DeleteExisting", func(t *testing.T) { - roleName := "test_delete_existing_role" - - // Create first - _, err := mgr.CreateRole(context.TODO(), schema.RoleMeta{ - Name: roleName, - }) - if !assert.NoError(err) { - t.FailNow() - } - - // Delete - role, err := mgr.DeleteRole(context.TODO(), roleName) - assert.NoError(err) - assert.NotNil(role) - assert.Equal(roleName, role.Name) - - // Verify it's gone - _, err = mgr.GetRole(context.TODO(), roleName) - assert.ErrorIs(err, pg.ErrNotFound) - }) - - t.Run("DeleteNonExistent", func(t *testing.T) { - _, err := mgr.DeleteRole(context.TODO(), "non_existing_role_xyz") - assert.Error(err) - assert.ErrorIs(err, pg.ErrNotFound) - }) - - t.Run("DeleteEmptyName", func(t *testing.T) { - _, err := mgr.DeleteRole(context.TODO(), "") - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) -} - -//////////////////////////////////////////////////////////////////////////////// -// UPDATE ROLE TESTS - -func Test_Manager_UpdateRole(t *testing.T) { - assert := assert.New(t) - conn := conn.Begin(t) - defer conn.Close() - - mgr, err := manager.New(context.TODO(), conn) - if !assert.NoError(err) { - t.FailNow() - } - - t.Run("RenameRole", func(t *testing.T) { - oldName := "test_rename_old_role" - newName := "test_rename_new_role" - t.Cleanup(func() { - mgr.DeleteRole(context.TODO(), oldName) - mgr.DeleteRole(context.TODO(), newName) - }) - - // Create - _, err := mgr.CreateRole(context.TODO(), schema.RoleMeta{ - Name: oldName, - }) - if !assert.NoError(err) { - t.FailNow() - } - - // Rename - role, err := mgr.UpdateRole(context.TODO(), oldName, schema.RoleMeta{ - Name: newName, - }) - assert.NoError(err) - assert.NotNil(role) - assert.Equal(newName, role.Name) - - // Old name should not exist - _, err = mgr.GetRole(context.TODO(), oldName) - assert.ErrorIs(err, pg.ErrNotFound) - - // New name should exist - _, err = mgr.GetRole(context.TODO(), newName) - assert.NoError(err) - }) - - t.Run("UpdateLogin", func(t *testing.T) { - roleName := "test_update_login_role" - t.Cleanup(func() { - mgr.DeleteRole(context.TODO(), roleName) - }) - - // Create without login - _, err := mgr.CreateRole(context.TODO(), schema.RoleMeta{ - Name: roleName, - Login: types.BoolPtr(false), - }) - if !assert.NoError(err) { - t.FailNow() - } - - // Enable login - role, err := mgr.UpdateRole(context.TODO(), roleName, schema.RoleMeta{ - Login: types.BoolPtr(true), - }) - assert.NoError(err) - assert.NotNil(role) - assert.True(types.PtrBool(role.Login)) - }) - - t.Run("UpdateConnectionLimit", func(t *testing.T) { - roleName := "test_update_connlimit_role" - t.Cleanup(func() { - mgr.DeleteRole(context.TODO(), roleName) - }) - - // Create - _, err := mgr.CreateRole(context.TODO(), schema.RoleMeta{ - Name: roleName, - }) - if !assert.NoError(err) { - t.FailNow() - } - - // Update connection limit - role, err := mgr.UpdateRole(context.TODO(), roleName, schema.RoleMeta{ - ConnectionLimit: types.Uint64Ptr(10), - }) - assert.NoError(err) - assert.NotNil(role) - assert.Equal(uint64(10), types.PtrUint64(role.ConnectionLimit)) - }) - - t.Run("UpdateEmptyName", func(t *testing.T) { - _, err := mgr.UpdateRole(context.TODO(), "", schema.RoleMeta{ - Login: types.BoolPtr(true), - }) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("UpdateToReservedPrefix", func(t *testing.T) { - roleName := "test_update_reserved_role" - t.Cleanup(func() { - mgr.DeleteRole(context.TODO(), roleName) - }) - - // Create - _, err := mgr.CreateRole(context.TODO(), schema.RoleMeta{ - Name: roleName, - }) - if !assert.NoError(err) { - t.FailNow() - } - - // Try to rename to reserved prefix - _, err = mgr.UpdateRole(context.TODO(), roleName, schema.RoleMeta{ - Name: "pg_reserved", - }) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("UpdateNonExistent", func(t *testing.T) { - _, err := mgr.UpdateRole(context.TODO(), "non_existing_role_xyz", schema.RoleMeta{ - Login: types.BoolPtr(true), - }) - assert.Error(err) - }) - - t.Run("UpdateAddGroupMembership", func(t *testing.T) { - groupName := "test_update_group" - roleName := "test_update_add_member" - t.Cleanup(func() { - mgr.DeleteRole(context.TODO(), roleName) - mgr.DeleteRole(context.TODO(), groupName) - }) - - // Create the group - _, err := mgr.CreateRole(context.TODO(), schema.RoleMeta{ - Name: groupName, - }) - if !assert.NoError(err) { - t.FailNow() - } - - // Create the role without membership - _, err = mgr.CreateRole(context.TODO(), schema.RoleMeta{ - Name: roleName, - }) - if !assert.NoError(err) { - t.FailNow() - } - - // Add to group - role, err := mgr.UpdateRole(context.TODO(), roleName, schema.RoleMeta{ - Groups: []string{groupName}, - }) - assert.NoError(err) - assert.NotNil(role) - assert.Contains(role.Groups, groupName) - }) - - t.Run("UpdateRemoveGroupMembership", func(t *testing.T) { - groupName := "test_update_remove_group" - roleName := "test_update_remove_member" - t.Cleanup(func() { - mgr.DeleteRole(context.TODO(), roleName) - mgr.DeleteRole(context.TODO(), groupName) - }) - - // Create the group - _, err := mgr.CreateRole(context.TODO(), schema.RoleMeta{ - Name: groupName, - }) - if !assert.NoError(err) { - t.FailNow() - } - - // Create the role with membership - _, err = mgr.CreateRole(context.TODO(), schema.RoleMeta{ - Name: roleName, - Groups: []string{groupName}, - }) - if !assert.NoError(err) { - t.FailNow() - } - - // Remove from group by setting empty groups - role, err := mgr.UpdateRole(context.TODO(), roleName, schema.RoleMeta{ - Groups: []string{}, - }) - assert.NoError(err) - assert.NotNil(role) - assert.NotContains(role.Groups, groupName) - }) - - t.Run("UpdateMultipleGroupMemberships", func(t *testing.T) { - group1Name := "test_multi_group1" - group2Name := "test_multi_group2" - group3Name := "test_multi_group3" - roleName := "test_multi_member" - t.Cleanup(func() { - mgr.DeleteRole(context.TODO(), roleName) - mgr.DeleteRole(context.TODO(), group1Name) - mgr.DeleteRole(context.TODO(), group2Name) - mgr.DeleteRole(context.TODO(), group3Name) - }) - - // Create the groups - for _, gname := range []string{group1Name, group2Name, group3Name} { - _, err := mgr.CreateRole(context.TODO(), schema.RoleMeta{ - Name: gname, - }) - if !assert.NoError(err) { - t.FailNow() - } - } - - // Create the role with membership in group1 and group2 - _, err = mgr.CreateRole(context.TODO(), schema.RoleMeta{ - Name: roleName, - Groups: []string{group1Name, group2Name}, - }) - if !assert.NoError(err) { - t.FailNow() - } - - // Update to be member of group2 and group3 (remove group1, keep group2, add group3) - role, err := mgr.UpdateRole(context.TODO(), roleName, schema.RoleMeta{ - Groups: []string{group2Name, group3Name}, - }) - assert.NoError(err) - assert.NotNil(role) - assert.NotContains(role.Groups, group1Name) - assert.Contains(role.Groups, group2Name) - assert.Contains(role.Groups, group3Name) - }) -} diff --git a/pkg/manager/schema/acl_test.go b/pkg/manager/schema/acl_test.go deleted file mode 100644 index cd37ea8..0000000 --- a/pkg/manager/schema/acl_test.go +++ /dev/null @@ -1,447 +0,0 @@ -package schema_test - -import ( - "encoding/json" - "testing" - - // Packages - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" - assert "github.com/stretchr/testify/assert" -) - -func Test_ACLItem_NewACLItem(t *testing.T) { - assert := assert.New(t) - - t.Run("FullPrivileges", func(t *testing.T) { - acl, err := schema.NewACLItem("miriam=arwdDxtm/miriam") - if assert.NoError(err) { - assert.Equal("miriam", acl.Role) - assert.Equal([]string{"INSERT", "SELECT", "UPDATE", "DELETE", "TRUNCATE", "REFERENCES", "TRIGGER", "MAINTAIN"}, acl.Priv) - assert.Equal("miriam", acl.Grantor) - t.Log(acl) - } - }) - - t.Run("PublicRole", func(t *testing.T) { - acl, err := schema.NewACLItem("=r/miriam") - if assert.NoError(err) { - assert.Equal(schema.DefaultAclRole, acl.Role) - assert.Equal([]string{"SELECT"}, acl.Priv) - assert.Equal("miriam", acl.Grantor) - t.Log(acl) - } - }) - - t.Run("WithGrantOption", func(t *testing.T) { - acl, err := schema.NewACLItem("miriam=r*w/") - if assert.NoError(err) { - assert.Equal("miriam", acl.Role) - assert.Equal([]string{"SELECT WITH GRANT OPTION", "UPDATE"}, acl.Priv) - assert.Equal(schema.DefaultAclRole, acl.Grantor) - t.Log(acl) - } - }) - - t.Run("InvalidFormat", func(t *testing.T) { - _, err := schema.NewACLItem("invalid") - assert.Error(err) - }) - - t.Run("EmptyString", func(t *testing.T) { - _, err := schema.NewACLItem("") - assert.Error(err) - }) - - t.Run("AllPrivileges", func(t *testing.T) { - // Test all privilege types - acl, err := schema.NewACLItem("user=arwdDxtCcTXUsAm/admin") - if assert.NoError(err) { - assert.Equal("user", acl.Role) - assert.Contains(acl.Priv, "INSERT") - assert.Contains(acl.Priv, "SELECT") - assert.Contains(acl.Priv, "UPDATE") - assert.Contains(acl.Priv, "DELETE") - assert.Contains(acl.Priv, "TRUNCATE") - assert.Contains(acl.Priv, "REFERENCES") - assert.Contains(acl.Priv, "TRIGGER") - assert.Contains(acl.Priv, "CREATE") - assert.Contains(acl.Priv, "CONNECT") - assert.Contains(acl.Priv, "TEMPORARY") - assert.Contains(acl.Priv, "EXECUTE") - assert.Contains(acl.Priv, "USAGE") - assert.Contains(acl.Priv, "SET") - assert.Contains(acl.Priv, "ALTER SYSTEM") - assert.Contains(acl.Priv, "MAINTAIN") - } - }) -} - -func Test_ACLItem_ParseACLItem(t *testing.T) { - assert := assert.New(t) - - t.Run("SimpleRole", func(t *testing.T) { - acl, err := schema.ParseACLItem("myuser:SELECT") - if assert.NoError(err) { - assert.Equal("myuser", acl.Role) - assert.Equal([]string{"SELECT"}, acl.Priv) - } - }) - - t.Run("MultiplePrivileges", func(t *testing.T) { - acl, err := schema.ParseACLItem("myuser:SELECT,INSERT,UPDATE") - if assert.NoError(err) { - assert.Equal("myuser", acl.Role) - assert.Equal([]string{"SELECT", "INSERT", "UPDATE"}, acl.Priv) - } - }) - - t.Run("QuotedRole", func(t *testing.T) { - acl, err := schema.ParseACLItem("\"my user\":SELECT") - if assert.NoError(err) { - assert.Equal("my user", acl.Role) - assert.Equal([]string{"SELECT"}, acl.Priv) - } - }) - - t.Run("CaseInsensitive", func(t *testing.T) { - acl, err := schema.ParseACLItem("user:select,INSERT,Update") - if assert.NoError(err) { - assert.Equal([]string{"SELECT", "INSERT", "UPDATE"}, acl.Priv) - } - }) - - t.Run("InvalidPrivilege", func(t *testing.T) { - _, err := schema.ParseACLItem("user:INVALID") - assert.Error(err) - }) - - t.Run("MissingPrivilege", func(t *testing.T) { - _, err := schema.ParseACLItem("user:") - assert.Error(err) - }) - - t.Run("MissingColon", func(t *testing.T) { - // A role without colon and privileges should fail - // Currently the parser accepts it in stSep state - this documents current behavior - // If stricter validation is needed, update UnmarshalText - acl, err := schema.ParseACLItem("user") - // Currently returns success with empty privileges - if err == nil { - assert.Equal("user", acl.Role) - assert.Empty(acl.Priv) - } - }) - - t.Run("EmptyString", func(t *testing.T) { - _, err := schema.ParseACLItem("") - assert.Error(err) - }) - - t.Run("DuplicatePrivileges", func(t *testing.T) { - acl, err := schema.ParseACLItem("user:SELECT,SELECT,INSERT") - if assert.NoError(err) { - // Should deduplicate - assert.Equal([]string{"SELECT", "INSERT"}, acl.Priv) - } - }) - - t.Run("AllPrivilege", func(t *testing.T) { - acl, err := schema.ParseACLItem("admin:ALL") - if assert.NoError(err) { - assert.Equal([]string{"ALL"}, acl.Priv) - assert.True(acl.IsAll()) - } - }) -} - -func Test_ACLItem_WithPriv(t *testing.T) { - assert := assert.New(t) - - t.Run("ReplacePrivileges", func(t *testing.T) { - original, _ := schema.ParseACLItem("user:SELECT,INSERT") - modified := original.WithPriv("UPDATE", "DELETE") - assert.Equal("user", modified.Role) - assert.Equal([]string{"UPDATE", "DELETE"}, modified.Priv) - // Original should be unchanged - assert.Equal([]string{"SELECT", "INSERT"}, original.Priv) - }) -} - -func Test_ACLItem_MarshalText(t *testing.T) { - assert := assert.New(t) - - t.Run("SimpleRole", func(t *testing.T) { - acl, _ := schema.ParseACLItem("user:SELECT,INSERT") - data, err := acl.MarshalText() - if assert.NoError(err) { - assert.Equal("user:SELECT,INSERT", string(data)) - } - }) - - t.Run("RoleWithSpaces", func(t *testing.T) { - acl := &schema.ACLItem{Role: "my user", Priv: []string{"SELECT"}} - data, err := acl.MarshalText() - if assert.NoError(err) { - assert.Equal("\"my user\":SELECT", string(data)) - } - }) - - t.Run("EmptyRole", func(t *testing.T) { - acl := &schema.ACLItem{Role: "", Priv: []string{"SELECT"}} - _, err := acl.MarshalText() - assert.Error(err) - }) - - t.Run("RoundTrip", func(t *testing.T) { - original, _ := schema.ParseACLItem("testuser:SELECT,INSERT,UPDATE") - data, err := original.MarshalText() - if assert.NoError(err) { - parsed, err := schema.ParseACLItem(string(data)) - if assert.NoError(err) { - assert.Equal(original.Role, parsed.Role) - assert.Equal(original.Priv, parsed.Priv) - } - } - }) -} - -func Test_ACLItem_UnmarshalJSON(t *testing.T) { - assert := assert.New(t) - - t.Run("ValidJSON", func(t *testing.T) { - data := `{"role": "testuser", "priv": ["SELECT", "INSERT"]}` - var acl schema.ACLItem - err := json.Unmarshal([]byte(data), &acl) - if assert.NoError(err) { - assert.Equal("testuser", acl.Role) - assert.Equal([]string{"SELECT", "INSERT"}, acl.Priv) - } - }) - - t.Run("PublicRole", func(t *testing.T) { - data := `{"role": "PUBLIC", "priv": ["SELECT"]}` - var acl schema.ACLItem - err := json.Unmarshal([]byte(data), &acl) - if assert.NoError(err) { - assert.Equal(schema.DefaultAclRole, acl.Role) - } - }) - - t.Run("CaseInsensitivePublic", func(t *testing.T) { - data := `{"role": "public", "priv": ["SELECT"]}` - var acl schema.ACLItem - err := json.Unmarshal([]byte(data), &acl) - if assert.NoError(err) { - assert.Equal(schema.DefaultAclRole, acl.Role) - } - }) - - t.Run("MissingRole", func(t *testing.T) { - data := `{"priv": ["SELECT"]}` - var acl schema.ACLItem - err := json.Unmarshal([]byte(data), &acl) - assert.Error(err) - }) - - t.Run("EmptyRole", func(t *testing.T) { - data := `{"role": "", "priv": ["SELECT"]}` - var acl schema.ACLItem - err := json.Unmarshal([]byte(data), &acl) - assert.Error(err) - }) - - t.Run("MissingPriv", func(t *testing.T) { - data := `{"role": "user"}` - var acl schema.ACLItem - err := json.Unmarshal([]byte(data), &acl) - assert.Error(err) - }) - - t.Run("InvalidPrivilege", func(t *testing.T) { - data := `{"role": "user", "priv": ["INVALID"]}` - var acl schema.ACLItem - err := json.Unmarshal([]byte(data), &acl) - assert.Error(err) - }) - - t.Run("InvalidPrivType", func(t *testing.T) { - data := `{"role": "user", "priv": [123]}` - var acl schema.ACLItem - err := json.Unmarshal([]byte(data), &acl) - assert.Error(err) - }) - - t.Run("StringFormat", func(t *testing.T) { - data := `"PUBLIC:TEMPORARY,CONNECT"` - var acl schema.ACLItem - err := json.Unmarshal([]byte(data), &acl) - if assert.NoError(err) { - assert.Equal(schema.DefaultAclRole, acl.Role) - assert.Equal([]string{"TEMPORARY", "CONNECT"}, acl.Priv) - } - }) - - t.Run("StringFormatWithRole", func(t *testing.T) { - data := `"fabric:CREATE,TEMPORARY,CONNECT"` - var acl schema.ACLItem - err := json.Unmarshal([]byte(data), &acl) - if assert.NoError(err) { - assert.Equal("fabric", acl.Role) - assert.Equal([]string{"CREATE", "TEMPORARY", "CONNECT"}, acl.Priv) - } - }) -} - -func Test_ACLList_Append(t *testing.T) { - assert := assert.New(t) - - t.Run("AppendNew", func(t *testing.T) { - var list schema.ACLList - item1, _ := schema.ParseACLItem("user1:SELECT") - item2, _ := schema.ParseACLItem("user2:INSERT") - list.Append(item1) - list.Append(item2) - assert.Len(list, 2) - }) - - t.Run("MergeDuplicateRole", func(t *testing.T) { - var list schema.ACLList - item1, _ := schema.ParseACLItem("user1:SELECT") - item2, _ := schema.ParseACLItem("user1:INSERT") - list.Append(item1) - list.Append(item2) - assert.Len(list, 1) - assert.Equal([]string{"SELECT", "INSERT"}, list[0].Priv) - }) - - t.Run("MergeNoDuplicatePriv", func(t *testing.T) { - var list schema.ACLList - item1, _ := schema.ParseACLItem("user1:SELECT,INSERT") - item2, _ := schema.ParseACLItem("user1:INSERT,UPDATE") - list.Append(item1) - list.Append(item2) - assert.Len(list, 1) - // Should not duplicate INSERT - assert.Equal([]string{"SELECT", "INSERT", "UPDATE"}, list[0].Priv) - }) -} - -func Test_ACLList_Find(t *testing.T) { - assert := assert.New(t) - - t.Run("FindExisting", func(t *testing.T) { - var list schema.ACLList - item1, _ := schema.ParseACLItem("user1:SELECT") - item2, _ := schema.ParseACLItem("user2:INSERT") - list.Append(item1) - list.Append(item2) - - found := list.Find("user1") - assert.NotNil(found) - assert.Equal("user1", found.Role) - }) - - t.Run("FindNotExisting", func(t *testing.T) { - var list schema.ACLList - item1, _ := schema.ParseACLItem("user1:SELECT") - list.Append(item1) - - found := list.Find("nonexistent") - assert.Nil(found) - }) - - t.Run("FindEmptyList", func(t *testing.T) { - var list schema.ACLList - found := list.Find("user1") - assert.Nil(found) - }) -} - -func Test_ACLList_UnmarshalText(t *testing.T) { - assert := assert.New(t) - - t.Run("MultipleItems", func(t *testing.T) { - var list schema.ACLList - err := list.UnmarshalText([]byte("user1:SELECT user2:INSERT")) - if assert.NoError(err) { - assert.Len(list, 2) - } - }) - - t.Run("SingleItem", func(t *testing.T) { - var list schema.ACLList - err := list.UnmarshalText([]byte("user1:SELECT,INSERT")) - if assert.NoError(err) { - assert.Len(list, 1) - assert.Equal([]string{"SELECT", "INSERT"}, list[0].Priv) - } - }) - - t.Run("EmptyInput", func(t *testing.T) { - var list schema.ACLList - err := list.UnmarshalText([]byte("")) - assert.NoError(err) - assert.Len(list, 0) - }) -} - -func Test_ACLList_UnmarshalJSON(t *testing.T) { - assert := assert.New(t) - - t.Run("ValidArray", func(t *testing.T) { - var list schema.ACLList - data := `[{"role": "user1", "priv": ["SELECT"]}, {"role": "user2", "priv": ["INSERT"]}]` - err := list.UnmarshalJSON([]byte(data)) - if assert.NoError(err) { - assert.Len(list, 2) - } - }) - - t.Run("EmptyArray", func(t *testing.T) { - var list schema.ACLList - data := `[]` - err := list.UnmarshalJSON([]byte(data)) - assert.NoError(err) - assert.Len(list, 0) - }) - - t.Run("MergesSameRole", func(t *testing.T) { - var list schema.ACLList - data := `[{"role": "user1", "priv": ["SELECT"]}, {"role": "user1", "priv": ["INSERT"]}]` - err := list.UnmarshalJSON([]byte(data)) - if assert.NoError(err) { - assert.Len(list, 1) - assert.Equal([]string{"SELECT", "INSERT"}, list[0].Priv) - } - }) -} - -func Test_ACLItem_IsAll(t *testing.T) { - assert := assert.New(t) - - t.Run("HasAll", func(t *testing.T) { - acl, _ := schema.ParseACLItem("admin:ALL") - assert.True(acl.IsAll()) - }) - - t.Run("NoAll", func(t *testing.T) { - acl, _ := schema.ParseACLItem("user:SELECT,INSERT") - assert.False(acl.IsAll()) - }) - - t.Run("AllWithOthers", func(t *testing.T) { - acl, _ := schema.ParseACLItem("admin:SELECT,ALL,INSERT") - assert.True(acl.IsAll()) - }) -} - -func Test_ACLItem_String(t *testing.T) { - assert := assert.New(t) - - t.Run("ProducesTextFormat", func(t *testing.T) { - acl, _ := schema.ParseACLItem("user:SELECT") - str := acl.String() - assert.Contains(str, "user") - assert.Contains(str, "SELECT") - }) -} diff --git a/pkg/manager/schema/connection_test.go b/pkg/manager/schema/connection_test.go deleted file mode 100644 index e49d8bb..0000000 --- a/pkg/manager/schema/connection_test.go +++ /dev/null @@ -1,200 +0,0 @@ -package schema_test - -import ( - "encoding/json" - "testing" - - // Packages - pg "github.com/mutablelogic/go-pg" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" - assert "github.com/stretchr/testify/assert" -) - -func Test_Connection_String(t *testing.T) { - assert := assert.New(t) - - t.Run("ValidConnection", func(t *testing.T) { - c := schema.Connection{ - Pid: 12345, - Database: "testdb", - Role: "testuser", - } - str := c.String() - assert.NotEmpty(str) - assert.Contains(str, "12345") - assert.Contains(str, "testdb") - assert.Contains(str, "testuser") - }) - - t.Run("JSONUnmarshal", func(t *testing.T) { - c := schema.Connection{ - Pid: 12345, - Database: "testdb", - Role: "testuser", - } - data, err := json.Marshal(c) - assert.NoError(err) - - var c2 schema.Connection - err = json.Unmarshal(data, &c2) - assert.NoError(err) - assert.Equal(c.Pid, c2.Pid) - assert.Equal(c.Database, c2.Database) - assert.Equal(c.Role, c2.Role) - }) -} - -func Test_ConnectionListRequest_String(t *testing.T) { - assert := assert.New(t) - - t.Run("EmptyRequest", func(t *testing.T) { - req := schema.ConnectionListRequest{} - str := req.String() - assert.NotEmpty(str) - }) - - t.Run("WithFilters", func(t *testing.T) { - db := "testdb" - role := "testrole" - state := "active" - req := schema.ConnectionListRequest{ - Database: &db, - Role: &role, - State: &state, - } - str := req.String() - assert.NotEmpty(str) - assert.Contains(str, "testdb") - assert.Contains(str, "testrole") - assert.Contains(str, "active") - }) -} - -func Test_ConnectionList_String(t *testing.T) { - assert := assert.New(t) - - t.Run("EmptyList", func(t *testing.T) { - list := schema.ConnectionList{Count: 0} - str := list.String() - assert.NotEmpty(str) - assert.Contains(str, "0") - }) - - t.Run("WithConnections", func(t *testing.T) { - list := schema.ConnectionList{ - Count: 1, - Body: []schema.Connection{ - {Pid: 12345, Database: "testdb", Role: "testuser"}, - }, - } - str := list.String() - assert.NotEmpty(str) - assert.Contains(str, "12345") - }) -} - -func Test_ConnectionListRequest_Select(t *testing.T) { - assert := assert.New(t) - - t.Run("ListOperation", func(t *testing.T) { - bind := pg.NewBind() - req := schema.ConnectionListRequest{} - sql, err := req.Select(bind, pg.List) - assert.NoError(err) - assert.NotEmpty(sql) - }) - - t.Run("ListWithDatabase", func(t *testing.T) { - bind := pg.NewBind() - db := "testdb" - req := schema.ConnectionListRequest{Database: &db} - sql, err := req.Select(bind, pg.List) - assert.NoError(err) - assert.NotEmpty(sql) - where := bind.Get("where").(string) - assert.Contains(where, "database") - }) - - t.Run("ListWithRole", func(t *testing.T) { - bind := pg.NewBind() - role := "testrole" - req := schema.ConnectionListRequest{Role: &role} - sql, err := req.Select(bind, pg.List) - assert.NoError(err) - assert.NotEmpty(sql) - where := bind.Get("where").(string) - assert.Contains(where, "role") - }) - - t.Run("ListWithState", func(t *testing.T) { - bind := pg.NewBind() - state := "active" - req := schema.ConnectionListRequest{State: &state} - sql, err := req.Select(bind, pg.List) - assert.NoError(err) - assert.NotEmpty(sql) - where := bind.Get("where").(string) - assert.Contains(where, "state") - }) - - t.Run("ListWithAllFilters", func(t *testing.T) { - bind := pg.NewBind() - db := "testdb" - role := "testrole" - state := "active" - req := schema.ConnectionListRequest{ - Database: &db, - Role: &role, - State: &state, - } - sql, err := req.Select(bind, pg.List) - assert.NoError(err) - assert.NotEmpty(sql) - where := bind.Get("where").(string) - assert.Contains(where, "AND") - }) - - t.Run("UnsupportedOperation", func(t *testing.T) { - bind := pg.NewBind() - req := schema.ConnectionListRequest{} - _, err := req.Select(bind, pg.Get) - assert.Error(err) - }) -} - -func Test_ConnectionPid_Select(t *testing.T) { - assert := assert.New(t) - - t.Run("GetOperation", func(t *testing.T) { - bind := pg.NewBind() - pid := schema.ConnectionPid(12345) - sql, err := pid.Select(bind, pg.Get) - assert.NoError(err) - assert.NotEmpty(sql) - assert.Equal(schema.ConnectionPid(12345), bind.Get("pid")) - }) - - t.Run("DeleteOperation", func(t *testing.T) { - bind := pg.NewBind() - pid := schema.ConnectionPid(12345) - sql, err := pid.Select(bind, pg.Delete) - assert.NoError(err) - assert.NotEmpty(sql) - assert.Contains(sql, "pg_terminate_backend") - }) - - t.Run("ZeroPid", func(t *testing.T) { - bind := pg.NewBind() - pid := schema.ConnectionPid(0) - _, err := pid.Select(bind, pg.Get) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("UnsupportedOperation", func(t *testing.T) { - bind := pg.NewBind() - pid := schema.ConnectionPid(12345) - _, err := pid.Select(bind, pg.Insert) - assert.Error(err) - }) -} diff --git a/pkg/manager/schema/database_test.go b/pkg/manager/schema/database_test.go deleted file mode 100644 index bf49e99..0000000 --- a/pkg/manager/schema/database_test.go +++ /dev/null @@ -1,382 +0,0 @@ -package schema_test - -import ( - "encoding/json" - "testing" - - // Packages - pg "github.com/mutablelogic/go-pg" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" - assert "github.com/stretchr/testify/assert" -) - -func Test_DatabaseMeta_Validate(t *testing.T) { - assert := assert.New(t) - - t.Run("ValidNameAndOwner", func(t *testing.T) { - d := schema.DatabaseMeta{Name: "testdb", Owner: "testuser"} - err := d.Validate() - assert.NoError(err) - }) - - t.Run("ValidNameNoOwner", func(t *testing.T) { - d := schema.DatabaseMeta{Name: "testdb"} - err := d.Validate() - assert.NoError(err) - }) - - t.Run("EmptyName", func(t *testing.T) { - d := schema.DatabaseMeta{Name: "", Owner: "testuser"} - err := d.Validate() - assert.Error(err) - }) - - t.Run("WhitespaceOnlyName", func(t *testing.T) { - d := schema.DatabaseMeta{Name: " ", Owner: "testuser"} - err := d.Validate() - assert.Error(err) - }) - - t.Run("ReservedPrefixName", func(t *testing.T) { - d := schema.DatabaseMeta{Name: "pg_testdb", Owner: "testuser"} - err := d.Validate() - assert.Error(err) - }) - - t.Run("ReservedPrefixOwner", func(t *testing.T) { - d := schema.DatabaseMeta{Name: "testdb", Owner: "pg_admin"} - err := d.Validate() - assert.Error(err) - }) - - t.Run("NameWithSpaces", func(t *testing.T) { - d := schema.DatabaseMeta{Name: " testdb ", Owner: "testuser"} - err := d.Validate() - assert.NoError(err) // Trimmed name is valid - }) -} - -func Test_DatabaseName_name(t *testing.T) { - assert := assert.New(t) - - t.Run("ValidName", func(t *testing.T) { - bind := pg.NewBind() - d := schema.DatabaseName("testdb") - _, err := d.Select(bind, pg.Get) - assert.NoError(err) - assert.Equal("testdb", bind.Get("name")) - }) - - t.Run("EmptyName", func(t *testing.T) { - bind := pg.NewBind() - d := schema.DatabaseName("") - _, err := d.Select(bind, pg.Get) - assert.Error(err) - }) - - t.Run("ReservedPrefix", func(t *testing.T) { - bind := pg.NewBind() - d := schema.DatabaseName("pg_mydb") - _, err := d.Select(bind, pg.Get) - assert.Error(err) - }) -} - -func Test_DatabaseListRequest_Select(t *testing.T) { - assert := assert.New(t) - - t.Run("ListOperation", func(t *testing.T) { - bind := pg.NewBind() - req := schema.DatabaseListRequest{} - sql, err := req.Select(bind, pg.List) - assert.NoError(err) - assert.NotEmpty(sql) - assert.Equal("ORDER BY name ASC", bind.Get("orderby")) - }) - - t.Run("UnsupportedOperation", func(t *testing.T) { - bind := pg.NewBind() - req := schema.DatabaseListRequest{} - _, err := req.Select(bind, pg.Get) - assert.Error(err) - }) -} - -func Test_DatabaseName_Select(t *testing.T) { - assert := assert.New(t) - - t.Run("GetOperation", func(t *testing.T) { - bind := pg.NewBind() - d := schema.DatabaseName("mydb") - sql, err := d.Select(bind, pg.Get) - assert.NoError(err) - assert.NotEmpty(sql) - assert.Equal("mydb", bind.Get("name")) - }) - - t.Run("UpdateOperation", func(t *testing.T) { - bind := pg.NewBind() - d := schema.DatabaseName("mydb") - sql, err := d.Select(bind, pg.Update) - assert.NoError(err) - assert.NotEmpty(sql) - assert.Contains(sql, "RENAME") - }) - - t.Run("DeleteOperation", func(t *testing.T) { - bind := pg.NewBind() - d := schema.DatabaseName("mydb") - sql, err := d.Select(bind, pg.Delete) - assert.NoError(err) - assert.NotEmpty(sql) - assert.Contains(sql, "DROP") - }) - - t.Run("DeleteWithForce", func(t *testing.T) { - bind := pg.NewBind() - bind.Set("force", true) - d := schema.DatabaseName("mydb") - _, err := d.Select(bind, pg.Delete) - assert.NoError(err) - assert.Equal("(FORCE)", bind.Get("with")) - }) - - t.Run("UnsupportedOperation", func(t *testing.T) { - bind := pg.NewBind() - d := schema.DatabaseName("mydb") - _, err := d.Select(bind, pg.Insert) - assert.Error(err) - }) -} - -func Test_DatabaseMeta_Select(t *testing.T) { - assert := assert.New(t) - - t.Run("UpdateOperation", func(t *testing.T) { - bind := pg.NewBind() - d := schema.DatabaseMeta{Name: "mydb", Owner: "newowner"} - sql, err := d.Select(bind, pg.Update) - assert.NoError(err) - assert.NotEmpty(sql) - assert.Equal("mydb", bind.Get("name")) - }) - - t.Run("EmptyName", func(t *testing.T) { - bind := pg.NewBind() - d := schema.DatabaseMeta{Name: "", Owner: "newowner"} - _, err := d.Select(bind, pg.Update) - assert.Error(err) - }) - - t.Run("UnsupportedOperation", func(t *testing.T) { - bind := pg.NewBind() - d := schema.DatabaseMeta{Name: "mydb"} - _, err := d.Select(bind, pg.Get) - assert.Error(err) - }) -} - -func Test_DatabaseMeta_Insert(t *testing.T) { - assert := assert.New(t) - - t.Run("ValidInsert", func(t *testing.T) { - bind := pg.NewBind() - d := schema.DatabaseMeta{Name: "newdb", Owner: "admin"} - sql, err := d.Insert(bind) - assert.NoError(err) - assert.NotEmpty(sql) - assert.Contains(sql, "CREATE DATABASE") - assert.Equal("newdb", bind.Get("name")) - }) - - t.Run("InsertWithoutOwner", func(t *testing.T) { - bind := pg.NewBind() - d := schema.DatabaseMeta{Name: "newdb"} - sql, err := d.Insert(bind) - assert.NoError(err) - assert.NotEmpty(sql) - assert.Equal("", bind.Get("with")) - }) - - t.Run("InsertWithOwner", func(t *testing.T) { - bind := pg.NewBind() - d := schema.DatabaseMeta{Name: "newdb", Owner: "myowner"} - _, err := d.Insert(bind) - assert.NoError(err) - with := bind.Get("with").(string) - assert.Contains(with, "WITH OWNER") - assert.Contains(with, "myowner") - }) - - t.Run("InvalidName", func(t *testing.T) { - bind := pg.NewBind() - d := schema.DatabaseMeta{Name: ""} - _, err := d.Insert(bind) - assert.Error(err) - }) - - t.Run("ReservedPrefixName", func(t *testing.T) { - bind := pg.NewBind() - d := schema.DatabaseMeta{Name: "pg_mydb"} - _, err := d.Insert(bind) - assert.Error(err) - }) - - t.Run("ReservedPrefixOwner", func(t *testing.T) { - bind := pg.NewBind() - d := schema.DatabaseMeta{Name: "mydb", Owner: "pg_admin"} - _, err := d.Insert(bind) - assert.Error(err) - }) -} - -func Test_DatabaseMeta_Update(t *testing.T) { - assert := assert.New(t) - - t.Run("ValidUpdate", func(t *testing.T) { - bind := pg.NewBind() - d := schema.DatabaseMeta{Name: "mydb", Owner: "newowner"} - err := d.Update(bind) - assert.NoError(err) - with := bind.Get("with").(string) - assert.Contains(with, "OWNER TO") - }) - - t.Run("UpdateNoOwner", func(t *testing.T) { - bind := pg.NewBind() - d := schema.DatabaseMeta{Name: "mydb"} - err := d.Update(bind) - assert.NoError(err) - assert.Equal("", bind.Get("with")) - }) - - t.Run("ReservedPrefixOwner", func(t *testing.T) { - bind := pg.NewBind() - d := schema.DatabaseMeta{Name: "mydb", Owner: "pg_system"} - err := d.Update(bind) - assert.Error(err) - }) -} - -func Test_DatabaseName_Insert(t *testing.T) { - assert := assert.New(t) - - t.Run("NotImplemented", func(t *testing.T) { - bind := pg.NewBind() - d := schema.DatabaseName("mydb") - _, err := d.Insert(bind) - assert.Error(err) - }) -} - -func Test_DatabaseName_Update(t *testing.T) { - assert := assert.New(t) - - t.Run("ValidRename", func(t *testing.T) { - bind := pg.NewBind() - bind.Set("name", "newname") - d := schema.DatabaseName("oldname") - err := d.Update(bind) - assert.NoError(err) - assert.Equal("oldname", bind.Get("old_name")) - }) - - t.Run("InvalidOldName", func(t *testing.T) { - bind := pg.NewBind() - bind.Set("name", "newname") - d := schema.DatabaseName("") - err := d.Update(bind) - assert.Error(err) - }) - - t.Run("ReservedPrefixOldName", func(t *testing.T) { - bind := pg.NewBind() - bind.Set("name", "newname") - d := schema.DatabaseName("pg_olddb") - err := d.Update(bind) - assert.Error(err) - }) - - t.Run("ReservedPrefixNewName", func(t *testing.T) { - bind := pg.NewBind() - bind.Set("name", "pg_newdb") - d := schema.DatabaseName("oldname") - err := d.Update(bind) - assert.Error(err) - }) - - t.Run("EmptyNewName", func(t *testing.T) { - bind := pg.NewBind() - bind.Set("name", "") - d := schema.DatabaseName("oldname") - err := d.Update(bind) - assert.Error(err) - }) -} - -func Test_Database_String(t *testing.T) { - assert := assert.New(t) - - t.Run("ProducesJSON", func(t *testing.T) { - d := schema.Database{ - Oid: 12345, - DatabaseMeta: schema.DatabaseMeta{ - Name: "testdb", - Owner: "admin", - }, - Size: 1024000, - } - str := d.String() - assert.Contains(str, "testdb") - assert.Contains(str, "admin") - // Should be valid JSON - var parsed map[string]any - err := json.Unmarshal([]byte(str), &parsed) - assert.NoError(err) - }) -} - -func Test_DatabaseMeta_String(t *testing.T) { - assert := assert.New(t) - - t.Run("ProducesJSON", func(t *testing.T) { - d := schema.DatabaseMeta{Name: "testdb", Owner: "admin"} - str := d.String() - assert.Contains(str, "testdb") - var parsed map[string]any - err := json.Unmarshal([]byte(str), &parsed) - assert.NoError(err) - }) -} - -func Test_DatabaseList_String(t *testing.T) { - assert := assert.New(t) - - t.Run("ProducesJSON", func(t *testing.T) { - list := schema.DatabaseList{ - Count: 2, - Body: []schema.Database{ - {Oid: 1, DatabaseMeta: schema.DatabaseMeta{Name: "db1"}}, - {Oid: 2, DatabaseMeta: schema.DatabaseMeta{Name: "db2"}}, - }, - } - str := list.String() - assert.Contains(str, "db1") - assert.Contains(str, "db2") - var parsed map[string]any - err := json.Unmarshal([]byte(str), &parsed) - assert.NoError(err) - }) -} - -func Test_DatabaseListRequest_String(t *testing.T) { - assert := assert.New(t) - - t.Run("ProducesJSON", func(t *testing.T) { - req := schema.DatabaseListRequest{} - str := req.String() - var parsed map[string]any - err := json.Unmarshal([]byte(str), &parsed) - assert.NoError(err) - }) -} diff --git a/pkg/manager/schema/doc.go b/pkg/manager/schema/doc.go deleted file mode 100644 index 94259af..0000000 --- a/pkg/manager/schema/doc.go +++ /dev/null @@ -1,8 +0,0 @@ -// Package schema defines all data types, request/response structures, and SQL -// queries for PostgreSQL management resources. -// -// Each resource type (roles, databases, schemas, objects, etc.) has its own -// file containing Go structs representing PostgreSQL objects, list request -// parameters for filtering and pagination, and methods that produce -// parameterized SQL queries. -package schema diff --git a/pkg/manager/schema/extension_test.go b/pkg/manager/schema/extension_test.go deleted file mode 100644 index 40a557e..0000000 --- a/pkg/manager/schema/extension_test.go +++ /dev/null @@ -1,388 +0,0 @@ -package schema_test - -import ( - "encoding/json" - "testing" - - // Packages - pg "github.com/mutablelogic/go-pg" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" - assert "github.com/stretchr/testify/assert" -) - -func Test_Extension_String(t *testing.T) { - assert := assert.New(t) - - t.Run("ValidExtension", func(t *testing.T) { - oid := uint32(12345) - ext := schema.Extension{ - Oid: &oid, - ExtensionMeta: schema.ExtensionMeta{ - Name: "postgis", - Owner: "postgres", - }, - DefaultVersion: "3.3.0", - Comment: "PostGIS geometry and geography types", - } - str := ext.String() - assert.NotEmpty(str) - assert.Contains(str, "postgis") - assert.Contains(str, "postgres") - }) - - t.Run("JSONUnmarshal", func(t *testing.T) { - oid := uint32(12345) - installedVersion := "3.3.0" - relocatable := true - ext := schema.Extension{ - Oid: &oid, - ExtensionMeta: schema.ExtensionMeta{ - Name: "postgis", - Owner: "postgres", - Schema: "public", - }, - DefaultVersion: "3.3.0", - InstalledVersion: &installedVersion, - Relocatable: &relocatable, - Comment: "PostGIS geometry", - Requires: []string{"plpgsql"}, - } - data, err := json.Marshal(ext) - assert.NoError(err) - - var ext2 schema.Extension - err = json.Unmarshal(data, &ext2) - assert.NoError(err) - assert.Equal(*ext.Oid, *ext2.Oid) - assert.Equal(ext.Name, ext2.Name) - assert.Equal(ext.Owner, ext2.Owner) - assert.Equal(ext.Schema, ext2.Schema) - assert.Equal(ext.DefaultVersion, ext2.DefaultVersion) - assert.Equal(*ext.InstalledVersion, *ext2.InstalledVersion) - assert.Equal(*ext.Relocatable, *ext2.Relocatable) - assert.Equal(ext.Requires, ext2.Requires) - }) - - t.Run("NotInstalledExtension", func(t *testing.T) { - ext := schema.Extension{ - ExtensionMeta: schema.ExtensionMeta{ - Name: "postgis", - }, - DefaultVersion: "3.3.0", - } - str := ext.String() - assert.NotEmpty(str) - assert.Contains(str, "postgis") - // Oid and InstalledVersion should be nil - assert.Nil(ext.Oid) - assert.Nil(ext.InstalledVersion) - }) -} - -func Test_ExtensionMeta_String(t *testing.T) { - assert := assert.New(t) - - t.Run("ValidMeta", func(t *testing.T) { - meta := schema.ExtensionMeta{ - Name: "postgis", - Schema: "public", - Version: "3.3.0", - } - str := meta.String() - assert.NotEmpty(str) - assert.Contains(str, "postgis") - assert.Contains(str, "public") - }) -} - -func Test_ExtensionListRequest_String(t *testing.T) { - assert := assert.New(t) - - t.Run("EmptyRequest", func(t *testing.T) { - req := schema.ExtensionListRequest{} - str := req.String() - assert.NotEmpty(str) - }) - - t.Run("WithInstalledFilter", func(t *testing.T) { - installed := true - req := schema.ExtensionListRequest{Installed: &installed} - str := req.String() - assert.NotEmpty(str) - assert.Contains(str, "true") - }) -} - -func Test_ExtensionList_String(t *testing.T) { - assert := assert.New(t) - - t.Run("EmptyList", func(t *testing.T) { - list := schema.ExtensionList{Count: 0} - str := list.String() - assert.NotEmpty(str) - assert.Contains(str, "0") - }) - - t.Run("WithExtensions", func(t *testing.T) { - list := schema.ExtensionList{ - Count: 1, - Body: []schema.Extension{ - { - ExtensionMeta: schema.ExtensionMeta{ - Name: "postgis", - }, - DefaultVersion: "3.3.0", - }, - }, - } - str := list.String() - assert.NotEmpty(str) - assert.Contains(str, "postgis") - }) -} - -func Test_ExtensionListRequest_Select(t *testing.T) { - assert := assert.New(t) - - t.Run("ListOperation", func(t *testing.T) { - bind := pg.NewBind() - req := schema.ExtensionListRequest{} - sql, err := req.Select(bind, pg.List) - assert.NoError(err) - assert.NotEmpty(sql) - assert.Equal("ORDER BY name ASC", bind.Get("orderby")) - assert.Equal("", bind.Get("where")) - }) - - t.Run("ListInstalledOnly", func(t *testing.T) { - bind := pg.NewBind() - installed := true - req := schema.ExtensionListRequest{Installed: &installed} - sql, err := req.Select(bind, pg.List) - assert.NoError(err) - assert.NotEmpty(sql) - where := bind.Get("where").(string) - assert.Contains(where, "installed_version IS NOT NULL") - }) - - t.Run("ListNotInstalledOnly", func(t *testing.T) { - bind := pg.NewBind() - installed := false - req := schema.ExtensionListRequest{Installed: &installed} - sql, err := req.Select(bind, pg.List) - assert.NoError(err) - assert.NotEmpty(sql) - where := bind.Get("where").(string) - assert.Contains(where, "installed_version IS NULL") - }) - - t.Run("UnsupportedOperation", func(t *testing.T) { - bind := pg.NewBind() - req := schema.ExtensionListRequest{} - _, err := req.Select(bind, pg.Get) - assert.Error(err) - }) -} - -func Test_ExtensionName_Select(t *testing.T) { - assert := assert.New(t) - - t.Run("GetOperation", func(t *testing.T) { - bind := pg.NewBind() - ext := schema.ExtensionName("postgis") - sql, err := ext.Select(bind, pg.Get) - assert.NoError(err) - assert.NotEmpty(sql) - assert.Equal("postgis", bind.Get("name")) - }) - - t.Run("DeleteOperation", func(t *testing.T) { - bind := pg.NewBind() - ext := schema.ExtensionName("postgis") - sql, err := ext.Select(bind, pg.Delete) - assert.NoError(err) - assert.NotEmpty(sql) - assert.Contains(sql, "DROP") - assert.Equal("postgis", bind.Get("name")) // Raw name - template handles quoting - assert.Equal("", bind.Get("cascade")) - }) - - t.Run("DeleteWithCascade", func(t *testing.T) { - bind := pg.NewBind() - bind.Set("cascade", true) - ext := schema.ExtensionName("postgis") - sql, err := ext.Select(bind, pg.Delete) - assert.NoError(err) - assert.NotEmpty(sql) - assert.Equal("CASCADE", bind.Get("cascade")) - }) - - t.Run("EmptyName", func(t *testing.T) { - bind := pg.NewBind() - ext := schema.ExtensionName("") - _, err := ext.Select(bind, pg.Get) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("WhitespaceOnlyName", func(t *testing.T) { - bind := pg.NewBind() - ext := schema.ExtensionName(" ") - _, err := ext.Select(bind, pg.Get) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("UnsupportedOperation", func(t *testing.T) { - bind := pg.NewBind() - ext := schema.ExtensionName("postgis") - _, err := ext.Select(bind, pg.Insert) - assert.Error(err) - }) -} - -func Test_ExtensionMeta_Insert(t *testing.T) { - assert := assert.New(t) - - t.Run("ValidInsert", func(t *testing.T) { - bind := pg.NewBind() - meta := schema.ExtensionMeta{Name: "postgis"} - sql, err := meta.Insert(bind) - assert.NoError(err) - assert.NotEmpty(sql) - assert.Contains(sql, "CREATE EXTENSION") - assert.Equal("postgis", bind.Get("name")) // Raw name - template handles quoting - assert.Equal("", bind.Get("with")) - assert.Equal("", bind.Get("version")) - }) - - t.Run("InsertWithSchema", func(t *testing.T) { - bind := pg.NewBind() - meta := schema.ExtensionMeta{Name: "postgis", Schema: "myschema"} - _, err := meta.Insert(bind) - assert.NoError(err) - with := bind.Get("with").(string) - assert.Contains(with, "WITH SCHEMA") - assert.Contains(with, "myschema") - }) - - t.Run("InsertWithVersion", func(t *testing.T) { - bind := pg.NewBind() - meta := schema.ExtensionMeta{Name: "postgis", Version: "3.3.0"} - _, err := meta.Insert(bind) - assert.NoError(err) - version := bind.Get("version").(string) - assert.Contains(version, "VERSION") - assert.Contains(version, "3.3.0") - }) - - t.Run("InsertWithSchemaAndVersion", func(t *testing.T) { - bind := pg.NewBind() - meta := schema.ExtensionMeta{Name: "postgis", Schema: "public", Version: "3.3.0"} - sql, err := meta.Insert(bind) - assert.NoError(err) - assert.NotEmpty(sql) - with := bind.Get("with").(string) - assert.Contains(with, "WITH SCHEMA") - version := bind.Get("version").(string) - assert.Contains(version, "VERSION") - }) - - t.Run("EmptyName", func(t *testing.T) { - bind := pg.NewBind() - meta := schema.ExtensionMeta{Name: ""} - _, err := meta.Insert(bind) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("WhitespaceOnlyName", func(t *testing.T) { - bind := pg.NewBind() - meta := schema.ExtensionMeta{Name: " "} - _, err := meta.Insert(bind) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("InsertDefaultNoCascade", func(t *testing.T) { - bind := pg.NewBind() - meta := schema.ExtensionMeta{Name: "postgis"} - _, err := meta.Insert(bind) - assert.NoError(err) - assert.Equal("", bind.Get("cascade")) - }) - - t.Run("InsertWithCascade", func(t *testing.T) { - bind := pg.NewBind() - bind.Set("cascade", true) - meta := schema.ExtensionMeta{Name: "postgis"} - _, err := meta.Insert(bind) - assert.NoError(err) - assert.Equal("CASCADE", bind.Get("cascade")) - }) -} - -func Test_ExtensionMeta_Update(t *testing.T) { - assert := assert.New(t) - - t.Run("ValidUpdate", func(t *testing.T) { - bind := pg.NewBind() - meta := schema.ExtensionMeta{Name: "postgis"} - err := meta.Update(bind) - assert.NoError(err) - assert.Equal("postgis", bind.Get("name")) // Raw name - template handles quoting - assert.Equal("", bind.Get("version")) - }) - - t.Run("UpdateWithVersion", func(t *testing.T) { - bind := pg.NewBind() - meta := schema.ExtensionMeta{Name: "postgis", Version: "3.4.0"} - err := meta.Update(bind) - assert.NoError(err) - version := bind.Get("version").(string) - assert.Contains(version, "TO") - assert.Contains(version, "3.4.0") - }) - - t.Run("EmptyName", func(t *testing.T) { - bind := pg.NewBind() - meta := schema.ExtensionMeta{Name: ""} - err := meta.Update(bind) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("WhitespaceOnlyName", func(t *testing.T) { - bind := pg.NewBind() - meta := schema.ExtensionMeta{Name: " "} - err := meta.Update(bind) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) -} - -func Test_ExtensionName_SpecialCharacters(t *testing.T) { - assert := assert.New(t) - - t.Run("NameWithQuotes", func(t *testing.T) { - bind := pg.NewBind() - ext := schema.ExtensionName("my\"ext") - sql, err := ext.Select(bind, pg.Delete) - assert.NoError(err) - assert.NotEmpty(sql) - // Raw name stored - template handles escaping - name := bind.Get("name").(string) - assert.Equal("my\"ext", name) - }) - - t.Run("NameWithSpaces", func(t *testing.T) { - bind := pg.NewBind() - ext := schema.ExtensionName("my extension") - sql, err := ext.Select(bind, pg.Delete) - assert.NoError(err) - assert.NotEmpty(sql) - // Raw name stored - template handles quoting - name := bind.Get("name").(string) - assert.Equal("my extension", name) - }) -} diff --git a/pkg/manager/schema/object_test.go b/pkg/manager/schema/object_test.go deleted file mode 100644 index 2fbf2fa..0000000 --- a/pkg/manager/schema/object_test.go +++ /dev/null @@ -1,593 +0,0 @@ -package schema_test - -import ( - "encoding/json" - "testing" - - // Packages - pg "github.com/mutablelogic/go-pg" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" - assert "github.com/stretchr/testify/assert" -) - -func Test_ObjectName_Validate(t *testing.T) { - assert := assert.New(t) - - t.Run("ValidSchemaAndName", func(t *testing.T) { - o := schema.ObjectName{Schema: "public", Name: "users"} - err := o.Validate() - assert.NoError(err) - }) - - t.Run("EmptySchema", func(t *testing.T) { - o := schema.ObjectName{Schema: "", Name: "users"} - err := o.Validate() - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("WhitespaceOnlySchema", func(t *testing.T) { - o := schema.ObjectName{Schema: " ", Name: "users"} - err := o.Validate() - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("EmptyName", func(t *testing.T) { - o := schema.ObjectName{Schema: "public", Name: ""} - err := o.Validate() - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("WhitespaceOnlyName", func(t *testing.T) { - o := schema.ObjectName{Schema: "public", Name: " "} - err := o.Validate() - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("BothEmpty", func(t *testing.T) { - o := schema.ObjectName{Schema: "", Name: ""} - err := o.Validate() - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("SchemaWithSpaces", func(t *testing.T) { - o := schema.ObjectName{Schema: " public ", Name: "users"} - err := o.Validate() - assert.NoError(err) // Trimmed schema is valid - }) - - t.Run("NameWithSpaces", func(t *testing.T) { - o := schema.ObjectName{Schema: "public", Name: " users "} - err := o.Validate() - assert.NoError(err) // Trimmed name is valid - }) -} - -func Test_ObjectName_Select(t *testing.T) { - assert := assert.New(t) - - t.Run("GetOperation", func(t *testing.T) { - bind := pg.NewBind() - o := schema.ObjectName{Schema: "public", Name: "users"} - sql, err := o.Select(bind, pg.Get) - assert.NoError(err) - assert.NotEmpty(sql) - assert.Equal("public", bind.Get("schema")) - assert.Equal("users", bind.Get("name")) - }) - - t.Run("GetOperationWithTrim", func(t *testing.T) { - bind := pg.NewBind() - o := schema.ObjectName{Schema: " myschema ", Name: " mytable "} - sql, err := o.Select(bind, pg.Get) - assert.NoError(err) - assert.NotEmpty(sql) - assert.Equal("myschema", bind.Get("schema")) - assert.Equal("mytable", bind.Get("name")) - }) - - t.Run("EmptySchema", func(t *testing.T) { - bind := pg.NewBind() - o := schema.ObjectName{Schema: "", Name: "users"} - _, err := o.Select(bind, pg.Get) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("EmptyName", func(t *testing.T) { - bind := pg.NewBind() - o := schema.ObjectName{Schema: "public", Name: ""} - _, err := o.Select(bind, pg.Get) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("UnsupportedListOperation", func(t *testing.T) { - bind := pg.NewBind() - o := schema.ObjectName{Schema: "public", Name: "users"} - _, err := o.Select(bind, pg.List) - assert.Error(err) - assert.ErrorIs(err, pg.ErrNotImplemented) - }) - - t.Run("UnsupportedInsertOperation", func(t *testing.T) { - bind := pg.NewBind() - o := schema.ObjectName{Schema: "public", Name: "users"} - _, err := o.Select(bind, pg.Insert) - assert.Error(err) - assert.ErrorIs(err, pg.ErrNotImplemented) - }) - - t.Run("UnsupportedUpdateOperation", func(t *testing.T) { - bind := pg.NewBind() - o := schema.ObjectName{Schema: "public", Name: "users"} - _, err := o.Select(bind, pg.Update) - assert.Error(err) - assert.ErrorIs(err, pg.ErrNotImplemented) - }) - - t.Run("UnsupportedDeleteOperation", func(t *testing.T) { - bind := pg.NewBind() - o := schema.ObjectName{Schema: "public", Name: "users"} - _, err := o.Select(bind, pg.Delete) - assert.Error(err) - assert.ErrorIs(err, pg.ErrNotImplemented) - }) -} - -func Test_ObjectListRequest_Select(t *testing.T) { - assert := assert.New(t) - - t.Run("ListOperationNoFilters", func(t *testing.T) { - bind := pg.NewBind() - req := schema.ObjectListRequest{} - sql, err := req.Select(bind, pg.List) - assert.NoError(err) - assert.NotEmpty(sql) - assert.Equal("ORDER BY schema ASC, name ASC", bind.Get("orderby")) - assert.Equal("", bind.Get("where")) - }) - - t.Run("ListWithSchemaFilter", func(t *testing.T) { - bind := pg.NewBind() - schemaName := "public" - req := schema.ObjectListRequest{Schema: &schemaName} - sql, err := req.Select(bind, pg.List) - assert.NoError(err) - assert.NotEmpty(sql) - where := bind.Get("where").(string) - assert.Contains(where, "schema = ") - assert.Contains(where, "public") - }) - - t.Run("ListWithDatabaseFilter", func(t *testing.T) { - bind := pg.NewBind() - database := "mydb" - req := schema.ObjectListRequest{Database: &database} - sql, err := req.Select(bind, pg.List) - assert.NoError(err) - assert.NotEmpty(sql) - where := bind.Get("where").(string) - assert.Contains(where, "database = ") - assert.Contains(where, "mydb") - }) - - t.Run("ListWithTypeFilter", func(t *testing.T) { - bind := pg.NewBind() - objectType := "TABLE" - req := schema.ObjectListRequest{Type: &objectType} - sql, err := req.Select(bind, pg.List) - assert.NoError(err) - assert.NotEmpty(sql) - where := bind.Get("where").(string) - assert.Contains(where, "type = ") - assert.Contains(where, "TABLE") - }) - - t.Run("ListWithMultipleFilters", func(t *testing.T) { - bind := pg.NewBind() - schemaName := "public" - database := "mydb" - objectType := "VIEW" - req := schema.ObjectListRequest{ - Schema: &schemaName, - Database: &database, - Type: &objectType, - } - sql, err := req.Select(bind, pg.List) - assert.NoError(err) - assert.NotEmpty(sql) - where := bind.Get("where").(string) - assert.Contains(where, "schema = ") - assert.Contains(where, "database = ") - assert.Contains(where, "type = ") - assert.Contains(where, " AND ") - }) - - t.Run("ListWithEmptyStringFilters", func(t *testing.T) { - bind := pg.NewBind() - emptySchema := "" - emptyType := " " - req := schema.ObjectListRequest{ - Schema: &emptySchema, - Type: &emptyType, - } - sql, err := req.Select(bind, pg.List) - assert.NoError(err) - assert.NotEmpty(sql) - // Empty/whitespace strings should not add where clauses - assert.Equal("", bind.Get("where")) - }) - - t.Run("ListWithOffsetLimit", func(t *testing.T) { - bind := pg.NewBind() - limit := uint64(25) - req := schema.ObjectListRequest{ - OffsetLimit: pg.OffsetLimit{ - Offset: 10, - Limit: &limit, - }, - } - sql, err := req.Select(bind, pg.List) - assert.NoError(err) - assert.NotEmpty(sql) - offsetlimit := bind.Get("offsetlimit").(string) - assert.Contains(offsetlimit, "OFFSET 10") - assert.Contains(offsetlimit, "LIMIT 25") - }) - - t.Run("UnsupportedGetOperation", func(t *testing.T) { - bind := pg.NewBind() - req := schema.ObjectListRequest{} - _, err := req.Select(bind, pg.Get) - assert.Error(err) - assert.ErrorIs(err, pg.ErrNotImplemented) - }) - - t.Run("UnsupportedInsertOperation", func(t *testing.T) { - bind := pg.NewBind() - req := schema.ObjectListRequest{} - _, err := req.Select(bind, pg.Insert) - assert.Error(err) - assert.ErrorIs(err, pg.ErrNotImplemented) - }) -} - -func Test_ObjectName_String(t *testing.T) { - assert := assert.New(t) - - t.Run("ValidJSON", func(t *testing.T) { - o := schema.ObjectName{Schema: "public", Name: "users"} - str := o.String() - assert.NotEmpty(str) - - var parsed map[string]interface{} - err := json.Unmarshal([]byte(str), &parsed) - assert.NoError(err) - assert.Equal("public", parsed["schema"]) - assert.Equal("users", parsed["name"]) - }) -} - -func Test_ObjectMeta_String(t *testing.T) { - assert := assert.New(t) - - t.Run("ValidJSON", func(t *testing.T) { - o := schema.ObjectMeta{Name: "users", Owner: "postgres"} - str := o.String() - assert.NotEmpty(str) - - var parsed map[string]interface{} - err := json.Unmarshal([]byte(str), &parsed) - assert.NoError(err) - assert.Equal("users", parsed["name"]) - assert.Equal("postgres", parsed["owner"]) - }) -} - -func Test_Object_String(t *testing.T) { - assert := assert.New(t) - - t.Run("ValidJSON", func(t *testing.T) { - tablespace := "pg_default" - o := schema.Object{ - Oid: 12345, - Database: "mydb", - Schema: "public", - Type: "TABLE", - ObjectMeta: schema.ObjectMeta{ - Name: "users", - Owner: "postgres", - }, - Tablespace: &tablespace, - Size: 1024, - } - str := o.String() - assert.NotEmpty(str) - - var parsed map[string]interface{} - err := json.Unmarshal([]byte(str), &parsed) - assert.NoError(err) - assert.Equal(float64(12345), parsed["oid"]) - assert.Equal("mydb", parsed["database"]) - assert.Equal("public", parsed["schema"]) - assert.Equal("TABLE", parsed["type"]) - assert.Equal("users", parsed["name"]) - assert.Equal("postgres", parsed["owner"]) - assert.Equal("pg_default", parsed["tablespace"]) - assert.Equal(float64(1024), parsed["bytes"]) - }) - - t.Run("NilTablespace", func(t *testing.T) { - o := schema.Object{ - Oid: 12345, - Database: "mydb", - Schema: "public", - Type: "VIEW", - ObjectMeta: schema.ObjectMeta{ - Name: "user_view", - Owner: "postgres", - }, - Tablespace: nil, - Size: 0, - } - str := o.String() - assert.NotEmpty(str) - - var parsed map[string]interface{} - err := json.Unmarshal([]byte(str), &parsed) - assert.NoError(err) - _, hasTablespace := parsed["tablespace"] - assert.False(hasTablespace) // omitempty should exclude nil tablespace - }) -} - -func Test_ObjectList_String(t *testing.T) { - assert := assert.New(t) - - t.Run("ValidJSON", func(t *testing.T) { - list := schema.ObjectList{ - Count: 2, - Body: []schema.Object{ - { - Oid: 1, - Database: "db1", - Schema: "public", - Type: "TABLE", - ObjectMeta: schema.ObjectMeta{ - Name: "table1", - Owner: "owner1", - }, - }, - { - Oid: 2, - Database: "db1", - Schema: "public", - Type: "VIEW", - ObjectMeta: schema.ObjectMeta{ - Name: "view1", - Owner: "owner2", - }, - }, - }, - } - str := list.String() - assert.NotEmpty(str) - - var parsed map[string]interface{} - err := json.Unmarshal([]byte(str), &parsed) - assert.NoError(err) - assert.Equal(float64(2), parsed["count"]) - body := parsed["body"].([]interface{}) - assert.Len(body, 2) - }) - - t.Run("EmptyBody", func(t *testing.T) { - list := schema.ObjectList{ - Count: 0, - Body: []schema.Object{}, - } - str := list.String() - assert.NotEmpty(str) - - var parsed map[string]interface{} - err := json.Unmarshal([]byte(str), &parsed) - assert.NoError(err) - assert.Equal(float64(0), parsed["count"]) - }) -} - -func Test_ObjectListRequest_String(t *testing.T) { - assert := assert.New(t) - - t.Run("ValidJSON", func(t *testing.T) { - schemaName := "public" - database := "mydb" - objectType := "TABLE" - req := schema.ObjectListRequest{ - Schema: &schemaName, - Database: &database, - Type: &objectType, - } - str := req.String() - assert.NotEmpty(str) - - var parsed map[string]interface{} - err := json.Unmarshal([]byte(str), &parsed) - assert.NoError(err) - assert.Equal("public", parsed["schema"]) - assert.Equal("mydb", parsed["database"]) - assert.Equal("TABLE", parsed["type"]) - }) - - t.Run("NilFields", func(t *testing.T) { - req := schema.ObjectListRequest{} - str := req.String() - assert.NotEmpty(str) - - var parsed map[string]interface{} - err := json.Unmarshal([]byte(str), &parsed) - assert.NoError(err) - _, hasSchema := parsed["schema"] - _, hasDatabase := parsed["database"] - _, hasType := parsed["type"] - assert.False(hasSchema) - assert.False(hasDatabase) - assert.False(hasType) - }) -} - -func Test_TableMeta_String(t *testing.T) { - assert := assert.New(t) - - t.Run("WithValues", func(t *testing.T) { - live := int64(1000) - dead := int64(50) - tm := schema.TableMeta{ - LiveTuples: &live, - DeadTuples: &dead, - } - str := tm.String() - assert.NotEmpty(str) - - var parsed map[string]interface{} - err := json.Unmarshal([]byte(str), &parsed) - assert.NoError(err) - assert.Equal(float64(1000), parsed["live_tuples"]) - assert.Equal(float64(50), parsed["dead_tuples"]) - }) - - t.Run("NilValues", func(t *testing.T) { - tm := schema.TableMeta{} - str := tm.String() - assert.NotEmpty(str) - - var parsed map[string]interface{} - err := json.Unmarshal([]byte(str), &parsed) - assert.NoError(err) - _, hasLive := parsed["live_tuples"] - _, hasDead := parsed["dead_tuples"] - assert.False(hasLive) - assert.False(hasDead) - }) - - t.Run("OnlyLiveTuples", func(t *testing.T) { - live := int64(500) - tm := schema.TableMeta{ - LiveTuples: &live, - } - str := tm.String() - - var parsed map[string]interface{} - err := json.Unmarshal([]byte(str), &parsed) - assert.NoError(err) - assert.Equal(float64(500), parsed["live_tuples"]) - _, hasDead := parsed["dead_tuples"] - assert.False(hasDead) - }) - - t.Run("ZeroValues", func(t *testing.T) { - live := int64(0) - dead := int64(0) - tm := schema.TableMeta{ - LiveTuples: &live, - DeadTuples: &dead, - } - str := tm.String() - - var parsed map[string]interface{} - err := json.Unmarshal([]byte(str), &parsed) - assert.NoError(err) - assert.Equal(float64(0), parsed["live_tuples"]) - assert.Equal(float64(0), parsed["dead_tuples"]) - }) -} - -func Test_Object_WithTableMeta(t *testing.T) { - assert := assert.New(t) - - t.Run("TableWithMeta", func(t *testing.T) { - live := int64(1000) - dead := int64(50) - o := schema.Object{ - Oid: 16384, - Database: "mydb", - Schema: "public", - Type: "TABLE", - ObjectMeta: schema.ObjectMeta{ - Name: "users", - Owner: "postgres", - }, - Size: 8192, - Table: &schema.TableMeta{ - LiveTuples: &live, - DeadTuples: &dead, - }, - } - str := o.String() - assert.NotEmpty(str) - - var parsed map[string]interface{} - err := json.Unmarshal([]byte(str), &parsed) - assert.NoError(err) - assert.Equal("TABLE", parsed["type"]) - assert.Equal("users", parsed["name"]) - - table, hasTable := parsed["table"].(map[string]interface{}) - assert.True(hasTable) - assert.Equal(float64(1000), table["live_tuples"]) - assert.Equal(float64(50), table["dead_tuples"]) - }) - - t.Run("IndexWithoutTableMeta", func(t *testing.T) { - o := schema.Object{ - Oid: 16385, - Database: "mydb", - Schema: "public", - Type: "INDEX", - ObjectMeta: schema.ObjectMeta{ - Name: "users_pkey", - Owner: "postgres", - }, - Size: 4096, - Table: nil, - } - str := o.String() - assert.NotEmpty(str) - - var parsed map[string]interface{} - err := json.Unmarshal([]byte(str), &parsed) - assert.NoError(err) - assert.Equal("INDEX", parsed["type"]) - assert.Equal("users_pkey", parsed["name"]) - - _, hasTable := parsed["table"] - assert.False(hasTable, "INDEX should not have table metadata") - }) - - t.Run("ViewWithoutTableMeta", func(t *testing.T) { - o := schema.Object{ - Oid: 16386, - Database: "mydb", - Schema: "public", - Type: "VIEW", - ObjectMeta: schema.ObjectMeta{ - Name: "active_users", - Owner: "postgres", - }, - Table: nil, - } - str := o.String() - - var parsed map[string]interface{} - err := json.Unmarshal([]byte(str), &parsed) - assert.NoError(err) - assert.Equal("VIEW", parsed["type"]) - _, hasTable := parsed["table"] - assert.False(hasTable, "VIEW should not have table metadata") - }) -} diff --git a/pkg/manager/schema/replicationslot_test.go b/pkg/manager/schema/replicationslot_test.go deleted file mode 100644 index 61adda1..0000000 --- a/pkg/manager/schema/replicationslot_test.go +++ /dev/null @@ -1,290 +0,0 @@ -package schema_test - -import ( - "encoding/json" - "testing" - - // Packages - pg "github.com/mutablelogic/go-pg" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" - assert "github.com/stretchr/testify/assert" -) - -func Test_ReplicationSlot_String(t *testing.T) { - assert := assert.New(t) - - t.Run("ValidSlot", func(t *testing.T) { - lagBytes := int64(1024) - lagMs := 0.5 - s := schema.ReplicationSlot{ - ReplicationSlotMeta: schema.ReplicationSlotMeta{ - Name: "my_slot", - Type: "physical", - Database: "", - }, - Status: "streaming", - ClientAddr: "192.168.1.100", - LagBytes: &lagBytes, - LagMs: &lagMs, - } - str := s.String() - assert.NotEmpty(str) - - var parsed schema.ReplicationSlot - err := json.Unmarshal([]byte(str), &parsed) - assert.NoError(err) - assert.Equal(s.Name, parsed.Name) - assert.Equal(s.Status, parsed.Status) - }) - - t.Run("EmptySlot", func(t *testing.T) { - s := schema.ReplicationSlot{} - str := s.String() - assert.NotEmpty(str) - - var parsed schema.ReplicationSlot - err := json.Unmarshal([]byte(str), &parsed) - assert.NoError(err) - }) -} - -func Test_ReplicationSlotMeta_String(t *testing.T) { - assert := assert.New(t) - - t.Run("PhysicalSlot", func(t *testing.T) { - m := schema.ReplicationSlotMeta{ - Name: "my_slot", - Type: "physical", - } - str := m.String() - assert.NotEmpty(str) - - var parsed schema.ReplicationSlotMeta - err := json.Unmarshal([]byte(str), &parsed) - assert.NoError(err) - assert.Equal("my_slot", parsed.Name) - }) - - t.Run("LogicalSlot", func(t *testing.T) { - m := schema.ReplicationSlotMeta{ - Name: "my_logical_slot", - Type: "logical", - Plugin: "pgoutput", - Database: "mydb", - } - str := m.String() - assert.NotEmpty(str) - - var parsed schema.ReplicationSlotMeta - err := json.Unmarshal([]byte(str), &parsed) - assert.NoError(err) - assert.Equal("pgoutput", parsed.Plugin) - }) -} - -func Test_ReplicationSlotList_String(t *testing.T) { - assert := assert.New(t) - - t.Run("WithSlots", func(t *testing.T) { - l := schema.ReplicationSlotList{ - Count: 2, - Body: []schema.ReplicationSlot{ - {ReplicationSlotMeta: schema.ReplicationSlotMeta{Name: "slot1", Type: "physical"}, Status: "inactive"}, - {ReplicationSlotMeta: schema.ReplicationSlotMeta{Name: "slot2", Type: "logical"}, Status: "streaming"}, - }, - } - str := l.String() - assert.NotEmpty(str) - - var parsed schema.ReplicationSlotList - err := json.Unmarshal([]byte(str), &parsed) - assert.NoError(err) - assert.Equal(uint64(2), parsed.Count) - assert.Len(parsed.Body, 2) - }) - - t.Run("EmptyList", func(t *testing.T) { - l := schema.ReplicationSlotList{} - str := l.String() - assert.NotEmpty(str) - - var parsed schema.ReplicationSlotList - err := json.Unmarshal([]byte(str), &parsed) - assert.NoError(err) - }) -} - -func Test_ReplicationSlotMeta_Validate(t *testing.T) { - assert := assert.New(t) - - t.Run("ValidPhysical", func(t *testing.T) { - m := schema.ReplicationSlotMeta{Name: "my_slot", Type: "physical"} - err := m.Validate() - assert.NoError(err) - }) - - t.Run("ValidLogical", func(t *testing.T) { - m := schema.ReplicationSlotMeta{Name: "my_slot", Type: "logical", Plugin: "pgoutput"} - err := m.Validate() - assert.NoError(err) - }) - - t.Run("EmptyName", func(t *testing.T) { - m := schema.ReplicationSlotMeta{Name: "", Type: "physical"} - err := m.Validate() - assert.Error(err) - }) - - t.Run("WhitespaceOnlyName", func(t *testing.T) { - m := schema.ReplicationSlotMeta{Name: " ", Type: "physical"} - err := m.Validate() - assert.Error(err) - }) - - t.Run("ReservedPrefixName", func(t *testing.T) { - m := schema.ReplicationSlotMeta{Name: "pg_my_slot", Type: "physical"} - err := m.Validate() - assert.Error(err) - }) - - t.Run("InvalidType", func(t *testing.T) { - m := schema.ReplicationSlotMeta{Name: "my_slot", Type: "invalid"} - err := m.Validate() - assert.Error(err) - }) - - t.Run("EmptyType", func(t *testing.T) { - m := schema.ReplicationSlotMeta{Name: "my_slot", Type: ""} - err := m.Validate() - assert.Error(err) - }) - - t.Run("LogicalWithoutPlugin", func(t *testing.T) { - m := schema.ReplicationSlotMeta{Name: "my_slot", Type: "logical"} - err := m.Validate() - assert.Error(err) - }) - - t.Run("LogicalWithEmptyPlugin", func(t *testing.T) { - m := schema.ReplicationSlotMeta{Name: "my_slot", Type: "logical", Plugin: " "} - err := m.Validate() - assert.Error(err) - }) - - t.Run("TypeCaseInsensitive", func(t *testing.T) { - m := schema.ReplicationSlotMeta{Name: "my_slot", Type: "PHYSICAL"} - err := m.Validate() - assert.NoError(err) - }) -} - -func Test_ReplicationSlotName_Select(t *testing.T) { - assert := assert.New(t) - - t.Run("GetOperation", func(t *testing.T) { - bind := pg.NewBind() - n := schema.ReplicationSlotName("my_slot") - sql, err := n.Select(bind, pg.Get) - assert.NoError(err) - assert.NotEmpty(sql) - assert.Equal("my_slot", bind.Get("name")) - }) - - t.Run("DeleteOperation", func(t *testing.T) { - bind := pg.NewBind() - n := schema.ReplicationSlotName("my_slot") - sql, err := n.Select(bind, pg.Delete) - assert.NoError(err) - assert.NotEmpty(sql) - assert.Contains(sql, "pg_drop_replication_slot") - }) - - t.Run("EmptyName", func(t *testing.T) { - bind := pg.NewBind() - n := schema.ReplicationSlotName("") - _, err := n.Select(bind, pg.Get) - assert.Error(err) - }) - - t.Run("ReservedPrefix", func(t *testing.T) { - bind := pg.NewBind() - n := schema.ReplicationSlotName("pg_my_slot") - _, err := n.Select(bind, pg.Get) - assert.Error(err) - }) - - t.Run("UnsupportedOperation", func(t *testing.T) { - bind := pg.NewBind() - n := schema.ReplicationSlotName("my_slot") - _, err := n.Select(bind, pg.Update) - assert.Error(err) - }) -} - -func Test_ReplicationSlotListRequest_Select(t *testing.T) { - assert := assert.New(t) - - t.Run("ListOperation", func(t *testing.T) { - bind := pg.NewBind() - req := schema.ReplicationSlotListRequest{} - sql, err := req.Select(bind, pg.List) - assert.NoError(err) - assert.NotEmpty(sql) - assert.Equal("ORDER BY name ASC", bind.Get("orderby")) - assert.Equal("", bind.Get("where")) - }) - - t.Run("UnsupportedOperation", func(t *testing.T) { - bind := pg.NewBind() - req := schema.ReplicationSlotListRequest{} - _, err := req.Select(bind, pg.Get) - assert.Error(err) - }) -} - -func Test_ReplicationSlotMeta_Insert(t *testing.T) { - assert := assert.New(t) - - t.Run("CreatePhysical", func(t *testing.T) { - bind := pg.NewBind() - m := schema.ReplicationSlotMeta{Name: "my_slot", Type: "physical"} - sql, err := m.Insert(bind) - assert.NoError(err) - assert.NotEmpty(sql) - assert.Contains(sql, "pg_create_physical_replication_slot") - assert.Equal("my_slot", bind.Get("name")) - }) - - t.Run("CreateLogical", func(t *testing.T) { - bind := pg.NewBind() - m := schema.ReplicationSlotMeta{Name: "my_slot", Type: "logical", Plugin: "pgoutput"} - sql, err := m.Insert(bind) - assert.NoError(err) - assert.NotEmpty(sql) - assert.Contains(sql, "pg_create_logical_replication_slot") - assert.Equal("pgoutput", bind.Get("plugin")) - }) - - t.Run("CreateWithTemporary", func(t *testing.T) { - bind := pg.NewBind() - m := schema.ReplicationSlotMeta{Name: "my_slot", Type: "physical", Temporary: true} - _, err := m.Insert(bind) - assert.NoError(err) - assert.Equal(true, bind.Get("temporary")) - }) - - t.Run("CreateWithTwoPhase", func(t *testing.T) { - bind := pg.NewBind() - m := schema.ReplicationSlotMeta{Name: "my_slot", Type: "logical", Plugin: "pgoutput", TwoPhase: true} - _, err := m.Insert(bind) - assert.NoError(err) - assert.Equal(true, bind.Get("two_phase")) - }) - - t.Run("InvalidMeta", func(t *testing.T) { - bind := pg.NewBind() - m := schema.ReplicationSlotMeta{Name: "", Type: "physical"} - _, err := m.Insert(bind) - assert.Error(err) - }) -} diff --git a/pkg/manager/schema/role_test.go b/pkg/manager/schema/role_test.go deleted file mode 100644 index b2f4b5e..0000000 --- a/pkg/manager/schema/role_test.go +++ /dev/null @@ -1,511 +0,0 @@ -package schema_test - -import ( - "testing" - "time" - - // Packages - pg "github.com/mutablelogic/go-pg" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" - types "github.com/mutablelogic/go-server/pkg/types" - assert "github.com/stretchr/testify/assert" -) - -func Test_RoleMeta_Validate(t *testing.T) { - assert := assert.New(t) - - t.Run("ValidName", func(t *testing.T) { - r := schema.RoleMeta{Name: "testrole"} - err := r.Validate() - assert.NoError(err) - }) - - t.Run("ValidNameWithUnderscore", func(t *testing.T) { - r := schema.RoleMeta{Name: "test_role"} - err := r.Validate() - assert.NoError(err) - }) - - t.Run("ValidNameWithNumbers", func(t *testing.T) { - r := schema.RoleMeta{Name: "role123"} - err := r.Validate() - assert.NoError(err) - }) - - t.Run("EmptyName", func(t *testing.T) { - r := schema.RoleMeta{Name: ""} - err := r.Validate() - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("WhitespaceOnlyName", func(t *testing.T) { - r := schema.RoleMeta{Name: " "} - err := r.Validate() - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("ReservedPrefixName", func(t *testing.T) { - r := schema.RoleMeta{Name: "pg_admin"} - err := r.Validate() - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("ReservedPrefixName2", func(t *testing.T) { - r := schema.RoleMeta{Name: "pg_"} - err := r.Validate() - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("NameWithSpaces", func(t *testing.T) { - r := schema.RoleMeta{Name: " testrole "} - err := r.Validate() - assert.NoError(err) // Trimmed name is valid - }) - - t.Run("InvalidIdentifier", func(t *testing.T) { - r := schema.RoleMeta{Name: "123role"} - err := r.Validate() - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("InvalidIdentifierWithSpecialChar", func(t *testing.T) { - r := schema.RoleMeta{Name: "role@name"} - err := r.Validate() - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) -} - -func Test_RoleName_Select(t *testing.T) { - assert := assert.New(t) - - t.Run("GetOperation", func(t *testing.T) { - bind := pg.NewBind() - r := schema.RoleName("testrole") - sql, err := r.Select(bind, pg.Get) - assert.NoError(err) - assert.NotEmpty(sql) - assert.Equal("testrole", bind.Get("name")) - }) - - t.Run("DeleteOperation", func(t *testing.T) { - bind := pg.NewBind() - r := schema.RoleName("testrole") - sql, err := r.Select(bind, pg.Delete) - assert.NoError(err) - assert.NotEmpty(sql) - assert.Contains(sql, "DROP ROLE") - }) - - t.Run("UpdateOperation", func(t *testing.T) { - bind := pg.NewBind() - r := schema.RoleName("oldrole") - sql, err := r.Select(bind, pg.Update) - assert.NoError(err) - assert.NotEmpty(sql) - assert.Contains(sql, "RENAME") - }) - - t.Run("EmptyName", func(t *testing.T) { - bind := pg.NewBind() - r := schema.RoleName("") - _, err := r.Select(bind, pg.Get) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("ReservedPrefixForUpdate", func(t *testing.T) { - bind := pg.NewBind() - r := schema.RoleName("pg_admin") - _, err := r.Select(bind, pg.Update) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("ReservedPrefixForDelete", func(t *testing.T) { - bind := pg.NewBind() - r := schema.RoleName("pg_admin") - _, err := r.Select(bind, pg.Delete) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("ReservedPrefixAllowedForGet", func(t *testing.T) { - bind := pg.NewBind() - r := schema.RoleName("pg_monitor") - sql, err := r.Select(bind, pg.Get) - assert.NoError(err) - assert.NotEmpty(sql) - }) - - t.Run("UnsupportedOperation", func(t *testing.T) { - bind := pg.NewBind() - r := schema.RoleName("testrole") - _, err := r.Select(bind, pg.Insert) - assert.Error(err) - assert.ErrorIs(err, pg.ErrNotImplemented) - }) -} - -func Test_RoleName_Update(t *testing.T) { - assert := assert.New(t) - - t.Run("ValidUpdate", func(t *testing.T) { - bind := pg.NewBind() - r := schema.RoleName("oldrole") - err := r.Update(bind) - assert.NoError(err) - assert.Equal("oldrole", bind.Get("old_name")) - }) - - t.Run("EmptyName", func(t *testing.T) { - bind := pg.NewBind() - r := schema.RoleName("") - err := r.Update(bind) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("WhitespaceName", func(t *testing.T) { - bind := pg.NewBind() - r := schema.RoleName(" ") - err := r.Update(bind) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("ReservedPrefixName", func(t *testing.T) { - bind := pg.NewBind() - r := schema.RoleName("pg_admin") - err := r.Update(bind) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("InvalidIdentifier", func(t *testing.T) { - bind := pg.NewBind() - r := schema.RoleName("123role") - err := r.Update(bind) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) -} - -func Test_RoleName_Insert(t *testing.T) { - assert := assert.New(t) - - t.Run("NotImplemented", func(t *testing.T) { - bind := pg.NewBind() - r := schema.RoleName("testrole") - _, err := r.Insert(bind) - assert.Error(err) - assert.ErrorIs(err, pg.ErrNotImplemented) - }) -} - -func Test_RoleMeta_Select(t *testing.T) { - assert := assert.New(t) - - t.Run("UpdateOperation", func(t *testing.T) { - bind := pg.NewBind() - r := schema.RoleMeta{Name: "testrole"} - sql, err := r.Select(bind, pg.Update) - assert.NoError(err) - assert.NotEmpty(sql) - assert.Contains(sql, "ALTER ROLE") - assert.Equal("testrole", bind.Get("name")) - }) - - t.Run("EmptyName", func(t *testing.T) { - bind := pg.NewBind() - r := schema.RoleMeta{Name: ""} - _, err := r.Select(bind, pg.Update) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("ReservedPrefixName", func(t *testing.T) { - bind := pg.NewBind() - r := schema.RoleMeta{Name: "pg_admin"} - _, err := r.Select(bind, pg.Update) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("UnsupportedOperation", func(t *testing.T) { - bind := pg.NewBind() - r := schema.RoleMeta{Name: "testrole"} - _, err := r.Select(bind, pg.Get) - assert.Error(err) - assert.ErrorIs(err, pg.ErrNotImplemented) - }) -} - -func Test_RoleMeta_Insert(t *testing.T) { - assert := assert.New(t) - - t.Run("ValidInsert", func(t *testing.T) { - bind := pg.NewBind() - r := schema.RoleMeta{Name: "newrole"} - sql, err := r.Insert(bind) - assert.NoError(err) - assert.NotEmpty(sql) - assert.Contains(sql, "CREATE ROLE") - assert.Equal("newrole", bind.Get("name")) - }) - - t.Run("EmptyName", func(t *testing.T) { - bind := pg.NewBind() - r := schema.RoleMeta{Name: ""} - _, err := r.Insert(bind) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("ReservedPrefixName", func(t *testing.T) { - bind := pg.NewBind() - r := schema.RoleMeta{Name: "pg_admin"} - _, err := r.Insert(bind) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("InsertWithSuperuser", func(t *testing.T) { - bind := pg.NewBind() - r := schema.RoleMeta{Name: "superuser_role", Superuser: types.BoolPtr(true)} - sql, err := r.Insert(bind) - assert.NoError(err) - assert.NotEmpty(sql) - with := bind.Get("with").(string) - assert.Contains(with, "SUPERUSER") - }) - - t.Run("InsertWithNoLogin", func(t *testing.T) { - bind := pg.NewBind() - r := schema.RoleMeta{Name: "group_role", Login: types.BoolPtr(false)} - sql, err := r.Insert(bind) - assert.NoError(err) - assert.NotEmpty(sql) - with := bind.Get("with").(string) - assert.Contains(with, "NOLOGIN") - }) - - t.Run("InsertWithConnectionLimit", func(t *testing.T) { - bind := pg.NewBind() - r := schema.RoleMeta{Name: "limited_role", ConnectionLimit: types.Uint64Ptr(5)} - sql, err := r.Insert(bind) - assert.NoError(err) - assert.NotEmpty(sql) - with := bind.Get("with").(string) - assert.Contains(with, "CONNECTION LIMIT 5") - }) - - t.Run("InsertWithPassword", func(t *testing.T) { - bind := pg.NewBind() - r := schema.RoleMeta{Name: "user_role", Password: types.StringPtr("secret123")} - sql, err := r.Insert(bind) - assert.NoError(err) - assert.NotEmpty(sql) - with := bind.Get("with").(string) - assert.Contains(with, "PASSWORD") - }) - - t.Run("InsertWithNullPassword", func(t *testing.T) { - bind := pg.NewBind() - r := schema.RoleMeta{Name: "nopass_role", Password: types.StringPtr("")} - sql, err := r.Insert(bind) - assert.NoError(err) - assert.NotEmpty(sql) - with := bind.Get("with").(string) - assert.Contains(with, "PASSWORD NULL") - }) - - t.Run("InsertWithExpires", func(t *testing.T) { - bind := pg.NewBind() - expires := time.Date(2025, 12, 31, 23, 59, 59, 0, time.UTC) - r := schema.RoleMeta{Name: "temp_role", Expires: &expires} - sql, err := r.Insert(bind) - assert.NoError(err) - assert.NotEmpty(sql) - with := bind.Get("with").(string) - assert.Contains(with, "VALID UNTIL") - assert.Contains(with, "2025-12-31") - }) - - t.Run("InsertWithGroups", func(t *testing.T) { - bind := pg.NewBind() - r := schema.RoleMeta{Name: "member_role", Groups: []string{"group1", "group2"}} - sql, err := r.Insert(bind) - assert.NoError(err) - assert.NotEmpty(sql) - with := bind.Get("with").(string) - assert.Contains(with, "IN ROLE") - assert.Contains(with, "group1") - assert.Contains(with, "group2") - }) - - t.Run("InsertWithMultipleOptions", func(t *testing.T) { - bind := pg.NewBind() - r := schema.RoleMeta{ - Name: "full_role", - Superuser: types.BoolPtr(false), - Login: types.BoolPtr(true), - CreateDatabases: types.BoolPtr(true), - CreateRoles: types.BoolPtr(false), - Inherit: types.BoolPtr(true), - } - sql, err := r.Insert(bind) - assert.NoError(err) - assert.NotEmpty(sql) - with := bind.Get("with").(string) - assert.Contains(with, "NOSUPERUSER") - assert.Contains(with, "LOGIN") - assert.Contains(with, "CREATEDB") - assert.Contains(with, "NOCREATEROLE") - assert.Contains(with, "INHERIT") - }) -} - -func Test_RoleMeta_Update(t *testing.T) { - assert := assert.New(t) - - t.Run("UpdateWithSuperuser", func(t *testing.T) { - bind := pg.NewBind() - r := schema.RoleMeta{Name: "testrole", Superuser: types.BoolPtr(true)} - err := r.Update(bind) - assert.NoError(err) - with := bind.Get("with").(string) - assert.Contains(with, "SUPERUSER") - }) - - t.Run("UpdateWithNoLogin", func(t *testing.T) { - bind := pg.NewBind() - r := schema.RoleMeta{Name: "testrole", Login: types.BoolPtr(false)} - err := r.Update(bind) - assert.NoError(err) - with := bind.Get("with").(string) - assert.Contains(with, "NOLOGIN") - }) - - t.Run("UpdateEmptyWith", func(t *testing.T) { - bind := pg.NewBind() - r := schema.RoleMeta{Name: "testrole"} - err := r.Update(bind) - assert.NoError(err) - with := bind.Get("with").(string) - assert.Empty(with) - }) - - t.Run("UpdateDoesNotIncludeGroups", func(t *testing.T) { - bind := pg.NewBind() - r := schema.RoleMeta{Name: "testrole", Groups: []string{"group1"}} - err := r.Update(bind) - assert.NoError(err) - with := bind.Get("with").(string) - // Groups are only included for insert, not update - assert.NotContains(with, "IN ROLE") - }) -} - -func Test_RoleListRequest_Select(t *testing.T) { - assert := assert.New(t) - - t.Run("ListOperation", func(t *testing.T) { - bind := pg.NewBind() - req := schema.RoleListRequest{} - sql, err := req.Select(bind, pg.List) - assert.NoError(err) - assert.NotEmpty(sql) - assert.Equal("", bind.Get("where")) - }) - - t.Run("ListWithOffsetLimit", func(t *testing.T) { - bind := pg.NewBind() - req := schema.RoleListRequest{ - OffsetLimit: pg.OffsetLimit{Offset: 10, Limit: types.Uint64Ptr(20)}, - } - sql, err := req.Select(bind, pg.List) - assert.NoError(err) - assert.NotEmpty(sql) - }) - - t.Run("UnsupportedOperation", func(t *testing.T) { - bind := pg.NewBind() - req := schema.RoleListRequest{} - _, err := req.Select(bind, pg.Get) - assert.Error(err) - assert.ErrorIs(err, pg.ErrNotImplemented) - }) -} - -func Test_Role_String(t *testing.T) { - assert := assert.New(t) - - t.Run("RoleString", func(t *testing.T) { - r := schema.Role{ - Oid: 12345, - RoleMeta: schema.RoleMeta{ - Name: "testrole", - Superuser: types.BoolPtr(true), - }, - } - str := r.String() - assert.Contains(str, "testrole") - assert.Contains(str, "12345") - }) - - t.Run("RoleMetaString", func(t *testing.T) { - r := schema.RoleMeta{ - Name: "testrole", - Login: types.BoolPtr(true), - } - str := r.String() - assert.Contains(str, "testrole") - }) - - t.Run("RoleListString", func(t *testing.T) { - list := schema.RoleList{ - Count: 2, - Body: []schema.Role{ - {Oid: 1, RoleMeta: schema.RoleMeta{Name: "role1"}}, - {Oid: 2, RoleMeta: schema.RoleMeta{Name: "role2"}}, - }, - } - str := list.String() - assert.Contains(str, "role1") - assert.Contains(str, "role2") - assert.Contains(str, `"count": 2`) - }) - - t.Run("RoleListRequestString", func(t *testing.T) { - req := schema.RoleListRequest{ - OffsetLimit: pg.OffsetLimit{Offset: 5, Limit: types.Uint64Ptr(10)}, - } - str := req.String() - // Should be valid JSON - assert.NotEmpty(str) - }) -} - -func Test_RoleList_Scan(t *testing.T) { - // Note: These are tested via integration tests with real database connections - // Here we just verify the types exist and have the correct structure - assert := assert.New(t) - - t.Run("RoleListType", func(t *testing.T) { - list := schema.RoleList{} - assert.Equal(uint64(0), list.Count) - assert.Nil(list.Body) - }) - - t.Run("RoleType", func(t *testing.T) { - role := schema.Role{} - assert.Equal(uint32(0), role.Oid) - assert.Empty(role.Name) - }) -} diff --git a/pkg/manager/schema/schema_test.go b/pkg/manager/schema/schema_test.go deleted file mode 100644 index f10fe1d..0000000 --- a/pkg/manager/schema/schema_test.go +++ /dev/null @@ -1,324 +0,0 @@ -package schema_test - -import ( - "encoding/json" - "testing" - - // Packages - pg "github.com/mutablelogic/go-pg" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" - assert "github.com/stretchr/testify/assert" -) - -func Test_SchemaName_Select(t *testing.T) { - assert := assert.New(t) - - t.Run("GetOperation", func(t *testing.T) { - bind := pg.NewBind() - s := schema.SchemaName("public") - sql, err := s.Select(bind, pg.Get) - assert.NoError(err) - assert.NotEmpty(sql) - assert.Equal("public", bind.Get("name")) - }) - - t.Run("UpdateOperation", func(t *testing.T) { - bind := pg.NewBind() - s := schema.SchemaName("myschema") - sql, err := s.Select(bind, pg.Update) - assert.NoError(err) - assert.NotEmpty(sql) - assert.Contains(sql, "RENAME") - }) - - t.Run("DeleteOperation", func(t *testing.T) { - bind := pg.NewBind() - s := schema.SchemaName("myschema") - sql, err := s.Select(bind, pg.Delete) - assert.NoError(err) - assert.NotEmpty(sql) - assert.Contains(sql, "DROP") - }) - - t.Run("DeleteWithForce", func(t *testing.T) { - bind := pg.NewBind() - bind.Set("force", true) - s := schema.SchemaName("myschema") - _, err := s.Select(bind, pg.Delete) - assert.NoError(err) - assert.Equal("CASCADE", bind.Get("with")) - }) - - t.Run("EmptyName", func(t *testing.T) { - bind := pg.NewBind() - s := schema.SchemaName("") - _, err := s.Select(bind, pg.Get) - assert.Error(err) - }) - - t.Run("WhitespaceOnlyName", func(t *testing.T) { - bind := pg.NewBind() - s := schema.SchemaName(" ") - _, err := s.Select(bind, pg.Get) - assert.Error(err) - }) - - t.Run("UnsupportedOperation", func(t *testing.T) { - bind := pg.NewBind() - s := schema.SchemaName("myschema") - _, err := s.Select(bind, pg.Insert) - assert.Error(err) - }) -} - -func Test_SchemaListRequest_Select(t *testing.T) { - assert := assert.New(t) - - t.Run("ListOperation", func(t *testing.T) { - bind := pg.NewBind() - req := schema.SchemaListRequest{} - sql, err := req.Select(bind, pg.List) - assert.NoError(err) - assert.NotEmpty(sql) - assert.Equal("ORDER BY name ASC", bind.Get("orderby")) - }) - - t.Run("ListWithDatabase", func(t *testing.T) { - bind := pg.NewBind() - db := "testdb" - req := schema.SchemaListRequest{Database: &db} - sql, err := req.Select(bind, pg.List) - assert.NoError(err) - assert.NotEmpty(sql) - where := bind.Get("where").(string) - assert.Contains(where, "database") - }) - - t.Run("UnsupportedOperation", func(t *testing.T) { - bind := pg.NewBind() - req := schema.SchemaListRequest{} - _, err := req.Select(bind, pg.Get) - assert.Error(err) - }) -} - -func Test_SchemaMeta_Select(t *testing.T) { - assert := assert.New(t) - - t.Run("UpdateOperation", func(t *testing.T) { - bind := pg.NewBind() - s := schema.SchemaMeta{Name: "myschema", Owner: "newowner"} - sql, err := s.Select(bind, pg.Update) - assert.NoError(err) - assert.NotEmpty(sql) - assert.Equal("myschema", bind.Get("name")) - }) - - t.Run("EmptyName", func(t *testing.T) { - bind := pg.NewBind() - s := schema.SchemaMeta{Name: "", Owner: "newowner"} - _, err := s.Select(bind, pg.Update) - assert.Error(err) - }) - - t.Run("UnsupportedOperation", func(t *testing.T) { - bind := pg.NewBind() - s := schema.SchemaMeta{Name: "myschema"} - _, err := s.Select(bind, pg.Get) - assert.Error(err) - }) -} - -func Test_SchemaMeta_Insert(t *testing.T) { - assert := assert.New(t) - - t.Run("ValidInsert", func(t *testing.T) { - bind := pg.NewBind() - s := schema.SchemaMeta{Name: "newschema", Owner: "admin"} - sql, err := s.Insert(bind) - assert.NoError(err) - assert.NotEmpty(sql) - assert.Contains(sql, "CREATE SCHEMA") - assert.Equal("newschema", bind.Get("name")) - }) - - t.Run("InsertWithOwner", func(t *testing.T) { - bind := pg.NewBind() - s := schema.SchemaMeta{Name: "newschema", Owner: "myowner"} - _, err := s.Insert(bind) - assert.NoError(err) - with := bind.Get("with").(string) - assert.Contains(with, "AUTHORIZATION") - assert.Contains(with, "myowner") - }) - - t.Run("InsertWithoutOwner", func(t *testing.T) { - bind := pg.NewBind() - s := schema.SchemaMeta{Name: "newschema"} - sql, err := s.Insert(bind) - assert.NoError(err) // Owner is optional for schema creation - assert.NotEmpty(sql) - assert.Contains(sql, "CREATE SCHEMA") - assert.NotContains(sql, "AUTHORIZATION") // No owner means no AUTHORIZATION clause - }) - - t.Run("InvalidName", func(t *testing.T) { - bind := pg.NewBind() - s := schema.SchemaMeta{Name: "", Owner: "admin"} - _, err := s.Insert(bind) - assert.Error(err) - }) - - t.Run("ReservedPrefixName", func(t *testing.T) { - bind := pg.NewBind() - s := schema.SchemaMeta{Name: "pg_myschema", Owner: "admin"} - _, err := s.Insert(bind) - assert.Error(err) - }) -} - -func Test_SchemaMeta_Update(t *testing.T) { - assert := assert.New(t) - - t.Run("ValidUpdate", func(t *testing.T) { - bind := pg.NewBind() - s := schema.SchemaMeta{Name: "myschema", Owner: "newowner"} - err := s.Update(bind) - assert.NoError(err) - with := bind.Get("with").(string) - assert.Contains(with, "OWNER TO") - }) - - t.Run("UpdateNoChanges", func(t *testing.T) { - bind := pg.NewBind() - s := schema.SchemaMeta{Name: "myschema"} - err := s.Update(bind) - assert.Error(err) // Should error when no changes specified - }) -} - -func Test_SchemaName_Insert(t *testing.T) { - assert := assert.New(t) - - t.Run("NotImplemented", func(t *testing.T) { - bind := pg.NewBind() - s := schema.SchemaName("myschema") - _, err := s.Insert(bind) - assert.Error(err) - }) -} - -func Test_SchemaName_Update(t *testing.T) { - assert := assert.New(t) - - t.Run("ValidRename", func(t *testing.T) { - bind := pg.NewBind() - bind.Set("name", "newname") - s := schema.SchemaName("oldname") - err := s.Update(bind) - assert.NoError(err) - assert.Equal("oldname", bind.Get("old_name")) - }) - - t.Run("InvalidOldName", func(t *testing.T) { - bind := pg.NewBind() - bind.Set("name", "newname") - s := schema.SchemaName("") - err := s.Update(bind) - assert.Error(err) - }) - - t.Run("ReservedPrefixOldName", func(t *testing.T) { - bind := pg.NewBind() - bind.Set("name", "newname") - s := schema.SchemaName("pg_oldschema") - err := s.Update(bind) - assert.Error(err) - }) - - t.Run("PublicSchemaRename", func(t *testing.T) { - bind := pg.NewBind() - bind.Set("name", "newname") - s := schema.SchemaName("public") - err := s.Update(bind) - assert.Error(err) // Cannot rename default schema - }) -} - -func Test_Schema_String(t *testing.T) { - assert := assert.New(t) - - t.Run("ProducesJSON", func(t *testing.T) { - s := schema.Schema{ - Oid: 12345, - Database: "testdb", - SchemaMeta: schema.SchemaMeta{ - Name: "myschema", - Owner: "admin", - }, - Size: 1024000, - } - str := s.String() - assert.Contains(str, "myschema") - assert.Contains(str, "admin") - // Should be valid JSON - var parsed map[string]any - err := json.Unmarshal([]byte(str), &parsed) - assert.NoError(err) - }) -} - -func Test_SchemaMeta_String(t *testing.T) { - assert := assert.New(t) - - t.Run("ProducesJSON", func(t *testing.T) { - s := schema.SchemaMeta{Name: "myschema", Owner: "admin"} - str := s.String() - assert.Contains(str, "myschema") - var parsed map[string]any - err := json.Unmarshal([]byte(str), &parsed) - assert.NoError(err) - }) -} - -func Test_SchemaList_String(t *testing.T) { - assert := assert.New(t) - - t.Run("ProducesJSON", func(t *testing.T) { - list := schema.SchemaList{ - Count: 2, - Body: []schema.Schema{ - {Oid: 1, SchemaMeta: schema.SchemaMeta{Name: "schema1"}}, - {Oid: 2, SchemaMeta: schema.SchemaMeta{Name: "schema2"}}, - }, - } - str := list.String() - assert.Contains(str, "schema1") - assert.Contains(str, "schema2") - var parsed map[string]any - err := json.Unmarshal([]byte(str), &parsed) - assert.NoError(err) - }) -} - -func Test_SchemaListRequest_String(t *testing.T) { - assert := assert.New(t) - - t.Run("ProducesJSON", func(t *testing.T) { - req := schema.SchemaListRequest{} - str := req.String() - var parsed map[string]any - err := json.Unmarshal([]byte(str), &parsed) - assert.NoError(err) - }) - - t.Run("WithDatabase", func(t *testing.T) { - db := "testdb" - req := schema.SchemaListRequest{Database: &db} - str := req.String() - assert.Contains(str, "testdb") - var parsed map[string]any - err := json.Unmarshal([]byte(str), &parsed) - assert.NoError(err) - }) -} diff --git a/pkg/manager/schema/setting_test.go b/pkg/manager/schema/setting_test.go deleted file mode 100644 index ba02400..0000000 --- a/pkg/manager/schema/setting_test.go +++ /dev/null @@ -1,146 +0,0 @@ -package schema_test - -import ( - "testing" - - // Packages - pg "github.com/mutablelogic/go-pg" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" - assert "github.com/stretchr/testify/assert" -) - -func Test_SettingListRequest_Select(t *testing.T) { - assert := assert.New(t) - - t.Run("ListOperation", func(t *testing.T) { - bind := pg.NewBind() - r := schema.SettingListRequest{} - q, err := r.Select(bind, pg.List) - assert.NoError(err) - assert.NotEmpty(q) - assert.Contains(q, "pg_catalog.pg_settings") - }) - - t.Run("UnsupportedOperation", func(t *testing.T) { - bind := pg.NewBind() - r := schema.SettingListRequest{} - _, err := r.Select(bind, pg.Get) - assert.Error(err) - }) - - t.Run("WithCategory", func(t *testing.T) { - bind := pg.NewBind() - category := "Connections and Authentication" - r := schema.SettingListRequest{Category: &category} - q, err := r.Select(bind, pg.List) - assert.NoError(err) - assert.NotEmpty(q) - where := bind.Get("where").(string) - assert.Contains(where, "category") - assert.Equal(category, bind.Get("category")) - }) - - t.Run("WithoutCategory", func(t *testing.T) { - bind := pg.NewBind() - r := schema.SettingListRequest{} - _, err := r.Select(bind, pg.List) - assert.NoError(err) - assert.Equal("", bind.Get("where")) - }) - - t.Run("Ordering", func(t *testing.T) { - bind := pg.NewBind() - r := schema.SettingListRequest{} - _, err := r.Select(bind, pg.List) - assert.NoError(err) - orderby := bind.Get("orderby").(string) - assert.Contains(orderby, "category") - assert.Contains(orderby, "name") - }) - - t.Run("OffsetLimit", func(t *testing.T) { - bind := pg.NewBind() - limit := uint64(50) - r := schema.SettingListRequest{ - OffsetLimit: pg.OffsetLimit{Offset: 10, Limit: &limit}, - } - _, err := r.Select(bind, pg.List) - assert.NoError(err) - offsetlimit := bind.Get("offsetlimit").(string) - assert.Contains(offsetlimit, "LIMIT 50") - assert.Contains(offsetlimit, "OFFSET 10") - }) - - t.Run("DefaultLimit", func(t *testing.T) { - bind := pg.NewBind() - r := schema.SettingListRequest{} - _, err := r.Select(bind, pg.List) - assert.NoError(err) - offsetlimit := bind.Get("offsetlimit").(string) - // Default limit should be SettingListLimit (500) - assert.Contains(offsetlimit, "LIMIT") - }) -} - -func Test_Setting_String(t *testing.T) { - assert := assert.New(t) - - t.Run("WithUnit", func(t *testing.T) { - unit := "kB" - value := "131072" - s := schema.Setting{ - Name: "shared_buffers", - SettingMeta: schema.SettingMeta{Value: &value}, - Unit: &unit, - Category: "Resource Usage / Memory", - Description: "Sets the number of shared memory buffers", - } - str := s.String() - assert.Contains(str, "shared_buffers") - assert.Contains(str, "131072") - assert.Contains(str, "kB") - }) - - t.Run("WithoutUnit", func(t *testing.T) { - value := "100" - s := schema.Setting{ - Name: "max_connections", - SettingMeta: schema.SettingMeta{Value: &value}, - Category: "Connections and Authentication", - Description: "Sets the maximum number of concurrent connections", - } - str := s.String() - assert.Contains(str, "max_connections") - assert.Contains(str, "100") - assert.NotContains(str, "unit") - }) -} - -func Test_SettingList_String(t *testing.T) { - assert := assert.New(t) - - t.Run("EmptyList", func(t *testing.T) { - list := schema.SettingList{ - Count: 0, - Body: []schema.Setting{}, - } - str := list.String() - assert.Contains(str, "count") - assert.Contains(str, "0") - }) - - t.Run("WithSettings", func(t *testing.T) { - value1 := "100" - value2 := "128MB" - list := schema.SettingList{ - Count: 2, - Body: []schema.Setting{ - {Name: "max_connections", SettingMeta: schema.SettingMeta{Value: &value1}, Category: "Connections"}, - {Name: "shared_buffers", SettingMeta: schema.SettingMeta{Value: &value2}, Category: "Memory"}, - }, - } - str := list.String() - assert.Contains(str, "max_connections") - assert.Contains(str, "shared_buffers") - }) -} diff --git a/pkg/manager/schema/statement_test.go b/pkg/manager/schema/statement_test.go deleted file mode 100644 index 41db900..0000000 --- a/pkg/manager/schema/statement_test.go +++ /dev/null @@ -1,266 +0,0 @@ -package schema_test - -import ( - "encoding/json" - "testing" - - // Packages - pg "github.com/mutablelogic/go-pg" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" - assert "github.com/stretchr/testify/assert" -) - -func Test_Statement_String(t *testing.T) { - assert := assert.New(t) - - t.Run("ValidStatement", func(t *testing.T) { - s := schema.Statement{ - Role: "testuser", - Database: "testdb", - QueryID: 12345, - Query: "SELECT * FROM users", - Calls: 100, - Rows: 500, - Total: 1234.56, - Min: 0.5, - Max: 50.0, - Mean: 12.34, - } - str := s.String() - assert.NotEmpty(str) - - // Verify it's valid JSON - var parsed schema.Statement - err := json.Unmarshal([]byte(str), &parsed) - assert.NoError(err) - assert.Equal(s.QueryID, parsed.QueryID) - assert.Equal(s.Query, parsed.Query) - }) - - t.Run("EmptyStatement", func(t *testing.T) { - s := schema.Statement{} - str := s.String() - assert.NotEmpty(str) - - // Verify it's valid JSON - var parsed schema.Statement - err := json.Unmarshal([]byte(str), &parsed) - assert.NoError(err) - }) -} - -func Test_StatementList_String(t *testing.T) { - assert := assert.New(t) - - t.Run("WithStatements", func(t *testing.T) { - l := schema.StatementList{ - Count: 2, - Body: []schema.Statement{ - {QueryID: 1, Query: "SELECT 1"}, - {QueryID: 2, Query: "SELECT 2"}, - }, - } - str := l.String() - assert.NotEmpty(str) - - // Verify it's valid JSON - var parsed schema.StatementList - err := json.Unmarshal([]byte(str), &parsed) - assert.NoError(err) - assert.Equal(uint64(2), parsed.Count) - assert.Len(parsed.Body, 2) - }) - - t.Run("EmptyList", func(t *testing.T) { - l := schema.StatementList{} - str := l.String() - assert.NotEmpty(str) - - // Verify it's valid JSON - var parsed schema.StatementList - err := json.Unmarshal([]byte(str), &parsed) - assert.NoError(err) - }) -} - -func Test_StatementListRequest_Select(t *testing.T) { - assert := assert.New(t) - - t.Run("ListOperation", func(t *testing.T) { - bind := pg.NewBind() - req := schema.StatementListRequest{} - sql, err := req.Select(bind, pg.List) - assert.NoError(err) - assert.NotEmpty(sql) - assert.Equal("ORDER BY database ASC, queryid ASC", bind.Get("orderby")) - assert.Equal("", bind.Get("where")) - }) - - t.Run("UnsupportedOperation", func(t *testing.T) { - bind := pg.NewBind() - req := schema.StatementListRequest{} - _, err := req.Select(bind, pg.Get) - assert.Error(err) - }) - - t.Run("FilterByDatabase", func(t *testing.T) { - bind := pg.NewBind() - db := "testdb" - req := schema.StatementListRequest{Database: &db} - sql, err := req.Select(bind, pg.List) - assert.NoError(err) - assert.NotEmpty(sql) - assert.Equal("testdb", bind.Get("database")) - assert.Contains(bind.Get("where"), "d.datname = @database") - }) - - t.Run("FilterByRole", func(t *testing.T) { - bind := pg.NewBind() - role := "testuser" - req := schema.StatementListRequest{Role: &role} - sql, err := req.Select(bind, pg.List) - assert.NoError(err) - assert.NotEmpty(sql) - assert.Equal("testuser", bind.Get("role")) - assert.Contains(bind.Get("where"), "u.rolname = @role") - }) - - t.Run("FilterByDatabaseAndRole", func(t *testing.T) { - bind := pg.NewBind() - db := "testdb" - role := "testuser" - req := schema.StatementListRequest{Database: &db, Role: &role} - sql, err := req.Select(bind, pg.List) - assert.NoError(err) - assert.NotEmpty(sql) - where := bind.Get("where").(string) - assert.Contains(where, "d.datname = @database") - assert.Contains(where, "u.rolname = @role") - assert.Contains(where, " AND ") - }) - - t.Run("EmptyDatabaseFilter", func(t *testing.T) { - bind := pg.NewBind() - db := "" - req := schema.StatementListRequest{Database: &db} - _, err := req.Select(bind, pg.List) - assert.NoError(err) - assert.Equal("", bind.Get("where")) - }) - - t.Run("EmptyRoleFilter", func(t *testing.T) { - bind := pg.NewBind() - role := "" - req := schema.StatementListRequest{Role: &role} - _, err := req.Select(bind, pg.List) - assert.NoError(err) - assert.Equal("", bind.Get("where")) - }) -} - -func Test_StatementListRequest_Sort(t *testing.T) { - assert := assert.New(t) - - t.Run("SortByCalls", func(t *testing.T) { - bind := pg.NewBind() - req := schema.StatementListRequest{Sort: "calls"} - _, err := req.Select(bind, pg.List) - assert.NoError(err) - orderby := bind.Get("orderby").(string) - assert.Contains(orderby, "calls DESC") - }) - - t.Run("SortByRows", func(t *testing.T) { - bind := pg.NewBind() - req := schema.StatementListRequest{Sort: "rows"} - _, err := req.Select(bind, pg.List) - assert.NoError(err) - orderby := bind.Get("orderby").(string) - assert.Contains(orderby, "rows DESC") - }) - - t.Run("SortByTotalMs", func(t *testing.T) { - bind := pg.NewBind() - req := schema.StatementListRequest{Sort: "total_ms"} - _, err := req.Select(bind, pg.List) - assert.NoError(err) - orderby := bind.Get("orderby").(string) - assert.Contains(orderby, "total_exec_time DESC") - }) - - t.Run("SortByMinMs", func(t *testing.T) { - bind := pg.NewBind() - req := schema.StatementListRequest{Sort: "min_ms"} - _, err := req.Select(bind, pg.List) - assert.NoError(err) - orderby := bind.Get("orderby").(string) - assert.Contains(orderby, "min_exec_time ASC") - }) - - t.Run("SortByMaxMs", func(t *testing.T) { - bind := pg.NewBind() - req := schema.StatementListRequest{Sort: "max_ms"} - _, err := req.Select(bind, pg.List) - assert.NoError(err) - orderby := bind.Get("orderby").(string) - assert.Contains(orderby, "max_exec_time DESC") - }) - - t.Run("SortByMeanMs", func(t *testing.T) { - bind := pg.NewBind() - req := schema.StatementListRequest{Sort: "mean_ms"} - _, err := req.Select(bind, pg.List) - assert.NoError(err) - orderby := bind.Get("orderby").(string) - assert.Contains(orderby, "mean_exec_time DESC") - }) - - t.Run("SortCaseInsensitive", func(t *testing.T) { - bind := pg.NewBind() - req := schema.StatementListRequest{Sort: "CALLS"} - _, err := req.Select(bind, pg.List) - assert.NoError(err) - orderby := bind.Get("orderby").(string) - assert.Contains(orderby, "calls DESC") - }) - - t.Run("InvalidSort", func(t *testing.T) { - bind := pg.NewBind() - req := schema.StatementListRequest{Sort: "invalid"} - _, err := req.Select(bind, pg.List) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("AlwaysOrderByDatabaseAndQueryId", func(t *testing.T) { - bind := pg.NewBind() - req := schema.StatementListRequest{Sort: "calls"} - _, err := req.Select(bind, pg.List) - assert.NoError(err) - orderby := bind.Get("orderby").(string) - assert.Contains(orderby, "ORDER BY database ASC, queryid ASC") - }) -} - -func Test_StatementListRequest_OffsetLimit(t *testing.T) { - assert := assert.New(t) - - t.Run("DefaultLimit", func(t *testing.T) { - bind := pg.NewBind() - req := schema.StatementListRequest{} - sql, err := req.Select(bind, pg.List) - assert.NoError(err) - assert.NotEmpty(sql) - }) - - t.Run("CustomOffsetLimit", func(t *testing.T) { - bind := pg.NewBind() - limit := uint64(50) - req := schema.StatementListRequest{ - OffsetLimit: pg.OffsetLimit{Offset: 10, Limit: &limit}, - } - sql, err := req.Select(bind, pg.List) - assert.NoError(err) - assert.NotEmpty(sql) - }) -} diff --git a/pkg/manager/schema/tablespace_test.go b/pkg/manager/schema/tablespace_test.go deleted file mode 100644 index e61d8d7..0000000 --- a/pkg/manager/schema/tablespace_test.go +++ /dev/null @@ -1,315 +0,0 @@ -package schema_test - -import ( - "encoding/json" - "testing" - - // Packages - pg "github.com/mutablelogic/go-pg" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" - assert "github.com/stretchr/testify/assert" -) - -func Test_Tablespace_String(t *testing.T) { - assert := assert.New(t) - - t.Run("ValidTablespace", func(t *testing.T) { - ts := schema.Tablespace{ - Oid: 12345, - TablespaceMeta: schema.TablespaceMeta{ - Name: "mytablespace", - Owner: "testuser", - }, - Location: "/data/tablespace", - } - str := ts.String() - assert.NotEmpty(str) - assert.Contains(str, "mytablespace") - assert.Contains(str, "testuser") - }) - - t.Run("JSONUnmarshal", func(t *testing.T) { - ts := schema.Tablespace{ - Oid: 12345, - TablespaceMeta: schema.TablespaceMeta{ - Name: "mytablespace", - Owner: "testuser", - }, - Location: "/data/tablespace", - } - data, err := json.Marshal(ts) - assert.NoError(err) - - var ts2 schema.Tablespace - err = json.Unmarshal(data, &ts2) - assert.NoError(err) - assert.Equal(ts.Oid, ts2.Oid) - assert.Equal(ts.Name, ts2.Name) - assert.Equal(ts.Owner, ts2.Owner) - assert.Equal(ts.Location, ts2.Location) - }) -} - -func Test_TablespaceMeta_String(t *testing.T) { - assert := assert.New(t) - - t.Run("ValidMeta", func(t *testing.T) { - meta := schema.TablespaceMeta{ - Name: "mytablespace", - Owner: "testuser", - } - str := meta.String() - assert.NotEmpty(str) - assert.Contains(str, "mytablespace") - assert.Contains(str, "testuser") - }) -} - -func Test_TablespaceListRequest_String(t *testing.T) { - assert := assert.New(t) - - t.Run("EmptyRequest", func(t *testing.T) { - req := schema.TablespaceListRequest{} - str := req.String() - assert.NotEmpty(str) - }) -} - -func Test_TablespaceList_String(t *testing.T) { - assert := assert.New(t) - - t.Run("EmptyList", func(t *testing.T) { - list := schema.TablespaceList{Count: 0} - str := list.String() - assert.NotEmpty(str) - assert.Contains(str, "0") - }) - - t.Run("WithTablespaces", func(t *testing.T) { - list := schema.TablespaceList{ - Count: 1, - Body: []schema.Tablespace{ - { - Oid: 12345, - TablespaceMeta: schema.TablespaceMeta{ - Name: "mytablespace", - }, - }, - }, - } - str := list.String() - assert.NotEmpty(str) - assert.Contains(str, "mytablespace") - }) -} - -func Test_TablespaceListRequest_Select(t *testing.T) { - assert := assert.New(t) - - t.Run("ListOperation", func(t *testing.T) { - bind := pg.NewBind() - req := schema.TablespaceListRequest{} - sql, err := req.Select(bind, pg.List) - assert.NoError(err) - assert.NotEmpty(sql) - assert.Equal("ORDER BY name ASC", bind.Get("orderby")) - }) - - t.Run("UnsupportedOperation", func(t *testing.T) { - bind := pg.NewBind() - req := schema.TablespaceListRequest{} - _, err := req.Select(bind, pg.Get) - assert.Error(err) - }) -} - -func Test_TablespaceName_Select(t *testing.T) { - assert := assert.New(t) - - t.Run("GetOperation", func(t *testing.T) { - bind := pg.NewBind() - ts := schema.TablespaceName("mytablespace") - sql, err := ts.Select(bind, pg.Get) - assert.NoError(err) - assert.NotEmpty(sql) - assert.Equal("mytablespace", bind.Get("name")) - }) - - t.Run("UpdateOperation", func(t *testing.T) { - bind := pg.NewBind() - ts := schema.TablespaceName("mytablespace") - sql, err := ts.Select(bind, pg.Update) - assert.NoError(err) - assert.NotEmpty(sql) - assert.Contains(sql, "RENAME") - }) - - t.Run("DeleteOperation", func(t *testing.T) { - bind := pg.NewBind() - ts := schema.TablespaceName("mytablespace") - sql, err := ts.Select(bind, pg.Delete) - assert.NoError(err) - assert.NotEmpty(sql) - assert.Contains(sql, "DROP") - }) - - t.Run("EmptyName", func(t *testing.T) { - bind := pg.NewBind() - ts := schema.TablespaceName("") - _, err := ts.Select(bind, pg.Get) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("WhitespaceOnlyName", func(t *testing.T) { - bind := pg.NewBind() - ts := schema.TablespaceName(" ") - _, err := ts.Select(bind, pg.Get) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("UnsupportedOperation", func(t *testing.T) { - bind := pg.NewBind() - ts := schema.TablespaceName("mytablespace") - _, err := ts.Select(bind, pg.Insert) - assert.Error(err) - }) -} - -func Test_TablespaceMeta_Select(t *testing.T) { - assert := assert.New(t) - - t.Run("UpdateOperation", func(t *testing.T) { - bind := pg.NewBind() - meta := schema.TablespaceMeta{Name: "mytablespace", Owner: "newowner"} - sql, err := meta.Select(bind, pg.Update) - assert.NoError(err) - assert.NotEmpty(sql) - assert.Equal("mytablespace", bind.Get("name")) - }) - - t.Run("EmptyName", func(t *testing.T) { - bind := pg.NewBind() - meta := schema.TablespaceMeta{Name: "", Owner: "newowner"} - _, err := meta.Select(bind, pg.Update) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("UnsupportedOperation", func(t *testing.T) { - bind := pg.NewBind() - meta := schema.TablespaceMeta{Name: "mytablespace"} - _, err := meta.Select(bind, pg.Get) - assert.Error(err) - }) -} - -func Test_TablespaceMeta_Insert(t *testing.T) { - assert := assert.New(t) - - t.Run("ValidInsert", func(t *testing.T) { - bind := pg.NewBind() - bind.Set("location", "/data/tablespace") - meta := schema.TablespaceMeta{Name: "newtablespace", Owner: "admin"} - sql, err := meta.Insert(bind) - assert.NoError(err) - assert.NotEmpty(sql) - assert.Contains(sql, "CREATE TABLESPACE") - assert.Equal("newtablespace", bind.Get("name")) - }) - - t.Run("InsertWithOwner", func(t *testing.T) { - bind := pg.NewBind() - bind.Set("location", "/data/tablespace") - meta := schema.TablespaceMeta{Name: "newtablespace", Owner: "myowner"} - _, err := meta.Insert(bind) - assert.NoError(err) - with := bind.Get("with").(string) - assert.Contains(with, "OWNER") - }) - - t.Run("EmptyName", func(t *testing.T) { - bind := pg.NewBind() - bind.Set("location", "/data/tablespace") - meta := schema.TablespaceMeta{Name: "", Owner: "admin"} - _, err := meta.Insert(bind) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("ReservedPrefixName", func(t *testing.T) { - bind := pg.NewBind() - bind.Set("location", "/data/tablespace") - meta := schema.TablespaceMeta{Name: "pg_mytablespace", Owner: "admin"} - _, err := meta.Insert(bind) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("MissingLocation", func(t *testing.T) { - bind := pg.NewBind() - meta := schema.TablespaceMeta{Name: "newtablespace", Owner: "admin"} - _, err := meta.Insert(bind) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("EmptyLocation", func(t *testing.T) { - bind := pg.NewBind() - bind.Set("location", "") - meta := schema.TablespaceMeta{Name: "newtablespace", Owner: "admin"} - _, err := meta.Insert(bind) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("RelativeLocation", func(t *testing.T) { - bind := pg.NewBind() - bind.Set("location", "data/tablespace") - meta := schema.TablespaceMeta{Name: "newtablespace", Owner: "admin"} - _, err := meta.Insert(bind) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) -} - -func Test_TablespaceName_Insert(t *testing.T) { - assert := assert.New(t) - - t.Run("NotImplemented", func(t *testing.T) { - bind := pg.NewBind() - ts := schema.TablespaceName("mytablespace") - _, err := ts.Insert(bind) - assert.Error(err) - assert.ErrorIs(err, pg.ErrNotImplemented) - }) -} - -func Test_TablespaceName_Update(t *testing.T) { - assert := assert.New(t) - - t.Run("ValidUpdate", func(t *testing.T) { - bind := pg.NewBind() - ts := schema.TablespaceName("mytablespace") - err := ts.Update(bind) - assert.NoError(err) - assert.Equal("mytablespace", bind.Get("old_name")) - }) - - t.Run("EmptyName", func(t *testing.T) { - bind := pg.NewBind() - ts := schema.TablespaceName("") - err := ts.Update(bind) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("ReservedPrefixName", func(t *testing.T) { - bind := pg.NewBind() - ts := schema.TablespaceName("pg_mytablespace") - err := ts.Update(bind) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) -} diff --git a/pkg/manager/schema_test.go b/pkg/manager/schema_test.go deleted file mode 100644 index b713f76..0000000 --- a/pkg/manager/schema_test.go +++ /dev/null @@ -1,540 +0,0 @@ -package manager_test - -import ( - "context" - "testing" - - // Packages - pg "github.com/mutablelogic/go-pg" - manager "github.com/mutablelogic/go-pg/pkg/manager" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" - assert "github.com/stretchr/testify/assert" -) - -//////////////////////////////////////////////////////////////////////////////// -// LIST SCHEMAS TESTS - -func Test_Manager_ListSchemas(t *testing.T) { - assert := assert.New(t) - conn := conn.Begin(t) - defer conn.Close() - - mgr, err := manager.New(context.TODO(), conn) - if !assert.NoError(err) { - t.FailNow() - } - - t.Run("ListAll", func(t *testing.T) { - schemas, err := mgr.ListSchemas(context.TODO(), schema.SchemaListRequest{}) - assert.NoError(err) - assert.NotNil(schemas) - // Should have at least the public schema in the default database - assert.GreaterOrEqual(schemas.Count, uint64(1)) - }) - - t.Run("ListWithPagination", func(t *testing.T) { - limit := uint64(1) - schemas, err := mgr.ListSchemas(context.TODO(), schema.SchemaListRequest{ - OffsetLimit: pg.OffsetLimit{Limit: &limit}, - }) - assert.NoError(err) - assert.NotNil(schemas) - assert.LessOrEqual(len(schemas.Body), 1) - }) - - t.Run("ListByDatabase", func(t *testing.T) { - dbName := "postgres" - schemas, err := mgr.ListSchemas(context.TODO(), schema.SchemaListRequest{ - Database: &dbName, - }) - assert.NoError(err) - assert.NotNil(schemas) - // All schemas should be from postgres database - for _, s := range schemas.Body { - assert.Equal(dbName, s.Database) - } - }) - - t.Run("ListFromMultipleDatabases", func(t *testing.T) { - dbName := "test_schema_list_multi" - t.Cleanup(func() { - mgr.DeleteDatabase(context.TODO(), dbName, true) - }) - - // Create a test database - _, err := mgr.CreateDatabase(context.TODO(), schema.DatabaseMeta{ - Name: dbName, - }) - if !assert.NoError(err) { - t.FailNow() - } - - // List all schemas (should include schemas from multiple databases) - schemas, err := mgr.ListSchemas(context.TODO(), schema.SchemaListRequest{}) - assert.NoError(err) - assert.NotNil(schemas) - - // Check that we have schemas from at least the new database and postgres - databases := make(map[string]bool) - for _, s := range schemas.Body { - databases[s.Database] = true - } - assert.True(databases[dbName], "should have schemas from test database") - assert.True(databases["postgres"], "should have schemas from postgres database") - }) -} - -//////////////////////////////////////////////////////////////////////////////// -// GET SCHEMA TESTS - -func Test_Manager_GetSchema(t *testing.T) { - assert := assert.New(t) - conn := conn.Begin(t) - defer conn.Close() - - mgr, err := manager.New(context.TODO(), conn) - if !assert.NoError(err) { - t.FailNow() - } - - t.Run("GetPublicSchema", func(t *testing.T) { - // public schema should always exist in postgres database - s, err := mgr.GetSchema(context.TODO(), "postgres", "public") - assert.NoError(err) - assert.NotNil(s) - assert.Equal("public", s.Name) - assert.Equal("postgres", s.Database) - }) - - t.Run("GetNonExistentSchema", func(t *testing.T) { - _, err := mgr.GetSchema(context.TODO(), "postgres", "non_existing_schema_xyz") - assert.Error(err) - assert.ErrorIs(err, pg.ErrNotFound) - }) - - t.Run("GetEmptyDatabase", func(t *testing.T) { - _, err := mgr.GetSchema(context.TODO(), "", "public") - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("GetEmptyNamespace", func(t *testing.T) { - _, err := mgr.GetSchema(context.TODO(), "postgres", "") - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("GetFromDifferentDatabase", func(t *testing.T) { - dbName := "test_get_schema_db" - t.Cleanup(func() { - mgr.DeleteDatabase(context.TODO(), dbName, true) - }) - - // Create a test database - _, err := mgr.CreateDatabase(context.TODO(), schema.DatabaseMeta{ - Name: dbName, - }) - if !assert.NoError(err) { - t.FailNow() - } - - // Get public schema from new database - s, err := mgr.GetSchema(context.TODO(), dbName, "public") - assert.NoError(err) - assert.NotNil(s) - assert.Equal("public", s.Name) - assert.Equal(dbName, s.Database) - }) -} - -//////////////////////////////////////////////////////////////////////////////// -// CREATE SCHEMA TESTS - -func Test_Manager_CreateSchema(t *testing.T) { - assert := assert.New(t) - conn := conn.Begin(t) - defer conn.Close() - - mgr, err := manager.New(context.TODO(), conn) - if !assert.NoError(err) { - t.FailNow() - } - - // Create a test database for schema operations - dbName := "test_create_schema_db" - t.Cleanup(func() { - mgr.DeleteDatabase(context.TODO(), dbName, true) - }) - _, err = mgr.CreateDatabase(context.TODO(), schema.DatabaseMeta{ - Name: dbName, - }) - if !assert.NoError(err) { - t.FailNow() - } - - t.Run("CreateSimple", func(t *testing.T) { - schemaName := "test_schema_simple" - t.Cleanup(func() { - mgr.DeleteSchema(context.TODO(), dbName, schemaName, true) - }) - - s, err := mgr.CreateSchema(context.TODO(), dbName, schema.SchemaMeta{ - Name: schemaName, - Owner: "postgres", - }) - assert.NoError(err) - assert.NotNil(s) - assert.Equal(schemaName, s.Name) - assert.Equal(dbName, s.Database) - }) - - t.Run("CreateWithACL", func(t *testing.T) { - schemaName := "test_schema_acl" - t.Cleanup(func() { - mgr.DeleteSchema(context.TODO(), dbName, schemaName, true) - }) - - s, err := mgr.CreateSchema(context.TODO(), dbName, schema.SchemaMeta{ - Name: schemaName, - Owner: "postgres", - Acl: schema.ACLList{ - {Role: "PUBLIC", Priv: []string{"USAGE"}}, - }, - }) - assert.NoError(err) - assert.NotNil(s) - - publicACL := s.Acl.Find("PUBLIC") - assert.NotNil(publicACL) - }) - - t.Run("CreateEmptyDatabase", func(t *testing.T) { - _, err := mgr.CreateSchema(context.TODO(), "", schema.SchemaMeta{ - Name: "test", - Owner: "postgres", - }) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("CreateEmptyName", func(t *testing.T) { - _, err := mgr.CreateSchema(context.TODO(), dbName, schema.SchemaMeta{ - Name: "", - Owner: "postgres", - }) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("CreateReservedPrefix", func(t *testing.T) { - _, err := mgr.CreateSchema(context.TODO(), dbName, schema.SchemaMeta{ - Name: "pg_reserved_test", - Owner: "postgres", - }) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("CreateDuplicate", func(t *testing.T) { - schemaName := "test_schema_duplicate" - t.Cleanup(func() { - mgr.DeleteSchema(context.TODO(), dbName, schemaName, true) - }) - - _, err := mgr.CreateSchema(context.TODO(), dbName, schema.SchemaMeta{ - Name: schemaName, - Owner: "postgres", - }) - assert.NoError(err) - - // Try to create again - _, err = mgr.CreateSchema(context.TODO(), dbName, schema.SchemaMeta{ - Name: schemaName, - Owner: "postgres", - }) - assert.Error(err) - }) - - t.Run("CreateInDifferentDatabases", func(t *testing.T) { - dbName2 := "test_create_schema_db2" - schemaName := "shared_schema_name" - t.Cleanup(func() { - mgr.DeleteSchema(context.TODO(), dbName, schemaName, true) - mgr.DeleteSchema(context.TODO(), dbName2, schemaName, true) - mgr.DeleteDatabase(context.TODO(), dbName2, true) - }) - - // Create second database - _, err := mgr.CreateDatabase(context.TODO(), schema.DatabaseMeta{ - Name: dbName2, - }) - if !assert.NoError(err) { - t.FailNow() - } - - // Create schema in first database - s1, err := mgr.CreateSchema(context.TODO(), dbName, schema.SchemaMeta{ - Name: schemaName, - Owner: "postgres", - }) - assert.NoError(err) - assert.NotNil(s1) - assert.Equal(dbName, s1.Database) - - // Create schema with same name in second database - s2, err := mgr.CreateSchema(context.TODO(), dbName2, schema.SchemaMeta{ - Name: schemaName, - Owner: "postgres", - }) - assert.NoError(err) - assert.NotNil(s2) - assert.Equal(dbName2, s2.Database) - }) -} - -//////////////////////////////////////////////////////////////////////////////// -// DELETE SCHEMA TESTS - -func Test_Manager_DeleteSchema(t *testing.T) { - assert := assert.New(t) - conn := conn.Begin(t) - defer conn.Close() - - mgr, err := manager.New(context.TODO(), conn) - if !assert.NoError(err) { - t.FailNow() - } - - // Create a test database for schema operations - dbName := "test_delete_schema_db" - t.Cleanup(func() { - mgr.DeleteDatabase(context.TODO(), dbName, true) - }) - _, err = mgr.CreateDatabase(context.TODO(), schema.DatabaseMeta{ - Name: dbName, - }) - if !assert.NoError(err) { - t.FailNow() - } - - t.Run("DeleteExisting", func(t *testing.T) { - schemaName := "test_schema_delete" - - // Create first - _, err := mgr.CreateSchema(context.TODO(), dbName, schema.SchemaMeta{ - Name: schemaName, - Owner: "postgres", - }) - if !assert.NoError(err) { - t.FailNow() - } - - // Delete - s, err := mgr.DeleteSchema(context.TODO(), dbName, schemaName, false) - assert.NoError(err) - assert.NotNil(s) - assert.Equal(schemaName, s.Name) - - // Verify it's gone - _, err = mgr.GetSchema(context.TODO(), dbName, schemaName) - assert.ErrorIs(err, pg.ErrNotFound) - }) - - t.Run("DeleteNonExistent", func(t *testing.T) { - _, err := mgr.DeleteSchema(context.TODO(), dbName, "non_existing_schema_xyz", false) - assert.Error(err) - assert.ErrorIs(err, pg.ErrNotFound) - }) - - t.Run("DeleteEmptyDatabase", func(t *testing.T) { - _, err := mgr.DeleteSchema(context.TODO(), "", "test", false) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("DeleteEmptyNamespace", func(t *testing.T) { - _, err := mgr.DeleteSchema(context.TODO(), dbName, "", false) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("DeleteWithForce", func(t *testing.T) { - schemaName := "test_schema_force" - - // Create first - _, err := mgr.CreateSchema(context.TODO(), dbName, schema.SchemaMeta{ - Name: schemaName, - Owner: "postgres", - }) - if !assert.NoError(err) { - t.FailNow() - } - - // Delete with force (CASCADE) - s, err := mgr.DeleteSchema(context.TODO(), dbName, schemaName, true) - assert.NoError(err) - assert.NotNil(s) - }) -} - -//////////////////////////////////////////////////////////////////////////////// -// UPDATE SCHEMA TESTS - -func Test_Manager_UpdateSchema(t *testing.T) { - assert := assert.New(t) - conn := conn.Begin(t) - defer conn.Close() - - mgr, err := manager.New(context.TODO(), conn) - if !assert.NoError(err) { - t.FailNow() - } - - // Create a test database for schema operations - dbName := "test_update_schema_db" - t.Cleanup(func() { - mgr.DeleteDatabase(context.TODO(), dbName, true) - }) - _, err = mgr.CreateDatabase(context.TODO(), schema.DatabaseMeta{ - Name: dbName, - }) - if !assert.NoError(err) { - t.FailNow() - } - - t.Run("RenameSchema", func(t *testing.T) { - oldName := "test_schema_old" - newName := "test_schema_new" - t.Cleanup(func() { - mgr.DeleteSchema(context.TODO(), dbName, oldName, true) - mgr.DeleteSchema(context.TODO(), dbName, newName, true) - }) - - // Create - _, err := mgr.CreateSchema(context.TODO(), dbName, schema.SchemaMeta{ - Name: oldName, - Owner: "postgres", - }) - if !assert.NoError(err) { - t.FailNow() - } - - // Rename - s, err := mgr.UpdateSchema(context.TODO(), dbName, oldName, schema.SchemaMeta{ - Name: newName, - }) - assert.NoError(err) - assert.NotNil(s) - assert.Equal(newName, s.Name) - - // Old name should not exist - _, err = mgr.GetSchema(context.TODO(), dbName, oldName) - assert.ErrorIs(err, pg.ErrNotFound) - - // New name should exist - _, err = mgr.GetSchema(context.TODO(), dbName, newName) - assert.NoError(err) - }) - - t.Run("UpdateEmptyDatabase", func(t *testing.T) { - _, err := mgr.UpdateSchema(context.TODO(), "", "test", schema.SchemaMeta{ - Name: "newname", - }) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("UpdateEmptyNamespace", func(t *testing.T) { - _, err := mgr.UpdateSchema(context.TODO(), dbName, "", schema.SchemaMeta{ - Name: "newname", - }) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("UpdateNonExistent", func(t *testing.T) { - _, err := mgr.UpdateSchema(context.TODO(), dbName, "non_existing_schema_xyz", schema.SchemaMeta{ - Name: "newname", - }) - assert.Error(err) - }) - - t.Run("UpdateAddACL", func(t *testing.T) { - schemaName := "test_schema_add_acl" - t.Cleanup(func() { - mgr.DeleteSchema(context.TODO(), dbName, schemaName, true) - }) - - // Create without ACL - _, err := mgr.CreateSchema(context.TODO(), dbName, schema.SchemaMeta{ - Name: schemaName, - Owner: "postgres", - }) - if !assert.NoError(err) { - t.FailNow() - } - - // Add ACL - s, err := mgr.UpdateSchema(context.TODO(), dbName, schemaName, schema.SchemaMeta{ - Acl: schema.ACLList{ - {Role: "PUBLIC", Priv: []string{"USAGE"}}, - }, - }) - assert.NoError(err) - assert.NotNil(s) - - publicACL := s.Acl.Find("PUBLIC") - assert.NotNil(publicACL) - }) - - t.Run("UpdateSchemaInDifferentDatabase", func(t *testing.T) { - dbName2 := "test_update_schema_db2" - schemaName := "shared_update_schema" - t.Cleanup(func() { - mgr.DeleteSchema(context.TODO(), dbName, schemaName, true) - mgr.DeleteSchema(context.TODO(), dbName2, schemaName, true) - mgr.DeleteDatabase(context.TODO(), dbName2, true) - }) - - // Create second database - _, err := mgr.CreateDatabase(context.TODO(), schema.DatabaseMeta{ - Name: dbName2, - }) - if !assert.NoError(err) { - t.FailNow() - } - - // Create schemas in both databases - _, err = mgr.CreateSchema(context.TODO(), dbName, schema.SchemaMeta{ - Name: schemaName, - Owner: "postgres", - }) - if !assert.NoError(err) { - t.FailNow() - } - - _, err = mgr.CreateSchema(context.TODO(), dbName2, schema.SchemaMeta{ - Name: schemaName, - Owner: "postgres", - }) - if !assert.NoError(err) { - t.FailNow() - } - - // Update schema in first database only - s1, err := mgr.UpdateSchema(context.TODO(), dbName, schemaName, schema.SchemaMeta{ - Acl: schema.ACLList{ - {Role: "PUBLIC", Priv: []string{"USAGE"}}, - }, - }) - assert.NoError(err) - assert.NotNil(s1.Acl.Find("PUBLIC")) - - // Schema in second database should not have the ACL - s2, err := mgr.GetSchema(context.TODO(), dbName2, schemaName) - assert.NoError(err) - // s2 may or may not have PUBLIC ACL depending on defaults - // The key is that they can be updated independently - assert.NotNil(s2) - }) -} diff --git a/pkg/manager/setting.go b/pkg/manager/setting.go deleted file mode 100644 index bcc7ce1..0000000 --- a/pkg/manager/setting.go +++ /dev/null @@ -1,74 +0,0 @@ -package manager - -import ( - "context" - - // Packages - pg "github.com/mutablelogic/go-pg" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" -) - -/////////////////////////////////////////////////////////////////////////////// -// PUBLIC METHODS - -// ListSettings returns all server settings, optionally filtered by category. -func (manager *Manager) ListSettings(ctx context.Context, req schema.SettingListRequest) (*schema.SettingList, error) { - var list schema.SettingList - if err := manager.conn.List(ctx, &list, req); err != nil { - return nil, err - } - return &list, nil -} - -// ListSettingCategories returns all distinct setting categories. -func (manager *Manager) ListSettingCategories(ctx context.Context) (*schema.SettingCategoryList, error) { - var list schema.SettingCategoryList - if err := manager.conn.List(ctx, &list, schema.SettingCategoryListRequest{}); err != nil { - return nil, err - } - return &list, nil -} - -// GetSetting returns a single setting by name. -func (manager *Manager) GetSetting(ctx context.Context, name string) (*schema.Setting, error) { - var setting schema.Setting - if err := manager.conn.Get(ctx, &setting, schema.SettingName(name)); err != nil { - return nil, err - } - return &setting, nil -} - -// UpdateSetting updates a setting value. If meta.Value is nil, the setting is reset to default. -// Returns the updated setting. Check the Context field to determine if ReloadConfig() or a -// server restart is needed for the change to take effect. -// Returns an error for settings with 'internal' context (cannot be changed) or -// 'postmaster' context (requires server restart, not supported via API). -func (manager *Manager) UpdateSetting(ctx context.Context, name string, meta schema.SettingMeta) (*schema.Setting, error) { - // First get the current setting to check its context - current, err := manager.GetSetting(ctx, name) - if err != nil { - return nil, err - } - - // Reject updates for settings that cannot be changed dynamically - switch current.Context { - case "internal": - return nil, pg.ErrBadParameter.Withf("setting %q cannot be changed (internal)", name) - case "postmaster": - return nil, pg.ErrBadParameter.Withf("setting %q requires server restart (postmaster context)", name) - } - - // Update the setting (ALTER SYSTEM doesn't return rows, so pass nil reader) - if err := manager.conn.Update(ctx, nil, schema.SettingName(name), meta); err != nil { - return nil, err - } - - // Get and return the updated setting - return manager.GetSetting(ctx, name) -} - -// ReloadConfig calls pg_reload_conf() to reload server configuration. -// This applies changes to settings with 'sighup' context without requiring a restart. -func (manager *Manager) ReloadConfig(ctx context.Context) error { - return manager.conn.Exec(ctx, "SELECT pg_reload_conf()") -} diff --git a/pkg/manager/setting_test.go b/pkg/manager/setting_test.go deleted file mode 100644 index 4360d38..0000000 --- a/pkg/manager/setting_test.go +++ /dev/null @@ -1,345 +0,0 @@ -package manager_test - -import ( - "context" - "testing" - - // Packages - pg "github.com/mutablelogic/go-pg" - manager "github.com/mutablelogic/go-pg/pkg/manager" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" - assert "github.com/stretchr/testify/assert" -) - -//////////////////////////////////////////////////////////////////////////////// -// LIST SETTINGS TESTS - -func Test_Manager_ListSettings(t *testing.T) { - assert := assert.New(t) - conn := conn.Begin(t) - defer conn.Close() - - mgr, err := manager.New(context.TODO(), conn) - if !assert.NoError(err) { - t.FailNow() - } - - t.Run("ListAll", func(t *testing.T) { - settings, err := mgr.ListSettings(context.TODO(), schema.SettingListRequest{}) - assert.NoError(err) - assert.NotNil(settings) - // PostgreSQL has many settings (typically 300+) - assert.GreaterOrEqual(settings.Count, uint64(100)) - assert.NotEmpty(settings.Body) - }) - - t.Run("ListWithPagination", func(t *testing.T) { - limit := uint64(10) - settings, err := mgr.ListSettings(context.TODO(), schema.SettingListRequest{ - OffsetLimit: pg.OffsetLimit{Limit: &limit}, - }) - assert.NoError(err) - assert.NotNil(settings) - assert.LessOrEqual(len(settings.Body), 10) - }) - - t.Run("ListWithOffset", func(t *testing.T) { - // First get all settings - allSettings, err := mgr.ListSettings(context.TODO(), schema.SettingListRequest{}) - assert.NoError(err) - - // Get with offset - limit := uint64(500) - settings, err := mgr.ListSettings(context.TODO(), schema.SettingListRequest{ - OffsetLimit: pg.OffsetLimit{Offset: 10, Limit: &limit}, - }) - assert.NoError(err) - assert.NotNil(settings) - assert.Less(len(settings.Body), int(allSettings.Count)) - }) - - t.Run("ListByCategory", func(t *testing.T) { - // First list all to find a valid category - allSettings, err := mgr.ListSettings(context.TODO(), schema.SettingListRequest{}) - assert.NoError(err) - assert.NotEmpty(allSettings.Body) - - // Use the category from the first setting - category := allSettings.Body[0].Category - settings, err := mgr.ListSettings(context.TODO(), schema.SettingListRequest{ - Category: &category, - }) - assert.NoError(err) - assert.NotNil(settings) - assert.NotEmpty(settings.Body) - // All returned settings should be in the specified category - for _, s := range settings.Body { - assert.Equal(category, s.Category) - } - }) - - t.Run("ListByCategoryNotFound", func(t *testing.T) { - category := "NonExistent Category XYZ" - settings, err := mgr.ListSettings(context.TODO(), schema.SettingListRequest{ - Category: &category, - }) - assert.NoError(err) - assert.NotNil(settings) - assert.Empty(settings.Body) - assert.Equal(uint64(0), settings.Count) - }) - - t.Run("SettingHasRequiredFields", func(t *testing.T) { - limit := uint64(5) - settings, err := mgr.ListSettings(context.TODO(), schema.SettingListRequest{ - OffsetLimit: pg.OffsetLimit{Limit: &limit}, - }) - assert.NoError(err) - assert.NotEmpty(settings.Body) - - for _, s := range settings.Body { - assert.NotEmpty(s.Name, "Setting should have a name") - assert.NotEmpty(s.Category, "Setting should have a category") - assert.NotEmpty(s.Context, "Setting should have a context") - } - }) - - t.Run("SettingContextValues", func(t *testing.T) { - settings, err := mgr.ListSettings(context.TODO(), schema.SettingListRequest{}) - assert.NoError(err) - assert.NotEmpty(settings.Body) - - validContexts := map[string]bool{ - "internal": true, - "postmaster": true, - "sighup": true, - "superuser": true, - "user": true, - "backend": true, - "superuser-backend": true, - } - - for _, s := range settings.Body { - assert.True(validContexts[s.Context], "Setting %s has invalid context: %s", s.Name, s.Context) - } - }) - - t.Run("KnownSettingsExist", func(t *testing.T) { - settings, err := mgr.ListSettings(context.TODO(), schema.SettingListRequest{}) - assert.NoError(err) - - // Build a map for easy lookup - settingMap := make(map[string]schema.Setting) - for _, s := range settings.Body { - settingMap[s.Name] = s - } - - // Check some well-known settings exist - knownSettings := []string{"max_connections", "shared_buffers", "work_mem", "server_version"} - for _, name := range knownSettings { - _, exists := settingMap[name] - assert.True(exists, "Expected setting %s to exist", name) - } - }) -} - -//////////////////////////////////////////////////////////////////////////////// -// LIST SETTING CATEGORIES TESTS - -func Test_Manager_ListSettingCategories(t *testing.T) { - assert := assert.New(t) - conn := conn.Begin(t) - defer conn.Close() - - mgr, err := manager.New(context.TODO(), conn) - if !assert.NoError(err) { - t.FailNow() - } - - t.Run("ListAll", func(t *testing.T) { - categories, err := mgr.ListSettingCategories(context.TODO()) - assert.NoError(err) - assert.NotNil(categories) - // PostgreSQL has multiple setting categories - assert.GreaterOrEqual(categories.Count, uint64(10)) - assert.NotEmpty(categories.Body) - }) - - t.Run("CategoriesAreSorted", func(t *testing.T) { - categories, err := mgr.ListSettingCategories(context.TODO()) - assert.NoError(err) - assert.NotEmpty(categories.Body) - - // Check categories are sorted alphabetically - for i := 1; i < len(categories.Body); i++ { - assert.LessOrEqual(categories.Body[i-1], categories.Body[i], "Categories should be sorted") - } - }) - - t.Run("CategoriesAreUnique", func(t *testing.T) { - categories, err := mgr.ListSettingCategories(context.TODO()) - assert.NoError(err) - - // Check all categories are unique - seen := make(map[string]bool) - for _, cat := range categories.Body { - assert.False(seen[cat], "Category %s should be unique", cat) - seen[cat] = true - } - }) - - t.Run("KnownCategoriesExist", func(t *testing.T) { - categories, err := mgr.ListSettingCategories(context.TODO()) - assert.NoError(err) - assert.NotEmpty(categories.Body) - - // Verify first category can be used to filter settings - firstCategory := categories.Body[0] - settings, err := mgr.ListSettings(context.TODO(), schema.SettingListRequest{ - Category: &firstCategory, - }) - assert.NoError(err) - assert.NotEmpty(settings.Body) - // All settings should match the category - for _, s := range settings.Body { - assert.Equal(firstCategory, s.Category) - } - }) - - t.Run("CountMatchesBody", func(t *testing.T) { - categories, err := mgr.ListSettingCategories(context.TODO()) - assert.NoError(err) - assert.Equal(int(categories.Count), len(categories.Body)) - }) -} - -//////////////////////////////////////////////////////////////////////////////// -// GET SETTING TESTS - -func Test_Manager_GetSetting(t *testing.T) { - assert := assert.New(t) - conn := conn.Begin(t) - defer conn.Close() - - mgr, err := manager.New(context.TODO(), conn) - if !assert.NoError(err) { - t.FailNow() - } - - t.Run("GetExisting", func(t *testing.T) { - setting, err := mgr.GetSetting(context.TODO(), "max_connections") - assert.NoError(err) - assert.NotNil(setting) - assert.Equal("max_connections", setting.Name) - assert.NotNil(setting.Value) - assert.NotEmpty(setting.Category) - assert.NotEmpty(setting.Context) - }) - - t.Run("GetNotFound", func(t *testing.T) { - setting, err := mgr.GetSetting(context.TODO(), "nonexistent_setting_xyz") - assert.Error(err) - assert.Nil(setting) - }) - - t.Run("GetEmptyName", func(t *testing.T) { - setting, err := mgr.GetSetting(context.TODO(), "") - assert.Error(err) - assert.Nil(setting) - }) - - t.Run("GetSettingWithUnit", func(t *testing.T) { - // shared_buffers typically has a unit (kB, MB, etc.) - setting, err := mgr.GetSetting(context.TODO(), "shared_buffers") - assert.NoError(err) - assert.NotNil(setting) - assert.Equal("shared_buffers", setting.Name) - // shared_buffers should have a unit like "8kB" - assert.NotNil(setting.Unit) - }) -} - -//////////////////////////////////////////////////////////////////////////////// -// UPDATE SETTING TESTS - -func Test_Manager_UpdateSetting(t *testing.T) { - assert := assert.New(t) - conn := conn.Begin(t) - defer conn.Close() - - mgr, err := manager.New(context.TODO(), conn) - if !assert.NoError(err) { - t.FailNow() - } - - t.Run("UpdateSuperuserSetting", func(t *testing.T) { - // log_min_duration_statement has superuser context - newValue := "500" - setting, err := mgr.UpdateSetting(context.TODO(), "log_min_duration_statement", schema.SettingMeta{ - Value: &newValue, - }) - assert.NoError(err) - assert.NotNil(setting) - assert.Equal("log_min_duration_statement", setting.Name) - }) - - t.Run("ResetSetting", func(t *testing.T) { - // Reset by passing nil value - setting, err := mgr.UpdateSetting(context.TODO(), "log_min_duration_statement", schema.SettingMeta{ - Value: nil, - }) - assert.NoError(err) - assert.NotNil(setting) - assert.Equal("log_min_duration_statement", setting.Name) - }) - - t.Run("UpdateNotFound", func(t *testing.T) { - newValue := "100" - setting, err := mgr.UpdateSetting(context.TODO(), "nonexistent_setting_xyz", schema.SettingMeta{ - Value: &newValue, - }) - assert.Error(err) - assert.Nil(setting) - }) - - t.Run("RejectInternalContext", func(t *testing.T) { - // block_size is an internal setting (cannot be changed) - newValue := "16384" - setting, err := mgr.UpdateSetting(context.TODO(), "block_size", schema.SettingMeta{ - Value: &newValue, - }) - assert.Error(err) - assert.Nil(setting) - assert.Contains(err.Error(), "internal") - }) - - t.Run("RejectPostmasterContext", func(t *testing.T) { - // max_connections is a postmaster setting (requires restart) - newValue := "200" - setting, err := mgr.UpdateSetting(context.TODO(), "max_connections", schema.SettingMeta{ - Value: &newValue, - }) - assert.Error(err) - assert.Nil(setting) - assert.Contains(err.Error(), "postmaster") - }) -} - -//////////////////////////////////////////////////////////////////////////////// -// RELOAD CONFIG TESTS - -func Test_Manager_ReloadConfig(t *testing.T) { - assert := assert.New(t) - conn := conn.Begin(t) - defer conn.Close() - - mgr, err := manager.New(context.TODO(), conn) - if !assert.NoError(err) { - t.FailNow() - } - - t.Run("ReloadSuccess", func(t *testing.T) { - err := mgr.ReloadConfig(context.TODO()) - assert.NoError(err) - }) -} diff --git a/pkg/manager/statement_test.go b/pkg/manager/statement_test.go deleted file mode 100644 index 9d24a92..0000000 --- a/pkg/manager/statement_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package manager_test - -import ( - "context" - "testing" - - // Packages - manager "github.com/mutablelogic/go-pg/pkg/manager" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" - assert "github.com/stretchr/testify/assert" -) - -//////////////////////////////////////////////////////////////////////////////// -// STAT STATEMENTS TESTS - -func Test_Manager_StatStatementsAvailable(t *testing.T) { - assert := assert.New(t) - conn := conn.Begin(t) - defer conn.Close() - - mgr, err := manager.New(context.TODO(), conn) - if !assert.NoError(err) { - t.FailNow() - } - - // Check that pg_stat_statements is available - assert.True(mgr.StatStatementsAvailable(), "pg_stat_statements should be available") -} - -func Test_Manager_ListStatements(t *testing.T) { - assert := assert.New(t) - conn := conn.Begin(t) - defer conn.Close() - - mgr, err := manager.New(context.TODO(), conn) - if !assert.NoError(err) { - t.FailNow() - } - - t.Run("ListAll", func(t *testing.T) { - list, err := mgr.ListStatements(context.TODO(), schema.StatementListRequest{}) - assert.NoError(err) - assert.NotNil(list) - t.Logf("Found %d statements", list.Count) - }) - - t.Run("SortByCalls", func(t *testing.T) { - list, err := mgr.ListStatements(context.TODO(), schema.StatementListRequest{ - Sort: "calls", - }) - assert.NoError(err) - assert.NotNil(list) - t.Logf("Found %d statements sorted by calls", list.Count) - }) - - t.Run("SortByTotalTime", func(t *testing.T) { - list, err := mgr.ListStatements(context.TODO(), schema.StatementListRequest{ - Sort: "total_ms", - }) - assert.NoError(err) - assert.NotNil(list) - }) -} - -func Test_Manager_ResetStatements(t *testing.T) { - assert := assert.New(t) - conn := conn.Begin(t) - defer conn.Close() - - mgr, err := manager.New(context.TODO(), conn) - if !assert.NoError(err) { - t.FailNow() - } - - // Reset statements - err = mgr.ResetStatements(context.TODO()) - assert.NoError(err) - - // List again - should be empty or near empty - list, err := mgr.ListStatements(context.TODO(), schema.StatementListRequest{}) - assert.NoError(err) - assert.NotNil(list) - t.Logf("After reset: %d statements", list.Count) -} diff --git a/pkg/manager/tablespace_test.go b/pkg/manager/tablespace_test.go deleted file mode 100644 index 415e471..0000000 --- a/pkg/manager/tablespace_test.go +++ /dev/null @@ -1,246 +0,0 @@ -package manager_test - -import ( - "context" - "testing" - - // Packages - pg "github.com/mutablelogic/go-pg" - manager "github.com/mutablelogic/go-pg/pkg/manager" - schema "github.com/mutablelogic/go-pg/pkg/manager/schema" - assert "github.com/stretchr/testify/assert" -) - -//////////////////////////////////////////////////////////////////////////////// -// LIST TABLESPACES TESTS - -func Test_Manager_ListTablespaces(t *testing.T) { - assert := assert.New(t) - conn := conn.Begin(t) - defer conn.Close() - - mgr, err := manager.New(context.TODO(), conn) - if !assert.NoError(err) { - t.FailNow() - } - - t.Run("ListAll", func(t *testing.T) { - tablespaces, err := mgr.ListTablespaces(context.TODO(), schema.TablespaceListRequest{}) - assert.NoError(err) - assert.NotNil(tablespaces) - assert.Equal(len(tablespaces.Body), int(tablespaces.Count)) - // Should have at least the default pg_default and pg_global tablespaces - assert.GreaterOrEqual(tablespaces.Count, uint64(2)) - }) - - t.Run("ListWithPagination", func(t *testing.T) { - limit := uint64(1) - tablespaces, err := mgr.ListTablespaces(context.TODO(), schema.TablespaceListRequest{ - OffsetLimit: pg.OffsetLimit{Limit: &limit}, - }) - assert.NoError(err) - assert.NotNil(tablespaces) - assert.LessOrEqual(len(tablespaces.Body), 1) - }) - - t.Run("ListWithOffset", func(t *testing.T) { - // First get all tablespaces - allTablespaces, err := mgr.ListTablespaces(context.TODO(), schema.TablespaceListRequest{}) - assert.NoError(err) - - if allTablespaces.Count < 2 { - t.Skip("Not enough tablespaces to test offset") - } - - // Get with offset - limit := uint64(10) - tablespaces, err := mgr.ListTablespaces(context.TODO(), schema.TablespaceListRequest{ - OffsetLimit: pg.OffsetLimit{Offset: 1, Limit: &limit}, - }) - assert.NoError(err) - assert.NotNil(tablespaces) - assert.Less(len(tablespaces.Body), int(allTablespaces.Count)) - }) -} - -//////////////////////////////////////////////////////////////////////////////// -// GET TABLESPACE TESTS - -func Test_Manager_GetTablespace(t *testing.T) { - assert := assert.New(t) - conn := conn.Begin(t) - defer conn.Close() - - mgr, err := manager.New(context.TODO(), conn) - if !assert.NoError(err) { - t.FailNow() - } - - t.Run("GetExisting", func(t *testing.T) { - // pg_default tablespace should always exist - tablespace, err := mgr.GetTablespace(context.TODO(), "pg_default") - assert.NoError(err) - assert.NotNil(tablespace) - assert.Equal("pg_default", tablespace.Name) - }) - - t.Run("GetPgGlobal", func(t *testing.T) { - // pg_global tablespace should always exist - tablespace, err := mgr.GetTablespace(context.TODO(), "pg_global") - assert.NoError(err) - assert.NotNil(tablespace) - assert.Equal("pg_global", tablespace.Name) - }) - - t.Run("GetNonExistent", func(t *testing.T) { - _, err := mgr.GetTablespace(context.TODO(), "non_existing_tablespace_xyz") - assert.Error(err) - assert.ErrorIs(err, pg.ErrNotFound) - }) - - t.Run("GetEmptyName", func(t *testing.T) { - _, err := mgr.GetTablespace(context.TODO(), "") - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) -} - -//////////////////////////////////////////////////////////////////////////////// -// CREATE TABLESPACE TESTS -// -// Note: Creating tablespaces requires a filesystem location that the postgres -// user can write to. In a containerized test environment, this may not be -// available, so these tests verify error handling for invalid inputs. - -func Test_Manager_CreateTablespace(t *testing.T) { - assert := assert.New(t) - conn := conn.Begin(t) - defer conn.Close() - - mgr, err := manager.New(context.TODO(), conn) - if !assert.NoError(err) { - t.FailNow() - } - - t.Run("CreateEmptyName", func(t *testing.T) { - _, err := mgr.CreateTablespace(context.TODO(), schema.TablespaceMeta{ - Name: "", - }, "/tmp/test_tablespace") - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("CreateReservedPrefix", func(t *testing.T) { - _, err := mgr.CreateTablespace(context.TODO(), schema.TablespaceMeta{ - Name: "pg_reserved_test", - }, "/tmp/test_tablespace") - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("CreateEmptyLocation", func(t *testing.T) { - _, err := mgr.CreateTablespace(context.TODO(), schema.TablespaceMeta{ - Name: "test_tablespace", - }, "") - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("CreateRelativeLocation", func(t *testing.T) { - _, err := mgr.CreateTablespace(context.TODO(), schema.TablespaceMeta{ - Name: "test_tablespace", - }, "relative/path") - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) -} - -//////////////////////////////////////////////////////////////////////////////// -// DELETE TABLESPACE TESTS - -func Test_Manager_DeleteTablespace(t *testing.T) { - assert := assert.New(t) - conn := conn.Begin(t) - defer conn.Close() - - mgr, err := manager.New(context.TODO(), conn) - if !assert.NoError(err) { - t.FailNow() - } - - t.Run("DeleteNonExistent", func(t *testing.T) { - _, err := mgr.DeleteTablespace(context.TODO(), "non_existing_ts_xyz") - assert.Error(err) - assert.ErrorIs(err, pg.ErrNotFound) - }) - - t.Run("DeleteEmptyName", func(t *testing.T) { - _, err := mgr.DeleteTablespace(context.TODO(), "") - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("DeleteSystemTablespace", func(t *testing.T) { - // Attempting to delete pg_default should fail - _, err := mgr.DeleteTablespace(context.TODO(), "pg_default") - assert.Error(err) - }) -} - -//////////////////////////////////////////////////////////////////////////////// -// UPDATE TABLESPACE TESTS - -func Test_Manager_UpdateTablespace(t *testing.T) { - assert := assert.New(t) - conn := conn.Begin(t) - defer conn.Close() - - mgr, err := manager.New(context.TODO(), conn) - if !assert.NoError(err) { - t.FailNow() - } - - t.Run("UpdateEmptyName", func(t *testing.T) { - _, err := mgr.UpdateTablespace(context.TODO(), "", schema.TablespaceMeta{ - Name: "newname", - }) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) - - t.Run("UpdateNonExistent", func(t *testing.T) { - _, err := mgr.UpdateTablespace(context.TODO(), "non_existing_ts_xyz", schema.TablespaceMeta{ - Name: "newname", - }) - assert.Error(err) - assert.ErrorIs(err, pg.ErrNotFound) - }) - - t.Run("UpdateToReservedPrefix", func(t *testing.T) { - // Get an existing tablespace first - tablespaces, err := mgr.ListTablespaces(context.TODO(), schema.TablespaceListRequest{}) - if !assert.NoError(err) { - t.FailNow() - } - - // Find a non-system tablespace to test with - var testTs *schema.Tablespace - for _, ts := range tablespaces.Body { - if ts.Name != "pg_default" && ts.Name != "pg_global" { - testTs = &ts - break - } - } - - if testTs == nil { - t.Skip("No non-system tablespace available for testing") - } - - // Try to rename to reserved prefix - _, err = mgr.UpdateTablespace(context.TODO(), testTs.Name, schema.TablespaceMeta{ - Name: "pg_reserved", - }) - assert.Error(err) - assert.ErrorIs(err, pg.ErrBadParameter) - }) -} diff --git a/pkg/test/main.go b/pkg/test/main.go index 1356ed1..29af11e 100644 --- a/pkg/test/main.go +++ b/pkg/test/main.go @@ -11,7 +11,6 @@ import ( // Packages pg "github.com/mutablelogic/go-pg" - manager "github.com/mutablelogic/go-pg/pkg/manager" types "github.com/mutablelogic/go-server/pkg/types" ) @@ -102,63 +101,63 @@ func (c *Conn) Close() { } } -// ManagerConn wraps a Manager with its underlying connection for testing -type ManagerConn struct { - *manager.Manager - pool pg.PoolConn - container *Container -} - -// NewManager creates a new Manager with a test container for integration testing. -// The returned ManagerConn must be closed after use. -func NewManager(t *testing.T) *ManagerConn { - t.Helper() - t.Log("Begin", t.Name()) - - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - // Start the container - name, err := os.Executable() - if err != nil { - t.Fatal(err) - } - verbose := slices.Contains(os.Args, "-test.v=true") - container, pool, err := NewPgxContainer(ctx, filepath.Base(name), verbose, func(ctx context.Context, sql string, args any, err error) { - if err != nil { - log.Printf("ERROR: %v", err) - } - if verbose || err != nil { - if args == nil { - log.Printf("SQL: %v", sql) - } else { - log.Printf("SQL: %v, ARGS: %v", sql, args) - } - } - }) - if err != nil { - t.Fatal(err) - } - - // Create the manager - mgr, err := manager.New(ctx, pool) - if err != nil { - pool.Close() - container.Close(ctx) - t.Fatal(err) - } - - return &ManagerConn{ - Manager: mgr, - pool: pool, - container: container, - } -} - -// Close closes the manager connection and container -func (m *ManagerConn) Close() { - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - m.pool.Close() - m.container.Close(ctx) -} +// // ManagerConn wraps a Manager with its underlying connection for testing +// type ManagerConn struct { +// *manager.Manager +// pool pg.PoolConn +// container *Container +// } + +// // NewManager creates a new Manager with a test container for integration testing. +// // The returned ManagerConn must be closed after use. +// func NewManager(t *testing.T) *ManagerConn { +// t.Helper() +// t.Log("Begin", t.Name()) + +// ctx, cancel := context.WithTimeout(context.Background(), timeout) +// defer cancel() + +// // Start the container +// name, err := os.Executable() +// if err != nil { +// t.Fatal(err) +// } +// verbose := slices.Contains(os.Args, "-test.v=true") +// container, pool, err := NewPgxContainer(ctx, filepath.Base(name), verbose, func(ctx context.Context, sql string, args any, err error) { +// if err != nil { +// log.Printf("ERROR: %v", err) +// } +// if verbose || err != nil { +// if args == nil { +// log.Printf("SQL: %v", sql) +// } else { +// log.Printf("SQL: %v, ARGS: %v", sql, args) +// } +// } +// }) +// if err != nil { +// t.Fatal(err) +// } + +// // Create the manager +// mgr, err := manager.New(ctx, pool) +// if err != nil { +// pool.Close() +// container.Close(ctx) +// t.Fatal(err) +// } + +// return &ManagerConn{ +// Manager: mgr, +// pool: pool, +// container: container, +// } +// } + +// // Close closes the manager connection and container +// func (m *ManagerConn) Close() { +// ctx, cancel := context.WithTimeout(context.Background(), timeout) +// defer cancel() +// m.pool.Close() +// m.container.Close(ctx) +// } diff --git a/pkg/version/doc.go b/pkg/version/doc.go deleted file mode 100644 index 5361fcf..0000000 --- a/pkg/version/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package version provides build version information. -package version diff --git a/pkg/version/version.go b/pkg/version/version.go deleted file mode 100644 index 779e7b3..0000000 --- a/pkg/version/version.go +++ /dev/null @@ -1,46 +0,0 @@ -package version - -import ( - "os" - "path/filepath" - "runtime" -) - -/////////////////////////////////////////////////////////////////////////////// -// GLOBALS - -var ( - GitSource string - GitTag string - GitBranch string - GitHash string - GoBuildTime string -) - -//////////////////////////////////////////////////////////////////////////////// -// PUBLIC METHODS - -func ExecName() string { - name, err := os.Executable() - if err != nil { - return "unknown" - } - return filepath.Base(name) -} - -func Version() string { - if GitTag != "" { - return GitTag - } - if GitBranch != "" { - return GitBranch - } - if GitHash != "" { - return GitHash - } - return "dev" -} - -func Compiler() string { - return runtime.Compiler + "/" + runtime.Version() -}