diff --git a/src/db/db.go b/src/db/db.go index 2048a427..440c70fc 100644 --- a/src/db/db.go +++ b/src/db/db.go @@ -21,40 +21,11 @@ import ( ) /* -Values of these kinds are ok to query even if they are not directly understood by pgtype. -This is common for custom types like: - - type ThreadType int +A general error to be used when no results are found. This is the error returned +by QueryOne, and can generally be used by other database helpers that fetch a single +result but find nothing. */ -var queryableKinds = []reflect.Kind{ - reflect.Int, -} - -/* -Checks if we are able to handle a particular type in a database query. This applies only to -primitive types and not structs, since the database only returns individual primitive types -and it is our job to stitch them back together into structs later. -*/ -func typeIsQueryable(t reflect.Type) bool { - _, isRecognizedByPgtype := connInfo.DataTypeForValue(reflect.New(t).Elem().Interface()) // if pgtype recognizes it, we don't need to dig in further for more `db` tags - // NOTE: boy it would be nice if we didn't have to do reflect.New here, considering that pgtype is just doing reflection on the value anyway - - if isRecognizedByPgtype { - return true - } else if t == reflect.TypeOf(uuid.UUID{}) { - return true - } - - // pgtype doesn't recognize it, but maybe it's a primitive type we can deal with - k := t.Kind() - for _, qk := range queryableKinds { - if k == qk { - return true - } - } - - return false -} +var NotFound = errors.New("not found") // This interface should match both a direct pgx connection or a pgx transaction. type ConnOrTx interface { @@ -102,14 +73,14 @@ type columnName []string // corresponds to a field index for use with Field on a reflect.Type or reflect.Value. type fieldPath []int -type StructQueryIterator[T any] struct { +type ResultIterator[T any] struct { fieldPaths []fieldPath rows pgx.Rows destType reflect.Type closed chan struct{} } -func (it *StructQueryIterator[T]) Next() (*T, bool) { +func (it *ResultIterator[T]) Next() (*T, bool) { hasNext := it.rows.Next() if !hasNext { it.Close() @@ -182,7 +153,7 @@ func (it *StructQueryIterator[T]) Next() (*T, bool) { return result.Interface().(*T), true } -func (it *StructQueryIterator[any]) Close() { +func (it *ResultIterator[any]) Close() { it.rows.Close() select { case it.closed <- struct{}{}: @@ -190,7 +161,7 @@ func (it *StructQueryIterator[any]) Close() { } } -func (it *StructQueryIterator[T]) ToSlice() []*T { +func (it *ResultIterator[T]) ToSlice() []*T { defer it.Close() var result []*T for { @@ -238,6 +209,11 @@ func followPathThroughStructs(structPtrVal reflect.Value, path []int) (reflect.V return val, field } +/* +Performs a SQL query and returns a slice of all the result rows. The query is just plain SQL, but make sure to read the package documentation for details. You must explicitly provide the type argument - this is how it knows what Go type to map the results to, and it cannot be inferred. + +Any SQL query may be performed, including INSERT and UPDATE - as long as it returns a result set, you can use this. If the query does not return a result set, or you simply do not care about the result set, call Exec directly on your pgx connection. +*/ func Query[T any](ctx context.Context, conn ConnOrTx, query string, args ...interface{}) ([]*T, error) { it, err := QueryIterator[T](ctx, conn, query, args...) if err != nil { @@ -247,7 +223,7 @@ func Query[T any](ctx context.Context, conn ConnOrTx, query string, args ...inte } } -func QueryIterator[T any](ctx context.Context, conn ConnOrTx, query string, args ...interface{}) (*StructQueryIterator[T], error) { +func QueryIterator[T any](ctx context.Context, conn ConnOrTx, query string, args ...interface{}) (*ResultIterator[T], error) { var destExample T destType := reflect.TypeOf(destExample) @@ -261,7 +237,7 @@ func QueryIterator[T any](ctx context.Context, conn ConnOrTx, query string, args return nil, err } - it := &StructQueryIterator[T]{ + it := &ResultIterator[T]{ fieldPaths: compiled.fieldPaths, rows: rows, destType: compiled.destType, @@ -285,6 +261,23 @@ func QueryIterator[T any](ctx context.Context, conn ConnOrTx, query string, args return it, nil } +func QueryOne[T any](ctx context.Context, conn ConnOrTx, query string, args ...interface{}) (*T, error) { + rows, err := QueryIterator[T](ctx, conn, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + result, hasRow := rows.Next() + if !hasRow { + return nil, NotFound + } + + return result, nil +} + +// TODO: QueryFunc? + type compiledQuery struct { query string destType reflect.Type @@ -408,27 +401,42 @@ func getColumnNamesAndPaths(destType reflect.Type, pathSoFar []int, prefix []str } /* -A general error to be used when no results are found. This is the error returned -by QueryOne, and can generally be used by other database helpers that fetch a single -result but find nothing. +Values of these kinds are ok to query even if they are not directly understood by pgtype. +This is common for custom types like: + + type ThreadType int */ -var NotFound = errors.New("not found") - -func QueryOne[T any](ctx context.Context, conn ConnOrTx, query string, args ...interface{}) (*T, error) { - rows, err := QueryIterator[T](ctx, conn, query, args...) - if err != nil { - return nil, err - } - defer rows.Close() - - result, hasRow := rows.Next() - if !hasRow { - return nil, NotFound - } - - return result, nil +var queryableKinds = []reflect.Kind{ + reflect.Int, } +/* +Checks if we are able to handle a particular type in a database query. This applies only to +primitive types and not structs, since the database only returns individual primitive types +and it is our job to stitch them back together into structs later. +*/ +func typeIsQueryable(t reflect.Type) bool { + _, isRecognizedByPgtype := connInfo.DataTypeForValue(reflect.New(t).Elem().Interface()) // if pgtype recognizes it, we don't need to dig in further for more `db` tags + // NOTE: boy it would be nice if we didn't have to do reflect.New here, considering that pgtype is just doing reflection on the value anyway + + if isRecognizedByPgtype { + return true + } else if t == reflect.TypeOf(uuid.UUID{}) { + return true + } + + // pgtype doesn't recognize it, but maybe it's a primitive type we can deal with + k := t.Kind() + for _, qk := range queryableKinds { + if k == qk { + return true + } + } + + return false +} + +// TODO: Delete in favor of `QueryOne` func QueryScalar(ctx context.Context, conn ConnOrTx, query string, args ...interface{}) (interface{}, error) { rows, err := conn.Query(ctx, query, args...) if err != nil { @@ -452,6 +460,7 @@ func QueryScalar(ctx context.Context, conn ConnOrTx, query string, args ...inter return nil, NotFound } +// TODO: Delete in favor of `QueryOne[string]` func QueryString(ctx context.Context, conn ConnOrTx, query string, args ...interface{}) (string, error) { result, err := QueryScalar(ctx, conn, query, args...) if err != nil { @@ -466,6 +475,7 @@ func QueryString(ctx context.Context, conn ConnOrTx, query string, args ...inter } } +// TODO: Delete in favor of `QueryOne[int]` func QueryInt(ctx context.Context, conn ConnOrTx, query string, args ...interface{}) (int, error) { result, err := QueryScalar(ctx, conn, query, args...) if err != nil { @@ -484,6 +494,7 @@ func QueryInt(ctx context.Context, conn ConnOrTx, query string, args ...interfac } } +// TODO: Delete in favor of `QueryOne[bool]` func QueryBool(ctx context.Context, conn ConnOrTx, query string, args ...interface{}) (bool, error) { result, err := QueryScalar(ctx, conn, query, args...) if err != nil { diff --git a/src/db/doc.go b/src/db/doc.go index 07140f79..6d66b48c 100644 --- a/src/db/doc.go +++ b/src/db/doc.go @@ -1,4 +1,59 @@ /* -Wow so dobument +This package contains lowish-level APIs for making database queries to our Postgres database. It streamlines the process of mapping query results to Go types, while allowing you to write arbitrary SQL queries. + +The primary functions are Query and QueryIterator. See the package and function examples for detailed usage. + +Query syntax + +This package allows a few small extensions to SQL syntax to streamline the interaction between Go and Postgres. + +Arguments can be provided using placeholders like $1, $2, etc. All arguments will be safely escaped and mapped from their Go type to the correct Postgres type. (This is a direct proxy to pgx.) + + projectIDs, err := db.Query[int](ctx, conn, + ` + SELECT id + FROM handmade_project + WHERE + slug = ANY($1) + AND hidden = $2 + `, + []string{"4coder", "metadesk"}, + false, + ) + +(This also demonstrates a useful tip: if you want to use a slice in your query, use Postgres arrays instead of IN.) + +When querying individual fields, you can simply select the field like so: + + ids, err := db.Query[int](ctx, conn, `SELECT id FROM handmade_project`) + +To query multiple columns at once, you may use a struct type with `db:"column_name"` tags, and the special $columns placeholder: + + type Project struct { + ID int `db:"id"` + Slug string `db:"slug"` + DateCreated time.Time `db:"date_created"` + } + projects, err := db.Query[Project](ctx, conn, `SELECT $columns FROM ...`) + // Resulting query: + // SELECT id, slug, date_created FROM ... + +Sometimes a table name prefix is required on each column to disambiguate between column names, especially when performing a JOIN. In those situations, you can include the prefix in the $columns placeholder like $columns{prefix}: + + type Project struct { + ID int `db:"id"` + Slug string `db:"slug"` + DateCreated time.Time `db:"date_created"` + } + orphanedProjects, err := db.Query[Project](ctx, conn, ` + SELECT $columns{projects} + FROM + handmade_project AS projects + LEFT JOIN handmade_user_projects AS uproj + WHERE + uproj.user_id IS NULL + `) + // Resulting query: + // SELECT projects.id, projects.slug, projects.date_created FROM ... */ package db