Compare commits

..

5 Commits

Author SHA1 Message Date
Ben Visness 8ff5f02916 wtf it works 2022-04-16 22:50:09 -05:00
Ben Visness 2229ac85d5 Update all callsites of db functions
Finish converting all callsites

Not too bad actually! Centralizing access into the helpers makes a big
difference.
2022-04-16 22:50:37 -05:00
Ben Visness 97360a1998 Rework and document the db API
Tests still pass, at least...now for everything else
2022-04-16 19:47:05 -05:00
Ben Visness a2917b98c0 Write some nice aspirational package docs 2022-04-16 13:57:41 -05:00
Ben Visness b9a4cb2361 Start of db rework: clean up, start generics, improve tests 2022-04-16 12:49:29 -05:00
41 changed files with 894 additions and 798 deletions

40
go.mod
View File

@ -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
View File

@ -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=

View File

@ -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,

View File

@ -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,
)

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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

59
src/db/doc.go Normal file
View File

@ -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

View File

@ -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
}

View File

@ -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"

View File

@ -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
}

View File

@ -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

View File

@ -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 {

View File

@ -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)

View File

@ -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)
}

View File

@ -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) {

View File

@ -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
}

View File

@ -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)

View File

@ -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")
}

View File

@ -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)

View File

@ -121,7 +121,6 @@
#welcome-logo svg {
height: 100%;
fill: currentColor;
opacity: 0.9;
}
#welcome-content a {

View File

@ -2,21 +2,22 @@
{{ define "content" }}
<div class="center-layout mw7 mv3 ph3 ph0-ns post-content">
<h2>Computers are amazing.</h2>
<p>Computers have changed our lives for the better. They allow us to learn, connect with each other, and express ourselves in amazing new ways. And every year computers get more powerful, less expensive, and more accessible - computers today can do things we hardly dreamed of twenty years ago.</p>
<h2>So why is software so terrible?</h2>
<p>Why do web pages take ten seconds to load? Why do apps mess up scrolling? Why does your phone battery still die so quickly? And why does each update somehow make the problem worse?</p>
<p>And why do we all use huge frameworks that no one understands? Why do our projects take several minutes to compile? Why do we have to restart our language servers every twenty minutes? And why does everyone think this is fine?</p>
<h2>We made it terrible.</h2>
<p>Not necessarily you or me, not necessarily anyone in particular. But we, the software development community, made it terrible through our thoughtless behavior. We ignored the hardware. We glued together libraries so we didn't have to learn. We built layers on top of layers, until no one knew how anything worked.</p>
<p>But worst of all: we put our own desires above the user's.</p>
<p>You may have learned that programming is about classes, monads, or type systems. You may have been taught to keep your code clean and pretty, abstract and future-proof. None of that matters when the end result is garbage.</p>
<h2>But there is another way.</h2>
<p>Some of us aren't satisfied with the current state of software. We think that wheels need to be reinvented. We like looking under the hood, understanding what others take for granted. We remember how software used to be, and know how much potential there is to make it better. We fight against the status quo, because we know how things <em>could</em> be.</p>
<p>This is what Handmade means. It's not a technique or a language or a management strategy. It's not a library or a framework or a paradigm. It's an idea. The idea that we can build software that works with the computer, not against it. The idea that the user matters more than the programmer. The idea that sometimes a small group can do more than an army of software engineers, and <em>do it better</em>.</p>
<p>You don't need a degree, a dissertation, or a decade of experience. You don't need an expensive computer or a certificate. All you need is an open mind and a sense of curiosity. We'll help you with the rest.</p>
<p>Modern computer hardware is amazing. Manufacturers have orchestrated billions of pieces of silicon into terrifyingly complex and efficient structures that sweep electrons through innumerable tangled paths, branchings, and reunions with the sole purpose of performing computations at more than a billion times per second. This awe-inspiring piece of computational wizardry has at its disposal multiple billions of uniquely addressible silicon plates where it can store the results of millions of computations in an array of several vanishingly small chips. All of this hardware, though each component often sits no further than 7 or 8 centimeters away from the others, cycles so fast that the speed of light, a physical law of the universe, limits the rate at which they communicate with each other.</p>
<h2>So why is software still slow?</h2>
<p>Why does it take your operating system 10 seconds, 30 seconds, a minute to boot up? Why does your word processor freeze when you save a document on the cloud? Why does your web browser take 3, 4, 10 seconds to load a web page? Why does your phone struggle to keep more than a few apps open at a time? And why does each update somehow make the problem worse?</p>
<h2>We made it slow.</h2>
<p>Not necessarily you, not necessarily me, not necessarily any single person in particular. But we, the software development community, made it slow by ignoring the fundamental reality of our occupation. We write code, code that runs on computers. Real computers, with central processing units and random access memory and hard disk drives and display buffers. Real computers, with integer and bitwise math and floating point units and L2 caches, with threads and cores and a tenuous little network connection to a million billion other computers. Real computers not built for ease of human understanding but for blindingly, incomprehensibly fast speed.</p>
<h2>A lot of us have forgotten that.</h2>
<p>In our haste to get our products, our projects, the works of our hands and minds, to as many people as possible, we take shortcuts. We make assumptions. We generalize, and abstract, and assume that just because these problems have been solved before that they never need to be solved again. We build abstraction layers, then forget we built them and build more on top.</p>
<p>And it's true that many of us think we do not have the time, the money, the mental bandwidth to always consider these things in detail. The deadline is approaching or the rent is due or we have taxes to fill out and a manager on our back and someone asking us why we always spend so much time at the office, and we just have to stick the library or virtual machine or garbage collector in there to cover up the places we can't think through right now.</p>
<p>Others of us were never taught to think about the computer itself. We learned about objects and classes and templates and how to make our code clean and pretty. We learned how to write code to make the client or the manager or the teacher happy, but made the processor churn. And because we did, that amazing speed we'd been granted was wasted, by us, in a death by a thousand abstraction layers.</p>
<h2>But some of us aren't satisfied with that.</h2>
<p>Some of us take a few extra steps into the covered territory, the wheels sitting, motionless, in a pile behind us, examine their designs and decide there is a better way. The more experienced among us remember how software used to be, the potential that we know exists for computer programs to be useful, general, and efficient. Others of us got fed up with the tools we were expected to use without complaint, but which failed us time and time again. Some of us are just curious and don't know what's good for us. Don't trust what we've been told is good for us.</p>
<p>We sat down and looked at our hardware, and examined our data, and thought about how to use the one to transform the other. We tinkered, and measured, and read, and compared, and wrote, and refined, and modified, and measured again, over and over, until we found we had built the same thing, but 10 times faster and incomparably more useful to the people we designed it for. And we had built it by hand.</p>
<p>That is what Handmade means. It's not a technique or a language or a management strategy, it isn't a formula or a library or an abstraction. It's an idea. The idea that we can build software that works with the computer, not against it. The idea that sometimes an individual programmer can be more productive than a large team, that a small group can do more than an army of software engineers and *do it better*. The idea that programming is about transforming data and we wield the code, the tool we use to bend that data to our will.</p>
<p>It doesn't require a degree, or a dissertation, or a decade of experience. You don't need an expensive computer or a certificate or even prior knowledge. All you need is an open mind and a sense of curiosity. We'll help you with the rest.</p>
<h2>Will you join us?</h2>
<p>Will you build your software by hand?</p>
<p class="c--dim i">Written by Ben Visness and the Handmade community. Original by Andrew Chronister.</p>
<p class="c--dim i">Written by Andrew Chronister</p>
</div>
{{ end }}

View File

@ -1,83 +1,102 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 2000 629" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g id="Wires" transform="matrix(26.3259,0,0,26.3259,-5070.03,-5340.32)">
<g transform="matrix(1,0,0,-1,-0.808418,430.006)">
<path d="M194.555,216.234C193.915,216.234 193.396,215.714 193.396,215.074C193.396,214.434 193.915,213.915 194.555,213.915C195.195,213.915 195.715,214.434 195.715,215.074C195.715,215.714 195.195,216.234 194.555,216.234ZM194.555,215.461C194.768,215.461 194.942,215.288 194.942,215.074C194.942,214.861 194.768,214.688 194.555,214.688C194.342,214.688 194.169,214.861 194.169,215.074C194.169,215.288 194.342,215.461 194.555,215.461Z"/>
<svg viewBox="0 0 3706 1082" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g id="Banner-Logo" serif:id="Banner Logo" transform="matrix(48.7805,0,0,48.7805,-9370.23,-9931.81)">
<g transform="matrix(1,0,0,1,-0.497771,-0.118656)">
<g transform="matrix(1,0,0,-1,-0.808418,430.006)">
<path d="M193.396,215.074L201.366,215.074" style="fill:none;"/>
</g>
<g transform="matrix(1,0,0,-1,-0.808418,430.006)">
<path d="M194.555,216.234C193.915,216.234 193.396,215.714 193.396,215.074C193.396,214.434 193.915,213.915 194.555,213.915C195.195,213.915 195.715,214.434 195.715,215.074C195.715,215.714 195.195,216.234 194.555,216.234ZM194.555,215.461C194.768,215.461 194.942,215.288 194.942,215.074C194.942,214.861 194.768,214.688 194.555,214.688C194.342,214.688 194.169,214.861 194.169,215.074C194.169,215.288 194.342,215.461 194.555,215.461Z" style=""/>
</g>
<g transform="matrix(1,0,0,-1,-0.808418,430.006)">
<path d="M195.328,214.688L201.366,214.688C201.579,214.688 201.753,214.861 201.753,215.074C201.753,215.288 201.579,215.461 201.366,215.461L195.328,215.461C195.115,215.461 194.942,215.288 194.942,215.074C194.942,214.861 195.115,214.688 195.328,214.688Z" style=""/>
</g>
<g transform="matrix(-1,0,0,-1,461.953,430.006)">
<path d="M193.396,215.074L201.366,215.074" style="fill:none;"/>
</g>
<g transform="matrix(-1,0,0,-1,461.953,430.006)">
<path d="M194.555,213.915C193.915,213.915 193.396,214.434 193.396,215.074C193.396,215.714 193.915,216.234 194.555,216.234C195.195,216.234 195.715,215.714 195.715,215.074C195.715,214.434 195.195,213.915 194.555,213.915ZM194.555,214.688C194.768,214.688 194.942,214.861 194.942,215.074C194.942,215.288 194.768,215.461 194.555,215.461C194.342,215.461 194.169,215.288 194.169,215.074C194.169,214.861 194.342,214.688 194.555,214.688Z" style=""/>
</g>
<g transform="matrix(-1,0,0,-1,461.953,430.006)">
<path d="M195.328,215.461L201.366,215.461C201.579,215.461 201.753,215.288 201.753,215.074C201.753,214.861 201.579,214.688 201.366,214.688L195.328,214.688C195.115,214.688 194.942,214.861 194.942,215.074C194.942,215.288 195.115,215.461 195.328,215.461Z" style=""/>
</g>
<g transform="matrix(1,0,0,-1,0.0431238,430.006)">
<path d="M200.515,215.074L201.656,213.933" style="fill:none;"/>
</g>
<g transform="matrix(1,0,0,-1,0.0431238,430.006)">
<path d="M200.241,214.801L201.383,213.66L201.929,214.206L200.788,215.348L200.241,214.801Z" style=""/>
</g>
<g transform="matrix(1,0,0,-1,58.9314,428.865)">
<path d="M200.515,215.074L201.656,213.933" style="fill:none;"/>
</g>
<g transform="matrix(1,0,0,-1,58.9314,428.865)">
<path d="M200.241,214.801L201.383,213.66L201.929,214.206L200.788,215.348L200.241,214.801Z" style=""/>
</g>
<g transform="matrix(1,0,0,-1,1.04312,431.006)">
<path d="M205.833,209.755L207.639,207.95" style="fill:none;"/>
</g>
<g transform="matrix(1,0,0,-1,1.04312,431.006)">
<path d="M205.56,209.482L207.365,207.677L207.912,208.223L206.107,210.029L205.56,209.482Z" style=""/>
</g>
<g transform="matrix(1,0,0,-1,1.04312,431.006)">
<path d="M229.737,206.754C229.436,207.055 228.946,207.055 228.644,206.754C228.342,206.452 228.342,205.962 228.644,205.66C228.946,205.359 229.436,205.359 229.737,205.66C230.039,205.962 230.039,206.452 229.737,206.754ZM229.191,206.207C229.191,206.207 229.191,206.207 229.191,206.207Z" style=""/>
</g>
<g transform="matrix(1,0,0,-1,1.04312,431.006)">
<path d="M207.639,207.564L227.288,207.564C227.288,207.564 228.644,206.207 228.644,206.207C228.795,206.056 229.04,206.056 229.191,206.207C229.342,206.358 229.342,206.603 229.191,206.754C228.534,207.411 227.721,208.223 227.721,208.223C227.648,208.296 227.55,208.337 227.448,208.337L207.639,208.337C207.425,208.337 207.252,208.163 207.252,207.95C207.252,207.737 207.425,207.564 207.639,207.564Z" style=""/>
</g>
<g transform="matrix(1,0,0,-1,1.04312,431.006)">
<path d="M230.737,207.564L251.13,207.564C251.233,207.564 251.331,207.604 251.403,207.677L253.209,209.482L252.662,210.029L250.97,208.337C250.97,208.337 230.737,208.337 230.737,208.337L230.737,207.564Z" style=""/>
</g>
<g transform="matrix(-1,0,0,1,459.812,-1.40971)">
<path d="M205.833,209.755L207.639,207.95" style="fill:none;"/>
</g>
<g transform="matrix(-1,0,0,1,459.812,-1.40971)">
<path d="M205.56,209.482L207.365,207.677L207.912,208.223L206.107,210.029L205.56,209.482Z" style=""/>
</g>
<g transform="matrix(-1,0,0,1,459.812,-1.40971)">
<path d="M229.737,206.754C229.436,207.055 228.946,207.055 228.644,206.754C228.342,206.452 228.342,205.962 228.644,205.66C228.946,205.359 229.436,205.359 229.737,205.66C230.039,205.962 230.039,206.452 229.737,206.754Z" style=""/>
</g>
<g transform="matrix(-1,0,0,1,459.812,-1.40971)">
<path d="M207.639,207.564L227.288,207.564C227.288,207.564 228.644,206.207 228.644,206.207C228.795,206.056 229.04,206.056 229.191,206.207C229.342,206.358 229.342,206.603 229.191,206.754C228.534,207.411 227.721,208.223 227.721,208.223C227.648,208.296 227.55,208.337 227.448,208.337L207.639,208.337C207.425,208.337 207.252,208.163 207.252,207.95C207.252,207.737 207.425,207.564 207.639,207.564Z" style=""/>
</g>
<g transform="matrix(-1,0,0,1,459.812,-1.40971)">
<path d="M230.737,207.564L251.13,207.564C251.233,207.564 251.331,207.604 251.403,207.677L253.209,209.482L252.662,210.029L250.97,208.337C250.97,208.337 230.737,208.337 230.737,208.337L230.737,207.564Z" style=""/>
</g>
<g transform="matrix(-1,0,0,-1,469.812,412.749)">
<path d="M240.737,208.337L250.97,208.337C250.97,208.337 251.662,209.029 251.662,209.029L252.209,208.482L251.403,207.677C251.331,207.604 251.233,207.564 251.13,207.564L240.737,207.564L240.737,208.337Z" style=""/>
</g>
<g transform="matrix(1,0,0,-1,-36.8607,412.749)">
<path d="M243.737,207.564L251.13,207.564C251.233,207.564 251.331,207.604 251.403,207.677L252.209,208.482L251.662,209.029L250.97,208.337C250.97,208.337 243.737,208.337 243.737,208.337L243.737,207.564Z" style=""/>
</g>
<g transform="matrix(1,0,0,-1,-31.3696,432.749)">
<path d="M242.737,207.564L251.13,207.564C251.233,207.564 251.331,207.604 251.403,207.677L253.209,209.482L252.662,210.029L250.97,208.337C250.97,208.337 242.737,208.337 242.737,208.337L242.737,207.564Z" style=""/>
</g>
<g transform="matrix(-1,0,0,-1,487.321,432.821)">
<path d="M242.737,208.337L250.97,208.337C250.97,208.337 252.662,210.029 252.662,210.029L253.209,209.482L251.403,207.677C251.331,207.604 251.233,207.564 251.13,207.564L242.737,207.564L242.737,208.337Z" style=""/>
</g>
<g transform="matrix(-1,0,0,1,492.867,-3.15102)">
<path d="M242.737,207.564L251.13,207.564C251.233,207.564 251.331,207.604 251.403,207.677L253.209,209.482L252.662,210.029L250.97,208.337C249.81,208.337 242.737,208.337 242.737,208.337L242.737,207.564Z" style=""/>
</g>
<g transform="matrix(1,0,0,1,-9.29379,0)">
<path d="M217.976,223.056L215.686,225.346" style="fill:none;"/>
</g>
<g transform="matrix(1,0,0,1,-9.29379,0)">
<path d="M216.233,225.892L217.326,224.799L216.233,223.706L215.139,224.799L216.233,225.892Z" style=""/>
</g>
<g transform="matrix(1,0,0,1,-9.29379,0)">
<path d="M217.702,222.783C217.702,222.783 216.89,223.595 216.233,224.252L216.779,224.799C217.436,224.142 218.249,223.329 218.249,223.329L217.702,222.783Z" style=""/>
</g>
</g>
<g transform="matrix(1,0,0,-1,-0.808418,430.006)">
<path d="M195.328,214.688L201.366,214.688C201.579,214.688 201.753,214.861 201.753,215.074C201.753,215.288 201.579,215.461 201.366,215.461L195.328,215.461C195.115,215.461 194.942,215.288 194.942,215.074C194.942,214.861 195.115,214.688 195.328,214.688Z"/>
</g>
<g transform="matrix(-1,0,0,-1,461.953,430.006)">
<path d="M194.555,213.915C193.915,213.915 193.396,214.434 193.396,215.074C193.396,215.714 193.915,216.234 194.555,216.234C195.195,216.234 195.715,215.714 195.715,215.074C195.715,214.434 195.195,213.915 194.555,213.915ZM194.555,214.688C194.768,214.688 194.942,214.861 194.942,215.074C194.942,215.288 194.768,215.461 194.555,215.461C194.342,215.461 194.169,215.288 194.169,215.074C194.169,214.861 194.342,214.688 194.555,214.688Z"/>
</g>
<g transform="matrix(-1,0,0,-1,461.953,430.006)">
<path d="M195.328,215.461L201.366,215.461C201.579,215.461 201.753,215.288 201.753,215.074C201.753,214.861 201.579,214.688 201.366,214.688L195.328,214.688C195.115,214.688 194.942,214.861 194.942,215.074C194.942,215.288 195.115,215.461 195.328,215.461Z"/>
</g>
<g transform="matrix(1,0,0,-1,0.0431238,430.006)">
<path d="M200.241,214.801L201.383,213.66L201.929,214.206L200.788,215.348L200.241,214.801Z"/>
</g>
<g transform="matrix(1,0,0,-1,58.9314,428.865)">
<path d="M200.241,214.801L201.383,213.66L201.929,214.206L200.788,215.348L200.241,214.801Z"/>
</g>
<g transform="matrix(1,0,0,-1,1.04312,431.006)">
<path d="M205.56,209.482L207.365,207.677L207.912,208.223L206.107,210.029L205.56,209.482Z"/>
</g>
<g transform="matrix(1,0,0,-1,1.04312,431.006)">
<path d="M229.737,206.754C229.436,207.055 228.946,207.055 228.644,206.754C228.342,206.452 228.342,205.962 228.644,205.66C228.946,205.359 229.436,205.359 229.737,205.66C230.039,205.962 230.039,206.452 229.737,206.754ZM229.192,206.208L229.191,206.207L229.192,206.208Z"/>
</g>
<g transform="matrix(1,0,0,-1,1.04312,431.006)">
<path d="M207.639,207.564L227.288,207.564C227.288,207.564 228.644,206.207 228.644,206.207C228.795,206.056 229.04,206.056 229.191,206.207C229.342,206.358 229.342,206.603 229.191,206.754C228.534,207.411 227.721,208.223 227.721,208.223C227.648,208.296 227.55,208.337 227.448,208.337L207.639,208.337C207.425,208.337 207.252,208.163 207.252,207.95C207.252,207.737 207.425,207.564 207.639,207.564Z"/>
</g>
<g transform="matrix(1,0,0,-1,1.04312,431.006)">
<path d="M230.737,207.564L251.13,207.564C251.233,207.564 251.331,207.604 251.403,207.677L253.209,209.482L252.662,210.029L250.97,208.337C250.97,208.337 230.737,208.337 230.737,208.337L230.737,207.564Z"/>
</g>
<g transform="matrix(-1,0,0,1,459.812,-1.40971)">
<path d="M205.56,209.482L207.365,207.677L207.912,208.223L206.107,210.029L205.56,209.482Z"/>
</g>
<g transform="matrix(-1,0,0,1,459.812,-1.40971)">
<path d="M229.737,206.754C229.436,207.055 228.946,207.055 228.644,206.754C228.342,206.452 228.342,205.962 228.644,205.66C228.946,205.359 229.436,205.359 229.737,205.66C230.039,205.962 230.039,206.452 229.737,206.754L229.737,206.754Z"/>
</g>
<g transform="matrix(-1,0,0,1,459.812,-1.40971)">
<path d="M207.639,207.564L227.288,207.564C227.288,207.564 228.644,206.207 228.644,206.207C228.795,206.056 229.04,206.056 229.191,206.207C229.342,206.358 229.342,206.603 229.191,206.754C228.534,207.411 227.721,208.223 227.721,208.223C227.648,208.296 227.55,208.337 227.448,208.337L207.639,208.337C207.425,208.337 207.252,208.163 207.252,207.95C207.252,207.737 207.425,207.564 207.639,207.564Z"/>
</g>
<g transform="matrix(-1,0,0,1,459.812,-1.40971)">
<path d="M230.737,207.564L251.13,207.564C251.233,207.564 251.331,207.604 251.403,207.677L253.209,209.482L252.662,210.029L250.97,208.337C250.97,208.337 230.737,208.337 230.737,208.337L230.737,207.564Z"/>
</g>
<g transform="matrix(-1,0,0,-1,469.812,412.749)">
<path d="M240.737,208.337L250.97,208.337C250.97,208.337 251.662,209.029 251.662,209.029L252.209,208.482L251.403,207.677C251.331,207.604 251.233,207.564 251.13,207.564L240.737,207.564L240.737,208.337Z"/>
</g>
<g transform="matrix(1,0,0,-1,-36.8607,412.749)">
<path d="M243.737,207.564L251.13,207.564C251.233,207.564 251.331,207.604 251.403,207.677L252.209,208.482L251.662,209.029L250.97,208.337C250.97,208.337 243.737,208.337 243.737,208.337L243.737,207.564Z"/>
</g>
<g transform="matrix(1,0,0,-1,-31.3696,432.749)">
<path d="M242.737,207.564L251.13,207.564C251.233,207.564 251.331,207.604 251.403,207.677L253.209,209.482L252.662,210.029L250.97,208.337C250.97,208.337 242.737,208.337 242.737,208.337L242.737,207.564Z"/>
</g>
<g transform="matrix(-1,0,0,-1,487.321,432.821)">
<path d="M242.737,208.337L250.97,208.337C250.97,208.337 252.662,210.029 252.662,210.029L253.209,209.482L251.403,207.677C251.331,207.604 251.233,207.564 251.13,207.564L242.737,207.564L242.737,208.337Z"/>
</g>
<g transform="matrix(-1,0,0,1,492.867,-3.15102)">
<path d="M242.737,207.564L251.13,207.564C251.233,207.564 251.331,207.604 251.403,207.677L253.209,209.482L252.662,210.029L250.97,208.337C249.81,208.337 242.737,208.337 242.737,208.337L242.737,207.564Z"/>
</g>
<g transform="matrix(1,0,0,1,-9.29379,0)">
<path d="M216.233,225.892L217.326,224.799L216.233,223.706L215.139,224.799L216.233,225.892Z"/>
</g>
<g transform="matrix(1,0,0,1,-9.29379,0)">
<path d="M217.702,222.783C217.702,222.783 216.89,223.595 216.233,224.252L216.779,224.799C217.436,224.142 218.249,223.329 218.249,223.329L217.702,222.783Z"/>
</g>
</g>
<g id="Name" transform="matrix(26.3259,0,0,26.3259,-5177.33,-4754.86)">
<path d="M213.538,196.869L213.538,188.32L214.496,188.32L214.496,192.663L216.491,192.663L216.491,188.32L217.448,188.32L217.448,196.869L216.491,196.869L216.491,193.62L214.496,193.62L214.496,196.869L213.538,196.869Z" style="fill-rule:nonzero;"/>
<path d="M221.267,188.32L223.079,196.869L222.087,196.869L221.757,195.216L219.751,195.216L219.409,196.869L218.417,196.869L220.241,188.32L221.267,188.32ZM219.922,194.258L221.586,194.258L220.754,190.03L219.922,194.258Z" style="fill-rule:nonzero;"/>
<path d="M227.331,188.32L228.242,188.32L228.242,196.869L227.433,196.869L225.131,190.816L225.131,196.869L224.219,196.869L224.219,188.32L225.028,188.32L227.331,194.372L227.331,188.32Z" style="fill-rule:nonzero;"/>
<path d="M229.496,196.869L229.496,188.32L231.457,188.32C232.072,188.32 232.566,188.514 232.939,188.901C233.311,189.289 233.497,189.798 233.497,190.429L233.497,194.76C233.497,195.391 233.311,195.9 232.939,196.287C232.566,196.675 232.072,196.869 231.457,196.869L229.496,196.869ZM230.454,195.911L231.457,195.911C231.784,195.911 232.046,195.807 232.243,195.598C232.441,195.389 232.54,195.11 232.54,194.76L232.54,190.429C232.54,190.079 232.441,189.8 232.243,189.591C232.046,189.382 231.784,189.277 231.457,189.277L230.454,189.277L230.454,195.911Z" style="fill-rule:nonzero;"/>
<g transform="matrix(1,0,0,1,0,0.0379854)">
<path d="M239.265,188.32L240.222,188.32L240.222,196.869L239.31,196.869L239.31,191.306L237.84,196.869L237.122,196.869L235.663,191.306L235.663,196.869L234.751,196.869L234.751,188.32L235.708,188.32L237.487,195.113L239.265,188.32Z" style="fill-rule:nonzero;"/>
</g>
<path d="M244.212,188.32L246.024,196.869L245.032,196.869L244.702,195.216L242.696,195.216L242.354,196.869L241.362,196.869L243.186,188.32L244.212,188.32ZM242.867,194.258L244.531,194.258L243.699,190.03L242.867,194.258Z" style="fill-rule:nonzero;"/>
<path d="M247.05,196.869L247.05,188.32L249.01,188.32C249.626,188.32 250.12,188.514 250.492,188.901C250.864,189.289 251.051,189.798 251.051,190.429L251.051,194.76C251.051,195.391 250.864,195.9 250.492,196.287C250.12,196.675 249.626,196.869 249.01,196.869L247.05,196.869ZM248.007,195.911L249.01,195.911C249.337,195.911 249.599,195.807 249.797,195.598C249.994,195.389 250.093,195.11 250.093,194.76L250.093,190.429C250.093,190.079 249.994,189.8 249.797,189.591C249.599,189.382 249.337,189.277 249.01,189.277L248.007,189.277L248.007,195.911Z" style="fill-rule:nonzero;"/>
<g transform="matrix(1,0,0,1,0,0.0379854)">
<path d="M255.758,188.32L255.758,189.277L253.262,189.277L253.262,192.081L255.45,192.081L255.45,193.039L253.262,193.039L253.262,195.911L255.758,195.911L255.758,196.869L252.304,196.869L252.304,188.32L255.758,188.32Z" style="fill-rule:nonzero;"/>
<g id="Name-3" serif:id="Name 3" transform="matrix(1,0,0,1,-4.57363,22.1201)">
<path d="M213.538,196.869L213.538,188.32L214.496,188.32L214.496,192.663L216.491,192.663L216.491,188.32L217.448,188.32L217.448,196.869L216.491,196.869L216.491,193.62L214.496,193.62L214.496,196.869L213.538,196.869Z" style="fill-opacity:0.85;fill-rule:nonzero;"/>
<path d="M221.267,188.32L223.079,196.869L222.087,196.869L221.757,195.216L219.751,195.216L219.409,196.869L218.417,196.869L220.241,188.32L221.267,188.32ZM219.922,194.258L221.586,194.258L220.754,190.03L219.922,194.258Z" style="fill-opacity:0.85;fill-rule:nonzero;"/>
<path d="M227.331,188.32L228.242,188.32L228.242,196.869L227.433,196.869L225.131,190.816L225.131,196.869L224.219,196.869L224.219,188.32L225.028,188.32L227.331,194.372L227.331,188.32Z" style="fill-opacity:0.85;fill-rule:nonzero;"/>
<path d="M229.496,196.869L229.496,188.32L231.457,188.32C232.072,188.32 232.566,188.514 232.939,188.901C233.311,189.289 233.497,189.798 233.497,190.429L233.497,194.76C233.497,195.391 233.311,195.9 232.939,196.287C232.566,196.675 232.072,196.869 231.457,196.869L229.496,196.869ZM230.454,195.911L231.457,195.911C231.784,195.911 232.046,195.807 232.243,195.598C232.441,195.389 232.54,195.11 232.54,194.76L232.54,190.429C232.54,190.079 232.441,189.8 232.243,189.591C232.046,189.382 231.784,189.277 231.457,189.277L230.454,189.277L230.454,195.911Z" style="fill-opacity:0.85;fill-rule:nonzero;"/>
<path d="M239.265,188.32L240.222,188.32L240.222,196.869L239.31,196.869L239.31,191.306L237.84,196.869L237.122,196.869L235.663,191.306L235.663,196.869L234.751,196.869L234.751,188.32L235.708,188.32L237.487,195.113L239.265,188.32Z" style="fill-opacity:0.85;fill-rule:nonzero;"/>
<path d="M244.212,188.32L246.024,196.869L245.032,196.869L244.702,195.216L242.696,195.216L242.354,196.869L241.362,196.869L243.186,188.32L244.212,188.32ZM242.867,194.258L244.531,194.258L243.699,190.03L242.867,194.258Z" style="fill-opacity:0.85;fill-rule:nonzero;"/>
<path d="M247.05,196.869L247.05,188.32L249.01,188.32C249.626,188.32 250.12,188.514 250.492,188.901C250.864,189.289 251.051,189.798 251.051,190.429L251.051,194.76C251.051,195.391 250.864,195.9 250.492,196.287C250.12,196.675 249.626,196.869 249.01,196.869L247.05,196.869ZM248.007,195.911L249.01,195.911C249.337,195.911 249.599,195.807 249.797,195.598C249.994,195.389 250.093,195.11 250.093,194.76L250.093,190.429C250.093,190.079 249.994,189.8 249.797,189.591C249.599,189.382 249.337,189.277 249.01,189.277L248.007,189.277L248.007,195.911Z" style="fill-opacity:0.85;fill-rule:nonzero;"/>
<path d="M255.758,188.32L255.758,189.277L253.262,189.277L253.262,192.081L255.45,192.081L255.45,193.039L253.262,193.039L253.262,195.911L255.758,195.911L255.758,196.869L252.304,196.869L252.304,188.32L255.758,188.32Z" style="fill-opacity:0.85;fill-rule:nonzero;"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -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

View File

@ -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")

View File

@ -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
}
}

View File

@ -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 {

View File

@ -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

View File

@ -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) {

View File

@ -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

View File

@ -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,
}
}

View File

@ -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

View File

@ -3,8 +3,6 @@ package website
import (
"math"
"strconv"
"git.handmade.network/hmn/hmn/src/utils"
)
func getPageInfo(
@ -16,7 +14,7 @@ func getPageInfo(
totalPages int,
ok bool,
) {
totalPages = utils.IntMax(1, int(math.Ceil(float64(totalItems)/float64(itemsPerPage))))
totalPages = int(math.Ceil(float64(totalItems) / float64(itemsPerPage)))
ok = true
page = 1

View File

@ -1,36 +0,0 @@
package website
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestGetPageInfo(t *testing.T) {
items := []struct {
name string
pageParam string
totalItems, perPage int
page, totalPages int
ok bool
}{
{"good, no param", "", 85, 10, 1, 9, true},
{"good", "2", 85, 10, 2, 9, true},
{"too big", "10", 85, 10, 0, 0, false},
{"too small", "0", 85, 10, 0, 0, false},
{"pizza", "pizza", 85, 10, 0, 0, false},
{"zero items, no param", "", 0, 10, 1, 1, true}, // should go to page 1
{"zero items, page 1", "1", 0, 10, 1, 1, true},
{"zero items, too big", "2", 0, 10, 0, 0, false},
{"zero items, too small", "0", 0, 10, 0, 0, false},
}
for _, item := range items {
t.Run(item.name, func(t *testing.T) {
page, totalPages, ok := getPageInfo(item.pageParam, item.totalItems, item.perPage)
assert.Equal(t, item.page, page)
assert.Equal(t, item.totalPages, totalPages)
assert.Equal(t, item.ok, ok)
})
}
}

View File

@ -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)
}
}

View File

@ -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 {

View File

@ -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
}

View File

@ -6,7 +6,7 @@ func Manifesto(c *RequestContext) ResponseData {
baseData := getBaseDataAutocrumb(c, "Handmade Manifesto")
baseData.OpenGraphItems = append(baseData.OpenGraphItems, templates.OpenGraphItem{
Property: "og:description",
Value: "Computers are amazing. So why is software so terrible?",
Value: "Modern computer hardware is amazing. Manufacturers have orchestrated billions of pieces of silicon into terrifyingly complex and efficient structures…",
})
var res ResponseData

View File

@ -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

View File

@ -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