From 8023af90fd906f1af192ef738078607ad4ebff4a Mon Sep 17 00:00:00 2001 From: Joel Wetzell Date: Sat, 5 Sep 2026 08:22:38 -0500 Subject: [PATCH] add support for connecting to postgres database --- go.mod | 6 +- go.sum | 18 +++- internal/module/db-postgres.go | 103 ++++++++++++++++++ internal/module/test/db-postgres_test.go | 126 +++++++++++++++++++++++ 4 files changed, 249 insertions(+), 4 deletions(-) create mode 100644 internal/module/db-postgres.go create mode 100644 internal/module/test/db-postgres_test.go diff --git a/go.mod b/go.mod index 4843376..8dd516c 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/extism/go-sdk v1.7.1 github.com/google/jsonschema-go v0.4.3 github.com/gorilla/websocket v1.5.3 + github.com/jackc/pgx/v5 v5.10.0 github.com/jwetzell/artnet-go v0.3.0 github.com/jwetzell/free-d-go v0.2.0 github.com/jwetzell/osc-go v0.3.1 @@ -33,6 +34,9 @@ require ( github.com/google/go-tpm v0.9.8 // indirect github.com/google/uuid v1.6.0 // indirect github.com/ianlancetaylor/demangle v0.0.0-20260505044615-1ff4bf46051f // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/klauspost/compress v1.19.2 // indirect github.com/mattn/go-isatty v0.0.24 // indirect github.com/minio/highwayhash v1.0.4 // indirect @@ -52,8 +56,8 @@ require ( golang.org/x/net v0.57.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect google.golang.org/protobuf v1.36.11 // indirect - gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect modernc.org/libc v1.75.6 // indirect modernc.org/libquickjs v0.13.1 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index 0f35827..0d92398 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,7 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= 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/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -36,6 +37,14 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/ianlancetaylor/demangle v0.0.0-20260505044615-1ff4bf46051f h1:NW3E2QSchEk63/fjeEvWOa2cE02FSv9ox//VE/N4c8g= github.com/ianlancetaylor/demangle v0.0.0-20260505044615-1ff4bf46051f/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +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.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/jwetzell/artnet-go v0.3.0 h1:MDHElOSbpgmIbYGHO9SoxsT7ynrYtV5/UHGT82geHk0= github.com/jwetzell/artnet-go v0.3.0/go.mod h1:zqRsSsrd6rPwJacwscPteDqGIqevjZ3Z6dpp092VJ1w= github.com/jwetzell/free-d-go v0.2.0 h1:8WQW4du8Sf3d18aUzk9n5jZtHE6WdDQHDFjkFffvycc= @@ -48,11 +57,8 @@ github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= @@ -79,6 +85,9 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 h1:ZF+QBjOI+tILZjBaFj3HgFonKXUcwgJ4djLb6i42S3Q= @@ -112,6 +121,8 @@ golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= @@ -121,6 +132,7 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8= diff --git a/internal/module/db-postgres.go b/internal/module/db-postgres.go new file mode 100644 index 0000000..342d11b --- /dev/null +++ b/internal/module/db-postgres.go @@ -0,0 +1,103 @@ +package module + +import ( + "context" + "database/sql" + "fmt" + "log/slog" + "sync" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/jwetzell/showbridge-go/internal/common" + "github.com/jwetzell/showbridge-go/internal/config" + + _ "github.com/jackc/pgx/v5/stdlib" +) + +func init() { + RegisterModule(ModuleRegistration{ + Type: "db.postgres", + Title: "PostgreSQL Database", + ParamsSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "url": { + Title: "Database URL", + Description: "the connection URL for the PostgreSQL database", + Type: "string", + MinLength: new(1), + }, + }, + Required: []string{"url"}, + AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, + }, + New: func(config config.ModuleConfig) (common.Module, error) { + params := config.Params + + urlString, err := params.GetString("url") + if err != nil { + return nil, fmt.Errorf("db.postgres url error: %w", err) + } + + return &DbPostgres{Url: urlString, config: config, logger: CreateLogger(config)}, nil + }, + }) +} + +type DbPostgres struct { + config config.ModuleConfig + Url string + ctx context.Context + inputHandler common.InputHandler + db *sql.DB + logger *slog.Logger + dbMu sync.Mutex + cancel context.CancelFunc +} + +func (dbs *DbPostgres) Id() string { + return dbs.config.Id +} + +func (dbs *DbPostgres) Type() string { + return dbs.config.Type +} + +func (dbs *DbPostgres) Start(ctx context.Context, inputHandler common.InputHandler) error { + dbs.logger.Debug("running") + dbs.inputHandler = inputHandler + moduleContext, cancel := context.WithCancel(ctx) + dbs.ctx = moduleContext + dbs.cancel = cancel + + db, err := sql.Open("pgx", dbs.Url) + if err != nil { + return fmt.Errorf("db.postgres error connecting to database: %w", err) + } + dbs.dbMu.Lock() + dbs.db = db + dbs.dbMu.Unlock() + <-dbs.ctx.Done() + dbs.logger.Debug("done") + return nil +} + +func (dbs *DbPostgres) Stop() { + if dbs.cancel != nil { + defer dbs.cancel() + } + dbs.dbMu.Lock() + defer dbs.dbMu.Unlock() + if dbs.db != nil { + dbs.db.Close() + } +} + +func (dbs *DbPostgres) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) { + dbs.dbMu.Lock() + defer dbs.dbMu.Unlock() + if dbs.db == nil { + return nil, fmt.Errorf("database not initialized") + } + return dbs.db.QueryContext(ctx, query, args...) +} diff --git a/internal/module/test/db-postgres_test.go b/internal/module/test/db-postgres_test.go new file mode 100644 index 0000000..22cf9f2 --- /dev/null +++ b/internal/module/test/db-postgres_test.go @@ -0,0 +1,126 @@ +package module_test + +import ( + "testing" + "time" + + "github.com/jwetzell/showbridge-go/internal/config" + "github.com/jwetzell/showbridge-go/internal/module" +) + +func TestDbPostgresFromRegistry(t *testing.T) { + registration, ok := module.GetModuleRegistration("db.postgres") + if !ok { + t.Fatalf("db.postgres module not registered") + } + + moduleInstance, err := registration.New(config.ModuleConfig{ + Id: "test", + Type: "db.postgres", + Params: map[string]any{ + "url": "postgres://localhost:5432", + }, + }) + + if err != nil { + t.Fatalf("failed to create db.postgres module: %s", err) + } + + if moduleInstance.Id() != "test" { + t.Fatalf("db.postgres module has wrong id: %s", moduleInstance.Id()) + } + + if moduleInstance.Type() != "db.postgres" { + t.Fatalf("db.postgres module has wrong type: %s", moduleInstance.Type()) + } +} + +func TestGoodDbPostgres(t *testing.T) { + + testCases := []struct { + name string + params map[string]any + }{} + + for _, test := range testCases { + t.Run(test.name, func(t *testing.T) { + + registration, ok := module.GetModuleRegistration("db.postgres") + if !ok { + t.Fatalf("db.postgres module not registered") + } + + moduleInstance, err := registration.New(config.ModuleConfig{ + Id: "test", + Type: "db.postgres", + Params: test.params, + }) + + if err != nil { + t.Fatalf("db.postgres failed to create module: %s", err) + } + // TODO(jwetzell) this is kind of hacky + go func() { + time.Sleep(1 * time.Second) + moduleInstance.Stop() + }() + err = moduleInstance.Start(t.Context(), nil) + + if err != nil { + t.Fatalf("db.postgres failed to start: %s", err) + } + }) + } +} + +func TestBadDbPostgres(t *testing.T) { + tests := []struct { + name string + params map[string]any + errorString string + }{ + { + name: "no url param", + params: map[string]any{}, + errorString: "db.postgres url error: not found", + }, + { + name: "non-string url", + params: map[string]any{"url": 123}, + errorString: "db.postgres url error: not a string", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + + registration, ok := module.GetModuleRegistration("db.postgres") + if !ok { + t.Fatalf("db.postgres module not registered") + } + + moduleInstance, err := registration.New(config.ModuleConfig{ + Id: "test", + Type: "db.postgres", + Params: test.params, + }) + + if err != nil { + if test.errorString != err.Error() { + t.Fatalf("db.postgres got error '%s', expected '%s'", err.Error(), test.errorString) + } + return + } + + err = moduleInstance.Start(t.Context(), nil) + + if err == nil { + t.Fatalf("db.postgres expected to fail") + } + + if err.Error() != test.errorString { + t.Fatalf("db.postgres got error '%s', expected '%s'", err.Error(), test.errorString) + } + }) + } +}