Start forum category index; fix reflection bugs

This commit is contained in:
Ben Visness 2021-05-03 09:51:07 -05:00
parent 285fd3eaf0
commit 5f763d334c
9 changed files with 338 additions and 79 deletions

View File

@ -3,10 +3,12 @@ package db
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"reflect" "reflect"
"strings" "strings"
"git.handmade.network/hmn/hmn/src/config" "git.handmade.network/hmn/hmn/src/config"
"git.handmade.network/hmn/hmn/src/logging"
"git.handmade.network/hmn/hmn/src/oops" "git.handmade.network/hmn/hmn/src/oops"
"github.com/jackc/pgtype" "github.com/jackc/pgtype"
"github.com/jackc/pgx/v4" "github.com/jackc/pgx/v4"
@ -15,6 +17,40 @@ import (
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
/*
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 CategoryKind int
*/
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
}
// 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 connInfo = pgtype.NewConnInfo() var connInfo = pgtype.NewConnInfo()
func NewConn() *pgx.Conn { func NewConn() *pgx.Conn {
@ -61,12 +97,35 @@ func (it *StructQueryIterator) Next() (interface{}, bool) {
panic(err) panic(err)
} }
// Better logging of panics in this confusing reflection process
var currentField reflect.StructField
var currentValue reflect.Value
defer func() {
if r := recover(); r != nil {
if currentValue.IsValid() {
logging.Error().
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 { for i, val := range vals {
if val == nil { if val == nil {
continue continue
} }
field := followPathThroughStructs(result, it.fieldPaths[i]) var field reflect.Value
field, currentField = followPathThroughStructs(result, it.fieldPaths[i])
if field.Kind() == reflect.Ptr { if field.Kind() == reflect.Ptr {
field.Set(reflect.New(field.Type().Elem())) field.Set(reflect.New(field.Type().Elem()))
field = field.Elem() field = field.Elem()
@ -78,6 +137,7 @@ func (it *StructQueryIterator) Next() (interface{}, bool) {
if valReflected.Kind() == reflect.Ptr { if valReflected.Kind() == reflect.Ptr {
valReflected = valReflected.Elem() valReflected = valReflected.Elem()
} }
currentValue = valReflected
switch field.Kind() { switch field.Kind() {
case reflect.Int: case reflect.Int:
@ -85,6 +145,9 @@ func (it *StructQueryIterator) Next() (interface{}, bool) {
default: default:
field.Set(valReflected) field.Set(valReflected)
} }
currentField = reflect.StructField{}
currentValue = reflect.Value{}
} }
return result.Interface(), true return result.Interface(), true
@ -111,22 +174,35 @@ func (it *StructQueryIterator) ToSlice() []interface{} {
return result return result
} }
func followPathThroughStructs(structVal reflect.Value, path []int) reflect.Value { func followPathThroughStructs(structPtrVal reflect.Value, path []int) (reflect.Value, reflect.StructField) {
if len(path) < 1 { if len(path) < 1 {
panic("can't follow an empty path") panic(oops.New(nil, "can't follow an empty path"))
} }
val := structVal 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 { for _, i := range path {
if val.Kind() == reflect.Ptr && val.Type().Elem().Kind() == reflect.Struct { if val.Kind() == reflect.Ptr && val.Type().Elem().Kind() == reflect.Struct {
if val.IsNil() { if val.IsNil() {
val.Set(reflect.New(val.Type())) val.Set(reflect.New(val.Type().Elem()))
} }
val = val.Elem() val = val.Elem()
} }
field = val.Type().Field(i)
val = val.Field(i) val = val.Field(i)
} }
return val return val, field
} }
func Query(ctx context.Context, conn *pgxpool.Pool, destExample interface{}, query string, args ...interface{}) (*StructQueryIterator, error) { func Query(ctx context.Context, conn *pgxpool.Pool, destExample interface{}, query string, args ...interface{}) (*StructQueryIterator, error) {
@ -154,7 +230,7 @@ func Query(ctx context.Context, conn *pgxpool.Pool, destExample interface{}, que
}, nil }, nil
} }
func getColumnNamesAndPaths(destType reflect.Type, pathSoFar []int, prefix string) ([]string, [][]int, error) { func getColumnNamesAndPaths(destType reflect.Type, pathSoFar []int, prefix string) (names []string, paths [][]int, err error) {
var columnNames []string var columnNames []string
var fieldPaths [][]int var fieldPaths [][]int
@ -172,14 +248,14 @@ func getColumnNamesAndPaths(destType reflect.Type, pathSoFar []int, prefix strin
if columnName := field.Tag.Get("db"); columnName != "" { if columnName := field.Tag.Get("db"); columnName != "" {
fieldType := field.Type fieldType := field.Type
if destType.Kind() == reflect.Ptr { if fieldType.Kind() == reflect.Ptr {
fieldType = destType.Elem() fieldType = fieldType.Elem()
} }
_, isRecognizedByPgtype := connInfo.DataTypeForValue(reflect.New(fieldType).Elem().Interface()) // if pgtype recognizes it, we don't need to dig in further for more `db` tags if typeIsQueryable(fieldType) {
// 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 columnNames = append(columnNames, prefix+columnName)
fieldPaths = append(fieldPaths, path)
if fieldType.Kind() == reflect.Struct && !isRecognizedByPgtype { } else if fieldType.Kind() == reflect.Struct {
subCols, subPaths, err := getColumnNamesAndPaths(fieldType, path, columnName+".") subCols, subPaths, err := getColumnNamesAndPaths(fieldType, path, columnName+".")
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
@ -187,8 +263,7 @@ func getColumnNamesAndPaths(destType reflect.Type, pathSoFar []int, prefix strin
columnNames = append(columnNames, subCols...) columnNames = append(columnNames, subCols...)
fieldPaths = append(fieldPaths, subPaths...) fieldPaths = append(fieldPaths, subPaths...)
} else { } else {
columnNames = append(columnNames, prefix+columnName) return nil, nil, oops.New(nil, "field '%s' in type %s has invalid type '%s'", field.Name, destType, field.Type)
fieldPaths = append(fieldPaths, path)
} }
} }
} }

53
src/db/db_test.go Normal file
View File

@ -0,0 +1,53 @@
package db
import (
"reflect"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
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"`
NoTag int
}
type Nested struct {
S S `db:"S"`
PS *S `db:"PS"`
NoTag S
}
names, paths, err := getColumnNamesAndPaths(reflect.TypeOf(Nested{}), 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{
{0, 0}, {0, 1}, {0, 2}, {0, 3}, {0, 4}, {0, 5},
{1, 0}, {1, 1}, {1, 2}, {1, 3}, {1, 4}, {1, 5},
}, paths)
assert.True(t, len(names) == len(paths))
}
testStruct := Nested{}
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))
}
}

View File

@ -0,0 +1,9 @@
{{ template "base.html" . }}
{{ define "content" }}
<div class="content-block">
{{ range .Threads }}
{{ template "thread_list_item.html" . }}
{{ end }}
</div>
{{ end }}

View File

@ -1,5 +1,5 @@
{{/* {{/*
This template is intended to display a single post or thread in the context of a forum, the feed, or a similar layout. This template is intended to display a single post in the context of a forum, the feed, or a similar layout.
It should be called with PostListItem. It should be called with PostListItem.
*/}} */}}

View File

@ -0,0 +1,36 @@
{{/*
This template is intended to display a single thread in the context of a forum, the feed, or a similar layout.
It should be called with ThreadListItem.
*/}}
<div class="post-list-item flex items-center ph3 pv2 {{ if .Unread }}unread{{ else }}read{{ end }} {{ .Classes }}">
<img class="avatar-icon mr2" src="{{ .FirstUser.AvatarUrl }}">
<div class="flex-grow-1 overflow-hidden">
<div class="breadcrumbs">
{{ range $i, $breadcrumb := .Breadcrumbs }}
{{ if gt $i 0 }} » {{ end }}
<a href="{{ $breadcrumb.Url }}">{{ $breadcrumb.Name }}</a>
{{ end }}
</div>
<div class="title nowrap truncate"><a href="{{ .Url }}">{{ .Title }}</a></div>
<div class="details">
<a class="user" href="{{ .FirstUser.ProfileUrl }}">{{ .FirstUser.Name }}</a> &mdash; <span class="datetime">{{ relativedate .FirstDate }}</span>
</div>
{{ with .Content }}
<div class="mt2">
{{ . }}
</div>
{{ end }}
</div>
<div class="latestpost dn flex-ns flex-shrink-0 items-center w5 ml2">
<img class="avatar-icon mr2" src="{{ .LastUser.AvatarUrl }}">
<div>
<div>Last post <span class="datetime">{{ relativedate .LastDate }}</span></div>
<a class="user" href="{{ .LastUser.ProfileUrl }}">{{ .LastUser.Name }}</a>
</div>
</div>
<div class="goto">
<a href="{{ .Url }}">&raquo;</a>
</div>
</div>

View File

@ -83,8 +83,26 @@ type PostListItem struct {
Title string Title string
Url string Url string
Breadcrumbs []Breadcrumb Breadcrumbs []Breadcrumb
User User User User
Date time.Time Date time.Time
Unread bool
Classes string
Content string
}
// Data from thread_list_item.html
type ThreadListItem struct {
Title string
Url string
Breadcrumbs []Breadcrumb
FirstUser User
FirstDate time.Time
LastUser User
LastDate time.Time
Unread bool Unread bool
Classes string Classes string
Content string Content string

View File

@ -1,46 +1,35 @@
package website package website
import ( import (
"fmt" "context"
"math" "math"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"git.handmade.network/hmn/hmn/src/templates"
"github.com/jackc/pgx/v4/pgxpool"
"git.handmade.network/hmn/hmn/src/db" "git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/models" "git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops" "git.handmade.network/hmn/hmn/src/oops"
) )
type forumCategoryData struct {
templates.BaseData
Threads []templates.ThreadListItem
}
func ForumCategory(c *RequestContext) ResponseData { func ForumCategory(c *RequestContext) ResponseData {
const threadsPerPage = 25 const threadsPerPage = 25
catPath := c.PathParams["cats"] catPath := c.PathParams["cats"]
catSlugs := strings.Split(catPath, "/") catSlugs := strings.Split(catPath, "/")
currentCatId := fetchCatIdFromSlugs(c.Context(), c.Conn, catSlugs, c.CurrentProject.ID)
catSlug := catSlugs[len(catSlugs)-1] categoryUrls := GetProjectCategoryUrls(c.Context(), c.Conn, c.CurrentProject.ID)
if len(catSlugs) == 1 {
catSlug = ""
}
// TODO: Is this query right? Do we need to do a better special case for when it's the root category?
currentCatId, err := db.QueryInt(c.Context(), c.Conn,
`
SELECT id
FROM handmade_category
WHERE
slug = $1
AND kind = $2
AND project_id = $3
`,
catSlug,
models.CatKindForum,
c.CurrentProject.ID,
)
if err != nil {
panic(oops.New(err, "failed to get current category id"))
}
numThreads, err := db.QueryInt(c.Context(), c.Conn, numThreads, err := db.QueryInt(c.Context(), c.Conn,
` `
@ -82,6 +71,8 @@ func ForumCategory(c *RequestContext) ResponseData {
Thread models.Thread `db:"thread"` Thread models.Thread `db:"thread"`
FirstPost models.Post `db:"firstpost"` FirstPost models.Post `db:"firstpost"`
LastPost models.Post `db:"lastpost"` LastPost models.Post `db:"lastpost"`
FirstUser *models.User `db:"firstuser"`
LastUser *models.User `db:"lastuser"`
ThreadLastReadTime *time.Time `db:"tlri.lastread"` ThreadLastReadTime *time.Time `db:"tlri.lastread"`
CatLastReadTime *time.Time `db:"clri.lastread"` CatLastReadTime *time.Time `db:"clri.lastread"`
} }
@ -92,15 +83,16 @@ func ForumCategory(c *RequestContext) ResponseData {
handmade_thread AS thread handmade_thread AS thread
JOIN handmade_post AS firstpost ON thread.first_id = firstpost.id JOIN handmade_post AS firstpost ON thread.first_id = firstpost.id
JOIN handmade_post AS lastpost ON thread.last_id = lastpost.id JOIN handmade_post AS lastpost ON thread.last_id = lastpost.id
LEFT OUTER JOIN handmade_threadlastreadinfo AS tlri ON ( LEFT JOIN auth_user AS firstuser ON firstpost.author_id = firstuser.id
LEFT JOIN auth_user AS lastuser ON lastpost.author_id = lastuser.id
LEFT JOIN handmade_threadlastreadinfo AS tlri ON (
tlri.thread_id = thread.id tlri.thread_id = thread.id
AND tlri.user_id = $2 AND tlri.user_id = $2
) )
LEFT OUTER JOIN handmade_categorylastreadinfo AS clri ON ( LEFT JOIN handmade_categorylastreadinfo AS clri ON (
clri.category_id = $1 clri.category_id = $1
AND clri.user_id = $2 AND clri.user_id = $2
) )
-- LEFT OUTER JOIN auth_user ON post.author_id = auth_user.id
WHERE WHERE
thread.category_id = $1 thread.category_id = $1
AND NOT thread.deleted AND NOT thread.deleted
@ -115,50 +107,112 @@ func ForumCategory(c *RequestContext) ResponseData {
if err != nil { if err != nil {
panic(oops.New(err, "failed to fetch threads")) panic(oops.New(err, "failed to fetch threads"))
} }
defer itMainThreads.Close()
var res ResponseData var threads []templates.ThreadListItem
for _, irow := range itMainThreads.ToSlice() { for _, irow := range itMainThreads.ToSlice() {
row := irow.(*mainPostsQueryResult) row := irow.(*mainPostsQueryResult)
res.Write([]byte(fmt.Sprintf("%s\n", row.Thread.Title)))
threads = append(threads, templates.ThreadListItem{
Title: row.Thread.Title,
Url: ThreadUrl(row.Thread, models.CatKindForum, categoryUrls[currentCatId]),
FirstUser: templates.UserToTemplate(row.FirstUser),
FirstDate: row.FirstPost.PostDate,
LastUser: templates.UserToTemplate(row.LastUser),
LastDate: row.LastPost.PostDate,
})
} }
// --------------------- // ---------------------
// Subcategory things // Subcategory things
// --------------------- // ---------------------
c.Perf.StartBlock("SQL", "Fetch subcategories") //c.Perf.StartBlock("SQL", "Fetch subcategories")
type queryResult struct { //type queryResult struct {
Cat models.Category `db:"cat"` // Cat models.Category `db:"cat"`
//}
//itSubcats, err := db.Query(c.Context(), c.Conn, queryResult{},
// `
// WITH current AS (
// SELECT id
// FROM handmade_category
// WHERE
// slug = $1
// AND kind = $2
// AND project_id = $3
// )
// SELECT $columns
// FROM
// handmade_category AS cat,
// current
// WHERE
// cat.id = current.id
// OR cat.parent_id = current.id
// `,
// catSlug,
// models.CatKindForum,
// c.CurrentProject.ID,
//)
//if err != nil {
// panic(oops.New(err, "failed to fetch subcategories"))
//}
//c.Perf.EndBlock()
//_ = itSubcats // TODO: Actually query subcategory post data
baseData := getBaseData(c)
baseData.Title = "yeet"
var res ResponseData
res.WriteTemplate("forum_category.html", forumCategoryData{
BaseData: baseData,
Threads: threads,
}, c.Perf)
return res
} }
itSubcats, err := db.Query(c.Context(), c.Conn, queryResult{},
func fetchCatIdFromSlugs(ctx context.Context, conn *pgxpool.Pool, catSlugs []string, projectId int) int {
if len(catSlugs) == 1 {
var err error
currentCatId, err := db.QueryInt(ctx, conn,
`
SELECT cat.id
FROM
handmade_category AS cat
JOIN handmade_project AS proj ON proj.forum_id = cat.id
WHERE
proj.id = $1
AND cat.kind = $2
`,
projectId,
models.CatKindForum,
)
if err != nil {
panic(oops.New(err, "failed to get root category id"))
}
return currentCatId
} else {
var err error
currentCatId, err := db.QueryInt(ctx, conn,
` `
WITH current AS (
SELECT id SELECT id
FROM handmade_category FROM handmade_category
WHERE WHERE
slug = $1 slug = $1
AND kind = $2 AND kind = $2
AND project_id = $3 AND project_id = $3
)
SELECT $columns
FROM
handmade_category AS cat,
current
WHERE
cat.id = current.id
OR cat.parent_id = current.id
`, `,
catSlug, catSlugs[len(catSlugs)-1],
models.CatKindForum, models.CatKindForum,
c.CurrentProject.ID, projectId,
) )
if err != nil { if err != nil {
panic(oops.New(err, "failed to fetch subcategories")) panic(oops.New(err, "failed to get current category id"))
} }
c.Perf.EndBlock()
_ = itSubcats // TODO: Actually query subcategory post data return currentCatId
}
return res
} }

View File

@ -103,8 +103,8 @@ func getBaseData(c *RequestContext) templates.BaseData {
func FetchProjectBySlug(ctx context.Context, conn *pgxpool.Pool, slug string) (*models.Project, error) { func FetchProjectBySlug(ctx context.Context, conn *pgxpool.Pool, slug string) (*models.Project, error) {
subdomainProjectRow, err := db.QueryOne(ctx, conn, models.Project{}, "SELECT $columns FROM handmade_project WHERE slug = $1", slug) subdomainProjectRow, err := db.QueryOne(ctx, conn, models.Project{}, "SELECT $columns FROM handmade_project WHERE slug = $1", slug)
if err == nil { if err == nil {
subdomainProject := subdomainProjectRow.(models.Project) subdomainProject := subdomainProjectRow.(*models.Project)
return &subdomainProject, nil return subdomainProject, nil
} else if !errors.Is(err, db.ErrNoMatchingRows) { } else if !errors.Is(err, db.ErrNoMatchingRows) {
return nil, oops.New(err, "failed to get projects by slug") return nil, oops.New(err, "failed to get projects by slug")
} }

View File

@ -124,3 +124,17 @@ func PostUrl(post models.Post, catKind models.CategoryKind, categoryUrl string)
return "" return ""
} }
func ThreadUrl(thread models.Thread, catKind models.CategoryKind, categoryUrl string) string {
categoryUrl = strings.TrimRight(categoryUrl, "/")
switch catKind {
// TODO: All the relevant post types. Maybe it doesn't make sense to lump them all together here.
case models.CatKindBlog:
return fmt.Sprintf("%s/p/%d", categoryUrl, thread.ID)
case models.CatKindForum:
return fmt.Sprintf("%s/t/%d", categoryUrl, thread.ID)
}
return ""
}