diff --git a/go.mod b/go.mod index 14bda8f9..06bf83b9 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/google/uuid v1.2.0 // indirect github.com/huandu/xstrings v1.3.2 // indirect github.com/imdario/mergo v0.3.12 // indirect + github.com/jackc/pgtype v1.6.2 github.com/jackc/pgx/v4 v4.10.1 github.com/julienschmidt/httprouter v1.3.0 github.com/mitchellh/copystructure v1.1.1 // indirect diff --git a/src/auth/session.go b/src/auth/session.go index ae4adca1..a41e7e5f 100644 --- a/src/auth/session.go +++ b/src/auth/session.go @@ -34,8 +34,7 @@ func makeSessionId() string { var ErrNoSession = errors.New("no session found") func GetSession(ctx context.Context, conn *pgxpool.Pool, id string) (*models.Session, error) { - var sess models.Session - err := db.QueryOneToStruct(ctx, conn, &sess, "SELECT $columns FROM sessions WHERE id = $1", id) + row, err := db.QueryOne(ctx, conn, models.Session{}, "SELECT $columns FROM sessions WHERE id = $1", id) if err != nil { if errors.Is(err, db.ErrNoMatchingRows) { return nil, ErrNoSession @@ -43,8 +42,9 @@ 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 + return sess, nil } func CreateSession(ctx context.Context, conn *pgxpool.Pool, username string) (*models.Session, error) { diff --git a/src/db/db.go b/src/db/db.go index 22863ca7..0f3404e2 100644 --- a/src/db/db.go +++ b/src/db/db.go @@ -5,15 +5,19 @@ import ( "errors" "reflect" "strings" + "time" "git.handmade.network/hmn/hmn/src/config" "git.handmade.network/hmn/hmn/src/oops" + "github.com/jackc/pgtype" "github.com/jackc/pgx/v4" "github.com/jackc/pgx/v4/log/zerologadapter" "github.com/jackc/pgx/v4/pgxpool" "github.com/rs/zerolog/log" ) +var connInfo = pgtype.NewConnInfo() + func NewConn() *pgx.Conn { conn, err := pgx.Connect(context.Background(), config.Config.Postgres.DSN()) if err != nil { @@ -40,20 +44,18 @@ func NewConnPool(minConns, maxConns int32) *pgxpool.Pool { } type StructQueryIterator struct { - fieldIndices []int - rows pgx.Rows + fieldPaths [][]int + rows pgx.Rows + destType reflect.Type } -func (it *StructQueryIterator) Next(dest interface{}) bool { +func (it *StructQueryIterator) Next() (interface{}, bool) { hasNext := it.rows.Next() if !hasNext { - return false + return nil, false } - v := reflect.ValueOf(dest) - if v.Kind() != reflect.Ptr { - panic(oops.New(nil, "Next requires a pointer type; got %v", v.Kind())) - } + result := reflect.New(it.destType) vals, err := it.rows.Values() if err != nil { @@ -61,47 +63,70 @@ func (it *StructQueryIterator) Next(dest interface{}) bool { } for i, val := range vals { - field := v.Elem().Field(it.fieldIndices[i]) + if val == nil { + continue + } + + field := followPathThroughStructs(result, it.fieldPaths[i]) + if field.Kind() == reflect.Ptr { + field.Set(reflect.New(field.Type().Elem())) + field = field.Elem() + } + switch field.Kind() { case reflect.Int: field.SetInt(reflect.ValueOf(val).Int()) - case reflect.Ptr: - // TODO: I'm pretty sure we don't handle nullable ints correctly lol. Maybe this needs to be a function somehow, and recurse onto itself?? Reflection + recursion sounds like a great idea - if val != nil { - field.Set(reflect.New(field.Type().Elem())) - field.Elem().Set(reflect.ValueOf(val)) - } default: field.Set(reflect.ValueOf(val)) } } - return true + return result.Interface(), true } func (it *StructQueryIterator) Close() { it.rows.Close() } -func QueryToStructs(ctx context.Context, conn *pgxpool.Pool, destType interface{}, query string, args ...interface{}) (StructQueryIterator, error) { - var fieldIndices []int - var columnNames []string - - t := reflect.TypeOf(destType) - if t.Kind() == reflect.Ptr { - t = t.Elem() - } - - if t.Kind() != reflect.Struct { - return StructQueryIterator{}, oops.New(nil, "QueryToStructs requires a struct type or a pointer to a struct type") - } - - for i := 0; i < t.NumField(); i++ { - f := t.Field(i) - if columnName := f.Tag.Get("db"); columnName != "" { - fieldIndices = append(fieldIndices, i) - columnNames = append(columnNames, columnName) +func (it *StructQueryIterator) ToSlice() []interface{} { + defer it.Close() + var result []interface{} + for { + row, ok := it.Next() + if !ok { + break } + result = append(result, row) + } + return result +} + +func followPathThroughStructs(structVal reflect.Value, path []int) reflect.Value { + if len(path) < 1 { + panic("can't follow an empty path") + } + + val := structVal + for _, i := range path { + if val.Kind() == reflect.Ptr && val.Type().Elem().Kind() == reflect.Struct { + if val.IsNil() { + val.Set(reflect.New(val.Type())) + } + val = val.Elem() + } + val = val.Field(i) + } + return val +} + +func Query(ctx context.Context, conn *pgxpool.Pool, destExample interface{}, query string, args ...interface{}) (StructQueryIterator, error) { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + destType := reflect.TypeOf(destExample) + columnNames, fieldPaths, err := getColumnNamesAndPaths(destType, nil, "") + if err != nil { + return StructQueryIterator{}, oops.New(err, "failed to generate column names") } columnNamesString := strings.Join(columnNames, ", ") @@ -109,28 +134,74 @@ func QueryToStructs(ctx context.Context, conn *pgxpool.Pool, destType interface{ rows, err := conn.Query(ctx, query, args...) if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + panic("query exceeded its deadline") + } return StructQueryIterator{}, err } return StructQueryIterator{ - fieldIndices: fieldIndices, - rows: rows, + fieldPaths: fieldPaths, + rows: rows, + destType: destType, }, nil } +func getColumnNamesAndPaths(destType reflect.Type, pathSoFar []int, prefix string) ([]string, [][]int, error) { + var columnNames []string + var fieldPaths [][]int + + 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) + } + + for i := 0; i < destType.NumField(); i++ { + field := destType.Field(i) + path := append(pathSoFar, i) + + if columnName := field.Tag.Get("db"); columnName != "" { + fieldType := field.Type + if destType.Kind() == reflect.Ptr { + fieldType = destType.Elem() + } + + _, isRecognizedByPgtype := connInfo.DataTypeForValue(reflect.New(fieldType)) // 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 fieldType.Kind() == reflect.Struct && !isRecognizedByPgtype { + subCols, subPaths, err := getColumnNamesAndPaths(fieldType, path, columnName+".") + if err != nil { + return nil, nil, err + } + columnNames = append(columnNames, subCols...) + fieldPaths = append(fieldPaths, subPaths...) + } else { + columnNames = append(columnNames, prefix+columnName) + fieldPaths = append(fieldPaths, path) + } + } + } + + return columnNames, fieldPaths, nil +} + var ErrNoMatchingRows = errors.New("no matching rows") -func QueryOneToStruct(ctx context.Context, conn *pgxpool.Pool, dest interface{}, query string, args ...interface{}) error { - rows, err := QueryToStructs(ctx, conn, dest, query, args...) +func QueryOne(ctx context.Context, conn *pgxpool.Pool, destExample interface{}, query string, args ...interface{}) (interface{}, error) { + rows, err := Query(ctx, conn, destExample, query, args...) if err != nil { - return err + return nil, err } defer rows.Close() - hasRow := rows.Next(dest) + result, hasRow := rows.Next() if !hasRow { - return ErrNoMatchingRows + return nil, ErrNoMatchingRows } - return nil + return result, nil } diff --git a/src/models/category.go b/src/models/category.go new file mode 100644 index 00000000..ec0b14de --- /dev/null +++ b/src/models/category.go @@ -0,0 +1,27 @@ +package models + +type CategoryType int + +const ( + CatTypeBlog CategoryType = iota + 1 + CatTypeForum + CatTypeStatic + CatTypeAnnotation + CatTypeWiki + CatTypeLibraryResource +) + +type Category struct { + ID int `db:"id"` + + ParentID *int `db:"parent_id"` + ProjectID *int `db:"project_id"` + + Slug *string `db:"slug"` + Name *string `db:"name"` + Blurb *string `db:"blurb"` + Kind CategoryType `db:"kind"` + Color1 string `db:"color_1"` + Color2 string `db:"color_2"` + Depth int `db:"depth"` +} diff --git a/src/models/post.go b/src/models/post.go new file mode 100644 index 00000000..c88d4033 --- /dev/null +++ b/src/models/post.go @@ -0,0 +1,31 @@ +package models + +import ( + "net" + "time" +) + +type Post struct { + ID int `db:"id"` + + // TODO: Document each of these + AuthorID *int `db:"author_id"` + CategoryID int `db:"category_id"` + ParentID *int `db:"parent_id"` + ThreadID *int `db:"thread_id"` + CurrentID int `db:"current_id"` + + Depth int `db:"depth"` + Slug string `db:"slug"` + AuthorName string `db:"author_name"` + PostDate time.Time `db:"postdate"` + IP net.IPNet `db:"ip"` + Sticky bool `db:"sticky"` + Moderated bool `db:"moderated"` // TODO: I'm not sure this is ever meaningfully used. It always seems to be 0 / false? + Hits int `db:"hits"` + Featured bool `db:"featured"` + FeatureVotes int `db:"featurevotes"` // TODO: Remove this column from the db, it's never used + + Preview string `db:"preview"` + ReadOnly bool `db:"readonly"` +} diff --git a/src/models/project.go b/src/models/project.go index 0cf88d11..f84c0517 100644 --- a/src/models/project.go +++ b/src/models/project.go @@ -1,14 +1,18 @@ package models +import "reflect" + const HMNProjectID = 1 +var ProjectType = reflect.TypeOf(Project{}) + type Project struct { ID int `db:"id"` - Slug string `db:"slug"` - Name string `db:"name"` - Blurb string `db:"blurb"` - Description string `db:"description"` + Slug *string `db:"slug"` // TODO: Migrate these to NOT NULL + Name *string `db:"name"` + Blurb *string `db:"blurb"` + Description *string `db:"description"` Color1 string `db:"color_1"` Color2 string `db:"color_2"` diff --git a/src/models/thread.go b/src/models/thread.go new file mode 100644 index 00000000..0929e303 --- /dev/null +++ b/src/models/thread.go @@ -0,0 +1,17 @@ +package models + +type Thread struct { + ID int `db:"id"` + + CategoryID int `db:"category_id"` + + Title string `db:"title"` + Hits int `db:"hits"` + ReplyCount int `db:"reply_count"` + Sticky bool `db:"sticky"` + Locked bool `db:"locked"` + Moderated int `db:"moderated"` + + FirstID *int `db:"first_id"` + LastID *int `db:"last_id"` +} diff --git a/src/models/user.go b/src/models/user.go index 8db8b0a0..0152e766 100644 --- a/src/models/user.go +++ b/src/models/user.go @@ -1,6 +1,11 @@ package models -import "time" +import ( + "reflect" + "time" +) + +var UserType = reflect.TypeOf(User{}) type User struct { ID int `db:"id"` diff --git a/src/templates/src/index.html b/src/templates/src/index.html index abddc79f..bf5419cf 100644 --- a/src/templates/src/index.html +++ b/src/templates/src/index.html @@ -3,3 +3,200 @@ {{ define "content" }} This is the index page. {{ end }} + +{{/* +{{ define "extrahead" }} + + + + + + +{{ end }} + +{% block columns %} +{% include "showcase/js_templates.html" %} +{% include "timeline/js_templates.html" %} +