Compare commits
	
		
			5 Commits
		
	
	
	| Author | SHA1 | Date | 
|---|---|---|
| 
							
							
								 | 
						8ff5f02916 | |
| 
							
							
								 | 
						2229ac85d5 | |
| 
							
							
								 | 
						97360a1998 | |
| 
							
							
								 | 
						a2917b98c0 | |
| 
							
							
								 | 
						b9a4cb2361 | 
							
								
								
									
										40
									
								
								go.mod
								
								
								
								
							
							
						
						
									
										40
									
								
								go.mod
								
								
								
								
							| 
						 | 
				
			
			@ -1,10 +1,8 @@
 | 
			
		|||
module git.handmade.network/hmn/hmn
 | 
			
		||||
 | 
			
		||||
go 1.16
 | 
			
		||||
go 1.18
 | 
			
		||||
 | 
			
		||||
require (
 | 
			
		||||
	github.com/Masterminds/goutils v1.1.1 // indirect
 | 
			
		||||
	github.com/Masterminds/semver v1.5.0 // indirect
 | 
			
		||||
	github.com/Masterminds/sprig v2.22.0+incompatible
 | 
			
		||||
	github.com/alecthomas/chroma v0.9.2
 | 
			
		||||
	github.com/aws/aws-sdk-go-v2 v1.8.1
 | 
			
		||||
| 
						 | 
				
			
			@ -16,13 +14,10 @@ require (
 | 
			
		|||
	github.com/go-stack/stack v1.8.0
 | 
			
		||||
	github.com/google/uuid v1.2.0
 | 
			
		||||
	github.com/gorilla/websocket v1.4.2
 | 
			
		||||
	github.com/huandu/xstrings v1.3.2 // indirect
 | 
			
		||||
	github.com/imdario/mergo v0.3.12 // indirect
 | 
			
		||||
	github.com/jackc/pgconn v1.8.0
 | 
			
		||||
	github.com/jackc/pgtype v1.6.2
 | 
			
		||||
	github.com/jackc/pgx/v4 v4.10.1
 | 
			
		||||
	github.com/jpillora/backoff v1.0.0
 | 
			
		||||
	github.com/mitchellh/copystructure v1.1.1 // indirect
 | 
			
		||||
	github.com/rs/zerolog v1.21.0
 | 
			
		||||
	github.com/spf13/cobra v1.1.3
 | 
			
		||||
	github.com/stretchr/testify v1.7.0
 | 
			
		||||
| 
						 | 
				
			
			@ -35,6 +30,39 @@ require (
 | 
			
		|||
	golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
require (
 | 
			
		||||
	github.com/Masterminds/goutils v1.1.1 // indirect
 | 
			
		||||
	github.com/Masterminds/semver v1.5.0 // indirect
 | 
			
		||||
	github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.4.1 // indirect
 | 
			
		||||
	github.com/aws/aws-sdk-go-v2/internal/ini v1.2.1 // indirect
 | 
			
		||||
	github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.2.2 // indirect
 | 
			
		||||
	github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.2.3 // indirect
 | 
			
		||||
	github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.5.3 // indirect
 | 
			
		||||
	github.com/aws/aws-sdk-go-v2/service/sso v1.3.3 // indirect
 | 
			
		||||
	github.com/aws/aws-sdk-go-v2/service/sts v1.6.2 // indirect
 | 
			
		||||
	github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 // indirect
 | 
			
		||||
	github.com/davecgh/go-spew v1.1.1 // indirect
 | 
			
		||||
	github.com/dlclark/regexp2 v1.4.0 // indirect
 | 
			
		||||
	github.com/huandu/xstrings v1.3.2 // indirect
 | 
			
		||||
	github.com/imdario/mergo v0.3.12 // indirect
 | 
			
		||||
	github.com/inconshreveable/mousetrap v1.0.0 // indirect
 | 
			
		||||
	github.com/jackc/chunkreader/v2 v2.0.1 // indirect
 | 
			
		||||
	github.com/jackc/pgio v1.0.0 // indirect
 | 
			
		||||
	github.com/jackc/pgpassfile v1.0.0 // indirect
 | 
			
		||||
	github.com/jackc/pgproto3/v2 v2.0.6 // indirect
 | 
			
		||||
	github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect
 | 
			
		||||
	github.com/jackc/puddle v1.1.3 // indirect
 | 
			
		||||
	github.com/mitchellh/copystructure v1.1.1 // indirect
 | 
			
		||||
	github.com/mitchellh/reflectwalk v1.0.1 // indirect
 | 
			
		||||
	github.com/pmezard/go-difflib v1.0.0 // indirect
 | 
			
		||||
	github.com/spf13/pflag v1.0.5 // indirect
 | 
			
		||||
	golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 // indirect
 | 
			
		||||
	golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4 // indirect
 | 
			
		||||
	golang.org/x/text v0.3.6 // indirect
 | 
			
		||||
	golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
 | 
			
		||||
	gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
replace (
 | 
			
		||||
	github.com/frustra/bbcode v0.0.0-20201127003707-6ef347fbe1c8 => github.com/HandmadeNetwork/bbcode v0.0.0-20210623031351-ec0e2e2e39d9
 | 
			
		||||
	github.com/yuin/goldmark v1.4.1 => github.com/HandmadeNetwork/goldmark v1.4.1-0.20210707024600-f7e596e26b5e
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										2
									
								
								go.sum
								
								
								
								
							
							
						
						
									
										2
									
								
								go.sum
								
								
								
								
							| 
						 | 
				
			
			@ -159,7 +159,6 @@ github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU=
 | 
			
		|||
github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
 | 
			
		||||
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
 | 
			
		||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
 | 
			
		||||
github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0=
 | 
			
		||||
github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
 | 
			
		||||
github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
 | 
			
		||||
github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
 | 
			
		||||
| 
						 | 
				
			
			@ -178,7 +177,6 @@ github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2 h1:JVX6jT/XfzNqIjye47
 | 
			
		|||
github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE=
 | 
			
		||||
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/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A=
 | 
			
		||||
github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78=
 | 
			
		||||
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA=
 | 
			
		||||
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg=
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -55,7 +55,7 @@ func addCreateProjectCommand(projectCommand *cobra.Command) {
 | 
			
		|||
			}
 | 
			
		||||
			hmn := p.Project
 | 
			
		||||
 | 
			
		||||
			newProjectID, err := db.QueryInt(ctx, tx,
 | 
			
		||||
			newProjectID, err := db.QueryOneScalar[int](ctx, tx,
 | 
			
		||||
				`
 | 
			
		||||
				INSERT INTO handmade_project (
 | 
			
		||||
					slug,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -210,7 +210,7 @@ func init() {
 | 
			
		|||
			}
 | 
			
		||||
			defer tx.Rollback(ctx)
 | 
			
		||||
 | 
			
		||||
			projectId, err := db.QueryInt(ctx, tx, `SELECT id FROM handmade_project WHERE slug = $1`, projectSlug)
 | 
			
		||||
			projectId, err := db.QueryOneScalar[int](ctx, tx, `SELECT id FROM handmade_project WHERE slug = $1`, projectSlug)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				panic(err)
 | 
			
		||||
			}
 | 
			
		||||
| 
						 | 
				
			
			@ -218,7 +218,7 @@ func init() {
 | 
			
		|||
			var parentId *int
 | 
			
		||||
			if parentSlug == "" {
 | 
			
		||||
				// Select the root subforum
 | 
			
		||||
				id, err := db.QueryInt(ctx, tx,
 | 
			
		||||
				id, err := db.QueryOneScalar[int](ctx, tx,
 | 
			
		||||
					`SELECT id FROM handmade_subforum WHERE parent_id IS NULL AND project_id = $1`,
 | 
			
		||||
					projectId,
 | 
			
		||||
				)
 | 
			
		||||
| 
						 | 
				
			
			@ -228,7 +228,7 @@ func init() {
 | 
			
		|||
				parentId = &id
 | 
			
		||||
			} else {
 | 
			
		||||
				// Select the parent
 | 
			
		||||
				id, err := db.QueryInt(ctx, tx,
 | 
			
		||||
				id, err := db.QueryOneScalar[int](ctx, tx,
 | 
			
		||||
					`SELECT id FROM handmade_subforum WHERE slug = $1 AND project_id = $2`,
 | 
			
		||||
					parentSlug, projectId,
 | 
			
		||||
				)
 | 
			
		||||
| 
						 | 
				
			
			@ -238,7 +238,7 @@ func init() {
 | 
			
		|||
				parentId = &id
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			newId, err := db.QueryInt(ctx, tx,
 | 
			
		||||
			newId, err := db.QueryOneScalar[int](ctx, tx,
 | 
			
		||||
				`
 | 
			
		||||
				INSERT INTO handmade_subforum (name, slug, blurb, parent_id, project_id)
 | 
			
		||||
				VALUES ($1, $2, $3, $4, $5)
 | 
			
		||||
| 
						 | 
				
			
			@ -289,12 +289,12 @@ func init() {
 | 
			
		|||
			}
 | 
			
		||||
			defer tx.Rollback(ctx)
 | 
			
		||||
 | 
			
		||||
			projectId, err := db.QueryInt(ctx, tx, `SELECT id FROM handmade_project WHERE slug = $1`, projectSlug)
 | 
			
		||||
			projectId, err := db.QueryOneScalar[int](ctx, tx, `SELECT id FROM handmade_project WHERE slug = $1`, projectSlug)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				panic(err)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			subforumId, err := db.QueryInt(ctx, tx,
 | 
			
		||||
			subforumId, err := db.QueryOneScalar[int](ctx, tx,
 | 
			
		||||
				`SELECT id FROM handmade_subforum WHERE slug = $1 AND project_id = $2`,
 | 
			
		||||
				subforumSlug, projectId,
 | 
			
		||||
			)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -140,7 +140,7 @@ func Create(ctx context.Context, dbConn db.ConnOrTx, in CreateInput) (*models.As
 | 
			
		|||
	}
 | 
			
		||||
 | 
			
		||||
	// Fetch and return the new record
 | 
			
		||||
	iasset, err := db.QueryOne(ctx, dbConn, models.Asset{},
 | 
			
		||||
	asset, err := db.QueryOne[models.Asset](ctx, dbConn,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		FROM handmade_asset
 | 
			
		||||
| 
						 | 
				
			
			@ -152,5 +152,5 @@ func Create(ctx context.Context, dbConn db.ConnOrTx, in CreateInput) (*models.As
 | 
			
		|||
		return nil, oops.New(err, "failed to fetch newly-created asset")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return iasset.(*models.Asset), nil
 | 
			
		||||
	return asset, nil
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -45,7 +45,7 @@ func makeCSRFToken() string {
 | 
			
		|||
var ErrNoSession = errors.New("no session found")
 | 
			
		||||
 | 
			
		||||
func GetSession(ctx context.Context, conn *pgxpool.Pool, id string) (*models.Session, error) {
 | 
			
		||||
	row, err := db.QueryOne(ctx, conn, models.Session{}, "SELECT $columns FROM sessions WHERE id = $1", id)
 | 
			
		||||
	sess, err := db.QueryOne[models.Session](ctx, conn, "SELECT $columns FROM sessions WHERE id = $1", id)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		if errors.Is(err, db.NotFound) {
 | 
			
		||||
			return nil, ErrNoSession
 | 
			
		||||
| 
						 | 
				
			
			@ -53,7 +53,6 @@ func GetSession(ctx context.Context, conn *pgxpool.Pool, id string) (*models.Ses
 | 
			
		|||
			return nil, oops.New(err, "failed to get session")
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	sess := row.(*models.Session)
 | 
			
		||||
 | 
			
		||||
	return sess, nil
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										617
									
								
								src/db/db.go
								
								
								
								
							
							
						
						
									
										617
									
								
								src/db/db.go
								
								
								
								
							| 
						 | 
				
			
			@ -5,6 +5,7 @@ import (
 | 
			
		|||
	"errors"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"reflect"
 | 
			
		||||
	"regexp"
 | 
			
		||||
	"strings"
 | 
			
		||||
 | 
			
		||||
	"git.handmade.network/hmn/hmn/src/config"
 | 
			
		||||
| 
						 | 
				
			
			@ -20,46 +21,17 @@ 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 {
 | 
			
		||||
	Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error)
 | 
			
		||||
	QueryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row
 | 
			
		||||
	Exec(ctx context.Context, sql string, args ...interface{}) (pgconn.CommandTag, error)
 | 
			
		||||
	Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
 | 
			
		||||
	QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
 | 
			
		||||
	Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
 | 
			
		||||
 | 
			
		||||
	// Both raw database connections and transactions in pgx can begin/commit
 | 
			
		||||
	// transactions. For database connections it does the obvious thing; for
 | 
			
		||||
| 
						 | 
				
			
			@ -70,6 +42,8 @@ type ConnOrTx interface {
 | 
			
		|||
 | 
			
		||||
var connInfo = pgtype.NewConnInfo()
 | 
			
		||||
 | 
			
		||||
// Creates a new connection to the HMN database.
 | 
			
		||||
// This connection is not safe for concurrent use.
 | 
			
		||||
func NewConn() *pgx.Conn {
 | 
			
		||||
	conn, err := pgx.Connect(context.Background(), config.Config.Postgres.DSN())
 | 
			
		||||
	if err != nil {
 | 
			
		||||
| 
						 | 
				
			
			@ -79,6 +53,8 @@ func NewConn() *pgx.Conn {
 | 
			
		|||
	return conn
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Creates a connection pool for the HMN database.
 | 
			
		||||
// The resulting pool is safe for concurrent use.
 | 
			
		||||
func NewConnPool(minConns, maxConns int32) *pgxpool.Pool {
 | 
			
		||||
	cfg, err := pgxpool.ParseConfig(config.Config.Postgres.DSN())
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -95,144 +71,20 @@ func NewConnPool(minConns, maxConns int32) *pgxpool.Pool {
 | 
			
		|||
	return conn
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type StructQueryIterator struct {
 | 
			
		||||
	fieldPaths [][]int
 | 
			
		||||
	rows       pgx.Rows
 | 
			
		||||
	destType   reflect.Type
 | 
			
		||||
	closed     chan struct{}
 | 
			
		||||
}
 | 
			
		||||
/*
 | 
			
		||||
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.
 | 
			
		||||
 | 
			
		||||
func (it *StructQueryIterator) Next() (interface{}, bool) {
 | 
			
		||||
	hasNext := it.rows.Next()
 | 
			
		||||
	if !hasNext {
 | 
			
		||||
		it.Close()
 | 
			
		||||
		return nil, false
 | 
			
		||||
	}
 | 
			
		||||
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.
 | 
			
		||||
 | 
			
		||||
	result := reflect.New(it.destType)
 | 
			
		||||
 | 
			
		||||
	vals, err := it.rows.Values()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		panic(err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Better logging of panics in this confusing reflection process
 | 
			
		||||
	var currentField reflect.StructField
 | 
			
		||||
	var currentValue reflect.Value
 | 
			
		||||
	var currentIdx int
 | 
			
		||||
	defer func() {
 | 
			
		||||
		if r := recover(); r != nil {
 | 
			
		||||
			if currentValue.IsValid() {
 | 
			
		||||
				logging.Error().
 | 
			
		||||
					Int("index", currentIdx).
 | 
			
		||||
					Str("field name", currentField.Name).
 | 
			
		||||
					Stringer("field type", currentField.Type).
 | 
			
		||||
					Interface("value", currentValue.Interface()).
 | 
			
		||||
					Stringer("value type", currentValue.Type()).
 | 
			
		||||
					Msg("panic in iterator")
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if currentField.Name != "" {
 | 
			
		||||
				panic(fmt.Errorf("panic while processing field '%s': %v", currentField.Name, r))
 | 
			
		||||
			} else {
 | 
			
		||||
				panic(r)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
	for i, val := range vals {
 | 
			
		||||
		currentIdx = i
 | 
			
		||||
		if val == nil {
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		var field reflect.Value
 | 
			
		||||
		field, currentField = followPathThroughStructs(result, it.fieldPaths[i])
 | 
			
		||||
		if field.Kind() == reflect.Ptr {
 | 
			
		||||
			field.Set(reflect.New(field.Type().Elem()))
 | 
			
		||||
			field = field.Elem()
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Some actual values still come through as pointers (like net.IPNet). Dunno why.
 | 
			
		||||
		// Regardless, we know it's not nil, so we can get at the contents.
 | 
			
		||||
		valReflected := reflect.ValueOf(val)
 | 
			
		||||
		if valReflected.Kind() == reflect.Ptr {
 | 
			
		||||
			valReflected = valReflected.Elem()
 | 
			
		||||
		}
 | 
			
		||||
		currentValue = valReflected
 | 
			
		||||
 | 
			
		||||
		switch field.Kind() {
 | 
			
		||||
		case reflect.Int:
 | 
			
		||||
			field.SetInt(valReflected.Int())
 | 
			
		||||
		default:
 | 
			
		||||
			field.Set(valReflected)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		currentField = reflect.StructField{}
 | 
			
		||||
		currentValue = reflect.Value{}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return result.Interface(), true
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (it *StructQueryIterator) Close() {
 | 
			
		||||
	it.rows.Close()
 | 
			
		||||
	select {
 | 
			
		||||
	case it.closed <- struct{}{}:
 | 
			
		||||
	default:
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (it *StructQueryIterator) ToSlice() []interface{} {
 | 
			
		||||
	defer it.Close()
 | 
			
		||||
	var result []interface{}
 | 
			
		||||
	for {
 | 
			
		||||
		row, ok := it.Next()
 | 
			
		||||
		if !ok {
 | 
			
		||||
			err := it.rows.Err()
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				panic(oops.New(err, "error while iterating through db results"))
 | 
			
		||||
			}
 | 
			
		||||
			break
 | 
			
		||||
		}
 | 
			
		||||
		result = append(result, row)
 | 
			
		||||
	}
 | 
			
		||||
	return result
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func followPathThroughStructs(structPtrVal reflect.Value, path []int) (reflect.Value, reflect.StructField) {
 | 
			
		||||
	if len(path) < 1 {
 | 
			
		||||
		panic(oops.New(nil, "can't follow an empty path"))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if structPtrVal.Kind() != reflect.Ptr || structPtrVal.Elem().Kind() != reflect.Struct {
 | 
			
		||||
		panic(oops.New(nil, "structPtrVal must be a pointer to a struct; got value of type %s", structPtrVal.Type()))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// more informative panic recovery
 | 
			
		||||
	var field reflect.StructField
 | 
			
		||||
	defer func() {
 | 
			
		||||
		if r := recover(); r != nil {
 | 
			
		||||
			panic(oops.New(nil, "panic at field '%s': %v", field.Name, r))
 | 
			
		||||
		}
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
	val := structPtrVal
 | 
			
		||||
	for _, i := range path {
 | 
			
		||||
		if val.Kind() == reflect.Ptr && val.Type().Elem().Kind() == reflect.Struct {
 | 
			
		||||
			if val.IsNil() {
 | 
			
		||||
				val.Set(reflect.New(val.Type().Elem()))
 | 
			
		||||
			}
 | 
			
		||||
			val = val.Elem()
 | 
			
		||||
		}
 | 
			
		||||
		field = val.Type().Field(i)
 | 
			
		||||
		val = val.Field(i)
 | 
			
		||||
	}
 | 
			
		||||
	return val, field
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func Query(ctx context.Context, conn ConnOrTx, destExample interface{}, query string, args ...interface{}) ([]interface{}, error) {
 | 
			
		||||
	it, err := QueryIterator(ctx, conn, destExample, query, args...)
 | 
			
		||||
This function always returns pointers to the values. This is convenient for structs, but for other types, you may wish to use QueryScalar.
 | 
			
		||||
*/
 | 
			
		||||
func Query[T any](
 | 
			
		||||
	ctx context.Context,
 | 
			
		||||
	conn ConnOrTx,
 | 
			
		||||
	query string,
 | 
			
		||||
	args ...any,
 | 
			
		||||
) ([]*T, error) {
 | 
			
		||||
	it, err := QueryIterator[T](ctx, conn, query, args...)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	} else {
 | 
			
		||||
| 
						 | 
				
			
			@ -240,27 +92,99 @@ func Query(ctx context.Context, conn ConnOrTx, destExample interface{}, query st
 | 
			
		|||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func QueryIterator(ctx context.Context, conn ConnOrTx, destExample interface{}, query string, args ...interface{}) (*StructQueryIterator, error) {
 | 
			
		||||
	destType := reflect.TypeOf(destExample)
 | 
			
		||||
	columnNames, fieldPaths, err := getColumnNamesAndPaths(destType, nil, nil)
 | 
			
		||||
/*
 | 
			
		||||
Identical to Query, but returns only the first result row. If there are no
 | 
			
		||||
rows in the result set, returns NotFound.
 | 
			
		||||
*/
 | 
			
		||||
func QueryOne[T any](
 | 
			
		||||
	ctx context.Context,
 | 
			
		||||
	conn ConnOrTx,
 | 
			
		||||
	query string,
 | 
			
		||||
	args ...any,
 | 
			
		||||
) (*T, error) {
 | 
			
		||||
	rows, err := QueryIterator[T](ctx, conn, query, args...)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, oops.New(err, "failed to generate column names")
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
	defer rows.Close()
 | 
			
		||||
 | 
			
		||||
	result, hasRow := rows.Next()
 | 
			
		||||
	if !hasRow {
 | 
			
		||||
		return nil, NotFound
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	columns := make([]string, 0, len(columnNames))
 | 
			
		||||
	for _, strSlice := range columnNames {
 | 
			
		||||
		tableName := strings.Join(strSlice[0:len(strSlice)-1], "_")
 | 
			
		||||
		fullName := strSlice[len(strSlice)-1]
 | 
			
		||||
		if tableName != "" {
 | 
			
		||||
			fullName = tableName + "." + fullName
 | 
			
		||||
	return result, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
Identical to Query, but returns concrete values instead of pointers. More convenient
 | 
			
		||||
for primitive types.
 | 
			
		||||
*/
 | 
			
		||||
func QueryScalar[T any](
 | 
			
		||||
	ctx context.Context,
 | 
			
		||||
	conn ConnOrTx,
 | 
			
		||||
	query string,
 | 
			
		||||
	args ...any,
 | 
			
		||||
) ([]T, error) {
 | 
			
		||||
	rows, err := QueryIterator[T](ctx, conn, query, args...)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
	defer rows.Close()
 | 
			
		||||
 | 
			
		||||
	var result []T
 | 
			
		||||
	for {
 | 
			
		||||
		val, hasRow := rows.Next()
 | 
			
		||||
		if !hasRow {
 | 
			
		||||
			break
 | 
			
		||||
		}
 | 
			
		||||
		columns = append(columns, fullName)
 | 
			
		||||
		result = append(result, *val)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	columnNamesString := strings.Join(columns, ", ")
 | 
			
		||||
	query = strings.Replace(query, "$columns", columnNamesString, -1)
 | 
			
		||||
	return result, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
	rows, err := conn.Query(ctx, query, args...)
 | 
			
		||||
/*
 | 
			
		||||
Identical to QueryScalar, but returns only the first result value. If there are
 | 
			
		||||
no rows in the result set, returns NotFound.
 | 
			
		||||
*/
 | 
			
		||||
func QueryOneScalar[T any](
 | 
			
		||||
	ctx context.Context,
 | 
			
		||||
	conn ConnOrTx,
 | 
			
		||||
	query string,
 | 
			
		||||
	args ...any,
 | 
			
		||||
) (T, error) {
 | 
			
		||||
	rows, err := QueryIterator[T](ctx, conn, query, args...)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		var zero T
 | 
			
		||||
		return zero, err
 | 
			
		||||
	}
 | 
			
		||||
	defer rows.Close()
 | 
			
		||||
 | 
			
		||||
	result, hasRow := rows.Next()
 | 
			
		||||
	if !hasRow {
 | 
			
		||||
		var zero T
 | 
			
		||||
		return zero, NotFound
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return *result, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
Identical to Query, but returns the ResultIterator instead of automatically converting the results to a slice. The iterator must be closed after use.
 | 
			
		||||
*/
 | 
			
		||||
func QueryIterator[T any](
 | 
			
		||||
	ctx context.Context,
 | 
			
		||||
	conn ConnOrTx,
 | 
			
		||||
	query string,
 | 
			
		||||
	args ...any,
 | 
			
		||||
) (*Iterator[T], error) {
 | 
			
		||||
	var destExample T
 | 
			
		||||
	destType := reflect.TypeOf(destExample)
 | 
			
		||||
 | 
			
		||||
	compiled := compileQuery(query, destType)
 | 
			
		||||
 | 
			
		||||
	rows, err := conn.Query(ctx, compiled.query, args...)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		if errors.Is(err, context.DeadlineExceeded) {
 | 
			
		||||
			panic("query exceeded its deadline")
 | 
			
		||||
| 
						 | 
				
			
			@ -268,11 +192,12 @@ func QueryIterator(ctx context.Context, conn ConnOrTx, destExample interface{},
 | 
			
		|||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	it := &StructQueryIterator{
 | 
			
		||||
		fieldPaths: fieldPaths,
 | 
			
		||||
		rows:       rows,
 | 
			
		||||
		destType:   destType,
 | 
			
		||||
		closed:     make(chan struct{}, 1),
 | 
			
		||||
	it := &Iterator[T]{
 | 
			
		||||
		fieldPaths:       compiled.fieldPaths,
 | 
			
		||||
		rows:             rows,
 | 
			
		||||
		destType:         compiled.destType,
 | 
			
		||||
		destTypeIsScalar: typeIsQueryable(compiled.destType),
 | 
			
		||||
		closed:           make(chan struct{}, 1),
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Ensure that iterators are closed if context is cancelled. Otherwise, iterators can hold
 | 
			
		||||
| 
						 | 
				
			
			@ -292,16 +217,72 @@ func QueryIterator(ctx context.Context, conn ConnOrTx, destExample interface{},
 | 
			
		|||
	return it, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func getColumnNamesAndPaths(destType reflect.Type, pathSoFar []int, prefix []string) (names [][]string, paths [][]int, err error) {
 | 
			
		||||
	var columnNames [][]string
 | 
			
		||||
	var fieldPaths [][]int
 | 
			
		||||
// TODO: QueryFunc?
 | 
			
		||||
 | 
			
		||||
type compiledQuery struct {
 | 
			
		||||
	query      string
 | 
			
		||||
	destType   reflect.Type
 | 
			
		||||
	fieldPaths []fieldPath
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var reColumnsPlaceholder = regexp.MustCompile(`\$columns({(.*?)})?`)
 | 
			
		||||
 | 
			
		||||
func compileQuery(query string, destType reflect.Type) compiledQuery {
 | 
			
		||||
	columnsMatch := reColumnsPlaceholder.FindStringSubmatch(query)
 | 
			
		||||
	hasColumnsPlaceholder := columnsMatch != nil
 | 
			
		||||
 | 
			
		||||
	if hasColumnsPlaceholder {
 | 
			
		||||
		// The presence of the $columns placeholder means that the destination type
 | 
			
		||||
		// must be a struct, and we will plonk that struct's fields into the query.
 | 
			
		||||
 | 
			
		||||
		if destType.Kind() != reflect.Struct {
 | 
			
		||||
			panic("$columns can only be used when querying into a struct")
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		var prefix []string
 | 
			
		||||
		prefixText := columnsMatch[2]
 | 
			
		||||
		if prefixText != "" {
 | 
			
		||||
			prefix = []string{prefixText}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		columnNames, fieldPaths := getColumnNamesAndPaths(destType, nil, prefix)
 | 
			
		||||
 | 
			
		||||
		columns := make([]string, 0, len(columnNames))
 | 
			
		||||
		for _, strSlice := range columnNames {
 | 
			
		||||
			tableName := strings.Join(strSlice[0:len(strSlice)-1], "_")
 | 
			
		||||
			fullName := strSlice[len(strSlice)-1]
 | 
			
		||||
			if tableName != "" {
 | 
			
		||||
				fullName = tableName + "." + fullName
 | 
			
		||||
			}
 | 
			
		||||
			columns = append(columns, fullName)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		columnNamesString := strings.Join(columns, ", ")
 | 
			
		||||
		query = reColumnsPlaceholder.ReplaceAllString(query, columnNamesString)
 | 
			
		||||
 | 
			
		||||
		return compiledQuery{
 | 
			
		||||
			query:      query,
 | 
			
		||||
			destType:   destType,
 | 
			
		||||
			fieldPaths: fieldPaths,
 | 
			
		||||
		}
 | 
			
		||||
	} else {
 | 
			
		||||
		return compiledQuery{
 | 
			
		||||
			query:    query,
 | 
			
		||||
			destType: destType,
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func getColumnNamesAndPaths(destType reflect.Type, pathSoFar []int, prefix []string) (names []columnName, paths []fieldPath) {
 | 
			
		||||
	var columnNames []columnName
 | 
			
		||||
	var fieldPaths []fieldPath
 | 
			
		||||
 | 
			
		||||
	if destType.Kind() == reflect.Ptr {
 | 
			
		||||
		destType = destType.Elem()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if destType.Kind() != reflect.Struct {
 | 
			
		||||
		return nil, nil, oops.New(nil, "can only get column names and paths from a struct, got type '%v' (at prefix '%v')", destType.Name(), prefix)
 | 
			
		||||
		panic(fmt.Errorf("can only get column names and paths from a struct, got type '%v' (at prefix '%v')", destType.Name(), prefix))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	type AnonPrefix struct {
 | 
			
		||||
| 
						 | 
				
			
			@ -348,108 +329,214 @@ func getColumnNamesAndPaths(destType reflect.Type, pathSoFar []int, prefix []str
 | 
			
		|||
				columnNames = append(columnNames, fieldColumnNames)
 | 
			
		||||
				fieldPaths = append(fieldPaths, path)
 | 
			
		||||
			} else if fieldType.Kind() == reflect.Struct {
 | 
			
		||||
				subCols, subPaths, err := getColumnNamesAndPaths(fieldType, path, fieldColumnNames)
 | 
			
		||||
				if err != nil {
 | 
			
		||||
					return nil, nil, err
 | 
			
		||||
				}
 | 
			
		||||
				subCols, subPaths := getColumnNamesAndPaths(fieldType, path, fieldColumnNames)
 | 
			
		||||
				columnNames = append(columnNames, subCols...)
 | 
			
		||||
				fieldPaths = append(fieldPaths, subPaths...)
 | 
			
		||||
			} else {
 | 
			
		||||
				return nil, nil, oops.New(nil, "field '%s' in type %s has invalid type '%s'", field.Name, destType, field.Type)
 | 
			
		||||
				panic(fmt.Errorf("field '%s' in type %s has invalid type '%s'", field.Name, destType, field.Type))
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return columnNames, fieldPaths, nil
 | 
			
		||||
	return columnNames, fieldPaths
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
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(ctx context.Context, conn ConnOrTx, destExample interface{}, query string, args ...interface{}) (interface{}, error) {
 | 
			
		||||
	rows, err := QueryIterator(ctx, conn, destExample, 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,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func QueryScalar(ctx context.Context, conn ConnOrTx, query string, args ...interface{}) (interface{}, error) {
 | 
			
		||||
	rows, err := conn.Query(ctx, query, args...)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
/*
 | 
			
		||||
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
 | 
			
		||||
	}
 | 
			
		||||
	defer rows.Close()
 | 
			
		||||
 | 
			
		||||
	if rows.Next() {
 | 
			
		||||
		vals, err := rows.Values()
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			panic(err)
 | 
			
		||||
	// 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
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type columnName []string
 | 
			
		||||
 | 
			
		||||
// A path to a particular field in query's destination type. Each index in the slice
 | 
			
		||||
// corresponds to a field index for use with Field on a reflect.Type or reflect.Value.
 | 
			
		||||
type fieldPath []int
 | 
			
		||||
 | 
			
		||||
type Iterator[T any] struct {
 | 
			
		||||
	fieldPaths       []fieldPath
 | 
			
		||||
	rows             pgx.Rows
 | 
			
		||||
	destType         reflect.Type
 | 
			
		||||
	destTypeIsScalar bool // NOTE(ben): Make sure this gets set every time destType gets set, based on typeIsQueryable(destType). This is kinda fragile...but also contained to this file, so doesn't seem worth a lazy evaluation or a constructor function.
 | 
			
		||||
	closed           chan struct{}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (it *Iterator[T]) Next() (*T, bool) {
 | 
			
		||||
	// TODO(ben): What happens if this panics? Does it leak resources? Do we need
 | 
			
		||||
	// to put a recover() here and close the rows?
 | 
			
		||||
 | 
			
		||||
	hasNext := it.rows.Next()
 | 
			
		||||
	if !hasNext {
 | 
			
		||||
		it.Close()
 | 
			
		||||
		return nil, false
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	result := reflect.New(it.destType)
 | 
			
		||||
 | 
			
		||||
	vals, err := it.rows.Values()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		panic(err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if it.destTypeIsScalar {
 | 
			
		||||
		// This type can be directly queried, meaning pgx recognizes it, it's
 | 
			
		||||
		// a simple scalar thing, and we can just take the easy way out.
 | 
			
		||||
		if len(vals) != 1 {
 | 
			
		||||
			return nil, oops.New(nil, "you must query exactly one field with QueryScalar, not %v", len(vals))
 | 
			
		||||
			panic(fmt.Errorf("tried to query a scalar value, but got %v values in the row", len(vals)))
 | 
			
		||||
		}
 | 
			
		||||
		setValueFromDB(result.Elem(), reflect.ValueOf(vals[0]))
 | 
			
		||||
		return result.Interface().(*T), true
 | 
			
		||||
	} else {
 | 
			
		||||
		var currentField reflect.StructField
 | 
			
		||||
		var currentValue reflect.Value
 | 
			
		||||
		var currentIdx int
 | 
			
		||||
 | 
			
		||||
		// Better logging of panics in this confusing reflection process
 | 
			
		||||
		defer func() {
 | 
			
		||||
			if r := recover(); r != nil {
 | 
			
		||||
				if currentValue.IsValid() {
 | 
			
		||||
					logging.Error().
 | 
			
		||||
						Int("index", currentIdx).
 | 
			
		||||
						Str("field name", currentField.Name).
 | 
			
		||||
						Stringer("field type", currentField.Type).
 | 
			
		||||
						Interface("value", currentValue.Interface()).
 | 
			
		||||
						Stringer("value type", currentValue.Type()).
 | 
			
		||||
						Msg("panic in iterator")
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				if currentField.Name != "" {
 | 
			
		||||
					panic(fmt.Errorf("panic while processing field '%s': %v", currentField.Name, r))
 | 
			
		||||
				} else {
 | 
			
		||||
					panic(r)
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}()
 | 
			
		||||
 | 
			
		||||
		for i, val := range vals {
 | 
			
		||||
			currentIdx = i
 | 
			
		||||
			if val == nil {
 | 
			
		||||
				continue
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			var field reflect.Value
 | 
			
		||||
			field, currentField = followPathThroughStructs(result, it.fieldPaths[i])
 | 
			
		||||
			if field.Kind() == reflect.Ptr {
 | 
			
		||||
				field.Set(reflect.New(field.Type().Elem()))
 | 
			
		||||
				field = field.Elem()
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			// Some actual values still come through as pointers (like net.IPNet). Dunno why.
 | 
			
		||||
			// Regardless, we know it's not nil, so we can get at the contents.
 | 
			
		||||
			valReflected := reflect.ValueOf(val)
 | 
			
		||||
			if valReflected.Kind() == reflect.Ptr {
 | 
			
		||||
				valReflected = valReflected.Elem()
 | 
			
		||||
			}
 | 
			
		||||
			currentValue = valReflected
 | 
			
		||||
 | 
			
		||||
			setValueFromDB(field, valReflected)
 | 
			
		||||
 | 
			
		||||
			currentField = reflect.StructField{}
 | 
			
		||||
			currentValue = reflect.Value{}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		return vals[0], nil
 | 
			
		||||
		return result.Interface().(*T), true
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil, NotFound
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func QueryString(ctx context.Context, conn ConnOrTx, query string, args ...interface{}) (string, error) {
 | 
			
		||||
	result, err := QueryScalar(ctx, conn, query, args...)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return "", err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	switch r := result.(type) {
 | 
			
		||||
	case string:
 | 
			
		||||
		return r, nil
 | 
			
		||||
func setValueFromDB(dest reflect.Value, value reflect.Value) {
 | 
			
		||||
	switch dest.Kind() {
 | 
			
		||||
	case reflect.Int:
 | 
			
		||||
		dest.SetInt(value.Int())
 | 
			
		||||
	default:
 | 
			
		||||
		return "", oops.New(nil, "QueryString got a non-string result: %v", result)
 | 
			
		||||
		dest.Set(value)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func QueryInt(ctx context.Context, conn ConnOrTx, query string, args ...interface{}) (int, error) {
 | 
			
		||||
	result, err := QueryScalar(ctx, conn, query, args...)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return 0, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	switch r := result.(type) {
 | 
			
		||||
	case int:
 | 
			
		||||
		return r, nil
 | 
			
		||||
	case int32:
 | 
			
		||||
		return int(r), nil
 | 
			
		||||
	case int64:
 | 
			
		||||
		return int(r), nil
 | 
			
		||||
func (it *Iterator[any]) Close() {
 | 
			
		||||
	it.rows.Close()
 | 
			
		||||
	select {
 | 
			
		||||
	case it.closed <- struct{}{}:
 | 
			
		||||
	default:
 | 
			
		||||
		return 0, oops.New(nil, "QueryInt got a non-int result: %v", result)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func QueryBool(ctx context.Context, conn ConnOrTx, query string, args ...interface{}) (bool, error) {
 | 
			
		||||
	result, err := QueryScalar(ctx, conn, query, args...)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return false, err
 | 
			
		||||
/*
 | 
			
		||||
Pulls all the remaining values into a slice, and closes the iterator.
 | 
			
		||||
*/
 | 
			
		||||
func (it *Iterator[T]) ToSlice() []*T {
 | 
			
		||||
	defer it.Close()
 | 
			
		||||
	var result []*T
 | 
			
		||||
	for {
 | 
			
		||||
		row, ok := it.Next()
 | 
			
		||||
		if !ok {
 | 
			
		||||
			err := it.rows.Err()
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				panic(oops.New(err, "error while iterating through db results"))
 | 
			
		||||
			}
 | 
			
		||||
			break
 | 
			
		||||
		}
 | 
			
		||||
		result = append(result, row)
 | 
			
		||||
	}
 | 
			
		||||
	return result
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func followPathThroughStructs(structPtrVal reflect.Value, path []int) (reflect.Value, reflect.StructField) {
 | 
			
		||||
	if len(path) < 1 {
 | 
			
		||||
		panic(oops.New(nil, "can't follow an empty path"))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	switch r := result.(type) {
 | 
			
		||||
	case bool:
 | 
			
		||||
		return r, nil
 | 
			
		||||
	default:
 | 
			
		||||
		return false, oops.New(nil, "QueryBool got a non-bool result: %v", result)
 | 
			
		||||
	if structPtrVal.Kind() != reflect.Ptr || structPtrVal.Elem().Kind() != reflect.Struct {
 | 
			
		||||
		panic(oops.New(nil, "structPtrVal must be a pointer to a struct; got value of type %s", structPtrVal.Type()))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// more informative panic recovery
 | 
			
		||||
	var field reflect.StructField
 | 
			
		||||
	defer func() {
 | 
			
		||||
		if r := recover(); r != nil {
 | 
			
		||||
			panic(oops.New(nil, "panic at field '%s': %v", field.Name, r))
 | 
			
		||||
		}
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
	val := structPtrVal
 | 
			
		||||
	for _, i := range path {
 | 
			
		||||
		if val.Kind() == reflect.Ptr && val.Type().Elem().Kind() == reflect.Struct {
 | 
			
		||||
			if val.IsNil() {
 | 
			
		||||
				val.Set(reflect.New(val.Type().Elem()))
 | 
			
		||||
			}
 | 
			
		||||
			val = val.Elem()
 | 
			
		||||
		}
 | 
			
		||||
		field = val.Type().Field(i)
 | 
			
		||||
		val = val.Field(i)
 | 
			
		||||
	}
 | 
			
		||||
	return val, field
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -10,52 +10,143 @@ import (
 | 
			
		|||
 | 
			
		||||
func TestPaths(t *testing.T) {
 | 
			
		||||
	type CustomInt int
 | 
			
		||||
	type S struct {
 | 
			
		||||
		I   int        `db:"I"`
 | 
			
		||||
		PI  *int       `db:"PI"`
 | 
			
		||||
		CI  CustomInt  `db:"CI"`
 | 
			
		||||
		PCI *CustomInt `db:"PCI"`
 | 
			
		||||
		B   bool       `db:"B"`
 | 
			
		||||
		PB  *bool      `db:"PB"`
 | 
			
		||||
	type S2 struct {
 | 
			
		||||
		B  bool  `db:"B"`  // field 0
 | 
			
		||||
		PB *bool `db:"PB"` // field 1
 | 
			
		||||
 | 
			
		||||
		NoTag int
 | 
			
		||||
		NoTag string // field 2
 | 
			
		||||
	}
 | 
			
		||||
	type S struct {
 | 
			
		||||
		I   int        `db:"I"`   // field 0
 | 
			
		||||
		PI  *int       `db:"PI"`  // field 1
 | 
			
		||||
		CI  CustomInt  `db:"CI"`  // field 2
 | 
			
		||||
		PCI *CustomInt `db:"PCI"` // field 3
 | 
			
		||||
		S2  `db:"S2"`  // field 4 (embedded!)
 | 
			
		||||
		PS2 *S2        `db:"PS2"` // field 5
 | 
			
		||||
 | 
			
		||||
		NoTag int // field 6
 | 
			
		||||
	}
 | 
			
		||||
	type Nested struct {
 | 
			
		||||
		S  S  `db:"S"`
 | 
			
		||||
		PS *S `db:"PS"`
 | 
			
		||||
		S  S  `db:"S"`  // field 0
 | 
			
		||||
		PS *S `db:"PS"` // field 1
 | 
			
		||||
 | 
			
		||||
		NoTag S
 | 
			
		||||
		NoTag S // field 2
 | 
			
		||||
	}
 | 
			
		||||
	type Embedded struct {
 | 
			
		||||
		NoTag S
 | 
			
		||||
		Nested
 | 
			
		||||
		NoTag  S // field 0
 | 
			
		||||
		Nested   // field 1
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	names, paths, err := getColumnNamesAndPaths(reflect.TypeOf(Embedded{}), nil, "")
 | 
			
		||||
	if assert.Nil(t, err) {
 | 
			
		||||
		assert.Equal(t, []string{
 | 
			
		||||
			"S.I", "S.PI",
 | 
			
		||||
			"S.CI", "S.PCI",
 | 
			
		||||
			"S.B", "S.PB",
 | 
			
		||||
			"PS.I", "PS.PI",
 | 
			
		||||
			"PS.CI", "PS.PCI",
 | 
			
		||||
			"PS.B", "PS.PB",
 | 
			
		||||
		}, names)
 | 
			
		||||
		assert.Equal(t, [][]int{
 | 
			
		||||
			{1, 0, 0}, {1, 0, 1}, {1, 0, 2}, {1, 0, 3}, {1, 0, 4}, {1, 0, 5},
 | 
			
		||||
			{1, 1, 0}, {1, 1, 1}, {1, 1, 2}, {1, 1, 3}, {1, 1, 4}, {1, 1, 5},
 | 
			
		||||
		}, paths)
 | 
			
		||||
		assert.True(t, len(names) == len(paths))
 | 
			
		||||
	}
 | 
			
		||||
	names, paths := getColumnNamesAndPaths(reflect.TypeOf(Embedded{}), nil, nil)
 | 
			
		||||
	assert.Equal(t, []columnName{
 | 
			
		||||
		{"S", "I"}, {"S", "PI"},
 | 
			
		||||
		{"S", "CI"}, {"S", "PCI"},
 | 
			
		||||
		{"S", "S2", "B"}, {"S", "S2", "PB"},
 | 
			
		||||
		{"S", "PS2", "B"}, {"S", "PS2", "PB"},
 | 
			
		||||
		{"PS", "I"}, {"PS", "PI"},
 | 
			
		||||
		{"PS", "CI"}, {"PS", "PCI"},
 | 
			
		||||
		{"PS", "S2", "B"}, {"PS", "S2", "PB"},
 | 
			
		||||
		{"PS", "PS2", "B"}, {"PS", "PS2", "PB"},
 | 
			
		||||
	}, names)
 | 
			
		||||
	assert.Equal(t, []fieldPath{
 | 
			
		||||
		{1, 0, 0}, {1, 0, 1}, // Nested.S.I, Nested.S.PI
 | 
			
		||||
		{1, 0, 2}, {1, 0, 3}, // Nested.S.CI, Nested.S.PCI
 | 
			
		||||
		{1, 0, 4, 0}, {1, 0, 4, 1}, // Nested.S.S2.B, Nested.S.S2.PB
 | 
			
		||||
		{1, 0, 5, 0}, {1, 0, 5, 1}, // Nested.S.PS2.B, Nested.S.PS2.PB
 | 
			
		||||
		{1, 1, 0}, {1, 1, 1}, // Nested.PS.I, Nested.PS.PI
 | 
			
		||||
		{1, 1, 2}, {1, 1, 3}, // Nested.PS.CI, Nested.PS.PCI
 | 
			
		||||
		{1, 1, 4, 0}, {1, 1, 4, 1}, // Nested.PS.S2.B, Nested.PS.S2.PB
 | 
			
		||||
		{1, 1, 5, 0}, {1, 1, 5, 1}, // Nested.PS.PS2.B, Nested.PS.PS2.PB
 | 
			
		||||
	}, paths)
 | 
			
		||||
	assert.True(t, len(names) == len(paths))
 | 
			
		||||
 | 
			
		||||
	testStruct := Embedded{}
 | 
			
		||||
	for i, path := range paths {
 | 
			
		||||
		val, field := followPathThroughStructs(reflect.ValueOf(&testStruct), path)
 | 
			
		||||
		assert.True(t, val.IsValid())
 | 
			
		||||
		assert.True(t, strings.Contains(names[i], field.Name))
 | 
			
		||||
		assert.True(t, strings.Contains(names[i][len(names[i])-1], field.Name))
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestCompileQuery(t *testing.T) {
 | 
			
		||||
	t.Run("simple struct", func(t *testing.T) {
 | 
			
		||||
		type Dest struct {
 | 
			
		||||
			Foo  int    `db:"foo"`
 | 
			
		||||
			Bar  bool   `db:"bar"`
 | 
			
		||||
			Nope string // no tag
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		compiled := compileQuery("SELECT $columns FROM greeblies", reflect.TypeOf(Dest{}))
 | 
			
		||||
		assert.Equal(t, "SELECT foo, bar FROM greeblies", compiled.query)
 | 
			
		||||
	})
 | 
			
		||||
	t.Run("complex structs", func(t *testing.T) {
 | 
			
		||||
		type CustomInt int
 | 
			
		||||
		type S2 struct {
 | 
			
		||||
			B  bool  `db:"B"`
 | 
			
		||||
			PB *bool `db:"PB"`
 | 
			
		||||
 | 
			
		||||
			NoTag string
 | 
			
		||||
		}
 | 
			
		||||
		type S struct {
 | 
			
		||||
			I   int        `db:"I"`
 | 
			
		||||
			PI  *int       `db:"PI"`
 | 
			
		||||
			CI  CustomInt  `db:"CI"`
 | 
			
		||||
			PCI *CustomInt `db:"PCI"`
 | 
			
		||||
			S2  `db:"S2"`  // embedded!
 | 
			
		||||
			PS2 *S2        `db:"PS2"`
 | 
			
		||||
 | 
			
		||||
			NoTag int
 | 
			
		||||
		}
 | 
			
		||||
		type Nested struct {
 | 
			
		||||
			S  S  `db:"S"`
 | 
			
		||||
			PS *S `db:"PS"`
 | 
			
		||||
 | 
			
		||||
			NoTag S
 | 
			
		||||
		}
 | 
			
		||||
		type Dest struct {
 | 
			
		||||
			NoTag S
 | 
			
		||||
			Nested
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		compiled := compileQuery("SELECT $columns FROM greeblies", reflect.TypeOf(Dest{}))
 | 
			
		||||
		assert.Equal(t, "SELECT S.I, S.PI, S.CI, S.PCI, S_S2.B, S_S2.PB, S_PS2.B, S_PS2.PB, PS.I, PS.PI, PS.CI, PS.PCI, PS_S2.B, PS_S2.PB, PS_PS2.B, PS_PS2.PB FROM greeblies", compiled.query)
 | 
			
		||||
	})
 | 
			
		||||
	t.Run("int", func(t *testing.T) {
 | 
			
		||||
		type Dest int
 | 
			
		||||
 | 
			
		||||
		// There should be no error here because we do not need to extract columns from
 | 
			
		||||
		// the destination type. There may be errors down the line in value iteration, but
 | 
			
		||||
		// that is always the case if the Go types don't match the query.
 | 
			
		||||
		compiled := compileQuery("SELECT id FROM greeblies", reflect.TypeOf(Dest(0)))
 | 
			
		||||
		assert.Equal(t, "SELECT id FROM greeblies", compiled.query)
 | 
			
		||||
	})
 | 
			
		||||
	t.Run("just one table", func(t *testing.T) {
 | 
			
		||||
		type Dest struct {
 | 
			
		||||
			Foo  int    `db:"foo"`
 | 
			
		||||
			Bar  bool   `db:"bar"`
 | 
			
		||||
			Nope string // no tag
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// The prefix is necessary because otherwise we would have to provide a struct with
 | 
			
		||||
		// a db tag in order to provide the query with the `greeblies.` prefix in the
 | 
			
		||||
		// final query. This comes up a lot when we do a JOIN to help with a condition, but
 | 
			
		||||
		// don't actually care about any of the data we joined to.
 | 
			
		||||
		compiled := compileQuery(
 | 
			
		||||
			"SELECT $columns{greeblies} FROM greeblies NATURAL JOIN props",
 | 
			
		||||
			reflect.TypeOf(Dest{}),
 | 
			
		||||
		)
 | 
			
		||||
		assert.Equal(t, "SELECT greeblies.foo, greeblies.bar FROM greeblies NATURAL JOIN props", compiled.query)
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	t.Run("using $columns without a struct is not allowed", func(t *testing.T) {
 | 
			
		||||
		type Dest int
 | 
			
		||||
 | 
			
		||||
		assert.Panics(t, func() {
 | 
			
		||||
			compileQuery("SELECT $columns FROM greeblies", reflect.TypeOf(Dest(0)))
 | 
			
		||||
		})
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestQueryBuilder(t *testing.T) {
 | 
			
		||||
	t.Run("happy time", func(t *testing.T) {
 | 
			
		||||
		var qb QueryBuilder
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,59 @@
 | 
			
		|||
/*
 | 
			
		||||
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
 | 
			
		||||
| 
						 | 
				
			
			@ -7,7 +7,7 @@ import (
 | 
			
		|||
 | 
			
		||||
type QueryBuilder struct {
 | 
			
		||||
	sql  strings.Builder
 | 
			
		||||
	args []interface{}
 | 
			
		||||
	args []any
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
| 
						 | 
				
			
			@ -18,7 +18,7 @@ of `$?` will be replaced with the correct argument number.
 | 
			
		|||
	foo ARG1 bar ARG2 baz $?
 | 
			
		||||
	foo ARG1 bar ARG2 baz ARG3
 | 
			
		||||
*/
 | 
			
		||||
func (qb *QueryBuilder) Add(sql string, args ...interface{}) {
 | 
			
		||||
func (qb *QueryBuilder) Add(sql string, args ...any) {
 | 
			
		||||
	numPlaceholders := strings.Count(sql, "$?")
 | 
			
		||||
	if numPlaceholders != len(args) {
 | 
			
		||||
		panic(fmt.Errorf("cannot add chunk to query; expected %d arguments but got %d", numPlaceholders, len(args)))
 | 
			
		||||
| 
						 | 
				
			
			@ -37,6 +37,6 @@ func (qb *QueryBuilder) String() string {
 | 
			
		|||
	return qb.sql.String()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (qb *QueryBuilder) Args() []interface{} {
 | 
			
		||||
func (qb *QueryBuilder) Args() []any {
 | 
			
		||||
	return qb.args
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -90,12 +90,9 @@ func (bot *botInstance) handleProfileCommand(ctx context.Context, i *Interaction
 | 
			
		|||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	type profileResult struct {
 | 
			
		||||
		HMNUser models.User `db:"auth_user"`
 | 
			
		||||
	}
 | 
			
		||||
	ires, err := db.QueryOne(ctx, bot.dbConn, profileResult{},
 | 
			
		||||
	hmnUser, err := db.QueryOne[models.User](ctx, bot.dbConn,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		SELECT $columns{auth_user}
 | 
			
		||||
		FROM
 | 
			
		||||
			handmade_discorduser AS duser
 | 
			
		||||
			JOIN auth_user ON duser.hmn_user_id = auth_user.id
 | 
			
		||||
| 
						 | 
				
			
			@ -122,16 +119,15 @@ func (bot *botInstance) handleProfileCommand(ctx context.Context, i *Interaction
 | 
			
		|||
		}
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
	res := ires.(*profileResult)
 | 
			
		||||
 | 
			
		||||
	projectsAndStuff, err := hmndata.FetchProjects(ctx, bot.dbConn, nil, hmndata.ProjectsQuery{
 | 
			
		||||
		OwnerIDs: []int{res.HMNUser.ID},
 | 
			
		||||
		OwnerIDs: []int{hmnUser.ID},
 | 
			
		||||
	})
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		logging.ExtractLogger(ctx).Error().Err(err).Msg("failed to fetch user projects")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	url := hmnurl.BuildUserProfile(res.HMNUser.Username)
 | 
			
		||||
	url := hmnurl.BuildUserProfile(hmnUser.Username)
 | 
			
		||||
	msg := fmt.Sprintf("<@%s>'s profile can be viewed at %s.", member.User.ID, url)
 | 
			
		||||
	if len(projectsAndStuff) > 0 {
 | 
			
		||||
		projectNoun := "projects"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -250,7 +250,7 @@ func (bot *botInstance) connect(ctx context.Context) error {
 | 
			
		|||
	// an old one or starting a new one.
 | 
			
		||||
 | 
			
		||||
	shouldResume := true
 | 
			
		||||
	isession, err := db.QueryOne(ctx, bot.dbConn, models.DiscordSession{}, `SELECT $columns FROM discord_session`)
 | 
			
		||||
	session, err := db.QueryOne[models.DiscordSession](ctx, bot.dbConn, `SELECT $columns FROM discord_session`)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		if errors.Is(err, db.NotFound) {
 | 
			
		||||
			// No session yet! Just identify and get on with it
 | 
			
		||||
| 
						 | 
				
			
			@ -262,8 +262,6 @@ func (bot *botInstance) connect(ctx context.Context) error {
 | 
			
		|||
 | 
			
		||||
	if shouldResume {
 | 
			
		||||
		// Reconnect to the previous session
 | 
			
		||||
		session := isession.(*models.DiscordSession)
 | 
			
		||||
 | 
			
		||||
		err := bot.sendGatewayMessage(ctx, GatewayMessage{
 | 
			
		||||
			Opcode: OpcodeResume,
 | 
			
		||||
			Data: Resume{
 | 
			
		||||
| 
						 | 
				
			
			@ -356,7 +354,7 @@ func (bot *botInstance) doSender(ctx context.Context) {
 | 
			
		|||
		}
 | 
			
		||||
		bot.didAckHeartbeat = false
 | 
			
		||||
 | 
			
		||||
		latestSequenceNumber, err := db.QueryInt(ctx, bot.dbConn, `SELECT sequence_number FROM discord_session`)
 | 
			
		||||
		latestSequenceNumber, err := db.QueryOneScalar[int](ctx, bot.dbConn, `SELECT sequence_number FROM discord_session`)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			log.Error().Err(err).Msg("failed to fetch latest sequence number from the db")
 | 
			
		||||
			return false
 | 
			
		||||
| 
						 | 
				
			
			@ -408,7 +406,7 @@ func (bot *botInstance) doSender(ctx context.Context) {
 | 
			
		|||
				}
 | 
			
		||||
				defer tx.Rollback(ctx)
 | 
			
		||||
 | 
			
		||||
				msgs, err := db.Query(ctx, tx, models.DiscordOutgoingMessage{}, `
 | 
			
		||||
				msgs, err := db.Query[models.DiscordOutgoingMessage](ctx, tx, `
 | 
			
		||||
					SELECT $columns
 | 
			
		||||
					FROM discord_outgoingmessages
 | 
			
		||||
					ORDER BY id ASC
 | 
			
		||||
| 
						 | 
				
			
			@ -418,8 +416,7 @@ func (bot *botInstance) doSender(ctx context.Context) {
 | 
			
		|||
					return
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				for _, imsg := range msgs {
 | 
			
		||||
					msg := imsg.(*models.DiscordOutgoingMessage)
 | 
			
		||||
				for _, msg := range msgs {
 | 
			
		||||
					if time.Now().After(msg.ExpiresAt) {
 | 
			
		||||
						continue
 | 
			
		||||
					}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -73,12 +73,9 @@ func RunHistoryWatcher(ctx context.Context, dbConn *pgxpool.Pool) <-chan struct{
 | 
			
		|||
func fetchMissingContent(ctx context.Context, dbConn *pgxpool.Pool) {
 | 
			
		||||
	log := logging.ExtractLogger(ctx)
 | 
			
		||||
 | 
			
		||||
	type query struct {
 | 
			
		||||
		Message models.DiscordMessage `db:"msg"`
 | 
			
		||||
	}
 | 
			
		||||
	imessagesWithoutContent, err := db.Query(ctx, dbConn, query{},
 | 
			
		||||
	messagesWithoutContent, err := db.Query[models.DiscordMessage](ctx, dbConn,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		SELECT $columns{msg}
 | 
			
		||||
		FROM
 | 
			
		||||
			handmade_discordmessage AS msg
 | 
			
		||||
			JOIN handmade_discorduser AS duser ON msg.user_id = duser.userid -- only fetch messages for linked discord users
 | 
			
		||||
| 
						 | 
				
			
			@ -95,10 +92,10 @@ func fetchMissingContent(ctx context.Context, dbConn *pgxpool.Pool) {
 | 
			
		|||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if len(imessagesWithoutContent) > 0 {
 | 
			
		||||
		log.Info().Msgf("There are %d Discord messages without content, fetching their content now...", len(imessagesWithoutContent))
 | 
			
		||||
	if len(messagesWithoutContent) > 0 {
 | 
			
		||||
		log.Info().Msgf("There are %d Discord messages without content, fetching their content now...", len(messagesWithoutContent))
 | 
			
		||||
	msgloop:
 | 
			
		||||
		for _, imsg := range imessagesWithoutContent {
 | 
			
		||||
		for _, msg := range messagesWithoutContent {
 | 
			
		||||
			select {
 | 
			
		||||
			case <-ctx.Done():
 | 
			
		||||
				log.Info().Msg("Scrape was canceled")
 | 
			
		||||
| 
						 | 
				
			
			@ -106,8 +103,6 @@ func fetchMissingContent(ctx context.Context, dbConn *pgxpool.Pool) {
 | 
			
		|||
			default:
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			msg := imsg.(*query).Message
 | 
			
		||||
 | 
			
		||||
			discordMsg, err := GetChannelMessage(ctx, msg.ChannelID, msg.ID)
 | 
			
		||||
			if errors.Is(err, NotFound) {
 | 
			
		||||
				// This message has apparently been deleted; delete it from our database
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -165,7 +165,7 @@ func InternMessage(
 | 
			
		|||
	dbConn db.ConnOrTx,
 | 
			
		||||
	msg *Message,
 | 
			
		||||
) error {
 | 
			
		||||
	_, err := db.QueryOne(ctx, dbConn, models.DiscordMessage{},
 | 
			
		||||
	_, err := db.QueryOne[models.DiscordMessage](ctx, dbConn,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		FROM handmade_discordmessage
 | 
			
		||||
| 
						 | 
				
			
			@ -219,7 +219,7 @@ type InternedMessage struct {
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
func FetchInternedMessage(ctx context.Context, dbConn db.ConnOrTx, msgId string) (*InternedMessage, error) {
 | 
			
		||||
	result, err := db.QueryOne(ctx, dbConn, InternedMessage{},
 | 
			
		||||
	interned, err := db.QueryOne[InternedMessage](ctx, dbConn,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		FROM
 | 
			
		||||
| 
						 | 
				
			
			@ -235,8 +235,6 @@ func FetchInternedMessage(ctx context.Context, dbConn db.ConnOrTx, msgId string)
 | 
			
		|||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	interned := result.(*InternedMessage)
 | 
			
		||||
	return interned, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -283,7 +281,7 @@ func HandleInternedMessage(ctx context.Context, dbConn db.ConnOrTx, msg *Message
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
func DeleteInternedMessage(ctx context.Context, dbConn db.ConnOrTx, interned *InternedMessage) error {
 | 
			
		||||
	isnippet, err := db.QueryOne(ctx, dbConn, models.Snippet{},
 | 
			
		||||
	snippet, err := db.QueryOne[models.Snippet](ctx, dbConn,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		FROM handmade_snippet
 | 
			
		||||
| 
						 | 
				
			
			@ -294,10 +292,6 @@ func DeleteInternedMessage(ctx context.Context, dbConn db.ConnOrTx, interned *In
 | 
			
		|||
	if err != nil && !errors.Is(err, db.NotFound) {
 | 
			
		||||
		return oops.New(err, "failed to fetch snippet for discord message")
 | 
			
		||||
	}
 | 
			
		||||
	var snippet *models.Snippet
 | 
			
		||||
	if !errors.Is(err, db.NotFound) {
 | 
			
		||||
		snippet = isnippet.(*models.Snippet)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// NOTE(asaf): Also deletes the following through a db cascade:
 | 
			
		||||
	//			   * handmade_discordmessageattachment
 | 
			
		||||
| 
						 | 
				
			
			@ -367,7 +361,7 @@ func SaveMessageContents(
 | 
			
		|||
				return oops.New(err, "failed to create or update message contents")
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			icontent, err := db.QueryOne(ctx, dbConn, models.DiscordMessageContent{},
 | 
			
		||||
			content, err := db.QueryOne[models.DiscordMessageContent](ctx, dbConn,
 | 
			
		||||
				`
 | 
			
		||||
				SELECT $columns
 | 
			
		||||
				FROM
 | 
			
		||||
| 
						 | 
				
			
			@ -380,7 +374,7 @@ func SaveMessageContents(
 | 
			
		|||
			if err != nil {
 | 
			
		||||
				return oops.New(err, "failed to fetch message contents")
 | 
			
		||||
			}
 | 
			
		||||
			interned.MessageContent = icontent.(*models.DiscordMessageContent)
 | 
			
		||||
			interned.MessageContent = content
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Save attachments
 | 
			
		||||
| 
						 | 
				
			
			@ -395,12 +389,12 @@ func SaveMessageContents(
 | 
			
		|||
 | 
			
		||||
		// Save / delete embeds
 | 
			
		||||
		if msg.OriginalHasFields("embeds") {
 | 
			
		||||
			numSavedEmbeds, err := db.QueryInt(ctx, dbConn,
 | 
			
		||||
			numSavedEmbeds, err := db.QueryOneScalar[int](ctx, dbConn,
 | 
			
		||||
				`
 | 
			
		||||
			SELECT COUNT(*)
 | 
			
		||||
			FROM handmade_discordmessageembed
 | 
			
		||||
			WHERE message_id = $1
 | 
			
		||||
			`,
 | 
			
		||||
				SELECT COUNT(*)
 | 
			
		||||
				FROM handmade_discordmessageembed
 | 
			
		||||
				WHERE message_id = $1
 | 
			
		||||
				`,
 | 
			
		||||
				msg.ID,
 | 
			
		||||
			)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
| 
						 | 
				
			
			@ -472,7 +466,7 @@ func saveAttachment(
 | 
			
		|||
	hmnUserID int,
 | 
			
		||||
	discordMessageID string,
 | 
			
		||||
) (*models.DiscordMessageAttachment, error) {
 | 
			
		||||
	iexisting, err := db.QueryOne(ctx, tx, models.DiscordMessageAttachment{},
 | 
			
		||||
	existing, err := db.QueryOne[models.DiscordMessageAttachment](ctx, tx,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		FROM handmade_discordmessageattachment
 | 
			
		||||
| 
						 | 
				
			
			@ -481,7 +475,7 @@ func saveAttachment(
 | 
			
		|||
		attachment.ID,
 | 
			
		||||
	)
 | 
			
		||||
	if err == nil {
 | 
			
		||||
		return iexisting.(*models.DiscordMessageAttachment), nil
 | 
			
		||||
		return existing, nil
 | 
			
		||||
	} else if errors.Is(err, db.NotFound) {
 | 
			
		||||
		// this is fine, just create it
 | 
			
		||||
	} else {
 | 
			
		||||
| 
						 | 
				
			
			@ -534,7 +528,7 @@ func saveAttachment(
 | 
			
		|||
		return nil, oops.New(err, "failed to save Discord attachment data")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	iDiscordAttachment, err := db.QueryOne(ctx, tx, models.DiscordMessageAttachment{},
 | 
			
		||||
	discordAttachment, err := db.QueryOne[models.DiscordMessageAttachment](ctx, tx,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		FROM handmade_discordmessageattachment
 | 
			
		||||
| 
						 | 
				
			
			@ -546,7 +540,7 @@ func saveAttachment(
 | 
			
		|||
		return nil, oops.New(err, "failed to fetch new Discord attachment data")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return iDiscordAttachment.(*models.DiscordMessageAttachment), nil
 | 
			
		||||
	return discordAttachment, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Saves an embed from Discord. NOTE: This is _not_ idempotent, so only call it
 | 
			
		||||
| 
						 | 
				
			
			@ -636,7 +630,7 @@ func saveEmbed(
 | 
			
		|||
		return nil, oops.New(err, "failed to insert new embed")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	iDiscordEmbed, err := db.QueryOne(ctx, tx, models.DiscordMessageEmbed{},
 | 
			
		||||
	discordEmbed, err := db.QueryOne[models.DiscordMessageEmbed](ctx, tx,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		FROM handmade_discordmessageembed
 | 
			
		||||
| 
						 | 
				
			
			@ -648,11 +642,11 @@ func saveEmbed(
 | 
			
		|||
		return nil, oops.New(err, "failed to fetch new Discord embed data")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return iDiscordEmbed.(*models.DiscordMessageEmbed), nil
 | 
			
		||||
	return discordEmbed, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func FetchSnippetForMessage(ctx context.Context, dbConn db.ConnOrTx, msgID string) (*models.Snippet, error) {
 | 
			
		||||
	iresult, err := db.QueryOne(ctx, dbConn, models.Snippet{},
 | 
			
		||||
	snippet, err := db.QueryOne[models.Snippet](ctx, dbConn,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		FROM handmade_snippet
 | 
			
		||||
| 
						 | 
				
			
			@ -669,7 +663,7 @@ func FetchSnippetForMessage(ctx context.Context, dbConn db.ConnOrTx, msgID strin
 | 
			
		|||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return iresult.(*models.Snippet), nil
 | 
			
		||||
	return snippet, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
| 
						 | 
				
			
			@ -805,12 +799,9 @@ func HandleSnippetForInternedMessage(ctx context.Context, dbConn db.ConnOrTx, in
 | 
			
		|||
			projectIDs[i] = p.Project.ID
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		type tagsRow struct {
 | 
			
		||||
			Tag models.Tag `db:"tags"`
 | 
			
		||||
		}
 | 
			
		||||
		iUserTags, err := db.Query(ctx, tx, tagsRow{},
 | 
			
		||||
		userTags, err := db.Query[models.Tag](ctx, tx,
 | 
			
		||||
			`
 | 
			
		||||
			SELECT $columns
 | 
			
		||||
			SELECT $columns{tags}
 | 
			
		||||
			FROM
 | 
			
		||||
				tags
 | 
			
		||||
				JOIN handmade_project AS project ON project.tag = tags.id
 | 
			
		||||
| 
						 | 
				
			
			@ -823,8 +814,7 @@ func HandleSnippetForInternedMessage(ctx context.Context, dbConn db.ConnOrTx, in
 | 
			
		|||
			return oops.New(err, "failed to fetch tags for user projects")
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		for _, itag := range iUserTags {
 | 
			
		||||
			tag := itag.(*tagsRow).Tag
 | 
			
		||||
		for _, tag := range userTags {
 | 
			
		||||
			allTags = append(allTags, tag.ID)
 | 
			
		||||
			for _, messageTag := range messageTags {
 | 
			
		||||
				if strings.EqualFold(tag.Text, messageTag) {
 | 
			
		||||
| 
						 | 
				
			
			@ -890,7 +880,7 @@ var RESnippetableUrl = regexp.MustCompile(`^https?://(youtu\.be|(www\.)?youtube\
 | 
			
		|||
 | 
			
		||||
func getSnippetAssetOrUrl(ctx context.Context, tx db.ConnOrTx, msg *models.DiscordMessage) (*uuid.UUID, *string, error) {
 | 
			
		||||
	// Check attachments
 | 
			
		||||
	attachments, err := db.Query(ctx, tx, models.DiscordMessageAttachment{},
 | 
			
		||||
	attachments, err := db.Query[models.DiscordMessageAttachment](ctx, tx,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		FROM handmade_discordmessageattachment
 | 
			
		||||
| 
						 | 
				
			
			@ -901,13 +891,12 @@ func getSnippetAssetOrUrl(ctx context.Context, tx db.ConnOrTx, msg *models.Disco
 | 
			
		|||
	if err != nil {
 | 
			
		||||
		return nil, nil, oops.New(err, "failed to fetch message attachments")
 | 
			
		||||
	}
 | 
			
		||||
	for _, iattachment := range attachments {
 | 
			
		||||
		attachment := iattachment.(*models.DiscordMessageAttachment)
 | 
			
		||||
	for _, attachment := range attachments {
 | 
			
		||||
		return &attachment.AssetID, nil, nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Check embeds
 | 
			
		||||
	embeds, err := db.Query(ctx, tx, models.DiscordMessageEmbed{},
 | 
			
		||||
	embeds, err := db.Query[models.DiscordMessageEmbed](ctx, tx,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		FROM handmade_discordmessageembed
 | 
			
		||||
| 
						 | 
				
			
			@ -918,8 +907,7 @@ func getSnippetAssetOrUrl(ctx context.Context, tx db.ConnOrTx, msg *models.Disco
 | 
			
		|||
	if err != nil {
 | 
			
		||||
		return nil, nil, oops.New(err, "failed to fetch discord embeds")
 | 
			
		||||
	}
 | 
			
		||||
	for _, iembed := range embeds {
 | 
			
		||||
		embed := iembed.(*models.DiscordMessageEmbed)
 | 
			
		||||
	for _, embed := range embeds {
 | 
			
		||||
		if embed.VideoID != nil {
 | 
			
		||||
			return embed.VideoID, nil, nil
 | 
			
		||||
		} else if embed.ImageID != nil {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -140,15 +140,15 @@ func FetchProjects(
 | 
			
		|||
	}
 | 
			
		||||
 | 
			
		||||
	// Do the query
 | 
			
		||||
	iprojects, err := db.Query(ctx, dbConn, projectRow{}, qb.String(), qb.Args()...)
 | 
			
		||||
	projectRows, err := db.Query[projectRow](ctx, dbConn, qb.String(), qb.Args()...)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, oops.New(err, "failed to fetch projects")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Fetch project owners to do permission checks
 | 
			
		||||
	projectIds := make([]int, len(iprojects))
 | 
			
		||||
	for i, iproject := range iprojects {
 | 
			
		||||
		projectIds[i] = iproject.(*projectRow).Project.ID
 | 
			
		||||
	projectIds := make([]int, len(projectRows))
 | 
			
		||||
	for i, p := range projectRows {
 | 
			
		||||
		projectIds[i] = p.Project.ID
 | 
			
		||||
	}
 | 
			
		||||
	projectOwners, err := FetchMultipleProjectsOwners(ctx, tx, projectIds)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
| 
						 | 
				
			
			@ -156,8 +156,7 @@ func FetchProjects(
 | 
			
		|||
	}
 | 
			
		||||
 | 
			
		||||
	var res []ProjectAndStuff
 | 
			
		||||
	for i, iproject := range iprojects {
 | 
			
		||||
		row := iproject.(*projectRow)
 | 
			
		||||
	for i, p := range projectRows {
 | 
			
		||||
		owners := projectOwners[i].Owners
 | 
			
		||||
 | 
			
		||||
		/*
 | 
			
		||||
| 
						 | 
				
			
			@ -191,10 +190,10 @@ func FetchProjects(
 | 
			
		|||
		}
 | 
			
		||||
 | 
			
		||||
		projectGenerallyVisible := true &&
 | 
			
		||||
			row.Project.Lifecycle.In(models.VisibleProjectLifecycles) &&
 | 
			
		||||
			!row.Project.Hidden &&
 | 
			
		||||
			(!row.Project.Personal || allOwnersApproved || row.Project.IsHMN())
 | 
			
		||||
		if row.Project.IsHMN() {
 | 
			
		||||
			p.Project.Lifecycle.In(models.VisibleProjectLifecycles) &&
 | 
			
		||||
			!p.Project.Hidden &&
 | 
			
		||||
			(!p.Project.Personal || allOwnersApproved || p.Project.IsHMN())
 | 
			
		||||
		if p.Project.IsHMN() {
 | 
			
		||||
			projectGenerallyVisible = true // hard override
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -205,11 +204,11 @@ func FetchProjects(
 | 
			
		|||
 | 
			
		||||
		if projectVisible {
 | 
			
		||||
			res = append(res, ProjectAndStuff{
 | 
			
		||||
				Project:        row.Project,
 | 
			
		||||
				LogoLightAsset: row.LogoLightAsset,
 | 
			
		||||
				LogoDarkAsset:  row.LogoDarkAsset,
 | 
			
		||||
				Project:        p.Project,
 | 
			
		||||
				LogoLightAsset: p.LogoLightAsset,
 | 
			
		||||
				LogoDarkAsset:  p.LogoDarkAsset,
 | 
			
		||||
				Owners:         owners,
 | 
			
		||||
				Tag:            row.Tag,
 | 
			
		||||
				Tag:            p.Tag,
 | 
			
		||||
			})
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
| 
						 | 
				
			
			@ -334,7 +333,7 @@ func FetchMultipleProjectsOwners(
 | 
			
		|||
		UserID    int `db:"user_id"`
 | 
			
		||||
		ProjectID int `db:"project_id"`
 | 
			
		||||
	}
 | 
			
		||||
	iuserprojects, err := db.Query(ctx, tx, userProject{},
 | 
			
		||||
	userProjects, err := db.Query[userProject](ctx, tx,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		FROM handmade_user_projects
 | 
			
		||||
| 
						 | 
				
			
			@ -348,9 +347,7 @@ func FetchMultipleProjectsOwners(
 | 
			
		|||
 | 
			
		||||
	// Get the unique user IDs from this set and fetch the users from the db
 | 
			
		||||
	var userIds []int
 | 
			
		||||
	for _, iuserproject := range iuserprojects {
 | 
			
		||||
		userProject := iuserproject.(*userProject)
 | 
			
		||||
 | 
			
		||||
	for _, userProject := range userProjects {
 | 
			
		||||
		addUserId := true
 | 
			
		||||
		for _, uid := range userIds {
 | 
			
		||||
			if uid == userProject.UserID {
 | 
			
		||||
| 
						 | 
				
			
			@ -361,14 +358,12 @@ func FetchMultipleProjectsOwners(
 | 
			
		|||
			userIds = append(userIds, userProject.UserID)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	type userQuery struct {
 | 
			
		||||
		User models.User `db:"auth_user"`
 | 
			
		||||
	}
 | 
			
		||||
	iusers, err := db.Query(ctx, tx, userQuery{},
 | 
			
		||||
	users, err := db.Query[models.User](ctx, tx,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		FROM auth_user
 | 
			
		||||
		LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id
 | 
			
		||||
		SELECT $columns{auth_user}
 | 
			
		||||
		FROM
 | 
			
		||||
			auth_user
 | 
			
		||||
			LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id
 | 
			
		||||
		WHERE
 | 
			
		||||
			auth_user.id = ANY($1)
 | 
			
		||||
		`,
 | 
			
		||||
| 
						 | 
				
			
			@ -383,9 +378,7 @@ func FetchMultipleProjectsOwners(
 | 
			
		|||
	for i, pid := range projectIds {
 | 
			
		||||
		res[i] = ProjectOwners{ProjectID: pid}
 | 
			
		||||
	}
 | 
			
		||||
	for _, iuserproject := range iuserprojects {
 | 
			
		||||
		userProject := iuserproject.(*userProject)
 | 
			
		||||
 | 
			
		||||
	for _, userProject := range userProjects {
 | 
			
		||||
		// Get a pointer to the existing record in the result
 | 
			
		||||
		var projectOwners *ProjectOwners
 | 
			
		||||
		for i := range res {
 | 
			
		||||
| 
						 | 
				
			
			@ -396,10 +389,9 @@ func FetchMultipleProjectsOwners(
 | 
			
		|||
 | 
			
		||||
		// Get the full user record we fetched
 | 
			
		||||
		var user *models.User
 | 
			
		||||
		for _, iuser := range iusers {
 | 
			
		||||
			u := iuser.(*userQuery).User
 | 
			
		||||
		for _, u := range users {
 | 
			
		||||
			if u.ID == userProject.UserID {
 | 
			
		||||
				user = &u
 | 
			
		||||
				user = u
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		if user == nil {
 | 
			
		||||
| 
						 | 
				
			
			@ -473,7 +465,7 @@ func SetProjectTag(
 | 
			
		|||
		resultTag = p.Tag
 | 
			
		||||
	} else if p.Project.TagID == nil {
 | 
			
		||||
		// Create a tag
 | 
			
		||||
		itag, err := db.QueryOne(ctx, tx, models.Tag{},
 | 
			
		||||
		tag, err := db.QueryOne[models.Tag](ctx, tx,
 | 
			
		||||
			`
 | 
			
		||||
			INSERT INTO tags (text) VALUES ($1)
 | 
			
		||||
			RETURNING $columns
 | 
			
		||||
| 
						 | 
				
			
			@ -483,7 +475,7 @@ func SetProjectTag(
 | 
			
		|||
		if err != nil {
 | 
			
		||||
			return nil, oops.New(err, "failed to create new tag for project")
 | 
			
		||||
		}
 | 
			
		||||
		resultTag = itag.(*models.Tag)
 | 
			
		||||
		resultTag = tag
 | 
			
		||||
 | 
			
		||||
		// Attach it to the project
 | 
			
		||||
		_, err = tx.Exec(ctx,
 | 
			
		||||
| 
						 | 
				
			
			@ -499,7 +491,7 @@ func SetProjectTag(
 | 
			
		|||
		}
 | 
			
		||||
	} else {
 | 
			
		||||
		// Update the text of an existing one
 | 
			
		||||
		itag, err := db.QueryOne(ctx, tx, models.Tag{},
 | 
			
		||||
		tag, err := db.QueryOne[models.Tag](ctx, tx,
 | 
			
		||||
			`
 | 
			
		||||
			UPDATE tags
 | 
			
		||||
			SET text = $1
 | 
			
		||||
| 
						 | 
				
			
			@ -511,7 +503,7 @@ func SetProjectTag(
 | 
			
		|||
		if err != nil {
 | 
			
		||||
			return nil, oops.New(err, "failed to update existing tag")
 | 
			
		||||
		}
 | 
			
		||||
		resultTag = itag.(*models.Tag)
 | 
			
		||||
		resultTag = tag
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	err = tx.Commit(ctx)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -44,10 +44,7 @@ func FetchSnippets(
 | 
			
		|||
 | 
			
		||||
	if len(q.Tags) > 0 {
 | 
			
		||||
		// Get snippet IDs with this tag, then use that in the main query
 | 
			
		||||
		type snippetIDRow struct {
 | 
			
		||||
			SnippetID int `db:"snippet_id"`
 | 
			
		||||
		}
 | 
			
		||||
		iSnippetIDs, err := db.Query(ctx, tx, snippetIDRow{},
 | 
			
		||||
		snippetIDs, err := db.QueryScalar[int](ctx, tx,
 | 
			
		||||
			`
 | 
			
		||||
			SELECT DISTINCT snippet_id
 | 
			
		||||
			FROM
 | 
			
		||||
| 
						 | 
				
			
			@ -63,14 +60,11 @@ func FetchSnippets(
 | 
			
		|||
		}
 | 
			
		||||
 | 
			
		||||
		// special early-out: no snippets found for these tags at all
 | 
			
		||||
		if len(iSnippetIDs) == 0 {
 | 
			
		||||
		if len(snippetIDs) == 0 {
 | 
			
		||||
			return nil, nil
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		q.IDs = make([]int, len(iSnippetIDs))
 | 
			
		||||
		for i := range iSnippetIDs {
 | 
			
		||||
			q.IDs[i] = iSnippetIDs[i].(*snippetIDRow).SnippetID
 | 
			
		||||
		}
 | 
			
		||||
		q.IDs = snippetIDs
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var qb db.QueryBuilder
 | 
			
		||||
| 
						 | 
				
			
			@ -125,16 +119,14 @@ func FetchSnippets(
 | 
			
		|||
		DiscordMessage *models.DiscordMessage `db:"discord_message"`
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	iresults, err := db.Query(ctx, tx, resultRow{}, qb.String(), qb.Args()...)
 | 
			
		||||
	results, err := db.Query[resultRow](ctx, tx, qb.String(), qb.Args()...)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, oops.New(err, "failed to fetch threads")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	result := make([]SnippetAndStuff, len(iresults)) // allocate extra space because why not
 | 
			
		||||
	snippetIDs := make([]int, len(iresults))
 | 
			
		||||
	for i, iresult := range iresults {
 | 
			
		||||
		row := *iresult.(*resultRow)
 | 
			
		||||
 | 
			
		||||
	result := make([]SnippetAndStuff, len(results)) // allocate extra space because why not
 | 
			
		||||
	snippetIDs := make([]int, len(results))
 | 
			
		||||
	for i, row := range results {
 | 
			
		||||
		result[i] = SnippetAndStuff{
 | 
			
		||||
			Snippet:        row.Snippet,
 | 
			
		||||
			Owner:          row.Owner,
 | 
			
		||||
| 
						 | 
				
			
			@ -150,7 +142,7 @@ func FetchSnippets(
 | 
			
		|||
		SnippetID int         `db:"snippet_tags.snippet_id"`
 | 
			
		||||
		Tag       *models.Tag `db:"tags"`
 | 
			
		||||
	}
 | 
			
		||||
	iSnippetTags, err := db.Query(ctx, tx, snippetTagRow{},
 | 
			
		||||
	snippetTags, err := db.Query[snippetTagRow](ctx, tx,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		FROM
 | 
			
		||||
| 
						 | 
				
			
			@ -170,8 +162,7 @@ func FetchSnippets(
 | 
			
		|||
	for i := range result {
 | 
			
		||||
		resultBySnippetId[result[i].Snippet.ID] = &result[i]
 | 
			
		||||
	}
 | 
			
		||||
	for _, iSnippetTag := range iSnippetTags {
 | 
			
		||||
		snippetTag := iSnippetTag.(*snippetTagRow)
 | 
			
		||||
	for _, snippetTag := range snippetTags {
 | 
			
		||||
		item := resultBySnippetId[snippetTag.SnippetID]
 | 
			
		||||
		item.Tags = append(item.Tags, snippetTag.Tag)
 | 
			
		||||
	}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -40,18 +40,11 @@ func FetchTags(ctx context.Context, dbConn db.ConnOrTx, q TagQuery) ([]*models.T
 | 
			
		|||
		qb.Add(`LIMIT $? OFFSET $?`, q.Limit, q.Offset)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	itags, err := db.Query(ctx, dbConn, models.Tag{}, qb.String(), qb.Args()...)
 | 
			
		||||
	tags, err := db.Query[models.Tag](ctx, dbConn, qb.String(), qb.Args()...)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, oops.New(err, "failed to fetch tags")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	res := make([]*models.Tag, len(itags))
 | 
			
		||||
	for i, itag := range itags {
 | 
			
		||||
		tag := itag.(*models.Tag)
 | 
			
		||||
		res[i] = tag
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return res, nil
 | 
			
		||||
	return tags, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func FetchTag(ctx context.Context, dbConn db.ConnOrTx, q TagQuery) (*models.Tag, error) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -145,15 +145,13 @@ func FetchThreads(
 | 
			
		|||
		ForumLastReadTime  *time.Time `db:"slri.lastread"`
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	iresults, err := db.Query(ctx, dbConn, resultRow{}, qb.String(), qb.Args()...)
 | 
			
		||||
	rows, err := db.Query[resultRow](ctx, dbConn, qb.String(), qb.Args()...)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, oops.New(err, "failed to fetch threads")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	result := make([]ThreadAndStuff, len(iresults))
 | 
			
		||||
	for i, iresult := range iresults {
 | 
			
		||||
		row := *iresult.(*resultRow)
 | 
			
		||||
 | 
			
		||||
	result := make([]ThreadAndStuff, len(rows))
 | 
			
		||||
	for i, row := range rows {
 | 
			
		||||
		hasRead := false
 | 
			
		||||
		if currentUser != nil && currentUser.MarkedAllReadAt.After(row.LastPost.PostDate) {
 | 
			
		||||
			hasRead = true
 | 
			
		||||
| 
						 | 
				
			
			@ -263,7 +261,7 @@ func CountThreads(
 | 
			
		|||
		)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	count, err := db.QueryInt(ctx, dbConn, qb.String(), qb.Args()...)
 | 
			
		||||
	count, err := db.QueryOneScalar[int](ctx, dbConn, qb.String(), qb.Args()...)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return 0, oops.New(err, "failed to fetch count of threads")
 | 
			
		||||
	}
 | 
			
		||||
| 
						 | 
				
			
			@ -405,15 +403,13 @@ func FetchPosts(
 | 
			
		|||
		qb.Add(`LIMIT $? OFFSET $?`, q.Limit, q.Offset)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	iresults, err := db.Query(ctx, dbConn, resultRow{}, qb.String(), qb.Args()...)
 | 
			
		||||
	rows, err := db.Query[resultRow](ctx, dbConn, qb.String(), qb.Args()...)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, oops.New(err, "failed to fetch posts")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	result := make([]PostAndStuff, len(iresults))
 | 
			
		||||
	for i, iresult := range iresults {
 | 
			
		||||
		row := *iresult.(*resultRow)
 | 
			
		||||
 | 
			
		||||
	result := make([]PostAndStuff, len(rows))
 | 
			
		||||
	for i, row := range rows {
 | 
			
		||||
		hasRead := false
 | 
			
		||||
		if currentUser != nil && currentUser.MarkedAllReadAt.After(row.Post.PostDate) {
 | 
			
		||||
			hasRead = true
 | 
			
		||||
| 
						 | 
				
			
			@ -595,7 +591,7 @@ func CountPosts(
 | 
			
		|||
		)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	count, err := db.QueryInt(ctx, dbConn, qb.String(), qb.Args()...)
 | 
			
		||||
	count, err := db.QueryOneScalar[int](ctx, dbConn, qb.String(), qb.Args()...)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return 0, oops.New(err, "failed to count posts")
 | 
			
		||||
	}
 | 
			
		||||
| 
						 | 
				
			
			@ -608,12 +604,9 @@ func UserCanEditPost(ctx context.Context, connOrTx db.ConnOrTx, user models.User
 | 
			
		|||
		return true
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	type postResult struct {
 | 
			
		||||
		AuthorID *int `db:"post.author_id"`
 | 
			
		||||
	}
 | 
			
		||||
	iresult, err := db.QueryOne(ctx, connOrTx, postResult{},
 | 
			
		||||
	authorID, err := db.QueryOneScalar[*int](ctx, connOrTx,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		SELECT post.author_id
 | 
			
		||||
		FROM
 | 
			
		||||
			handmade_post AS post
 | 
			
		||||
		WHERE
 | 
			
		||||
| 
						 | 
				
			
			@ -629,9 +622,8 @@ func UserCanEditPost(ctx context.Context, connOrTx db.ConnOrTx, user models.User
 | 
			
		|||
			panic(oops.New(err, "failed to get author of post when checking permissions"))
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	result := iresult.(*postResult)
 | 
			
		||||
 | 
			
		||||
	return result.AuthorID != nil && *result.AuthorID == user.ID
 | 
			
		||||
	return authorID != nil && *authorID == user.ID
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func CreateNewPost(
 | 
			
		||||
| 
						 | 
				
			
			@ -709,7 +701,7 @@ func DeletePost(
 | 
			
		|||
		FirstPostID int  `db:"first_id"`
 | 
			
		||||
		Deleted     bool `db:"deleted"`
 | 
			
		||||
	}
 | 
			
		||||
	ti, err := db.QueryOne(ctx, tx, threadInfo{},
 | 
			
		||||
	info, err := db.QueryOne[threadInfo](ctx, tx,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		FROM
 | 
			
		||||
| 
						 | 
				
			
			@ -722,7 +714,6 @@ func DeletePost(
 | 
			
		|||
	if err != nil {
 | 
			
		||||
		panic(oops.New(err, "failed to fetch thread info"))
 | 
			
		||||
	}
 | 
			
		||||
	info := ti.(*threadInfo)
 | 
			
		||||
	if info.Deleted {
 | 
			
		||||
		return true
 | 
			
		||||
	}
 | 
			
		||||
| 
						 | 
				
			
			@ -848,12 +839,9 @@ func CreatePostVersion(ctx context.Context, tx pgx.Tx, postId int, unparsedConte
 | 
			
		|||
		keys = append(keys, key)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	type assetId struct {
 | 
			
		||||
		AssetID uuid.UUID `db:"id"`
 | 
			
		||||
	}
 | 
			
		||||
	assetResult, err := db.Query(ctx, tx, assetId{},
 | 
			
		||||
	assetIDs, err := db.QueryScalar[uuid.UUID](ctx, tx,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		SELECT id
 | 
			
		||||
		FROM handmade_asset
 | 
			
		||||
		WHERE s3_key = ANY($1)
 | 
			
		||||
		`,
 | 
			
		||||
| 
						 | 
				
			
			@ -865,8 +853,8 @@ func CreatePostVersion(ctx context.Context, tx pgx.Tx, postId int, unparsedConte
 | 
			
		|||
 | 
			
		||||
	var values [][]interface{}
 | 
			
		||||
 | 
			
		||||
	for _, asset := range assetResult {
 | 
			
		||||
		values = append(values, []interface{}{postId, asset.(*assetId).AssetID})
 | 
			
		||||
	for _, assetID := range assetIDs {
 | 
			
		||||
		values = append(values, []interface{}{postId, assetID})
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	_, err = tx.CopyFrom(ctx, pgx.Identifier{"handmade_post_asset_usage"}, []string{"post_id", "asset_id"}, pgx.CopyFromRows(values))
 | 
			
		||||
| 
						 | 
				
			
			@ -886,7 +874,7 @@ Returns errThreadEmpty if the thread contains no visible posts any more.
 | 
			
		|||
You should probably mark the thread as deleted in this case.
 | 
			
		||||
*/
 | 
			
		||||
func FixThreadPostIds(ctx context.Context, tx pgx.Tx, threadId int) error {
 | 
			
		||||
	postsIter, err := db.Query(ctx, tx, models.Post{},
 | 
			
		||||
	posts, err := db.Query[models.Post](ctx, tx,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		FROM handmade_post
 | 
			
		||||
| 
						 | 
				
			
			@ -901,9 +889,7 @@ func FixThreadPostIds(ctx context.Context, tx pgx.Tx, threadId int) error {
 | 
			
		|||
	}
 | 
			
		||||
 | 
			
		||||
	var firstPost, lastPost *models.Post
 | 
			
		||||
	for _, ipost := range postsIter {
 | 
			
		||||
		post := ipost.(*models.Post)
 | 
			
		||||
 | 
			
		||||
	for _, post := range posts {
 | 
			
		||||
		if firstPost == nil || post.PostDate.Before(firstPost.PostDate) {
 | 
			
		||||
			firstPost = post
 | 
			
		||||
		}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -22,12 +22,9 @@ type TwitchStreamer struct {
 | 
			
		|||
var twitchRegex = regexp.MustCompile(`twitch\.tv/(?P<login>[^/]+)$`)
 | 
			
		||||
 | 
			
		||||
func FetchTwitchStreamers(ctx context.Context, dbConn db.ConnOrTx) ([]TwitchStreamer, error) {
 | 
			
		||||
	type linkResult struct {
 | 
			
		||||
		Link models.Link `db:"link"`
 | 
			
		||||
	}
 | 
			
		||||
	streamers, err := db.Query(ctx, dbConn, linkResult{},
 | 
			
		||||
	dbStreamers, err := db.Query[models.Link](ctx, dbConn,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		SELECT $columns{link}
 | 
			
		||||
		FROM
 | 
			
		||||
			handmade_links AS link
 | 
			
		||||
			LEFT JOIN auth_user AS link_owner ON link_owner.id = link.user_id
 | 
			
		||||
| 
						 | 
				
			
			@ -49,10 +46,8 @@ func FetchTwitchStreamers(ctx context.Context, dbConn db.ConnOrTx) ([]TwitchStre
 | 
			
		|||
		return nil, oops.New(err, "failed to fetch twitch links")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	result := make([]TwitchStreamer, 0, len(streamers))
 | 
			
		||||
	for _, s := range streamers {
 | 
			
		||||
		dbStreamer := s.(*linkResult).Link
 | 
			
		||||
 | 
			
		||||
	result := make([]TwitchStreamer, 0, len(dbStreamers))
 | 
			
		||||
	for _, dbStreamer := range dbStreamers {
 | 
			
		||||
		streamer := TwitchStreamer{
 | 
			
		||||
			UserID:    dbStreamer.UserID,
 | 
			
		||||
			ProjectID: dbStreamer.ProjectID,
 | 
			
		||||
| 
						 | 
				
			
			@ -81,7 +76,7 @@ func FetchTwitchStreamers(ctx context.Context, dbConn db.ConnOrTx) ([]TwitchStre
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
func FetchTwitchLoginsForUserOrProject(ctx context.Context, dbConn db.ConnOrTx, userId *int, projectId *int) ([]string, error) {
 | 
			
		||||
	links, err := db.Query(ctx, dbConn, models.Link{},
 | 
			
		||||
	links, err := db.Query[models.Link](ctx, dbConn,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		FROM
 | 
			
		||||
| 
						 | 
				
			
			@ -100,8 +95,7 @@ func FetchTwitchLoginsForUserOrProject(ctx context.Context, dbConn db.ConnOrTx,
 | 
			
		|||
	result := make([]string, 0, len(links))
 | 
			
		||||
 | 
			
		||||
	for _, l := range links {
 | 
			
		||||
		url := l.(*models.Link).URL
 | 
			
		||||
		match := twitchRegex.FindStringSubmatch(url)
 | 
			
		||||
		match := twitchRegex.FindStringSubmatch(l.URL)
 | 
			
		||||
		if match != nil {
 | 
			
		||||
			login := strings.ToLower(match[twitchRegex.SubexpIndex("login")])
 | 
			
		||||
			result = append(result, login)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -110,7 +110,7 @@ func (m PersonalProjects) Up(ctx context.Context, tx pgx.Tx) error {
 | 
			
		|||
	// Port "jam snippets" to use a tag
 | 
			
		||||
	//
 | 
			
		||||
 | 
			
		||||
	jamTagId, err := db.QueryInt(ctx, tx, `INSERT INTO tags (text) VALUES ('wheeljam') RETURNING id`)
 | 
			
		||||
	jamTagId, err := db.QueryOneScalar[int](ctx, tx, `INSERT INTO tags (text) VALUES ('wheeljam') RETURNING id`)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return oops.New(err, "failed to create jam tag")
 | 
			
		||||
	}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -44,14 +44,10 @@ func (node *SubforumTreeNode) GetLineage() []*Subforum {
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
func GetFullSubforumTree(ctx context.Context, conn *pgxpool.Pool) SubforumTree {
 | 
			
		||||
	type subforumRow struct {
 | 
			
		||||
		Subforum Subforum `db:"sf"`
 | 
			
		||||
	}
 | 
			
		||||
	rowsSlice, err := db.Query(ctx, conn, subforumRow{},
 | 
			
		||||
	subforums, err := db.Query[Subforum](ctx, conn,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		FROM
 | 
			
		||||
			handmade_subforum as sf
 | 
			
		||||
		FROM handmade_subforum
 | 
			
		||||
		ORDER BY sort, id ASC
 | 
			
		||||
		`,
 | 
			
		||||
	)
 | 
			
		||||
| 
						 | 
				
			
			@ -59,10 +55,9 @@ func GetFullSubforumTree(ctx context.Context, conn *pgxpool.Pool) SubforumTree {
 | 
			
		|||
		panic(oops.New(err, "failed to fetch subforum tree"))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	sfTreeMap := make(map[int]*SubforumTreeNode, len(rowsSlice))
 | 
			
		||||
	for _, row := range rowsSlice {
 | 
			
		||||
		sf := row.(*subforumRow).Subforum
 | 
			
		||||
		sfTreeMap[sf.ID] = &SubforumTreeNode{Subforum: sf}
 | 
			
		||||
	sfTreeMap := make(map[int]*SubforumTreeNode, len(subforums))
 | 
			
		||||
	for _, sf := range subforums {
 | 
			
		||||
		sfTreeMap[sf.ID] = &SubforumTreeNode{Subforum: *sf}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, node := range sfTreeMap {
 | 
			
		||||
| 
						 | 
				
			
			@ -71,9 +66,8 @@ func GetFullSubforumTree(ctx context.Context, conn *pgxpool.Pool) SubforumTree {
 | 
			
		|||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, row := range rowsSlice {
 | 
			
		||||
	for _, cat := range subforums {
 | 
			
		||||
		// NOTE(asaf): Doing this in a separate loop over rowsSlice to ensure that Children are in db order.
 | 
			
		||||
		cat := row.(*subforumRow).Subforum
 | 
			
		||||
		node := sfTreeMap[cat.ID]
 | 
			
		||||
		if node.Parent != nil {
 | 
			
		||||
			node.Parent.Children = append(node.Parent.Children, node)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -440,7 +440,7 @@ func updateStreamStatusInDB(ctx context.Context, conn db.ConnOrTx, status *strea
 | 
			
		|||
	inserted := false
 | 
			
		||||
	if isStatusRelevant(status) {
 | 
			
		||||
		log.Debug().Msg("Status relevant")
 | 
			
		||||
		_, err := db.QueryOne(ctx, conn, models.TwitchStream{},
 | 
			
		||||
		_, err := db.QueryOne[models.TwitchStream](ctx, conn,
 | 
			
		||||
			`
 | 
			
		||||
			SELECT $columns
 | 
			
		||||
			FROM twitch_streams
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -207,7 +207,7 @@ func AdminApprovalQueue(c *RequestContext) ResponseData {
 | 
			
		|||
		userIds = append(userIds, u.User.ID)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	userLinks, err := db.Query(c.Context(), c.Conn, models.Link{},
 | 
			
		||||
	userLinks, err := db.Query[models.Link](c.Context(), c.Conn,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		FROM
 | 
			
		||||
| 
						 | 
				
			
			@ -222,8 +222,7 @@ func AdminApprovalQueue(c *RequestContext) ResponseData {
 | 
			
		|||
		return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user links"))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, ul := range userLinks {
 | 
			
		||||
		link := ul.(*models.Link)
 | 
			
		||||
	for _, link := range userLinks {
 | 
			
		||||
		userData := unapprovedUsers[userIDToDataIdx[*link.UserID]]
 | 
			
		||||
		userData.UserLinks = append(userData.UserLinks, templates.LinkToTemplate(link))
 | 
			
		||||
	}
 | 
			
		||||
| 
						 | 
				
			
			@ -257,12 +256,9 @@ func AdminApprovalQueueSubmit(c *RequestContext) ResponseData {
 | 
			
		|||
		return RejectRequest(c, "User id can't be parsed")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	type userQuery struct {
 | 
			
		||||
		User models.User `db:"auth_user"`
 | 
			
		||||
	}
 | 
			
		||||
	u, err := db.QueryOne(c.Context(), c.Conn, userQuery{},
 | 
			
		||||
	user, err := db.QueryOne[models.User](c.Context(), c.Conn,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		SELECT $columns{auth_user}
 | 
			
		||||
			FROM auth_user
 | 
			
		||||
			LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id
 | 
			
		||||
		WHERE auth_user.id = $1
 | 
			
		||||
| 
						 | 
				
			
			@ -276,7 +272,6 @@ func AdminApprovalQueueSubmit(c *RequestContext) ResponseData {
 | 
			
		|||
			return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user"))
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	user := u.(*userQuery).User
 | 
			
		||||
 | 
			
		||||
	whatHappened := ""
 | 
			
		||||
	if action == ApprovalQueueActionApprove {
 | 
			
		||||
| 
						 | 
				
			
			@ -337,7 +332,7 @@ type UnapprovedPost struct {
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
func fetchUnapprovedPosts(c *RequestContext) ([]*UnapprovedPost, error) {
 | 
			
		||||
	it, err := db.Query(c.Context(), c.Conn, UnapprovedPost{},
 | 
			
		||||
	posts, err := db.Query[UnapprovedPost](c.Context(), c.Conn,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		FROM
 | 
			
		||||
| 
						 | 
				
			
			@ -358,11 +353,7 @@ func fetchUnapprovedPosts(c *RequestContext) ([]*UnapprovedPost, error) {
 | 
			
		|||
	if err != nil {
 | 
			
		||||
		return nil, oops.New(err, "failed to fetch unapproved posts")
 | 
			
		||||
	}
 | 
			
		||||
	var res []*UnapprovedPost
 | 
			
		||||
	for _, iresult := range it {
 | 
			
		||||
		res = append(res, iresult.(*UnapprovedPost))
 | 
			
		||||
	}
 | 
			
		||||
	return res, nil
 | 
			
		||||
	return posts, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type UnapprovedProject struct {
 | 
			
		||||
| 
						 | 
				
			
			@ -372,12 +363,9 @@ type UnapprovedProject struct {
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
func fetchUnapprovedProjects(c *RequestContext) ([]UnapprovedProject, error) {
 | 
			
		||||
	type unapprovedUser struct {
 | 
			
		||||
		ID int `db:"id"`
 | 
			
		||||
	}
 | 
			
		||||
	it, err := db.Query(c.Context(), c.Conn, unapprovedUser{},
 | 
			
		||||
	ownerIDs, err := db.QueryScalar[int](c.Context(), c.Conn,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		SELECT id
 | 
			
		||||
		FROM
 | 
			
		||||
			auth_user AS u
 | 
			
		||||
		WHERE
 | 
			
		||||
| 
						 | 
				
			
			@ -388,10 +376,6 @@ func fetchUnapprovedProjects(c *RequestContext) ([]UnapprovedProject, error) {
 | 
			
		|||
	if err != nil {
 | 
			
		||||
		return nil, oops.New(err, "failed to fetch unapproved users")
 | 
			
		||||
	}
 | 
			
		||||
	ownerIDs := make([]int, 0, len(it))
 | 
			
		||||
	for _, uid := range it {
 | 
			
		||||
		ownerIDs = append(ownerIDs, uid.(*unapprovedUser).ID)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	projects, err := hmndata.FetchProjects(c.Context(), c.Conn, c.CurrentUser, hmndata.ProjectsQuery{
 | 
			
		||||
		OwnerIDs:      ownerIDs,
 | 
			
		||||
| 
						 | 
				
			
			@ -406,7 +390,7 @@ func fetchUnapprovedProjects(c *RequestContext) ([]UnapprovedProject, error) {
 | 
			
		|||
		projectIDs = append(projectIDs, p.Project.ID)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	projectLinks, err := db.Query(c.Context(), c.Conn, models.Link{},
 | 
			
		||||
	projectLinks, err := db.Query[models.Link](c.Context(), c.Conn,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		FROM
 | 
			
		||||
| 
						 | 
				
			
			@ -425,8 +409,7 @@ func fetchUnapprovedProjects(c *RequestContext) ([]UnapprovedProject, error) {
 | 
			
		|||
 | 
			
		||||
	for idx, proj := range projects {
 | 
			
		||||
		links := make([]*models.Link, 0, 10) // NOTE(asaf): 10 should be enough for most projects.
 | 
			
		||||
		for _, l := range projectLinks {
 | 
			
		||||
			link := l.(*models.Link)
 | 
			
		||||
		for _, link := range projectLinks {
 | 
			
		||||
			if *link.ProjectID == proj.Project.ID {
 | 
			
		||||
				links = append(links, link)
 | 
			
		||||
			}
 | 
			
		||||
| 
						 | 
				
			
			@ -455,7 +438,7 @@ func deleteAllPostsForUser(ctx context.Context, conn *pgxpool.Pool, userId int)
 | 
			
		|||
		ThreadID int `db:"thread.id"`
 | 
			
		||||
		PostID   int `db:"post.id"`
 | 
			
		||||
	}
 | 
			
		||||
	it, err := db.Query(ctx, tx, toDelete{},
 | 
			
		||||
	rows, err := db.Query[toDelete](ctx, tx,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		FROM
 | 
			
		||||
| 
						 | 
				
			
			@ -471,8 +454,7 @@ func deleteAllPostsForUser(ctx context.Context, conn *pgxpool.Pool, userId int)
 | 
			
		|||
		return oops.New(err, "failed to fetch posts to delete for user")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, iResult := range it {
 | 
			
		||||
		row := iResult.(*toDelete)
 | 
			
		||||
	for _, row := range rows {
 | 
			
		||||
		hmndata.DeletePost(ctx, tx, row.ThreadID, row.PostID)
 | 
			
		||||
	}
 | 
			
		||||
	err = tx.Commit(ctx)
 | 
			
		||||
| 
						 | 
				
			
			@ -489,9 +471,9 @@ func deleteAllProjectsForUser(ctx context.Context, conn *pgxpool.Pool, userId in
 | 
			
		|||
	}
 | 
			
		||||
	defer tx.Rollback(ctx)
 | 
			
		||||
 | 
			
		||||
	toDelete, err := db.Query(ctx, tx, models.Project{},
 | 
			
		||||
	projectIDsToDelete, err := db.QueryScalar[int](ctx, tx,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		SELECT project.id
 | 
			
		||||
		FROM
 | 
			
		||||
			handmade_project AS project
 | 
			
		||||
			JOIN handmade_user_projects AS up ON up.project_id = project.id
 | 
			
		||||
| 
						 | 
				
			
			@ -504,17 +486,12 @@ func deleteAllProjectsForUser(ctx context.Context, conn *pgxpool.Pool, userId in
 | 
			
		|||
		return oops.New(err, "failed to fetch user's projects")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var projectIds []int
 | 
			
		||||
	for _, p := range toDelete {
 | 
			
		||||
		projectIds = append(projectIds, p.(*models.Project).ID)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if len(projectIds) > 0 {
 | 
			
		||||
	if len(projectIDsToDelete) > 0 {
 | 
			
		||||
		_, err = tx.Exec(ctx,
 | 
			
		||||
			`
 | 
			
		||||
			DELETE FROM handmade_project WHERE id = ANY($1)
 | 
			
		||||
			`,
 | 
			
		||||
			projectIds,
 | 
			
		||||
			projectIDsToDelete,
 | 
			
		||||
		)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return oops.New(err, "failed to delete user's projects")
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -19,12 +19,9 @@ func APICheckUsername(c *RequestContext) ResponseData {
 | 
			
		|||
		requestedUsername := usernameArgs[0]
 | 
			
		||||
		found = true
 | 
			
		||||
		c.Perf.StartBlock("SQL", "Fetch user")
 | 
			
		||||
		type userQuery struct {
 | 
			
		||||
			User models.User `db:"auth_user"`
 | 
			
		||||
		}
 | 
			
		||||
		userResult, err := db.QueryOne(c.Context(), c.Conn, userQuery{},
 | 
			
		||||
		user, err := db.QueryOne[models.User](c.Context(), c.Conn,
 | 
			
		||||
			`
 | 
			
		||||
			SELECT $columns
 | 
			
		||||
			SELECT $columns{auth_user}
 | 
			
		||||
			FROM
 | 
			
		||||
				auth_user
 | 
			
		||||
				LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id
 | 
			
		||||
| 
						 | 
				
			
			@ -43,7 +40,7 @@ func APICheckUsername(c *RequestContext) ResponseData {
 | 
			
		|||
				return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user: %s", requestedUsername))
 | 
			
		||||
			}
 | 
			
		||||
		} else {
 | 
			
		||||
			canonicalUsername = userResult.(*userQuery).User.Username
 | 
			
		||||
			canonicalUsername = user.Username
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -75,13 +75,11 @@ func Login(c *RequestContext) ResponseData {
 | 
			
		|||
		return res
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	type userQuery struct {
 | 
			
		||||
		User models.User `db:"auth_user"`
 | 
			
		||||
	}
 | 
			
		||||
	userRow, err := db.QueryOne(c.Context(), c.Conn, userQuery{},
 | 
			
		||||
	user, err := db.QueryOne[models.User](c.Context(), c.Conn,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
			FROM auth_user
 | 
			
		||||
		SELECT $columns{auth_user}
 | 
			
		||||
		FROM
 | 
			
		||||
			auth_user
 | 
			
		||||
			LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id
 | 
			
		||||
		WHERE LOWER(username) = LOWER($1)
 | 
			
		||||
		`,
 | 
			
		||||
| 
						 | 
				
			
			@ -94,7 +92,6 @@ func Login(c *RequestContext) ResponseData {
 | 
			
		|||
			return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to look up user by username"))
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	user := &userRow.(*userQuery).User
 | 
			
		||||
 | 
			
		||||
	success, err := tryLogin(c, user, password)
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -174,7 +171,7 @@ func RegisterNewUserSubmit(c *RequestContext) ResponseData {
 | 
			
		|||
 | 
			
		||||
	c.Perf.StartBlock("SQL", "Check for existing usernames and emails")
 | 
			
		||||
	userAlreadyExists := true
 | 
			
		||||
	_, err := db.QueryInt(c.Context(), c.Conn,
 | 
			
		||||
	_, err := db.QueryOneScalar[int](c.Context(), c.Conn,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT id
 | 
			
		||||
		FROM auth_user
 | 
			
		||||
| 
						 | 
				
			
			@ -195,7 +192,7 @@ func RegisterNewUserSubmit(c *RequestContext) ResponseData {
 | 
			
		|||
	}
 | 
			
		||||
 | 
			
		||||
	emailAlreadyExists := true
 | 
			
		||||
	_, err = db.QueryInt(c.Context(), c.Conn,
 | 
			
		||||
	_, err = db.QueryOneScalar[int](c.Context(), c.Conn,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT id
 | 
			
		||||
		FROM auth_user
 | 
			
		||||
| 
						 | 
				
			
			@ -454,17 +451,16 @@ func RequestPasswordResetSubmit(c *RequestContext) ResponseData {
 | 
			
		|||
		return RejectRequest(c, "You must provide a username and an email address.")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var user *models.User
 | 
			
		||||
 | 
			
		||||
	c.Perf.StartBlock("SQL", "Fetching user")
 | 
			
		||||
	type userQuery struct {
 | 
			
		||||
		User models.User `db:"auth_user"`
 | 
			
		||||
	}
 | 
			
		||||
	userRow, err := db.QueryOne(c.Context(), c.Conn, userQuery{},
 | 
			
		||||
	user, err := db.QueryOne[models.User](c.Context(), c.Conn,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		FROM auth_user
 | 
			
		||||
		LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id
 | 
			
		||||
		SELECT $columns{auth_user}
 | 
			
		||||
		FROM
 | 
			
		||||
			auth_user
 | 
			
		||||
			LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id
 | 
			
		||||
		WHERE
 | 
			
		||||
			LOWER(username) = LOWER($1)
 | 
			
		||||
			AND LOWER(email) = LOWER($2)
 | 
			
		||||
| 
						 | 
				
			
			@ -478,13 +474,10 @@ func RequestPasswordResetSubmit(c *RequestContext) ResponseData {
 | 
			
		|||
			return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to look up user by username"))
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	if userRow != nil {
 | 
			
		||||
		user = &userRow.(*userQuery).User
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if user != nil {
 | 
			
		||||
		c.Perf.StartBlock("SQL", "Fetching existing token")
 | 
			
		||||
		tokenRow, err := db.QueryOne(c.Context(), c.Conn, models.OneTimeToken{},
 | 
			
		||||
		resetToken, err := db.QueryOne[models.OneTimeToken](c.Context(), c.Conn,
 | 
			
		||||
			`
 | 
			
		||||
			SELECT $columns
 | 
			
		||||
			FROM handmade_onetimetoken
 | 
			
		||||
| 
						 | 
				
			
			@ -501,10 +494,6 @@ func RequestPasswordResetSubmit(c *RequestContext) ResponseData {
 | 
			
		|||
				return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch onetimetoken for user"))
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		var resetToken *models.OneTimeToken
 | 
			
		||||
		if tokenRow != nil {
 | 
			
		||||
			resetToken = tokenRow.(*models.OneTimeToken)
 | 
			
		||||
		}
 | 
			
		||||
		now := time.Now()
 | 
			
		||||
 | 
			
		||||
		if resetToken != nil {
 | 
			
		||||
| 
						 | 
				
			
			@ -527,7 +516,7 @@ func RequestPasswordResetSubmit(c *RequestContext) ResponseData {
 | 
			
		|||
 | 
			
		||||
		if resetToken == nil {
 | 
			
		||||
			c.Perf.StartBlock("SQL", "Creating new token")
 | 
			
		||||
			tokenRow, err := db.QueryOne(c.Context(), c.Conn, models.OneTimeToken{},
 | 
			
		||||
			newToken, err := db.QueryOne[models.OneTimeToken](c.Context(), c.Conn,
 | 
			
		||||
				`
 | 
			
		||||
				INSERT INTO handmade_onetimetoken (token_type, created, expires, token_content, owner_id)
 | 
			
		||||
				VALUES ($1, $2, $3, $4, $5)
 | 
			
		||||
| 
						 | 
				
			
			@ -543,7 +532,7 @@ func RequestPasswordResetSubmit(c *RequestContext) ResponseData {
 | 
			
		|||
			if err != nil {
 | 
			
		||||
				return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to create onetimetoken"))
 | 
			
		||||
			}
 | 
			
		||||
			resetToken = tokenRow.(*models.OneTimeToken)
 | 
			
		||||
			resetToken = newToken
 | 
			
		||||
 | 
			
		||||
			err = email.SendPasswordReset(user.Email, user.BestName(), user.Username, resetToken.Content, resetToken.Expires, c.Perf)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
| 
						 | 
				
			
			@ -787,7 +776,7 @@ func validateUsernameAndToken(c *RequestContext, username string, token string,
 | 
			
		|||
		User         models.User          `db:"auth_user"`
 | 
			
		||||
		OneTimeToken *models.OneTimeToken `db:"onetimetoken"`
 | 
			
		||||
	}
 | 
			
		||||
	row, err := db.QueryOne(c.Context(), c.Conn, userAndTokenQuery{},
 | 
			
		||||
	data, err := db.QueryOne[userAndTokenQuery](c.Context(), c.Conn,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		FROM auth_user
 | 
			
		||||
| 
						 | 
				
			
			@ -807,8 +796,7 @@ func validateUsernameAndToken(c *RequestContext, username string, token string,
 | 
			
		|||
			return result
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	if row != nil {
 | 
			
		||||
		data := row.(*userAndTokenQuery)
 | 
			
		||||
	if data != nil {
 | 
			
		||||
		result.User = &data.User
 | 
			
		||||
		result.OneTimeToken = data.OneTimeToken
 | 
			
		||||
		if result.OneTimeToken != nil {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -558,7 +558,7 @@ func getCommonBlogData(c *RequestContext) (commonBlogData, bool) {
 | 
			
		|||
		res.ThreadID = threadId
 | 
			
		||||
 | 
			
		||||
		c.Perf.StartBlock("SQL", "Verify that the thread exists")
 | 
			
		||||
		threadExists, err := db.QueryBool(c.Context(), c.Conn,
 | 
			
		||||
		threadExists, err := db.QueryOneScalar[bool](c.Context(), c.Conn,
 | 
			
		||||
			`
 | 
			
		||||
			SELECT COUNT(*) > 0
 | 
			
		||||
			FROM handmade_thread
 | 
			
		||||
| 
						 | 
				
			
			@ -586,7 +586,7 @@ func getCommonBlogData(c *RequestContext) (commonBlogData, bool) {
 | 
			
		|||
		res.PostID = postId
 | 
			
		||||
 | 
			
		||||
		c.Perf.StartBlock("SQL", "Verify that the post exists")
 | 
			
		||||
		postExists, err := db.QueryBool(c.Context(), c.Conn,
 | 
			
		||||
		postExists, err := db.QueryOneScalar[bool](c.Context(), c.Conn,
 | 
			
		||||
			`
 | 
			
		||||
			SELECT COUNT(*) > 0
 | 
			
		||||
			FROM handmade_post
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -104,7 +104,7 @@ func DiscordUnlink(c *RequestContext) ResponseData {
 | 
			
		|||
	}
 | 
			
		||||
	defer tx.Rollback(c.Context())
 | 
			
		||||
 | 
			
		||||
	iDiscordUser, err := db.QueryOne(c.Context(), tx, models.DiscordUser{},
 | 
			
		||||
	discordUser, err := db.QueryOne[models.DiscordUser](c.Context(), tx,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		FROM handmade_discorduser
 | 
			
		||||
| 
						 | 
				
			
			@ -119,7 +119,6 @@ func DiscordUnlink(c *RequestContext) ResponseData {
 | 
			
		|||
			return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to get Discord user for unlink"))
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	discordUser := iDiscordUser.(*models.DiscordUser)
 | 
			
		||||
 | 
			
		||||
	_, err = tx.Exec(c.Context(),
 | 
			
		||||
		`
 | 
			
		||||
| 
						 | 
				
			
			@ -146,7 +145,7 @@ func DiscordUnlink(c *RequestContext) ResponseData {
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
func DiscordShowcaseBacklog(c *RequestContext) ResponseData {
 | 
			
		||||
	iduser, err := db.QueryOne(c.Context(), c.Conn, models.DiscordUser{},
 | 
			
		||||
	duser, err := db.QueryOne[models.DiscordUser](c.Context(), c.Conn,
 | 
			
		||||
		`SELECT $columns FROM handmade_discorduser WHERE hmn_user_id = $1`,
 | 
			
		||||
		c.CurrentUser.ID,
 | 
			
		||||
	)
 | 
			
		||||
| 
						 | 
				
			
			@ -157,14 +156,10 @@ func DiscordShowcaseBacklog(c *RequestContext) ResponseData {
 | 
			
		|||
	} else if err != nil {
 | 
			
		||||
		return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to get discord user"))
 | 
			
		||||
	}
 | 
			
		||||
	duser := iduser.(*models.DiscordUser)
 | 
			
		||||
 | 
			
		||||
	type messageIdQuery struct {
 | 
			
		||||
		MessageID string `db:"msg.id"`
 | 
			
		||||
	}
 | 
			
		||||
	iMsgIDs, err := db.Query(c.Context(), c.Conn, messageIdQuery{},
 | 
			
		||||
	msgIDs, err := db.QueryScalar[string](c.Context(), c.Conn,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		SELECT msg.id
 | 
			
		||||
		FROM
 | 
			
		||||
			handmade_discordmessage AS msg
 | 
			
		||||
		WHERE
 | 
			
		||||
| 
						 | 
				
			
			@ -178,10 +173,6 @@ func DiscordShowcaseBacklog(c *RequestContext) ResponseData {
 | 
			
		|||
		return c.ErrorResponse(http.StatusInternalServerError, err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var msgIDs []string
 | 
			
		||||
	for _, imsgId := range iMsgIDs {
 | 
			
		||||
		msgIDs = append(msgIDs, imsgId.(*messageIdQuery).MessageID)
 | 
			
		||||
	}
 | 
			
		||||
	for _, msgID := range msgIDs {
 | 
			
		||||
		interned, err := discord.FetchInternedMessage(c.Context(), c.Conn, msgID)
 | 
			
		||||
		if err != nil && !errors.Is(err, db.NotFound) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -936,7 +936,7 @@ func addForumUrlsToPost(urlContext *hmnurl.UrlContext, p *templates.Post, subfor
 | 
			
		|||
// Takes a template post and adds information about how many posts the user has made
 | 
			
		||||
// on the site.
 | 
			
		||||
func addAuthorCountsToPost(ctx context.Context, conn db.ConnOrTx, p *templates.Post) {
 | 
			
		||||
	numPosts, err := db.QueryInt(ctx, conn,
 | 
			
		||||
	numPosts, err := db.QueryOneScalar[int](ctx, conn,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT COUNT(*)
 | 
			
		||||
		FROM
 | 
			
		||||
| 
						 | 
				
			
			@ -956,7 +956,7 @@ func addAuthorCountsToPost(ctx context.Context, conn db.ConnOrTx, p *templates.P
 | 
			
		|||
		p.AuthorNumPosts = numPosts
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	numProjects, err := db.QueryInt(ctx, conn,
 | 
			
		||||
	numProjects, err := db.QueryOneScalar[int](ctx, conn,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT COUNT(*)
 | 
			
		||||
		FROM
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -89,8 +89,7 @@ func SaveImageFile(c *RequestContext, dbConn db.ConnOrTx, fileFieldName string,
 | 
			
		|||
		img.Seek(0, io.SeekStart)
 | 
			
		||||
		io.Copy(hasher, img) // NOTE(asaf): Writing to hash.Hash never returns an error according to the docs
 | 
			
		||||
		sha1sum := hasher.Sum(nil)
 | 
			
		||||
		// TODO(db): Should use insert helper
 | 
			
		||||
		imageFile, err := db.QueryOne(c.Context(), dbConn, models.ImageFile{},
 | 
			
		||||
		imageFile, err := db.QueryOne[models.ImageFile](c.Context(), dbConn,
 | 
			
		||||
			`
 | 
			
		||||
			INSERT INTO handmade_imagefile (file, size, sha1sum, protected, width, height)
 | 
			
		||||
			VALUES ($1, $2, $3, $4, $5, $6)
 | 
			
		||||
| 
						 | 
				
			
			@ -105,7 +104,7 @@ func SaveImageFile(c *RequestContext, dbConn db.ConnOrTx, fileFieldName string,
 | 
			
		|||
		}
 | 
			
		||||
 | 
			
		||||
		return SaveImageFileResult{
 | 
			
		||||
			ImageFile: imageFile.(*models.ImageFile),
 | 
			
		||||
			ImageFile: imageFile,
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -30,10 +30,9 @@ func ParseLinks(text string) []ParsedLink {
 | 
			
		|||
	return res
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func LinksToText(links []interface{}) string {
 | 
			
		||||
func LinksToText(links []*models.Link) string {
 | 
			
		||||
	linksText := ""
 | 
			
		||||
	for _, l := range links {
 | 
			
		||||
		link := l.(*models.Link)
 | 
			
		||||
	for _, link := range links {
 | 
			
		||||
		linksText += fmt.Sprintf("%s %s\n", link.URL, link.Name)
 | 
			
		||||
	}
 | 
			
		||||
	return linksText
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -532,11 +532,12 @@ func FetchPodcast(c *RequestContext, projectId int, fetchEpisodes bool, episodeG
 | 
			
		|||
		Podcast       models.Podcast `db:"podcast"`
 | 
			
		||||
		ImageFilename string         `db:"imagefile.file"`
 | 
			
		||||
	}
 | 
			
		||||
	podcastQueryResult, err := db.QueryOne(c.Context(), c.Conn, podcastQuery{},
 | 
			
		||||
	podcastQueryResult, err := db.QueryOne[podcastQuery](c.Context(), c.Conn,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		FROM handmade_podcast AS podcast
 | 
			
		||||
		LEFT JOIN handmade_imagefile AS imagefile ON imagefile.id = podcast.image_id
 | 
			
		||||
		FROM
 | 
			
		||||
			handmade_podcast AS podcast
 | 
			
		||||
			LEFT JOIN handmade_imagefile AS imagefile ON imagefile.id = podcast.image_id
 | 
			
		||||
		WHERE podcast.project_id = $1
 | 
			
		||||
		`,
 | 
			
		||||
		projectId,
 | 
			
		||||
| 
						 | 
				
			
			@ -549,18 +550,15 @@ func FetchPodcast(c *RequestContext, projectId int, fetchEpisodes bool, episodeG
 | 
			
		|||
			return result, oops.New(err, "failed to fetch podcast")
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	podcast := podcastQueryResult.(*podcastQuery).Podcast
 | 
			
		||||
	podcastImageFilename := podcastQueryResult.(*podcastQuery).ImageFilename
 | 
			
		||||
	podcast := podcastQueryResult.Podcast
 | 
			
		||||
	podcastImageFilename := podcastQueryResult.ImageFilename
 | 
			
		||||
	result.Podcast = &podcast
 | 
			
		||||
	result.ImageFile = podcastImageFilename
 | 
			
		||||
 | 
			
		||||
	if fetchEpisodes {
 | 
			
		||||
		type podcastEpisodeQuery struct {
 | 
			
		||||
			Episode models.PodcastEpisode `db:"episode"`
 | 
			
		||||
		}
 | 
			
		||||
		if episodeGUID == "" {
 | 
			
		||||
			c.Perf.StartBlock("SQL", "Fetch podcast episodes")
 | 
			
		||||
			podcastEpisodeQueryResult, err := db.Query(c.Context(), c.Conn, podcastEpisodeQuery{},
 | 
			
		||||
			episodes, err := db.Query[models.PodcastEpisode](c.Context(), c.Conn,
 | 
			
		||||
				`
 | 
			
		||||
				SELECT $columns
 | 
			
		||||
				FROM handmade_podcastepisode AS episode
 | 
			
		||||
| 
						 | 
				
			
			@ -573,16 +571,14 @@ func FetchPodcast(c *RequestContext, projectId int, fetchEpisodes bool, episodeG
 | 
			
		|||
			if err != nil {
 | 
			
		||||
				return result, oops.New(err, "failed to fetch podcast episodes")
 | 
			
		||||
			}
 | 
			
		||||
			for _, episodeRow := range podcastEpisodeQueryResult {
 | 
			
		||||
				result.Episodes = append(result.Episodes, &episodeRow.(*podcastEpisodeQuery).Episode)
 | 
			
		||||
			}
 | 
			
		||||
			result.Episodes = episodes
 | 
			
		||||
		} else {
 | 
			
		||||
			guid, err := uuid.Parse(episodeGUID)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				return result, err
 | 
			
		||||
			}
 | 
			
		||||
			c.Perf.StartBlock("SQL", "Fetch podcast episode")
 | 
			
		||||
			podcastEpisodeQueryResult, err := db.QueryOne(c.Context(), c.Conn, podcastEpisodeQuery{},
 | 
			
		||||
			episode, err := db.QueryOne[models.PodcastEpisode](c.Context(), c.Conn,
 | 
			
		||||
				`
 | 
			
		||||
				SELECT $columns
 | 
			
		||||
				FROM handmade_podcastepisode AS episode
 | 
			
		||||
| 
						 | 
				
			
			@ -599,8 +595,7 @@ func FetchPodcast(c *RequestContext, projectId int, fetchEpisodes bool, episodeG
 | 
			
		|||
					return result, oops.New(err, "failed to fetch podcast episode")
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
			episode := podcastEpisodeQueryResult.(*podcastEpisodeQuery).Episode
 | 
			
		||||
			result.Episodes = append(result.Episodes, &episode)
 | 
			
		||||
			result.Episodes = append(result.Episodes, episode)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -187,12 +187,9 @@ func ProjectHomepage(c *RequestContext) ResponseData {
 | 
			
		|||
	}
 | 
			
		||||
 | 
			
		||||
	c.Perf.StartBlock("SQL", "Fetching screenshots")
 | 
			
		||||
	type screenshotQuery struct {
 | 
			
		||||
		Filename string `db:"screenshot.file"`
 | 
			
		||||
	}
 | 
			
		||||
	screenshotQueryResult, err := db.Query(c.Context(), c.Conn, screenshotQuery{},
 | 
			
		||||
	screenshotFilenames, err := db.QueryScalar[string](c.Context(), c.Conn,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		SELECT screenshot.file
 | 
			
		||||
		FROM
 | 
			
		||||
			handmade_imagefile AS screenshot
 | 
			
		||||
			INNER JOIN handmade_project_screenshots ON screenshot.id = handmade_project_screenshots.imagefile_id
 | 
			
		||||
| 
						 | 
				
			
			@ -207,10 +204,7 @@ func ProjectHomepage(c *RequestContext) ResponseData {
 | 
			
		|||
	c.Perf.EndBlock()
 | 
			
		||||
 | 
			
		||||
	c.Perf.StartBlock("SQL", "Fetching project links")
 | 
			
		||||
	type projectLinkQuery struct {
 | 
			
		||||
		Link models.Link `db:"link"`
 | 
			
		||||
	}
 | 
			
		||||
	projectLinkResult, err := db.Query(c.Context(), c.Conn, projectLinkQuery{},
 | 
			
		||||
	projectLinks, err := db.Query[models.Link](c.Context(), c.Conn,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		FROM
 | 
			
		||||
| 
						 | 
				
			
			@ -237,7 +231,7 @@ func ProjectHomepage(c *RequestContext) ResponseData {
 | 
			
		|||
		Thread models.Thread `db:"thread"`
 | 
			
		||||
		Author models.User   `db:"author"`
 | 
			
		||||
	}
 | 
			
		||||
	postQueryResult, err := db.Query(c.Context(), c.Conn, postQuery{},
 | 
			
		||||
	posts, err := db.Query[postQuery](c.Context(), c.Conn,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		FROM
 | 
			
		||||
| 
						 | 
				
			
			@ -318,21 +312,21 @@ func ProjectHomepage(c *RequestContext) ResponseData {
 | 
			
		|||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, screenshot := range screenshotQueryResult {
 | 
			
		||||
		templateData.Screenshots = append(templateData.Screenshots, hmnurl.BuildUserFile(screenshot.(*screenshotQuery).Filename))
 | 
			
		||||
	for _, screenshotFilename := range screenshotFilenames {
 | 
			
		||||
		templateData.Screenshots = append(templateData.Screenshots, hmnurl.BuildUserFile(screenshotFilename))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, link := range projectLinkResult {
 | 
			
		||||
		templateData.ProjectLinks = append(templateData.ProjectLinks, templates.LinkToTemplate(&link.(*projectLinkQuery).Link))
 | 
			
		||||
	for _, link := range projectLinks {
 | 
			
		||||
		templateData.ProjectLinks = append(templateData.ProjectLinks, templates.LinkToTemplate(link))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, post := range postQueryResult {
 | 
			
		||||
	for _, post := range posts {
 | 
			
		||||
		templateData.RecentActivity = append(templateData.RecentActivity, PostToTimelineItem(
 | 
			
		||||
			c.UrlContext,
 | 
			
		||||
			lineageBuilder,
 | 
			
		||||
			&post.(*postQuery).Post,
 | 
			
		||||
			&post.(*postQuery).Thread,
 | 
			
		||||
			&post.(*postQuery).Author,
 | 
			
		||||
			&post.Post,
 | 
			
		||||
			&post.Thread,
 | 
			
		||||
			&post.Author,
 | 
			
		||||
			c.Theme,
 | 
			
		||||
		))
 | 
			
		||||
	}
 | 
			
		||||
| 
						 | 
				
			
			@ -498,7 +492,7 @@ func ProjectEdit(c *RequestContext) ResponseData {
 | 
			
		|||
	}
 | 
			
		||||
 | 
			
		||||
	c.Perf.StartBlock("SQL", "Fetching project links")
 | 
			
		||||
	projectLinkResult, err := db.Query(c.Context(), c.Conn, models.Link{},
 | 
			
		||||
	projectLinks, err := db.Query[models.Link](c.Context(), c.Conn,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		FROM
 | 
			
		||||
| 
						 | 
				
			
			@ -525,7 +519,7 @@ func ProjectEdit(c *RequestContext) ResponseData {
 | 
			
		|||
		c.Theme,
 | 
			
		||||
	)
 | 
			
		||||
 | 
			
		||||
	projectSettings.LinksText = LinksToText(projectLinkResult)
 | 
			
		||||
	projectSettings.LinksText = LinksToText(projectLinks)
 | 
			
		||||
 | 
			
		||||
	var res ResponseData
 | 
			
		||||
	res.MustWriteTemplate("project_edit.html", ProjectEditData{
 | 
			
		||||
| 
						 | 
				
			
			@ -822,14 +816,12 @@ func updateProject(ctx context.Context, tx pgx.Tx, user *models.User, payload *P
 | 
			
		|||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	type userQuery struct {
 | 
			
		||||
		User models.User `db:"auth_user"`
 | 
			
		||||
	}
 | 
			
		||||
	ownerRows, err := db.Query(ctx, tx, userQuery{},
 | 
			
		||||
	owners, err := db.Query[models.User](ctx, tx,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		FROM auth_user
 | 
			
		||||
		LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id
 | 
			
		||||
		SELECT $columns{auth_user}
 | 
			
		||||
		FROM
 | 
			
		||||
			auth_user
 | 
			
		||||
			LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id
 | 
			
		||||
		WHERE LOWER(username) = ANY ($1)
 | 
			
		||||
		`,
 | 
			
		||||
		payload.OwnerUsernames,
 | 
			
		||||
| 
						 | 
				
			
			@ -849,7 +841,7 @@ func updateProject(ctx context.Context, tx pgx.Tx, user *models.User, payload *P
 | 
			
		|||
		return oops.New(err, "Failed to delete project owners")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, ownerRow := range ownerRows {
 | 
			
		||||
	for _, owner := range owners {
 | 
			
		||||
		_, err = tx.Exec(ctx,
 | 
			
		||||
			`
 | 
			
		||||
			INSERT INTO handmade_user_projects
 | 
			
		||||
| 
						 | 
				
			
			@ -857,7 +849,7 @@ func updateProject(ctx context.Context, tx pgx.Tx, user *models.User, payload *P
 | 
			
		|||
			VALUES
 | 
			
		||||
				($1,      $2)
 | 
			
		||||
			`,
 | 
			
		||||
			ownerRow.(*userQuery).User.ID,
 | 
			
		||||
			owner.ID,
 | 
			
		||||
			payload.ProjectID,
 | 
			
		||||
		)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -548,13 +548,11 @@ func getCurrentUserAndSession(c *RequestContext, sessionId string) (*models.User
 | 
			
		|||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	type userQuery struct {
 | 
			
		||||
		User models.User `db:"auth_user"`
 | 
			
		||||
	}
 | 
			
		||||
	userRow, err := db.QueryOne(c.Context(), c.Conn, userQuery{},
 | 
			
		||||
	user, err := db.QueryOne[models.User](c.Context(), c.Conn,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
			FROM auth_user
 | 
			
		||||
		SELECT $columns{auth_user}
 | 
			
		||||
		FROM
 | 
			
		||||
			auth_user
 | 
			
		||||
			LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id
 | 
			
		||||
		WHERE username = $1
 | 
			
		||||
		`,
 | 
			
		||||
| 
						 | 
				
			
			@ -568,7 +566,6 @@ func getCurrentUserAndSession(c *RequestContext, sessionId string) (*models.User
 | 
			
		|||
			return nil, nil, oops.New(err, "failed to get user for session")
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	user := &userRow.(*userQuery).User
 | 
			
		||||
 | 
			
		||||
	return user, session, nil
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -70,7 +70,7 @@ func TwitchEventSubCallback(c *RequestContext) ResponseData {
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
func TwitchDebugPage(c *RequestContext) ResponseData {
 | 
			
		||||
	streams, err := db.Query(c.Context(), c.Conn, models.TwitchStream{},
 | 
			
		||||
	streams, err := db.Query[models.TwitchStream](c.Context(), c.Conn,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		FROM
 | 
			
		||||
| 
						 | 
				
			
			@ -83,8 +83,7 @@ func TwitchDebugPage(c *RequestContext) ResponseData {
 | 
			
		|||
	}
 | 
			
		||||
 | 
			
		||||
	html := ""
 | 
			
		||||
	for _, stream := range streams {
 | 
			
		||||
		s := stream.(*models.TwitchStream)
 | 
			
		||||
	for _, s := range streams {
 | 
			
		||||
		html += fmt.Sprintf(`<a href="https://twitch.tv/%s">%s</a>%s<br />`, s.Login, s.Login, s.Title)
 | 
			
		||||
	}
 | 
			
		||||
	var res ResponseData
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -53,12 +53,9 @@ func UserProfile(c *RequestContext) ResponseData {
 | 
			
		|||
		profileUser = c.CurrentUser
 | 
			
		||||
	} else {
 | 
			
		||||
		c.Perf.StartBlock("SQL", "Fetch user")
 | 
			
		||||
		type userQuery struct {
 | 
			
		||||
			User models.User `db:"auth_user"`
 | 
			
		||||
		}
 | 
			
		||||
		userResult, err := db.QueryOne(c.Context(), c.Conn, userQuery{},
 | 
			
		||||
		user, err := db.QueryOne[models.User](c.Context(), c.Conn,
 | 
			
		||||
			`
 | 
			
		||||
			SELECT $columns
 | 
			
		||||
			SELECT $columns{auth_user}
 | 
			
		||||
			FROM
 | 
			
		||||
				auth_user
 | 
			
		||||
				LEFT JOIN handmade_asset AS auth_user_avatar ON auth_user_avatar.id = auth_user.avatar_asset_id
 | 
			
		||||
| 
						 | 
				
			
			@ -75,7 +72,7 @@ func UserProfile(c *RequestContext) ResponseData {
 | 
			
		|||
				return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user: %s", username))
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		profileUser = &userResult.(*userQuery).User
 | 
			
		||||
		profileUser = user
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	{
 | 
			
		||||
| 
						 | 
				
			
			@ -87,10 +84,7 @@ func UserProfile(c *RequestContext) ResponseData {
 | 
			
		|||
	}
 | 
			
		||||
 | 
			
		||||
	c.Perf.StartBlock("SQL", "Fetch user links")
 | 
			
		||||
	type userLinkQuery struct {
 | 
			
		||||
		UserLink models.Link `db:"link"`
 | 
			
		||||
	}
 | 
			
		||||
	userLinksSlice, err := db.Query(c.Context(), c.Conn, userLinkQuery{},
 | 
			
		||||
	userLinks, err := db.Query[models.Link](c.Context(), c.Conn,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		FROM
 | 
			
		||||
| 
						 | 
				
			
			@ -104,9 +98,9 @@ func UserProfile(c *RequestContext) ResponseData {
 | 
			
		|||
	if err != nil {
 | 
			
		||||
		return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch links for user: %s", username))
 | 
			
		||||
	}
 | 
			
		||||
	profileUserLinks := make([]templates.Link, 0, len(userLinksSlice))
 | 
			
		||||
	for _, l := range userLinksSlice {
 | 
			
		||||
		profileUserLinks = append(profileUserLinks, templates.LinkToTemplate(&l.(*userLinkQuery).UserLink))
 | 
			
		||||
	profileUserLinks := make([]templates.Link, 0, len(userLinks))
 | 
			
		||||
	for _, l := range userLinks {
 | 
			
		||||
		profileUserLinks = append(profileUserLinks, templates.LinkToTemplate(l))
 | 
			
		||||
	}
 | 
			
		||||
	c.Perf.EndBlock()
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -231,7 +225,7 @@ func UserSettings(c *RequestContext) ResponseData {
 | 
			
		|||
		DiscordShowcaseBacklogUrl string
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	links, err := db.Query(c.Context(), c.Conn, models.Link{},
 | 
			
		||||
	links, err := db.Query[models.Link](c.Context(), c.Conn,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		FROM handmade_links
 | 
			
		||||
| 
						 | 
				
			
			@ -248,7 +242,7 @@ func UserSettings(c *RequestContext) ResponseData {
 | 
			
		|||
 | 
			
		||||
	var tduser *templates.DiscordUser
 | 
			
		||||
	var numUnsavedMessages int
 | 
			
		||||
	iduser, err := db.QueryOne(c.Context(), c.Conn, models.DiscordUser{},
 | 
			
		||||
	duser, err := db.QueryOne[models.DiscordUser](c.Context(), c.Conn,
 | 
			
		||||
		`
 | 
			
		||||
		SELECT $columns
 | 
			
		||||
		FROM handmade_discorduser
 | 
			
		||||
| 
						 | 
				
			
			@ -261,11 +255,10 @@ func UserSettings(c *RequestContext) ResponseData {
 | 
			
		|||
	} else if err != nil {
 | 
			
		||||
		return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user's Discord account"))
 | 
			
		||||
	} else {
 | 
			
		||||
		duser := iduser.(*models.DiscordUser)
 | 
			
		||||
		tmp := templates.DiscordUserToTemplate(duser)
 | 
			
		||||
		tduser = &tmp
 | 
			
		||||
 | 
			
		||||
		numUnsavedMessages, err = db.QueryInt(c.Context(), c.Conn,
 | 
			
		||||
		numUnsavedMessages, err = db.QueryOneScalar[int](c.Context(), c.Conn,
 | 
			
		||||
			`
 | 
			
		||||
			SELECT COUNT(*)
 | 
			
		||||
			FROM
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue