Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions boost/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ func MakeApp(env *env.Environment, validator *pkg.Validator) (*App, error) {

router := Router{
Env: env,
Db: db,
Mux: baseHttp.NewServeMux(),
Pipeline: middleware.Pipeline{
Env: env,
Expand Down Expand Up @@ -69,4 +70,5 @@ func (a *App) Boot() {
router.Talks()
router.Education()
router.Recommendations()
router.Posts()
}
14 changes: 14 additions & 0 deletions boost/router.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package boost

import (
"github.com/oullin/database"
"github.com/oullin/database/repository"
"github.com/oullin/env"
"github.com/oullin/handler"
"github.com/oullin/pkg/http"
Expand All @@ -12,6 +14,7 @@ type Router struct {
Env *env.Environment
Mux *baseHttp.ServeMux
Pipeline middleware.Pipeline
Db *database.Connection
}

func (r *Router) PipelineFor(apiHandler http.ApiHandler) baseHttp.HandlerFunc {
Expand All @@ -28,6 +31,17 @@ func (r *Router) PipelineFor(apiHandler http.ApiHandler) baseHttp.HandlerFunc {
)
}

func (r *Router) Posts() {
repo := repository.Posts{DB: r.Db}
abstract := handler.MakePostsHandler(&repo)

index := r.PipelineFor(abstract.Index)
show := r.PipelineFor(abstract.Show)

r.Mux.HandleFunc("GET /posts", index)
r.Mux.HandleFunc("GET /posts/{slug}", show)
}

func (r *Router) Profile() {
abstract := handler.MakeProfileHandler("./storage/fixture/profile.json")

Expand Down
2 changes: 1 addition & 1 deletion config/makefile/app.mk
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ audit:
watch:
# --- Works with (air).
# https://github.com/air-verse/air
cd $(APP_PATH) && air
cd $(APP_PATH) && air -d

install-air:
# --- Works with (air).
Expand Down
4 changes: 4 additions & 0 deletions database/connection.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ func (c *Connection) Sql() *gorm.DB {
return c.driver
}

func (c *Connection) GetSession() *gorm.Session {
return &gorm.Session{QueryFields: true}
}

func (c *Connection) Transaction(callback func(db *gorm.DB) error) error {
return c.driver.Transaction(callback)
}
23 changes: 23 additions & 0 deletions database/repository/pagination/paginate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package pagination

type Paginate struct {
Page int
Limit int
NumItems int64
}

func (a *Paginate) SetNumItems(number int64) {
a.NumItems = number
}

func (a *Paginate) GetNumItemsAsInt() int64 {
return a.NumItems
}

func (a *Paginate) GetNumItemsAsFloat() float64 {
return float64(a.NumItems)
}

func (a *Paginate) GetLimit() int {
return a.Limit
}
90 changes: 90 additions & 0 deletions database/repository/pagination/pagination.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package pagination

import "math"

const MinPage = 1
const MaxLimit = 100

// Pagination holds the data for a single page along with all pagination metadata.
// It's generic and can be used for any data type.
//
// NextPage and PreviousPage are pointers (*int) so they can be nil (and omitted from JSON output)
// when there isn't a next or previous page.
type Pagination[T any] struct {
Data []T `json:"data"`
Page int `json:"page"`
Total int64 `json:"total"`
PageSize int `json:"page_size"`
TotalPages int `json:"total_pages"`
NextPage *int `json:"next_page,omitempty"`
PreviousPage *int `json:"previous_page,omitempty"`
}

func MakePagination[T any](data []T, paginate Paginate) *Pagination[T] {
pSize := float64(paginate.Limit)
if pSize <= 0 {
pSize = 10
}

totalPages := int(
math.Ceil(paginate.GetNumItemsAsFloat() / pSize),
)

pagination := Pagination[T]{
Data: data,
Page: paginate.Page,
Total: paginate.GetNumItemsAsInt(),
PageSize: paginate.Limit,
TotalPages: totalPages,
NextPage: nil,
PreviousPage: nil,
}

var nextPage *int
if pagination.Page < pagination.TotalPages {
p := pagination.Page + 1
nextPage = &p
}

var prevPage *int
if pagination.Page > 1 && pagination.Page <= pagination.TotalPages {
p := pagination.Page - 1
prevPage = &p
}

pagination.NextPage = nextPage
pagination.PreviousPage = prevPage

return &pagination
}

// HydratePagination transforms a paginated result containing items of a source type (S)
// into a new result containing items of a destination type (D).
//
// It takes a source Pagination and a mapper function that defines the conversion
// logic from an item of type S to an item of type D.
//
// Type Parameters:
// - S: The source type (e.g., a database model like database.Post).
// - D: The destination type (e.g., an API response DTO like PostResponse).
//
// The function returns a new Pagination with the transformed data, while preserving
// all original pagination metadata (Total, CurrentPage, etc.).
func HydratePagination[S any, D any](source *Pagination[S], mapper func(S) D) *Pagination[D] {
mappedData := make([]D, len(source.Data))

// Iterate over the source data and apply the mapper function
for i, item := range source.Data {
mappedData[i] = mapper(item)
}

return &Pagination[D]{
Data: mappedData,
Total: source.Total,
Page: source.Page,
PageSize: source.PageSize,
TotalPages: source.TotalPages,
NextPage: source.NextPage,
PreviousPage: source.PreviousPage,
}
}
63 changes: 63 additions & 0 deletions database/repository/posts.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"fmt"
"github.com/google/uuid"
"github.com/oullin/database"
"github.com/oullin/database/repository/pagination"
"github.com/oullin/database/repository/queries"
"github.com/oullin/pkg/gorm"
)

Expand All @@ -13,6 +15,67 @@ type Posts struct {
Tags *Tags
}

func (p Posts) GetPosts(filters queries.PostFilters, paginate pagination.Paginate) (*pagination.Pagination[database.Post], error) {
var numItems int64
var posts []database.Post

query := p.DB.Sql().
Model(&database.Post{}).
Where("posts.published_at is not null"). // only published posts will be selected.
Where("posts.deleted_at is null") // deleted posted will be discarded.

queries.ApplyPostsFilters(&filters, query)

countQuery := query.
Session(p.DB.GetSession()). // clone the based query.
Distinct("posts.id") // remove duplicated posts to get the actual count.

if err := countQuery.Count(&numItems).Error; err != nil {
return nil, err
}

offset := (paginate.Page - 1) * paginate.Limit

err := query.Preload("Author").
Preload("Categories").
Preload("Tags").
Order("posts.published_at DESC").
Limit(paginate.Limit).
Offset(offset).
Distinct(). // remove duplications if any after applying JOINS
Find(&posts).Error

if err != nil {
return nil, err
}

paginate.SetNumItems(numItems)
result := pagination.MakePagination[database.Post](posts, paginate)

return result, nil
}

func (p Posts) FindBy(slug string) *database.Post {
post := database.Post{}

result := p.DB.Sql().
Preload("Author").
Preload("Categories").
Preload("Tags").
Where("LOWER(slug) = ?", slug).
First(&post)

if gorm.HasDbIssues(result.Error) {
return nil
}

if result.RowsAffected > 0 {
return &post
}

return nil
}

func (p Posts) FindCategoryBy(slug string) *database.Category {
return p.Categories.FindBy(slug)
}
Expand Down
67 changes: 67 additions & 0 deletions database/repository/queries/posts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package queries

import (
"gorm.io/gorm"
)

// ApplyPostsFilters The given query master table is "posts"
func ApplyPostsFilters(filters *PostFilters, query *gorm.DB) {
if filters == nil {
return
}

if filters.GetTitle() != "" {
query.Where("LOWER(posts.title) ILIKE ?", "%"+filters.GetTitle()+"%")
}

if filters.GetText() != "" {
query.
Where("LOWER(posts.slug) ILIKE ? OR LOWER(posts.excerpt) ILIKE ? OR LOWER(posts.content) ILIKE ?",
"%"+filters.GetText()+"%",
"%"+filters.GetText()+"%",
"%"+filters.GetText()+"%",
)
}

if filters.GetAuthor() != "" {
query.
Joins("JOIN users ON posts.author_id = users.id").
Where("users.deleted_at IS NULL").
Where("("+
"LOWER(users.bio) ILIKE ? OR LOWER(users.first_name) LIKE ? OR LOWER(users.last_name) LIKE ? OR LOWER(users.display_name) ILIKE ?"+
")",
"%"+filters.GetAuthor()+"%",
"%"+filters.GetAuthor()+"%",
"%"+filters.GetAuthor()+"%",
"%"+filters.GetAuthor()+"%",
)
}

if filters.GetCategory() != "" {
query.
Joins("JOIN post_categories ON post_categories.post_id = posts.id").
Joins("JOIN categories ON categories.id = post_categories.category_id").
Where("categories.deleted_at IS NULL").
Where("("+
"LOWER(categories.slug) ILIKE ? OR LOWER(categories.name) ILIKE ? OR LOWER(categories.description) ILIKE ?"+
")",
"%"+filters.GetCategory()+"%",
"%"+filters.GetCategory()+"%",
"%"+filters.GetCategory()+"%",
)
}

if filters.GetTag() != "" {
query.
Joins("JOIN post_tags ON post_tags.post_id = posts.id").
Joins("JOIN tags ON tags.id = post_tags.tag_id").
Where("tags.deleted_at IS NULL").
Where("("+
"LOWER(tags.slug) ILIKE ? OR LOWER(tags.name) ILIKE ? OR LOWER(tags.description) ILIKE ?"+
")",
"%"+filters.GetTag()+"%",
"%"+filters.GetTag()+"%",
"%"+filters.GetTag()+"%",
)
}
}
40 changes: 40 additions & 0 deletions database/repository/queries/posts_filters.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package queries

import (
"github.com/oullin/pkg"
"strings"
)

type PostFilters struct {
Text string
Title string // Will perform a case-insensitive partial match
Author string
Category string
Tag string
}

func (f PostFilters) GetText() string {
return f.sanitiseString(f.Text)
}

func (f PostFilters) GetTitle() string {
return f.sanitiseString(f.Title)
}

func (f PostFilters) GetAuthor() string {
return f.sanitiseString(f.Author)
}

func (f PostFilters) GetCategory() string {
return f.sanitiseString(f.Category)
}

func (f PostFilters) GetTag() string {
return f.sanitiseString(f.Tag)
}

func (f PostFilters) sanitiseString(seed string) string {
str := pkg.MakeStringable(seed)

return strings.TrimSpace(str.ToLower())
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ require (
github.com/lib/pq v1.10.9
golang.org/x/crypto v0.39.0
golang.org/x/term v0.32.0
golang.org/x/text v0.26.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.30.0
Expand All @@ -30,7 +31,6 @@ require (
golang.org/x/net v0.41.0 // indirect
golang.org/x/sync v0.15.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.26.0 // indirect
)

replace github.com/oullin/boost => ./boost
Loading
Loading