Browse Source

add item tags to backend

master
Gisle Aune 1 year ago
parent
commit
926723a12e
  1. 32
      entities/item.go
  2. 17
      internal/genutils/retain.go
  3. 7
      internal/validate/tag.go
  4. 1
      models/errors.go
  5. 3
      models/item.go
  6. 8
      ports/mysql/db.go
  7. 113
      ports/mysql/items.go
  8. 40
      ports/mysql/mysqlcore/db.go
  9. 6
      ports/mysql/mysqlcore/models.go
  10. 97
      ports/mysql/mysqlcore/tag.sql.go
  11. 22
      ports/mysql/queries/tag.sql
  12. 16
      scripts/goose-mysql/20221119134102_create_table_tag.sql
  13. 54
      usecases/items/service.go

32
entities/item.go

@ -2,6 +2,8 @@ package entities
import (
"git.aiterp.net/stufflog3/stufflog3/models"
"sort"
"strings"
"time"
)
@ -13,6 +15,7 @@ type Item struct {
RequirementID *int `json:"requirementId,omitempty"`
Name string `json:"name"`
Description string `json:"description"`
Tags []string `json:"tags"`
CreatedTime time.Time `json:"createdTime"`
AcquiredTime *time.Time `json:"acquiredTime"`
ScheduledDate *models.Date `json:"scheduledDate"`
@ -33,6 +36,16 @@ func (item *Item) AcquiredBetween(fromTime, toTime time.Time) bool {
return item.AcquiredTime != nil && !item.AcquiredTime.Before(fromTime) && item.AcquiredTime.Before(toTime)
}
func (item *Item) HasTag(tag string) bool {
for _, tag2 := range item.Tags {
if strings.EqualFold(tag, tag2) {
return true
}
}
return false
}
func (item *Item) ApplyUpdate(update models.ItemUpdate) {
if update.RequirementID != nil {
if *update.RequirementID <= 0 {
@ -62,4 +75,23 @@ func (item *Item) ApplyUpdate(update models.ItemUpdate) {
if update.ClearAcquiredTime {
item.AcquiredTime = nil
}
if update.RemoveTags != nil || update.AddTags != nil {
item.Tags = append(item.Tags[:0:0], item.Tags...)
for _, tagToRemove := range update.RemoveTags {
for i, tag := range item.Tags {
if strings.EqualFold(tag, tagToRemove) {
{
item.Tags = append(item.Tags[:i], item.Tags[i+1:]...)
break
}
}
}
}
for _, tagToAdd := range update.AddTags {
item.Tags = append(item.Tags, tagToAdd)
}
if len(update.AddTags) > 0 {
sort.Strings(item.Tags)
}
}
}

17
internal/genutils/retain.go

@ -0,0 +1,17 @@
package genutils
func Retain[T any](arr []T, cb func(T) bool) []T {
return RetainInPlace(append(arr[:0:0], arr...), cb)
}
func RetainInPlace[T any](arr []T, cb func(T) bool) []T {
count := 0
for _, v := range arr {
if cb(v) {
arr[count] = v
count += 1
}
}
return arr[:count]
}

7
internal/validate/tag.go

@ -0,0 +1,7 @@
package validate
import "strings"
func Tag(s string) bool {
return !strings.ContainsAny(s, ",;\t\r\n <>")
}

1
models/errors.go

@ -55,6 +55,7 @@ type BadInputError struct {
Problem string `json:"problem"`
Min interface{} `json:"min,omitempty"`
Max interface{} `json:"max,omitempty"`
Element interface{} `json:"element,omitempty"`
}
func (e BadInputError) Error() string {

3
models/item.go

@ -9,6 +9,8 @@ type ItemUpdate struct {
Description *string `json:"description"`
AcquiredTime *time.Time `json:"acquiredTime"`
ScheduledDate *Date `json:"scheduledDate"`
AddTags []string `json:"addTags"`
RemoveTags []string `json:"removeTags"`
ClearAcquiredTime bool `json:"clearAcquiredTime"`
ClearScheduledDate bool `json:"clearScheduledDate"`
@ -24,6 +26,7 @@ type ItemFilter struct {
ProjectIDs []int `json:"projectIds,omitempty"`
RequirementIDs []int `json:"requirementIds,omitempty"`
StatIDs []int `json:"statIds,omitempty"`
Tags []string `json:"tags,omitempty"`
Loose bool `json:"loose,omitempty"`
UnScheduled bool `json:"unScheduled,omitempty"`
UnAcquired bool `json:"unAcquired,omitempty"`

8
ports/mysql/db.go

@ -75,7 +75,7 @@ func Connect(host string, port int, username, password, database string) (*Datab
}
q := mysqlcore.New(db)
return &Database{db: db, q: q}, nil
}
@ -129,3 +129,9 @@ func sqlJsonPtr(ptr interface{}) sqltypes.NullRawMessage {
return sqltypes.NullRawMessage{Valid: false}
}
}
const (
tagObjectKindItem = iota
tagObjectKindRequirement
tagObjectKindProject
)

113
ports/mysql/items.go

@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"git.aiterp.net/stufflog3/stufflog3/entities"
"git.aiterp.net/stufflog3/stufflog3/internal/genutils"
"git.aiterp.net/stufflog3/stufflog3/models"
"git.aiterp.net/stufflog3/stufflog3/ports/mysql/mysqlcore"
"git.aiterp.net/stufflog3/stufflog3/ports/mysql/sqltypes"
@ -23,6 +24,11 @@ func (r *itemRepository) Find(ctx context.Context, scopeID, itemID int) (*entiti
return nil, err
}
tags, err := r.q.ListTagsByObject(ctx, mysqlcore.ListTagsByObjectParams{
ObjectKind: tagObjectKindItem,
ObjectID: row.ID,
})
return &entities.Item{
ID: row.ID,
ScopeID: row.ScopeID,
@ -34,6 +40,7 @@ func (r *itemRepository) Find(ctx context.Context, scopeID, itemID int) (*entiti
CreatedTime: row.CreatedTime,
AcquiredTime: timePtr(row.AcquiredTime),
ScheduledDate: row.ScheduledDate.AsPtr(),
Tags: tags,
}, nil
}
@ -54,6 +61,50 @@ func (r *itemRepository) Fetch(ctx context.Context, filter models.ItemFilter) ([
if filter.StatIDs != nil && len(filter.StatIDs) == 0 {
return []entities.Item{}, nil
}
if filter.Tags != nil && len(filter.Tags) == 0 {
return []entities.Item{}, nil
}
// For tags, use the ID filter to avoid making too much of a messy query below.
if filter.Tags != nil {
query, args, err := squirrel.Select("object_id, tag_name").
From("tag").
Where(squirrel.Eq{"tag_name": filter.Tags, "object_kind": tagObjectKindItem}).
ToSql()
if err != nil {
return nil, err
}
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
ids := make([]int, 0, 16)
matches := make(map[int]int, 64)
for rows.Next() {
var objectID int
var tagName string
err := rows.Scan(&objectID, &tagName)
if err != nil {
return nil, err
}
if matches[objectID] == 0 {
ids = append(ids, objectID)
}
matches[objectID] += 1
}
err = rows.Close()
if err != nil {
return nil, err
}
if filter.IDs == nil {
filter.IDs = ids
}
filter.IDs = genutils.RetainInPlace(filter.IDs, func(id int) bool {
return matches[id] == len(filter.Tags)
})
}
sq := squirrel.Select(
"i.id, i.scope_id, i.project_requirement_id, pr.project_id, i.owner_id, i.name," +
@ -208,7 +259,14 @@ func (r *itemRepository) Fetch(ctx context.Context, filter models.ItemFilter) ([
}
func (r *itemRepository) Insert(ctx context.Context, item entities.Item) (*entities.Item, error) {
res, err := r.q.InsertItem(ctx, mysqlcore.InsertItemParams{
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
q := mysqlcore.New(tx)
res, err := q.InsertItem(ctx, mysqlcore.InsertItemParams{
ScopeID: item.ScopeID,
ProjectRequirementID: sqlIntPtr(item.RequirementID),
Name: item.Name,
@ -227,13 +285,37 @@ func (r *itemRepository) Insert(ctx context.Context, item entities.Item) (*entit
}
item.ID = int(id)
for _, tag := range item.Tags {
err := q.InsertTag(ctx, mysqlcore.InsertTagParams{
ObjectKind: tagObjectKindItem,
ObjectID: item.ID,
TagName: tag,
})
if err != nil {
return nil, err
}
}
err = tx.Commit()
if err != nil {
return nil, err
}
return &item, nil
}
func (r *itemRepository) Update(ctx context.Context, item entities.Item, update models.ItemUpdate) error {
item.ApplyUpdate(update)
return r.q.UpdateItem(ctx, mysqlcore.UpdateItemParams{
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
q := mysqlcore.New(tx)
err = q.UpdateItem(ctx, mysqlcore.UpdateItemParams{
ProjectRequirementID: sqlIntPtr(item.RequirementID),
Name: item.Name,
Description: item.Description,
@ -242,6 +324,33 @@ func (r *itemRepository) Update(ctx context.Context, item entities.Item, update
OwnerID: item.OwnerID,
ID: item.ID,
})
if err != nil {
return err
}
for _, tag := range update.RemoveTags {
err := q.DeleteTag(ctx, mysqlcore.DeleteTagParams{
ObjectKind: tagObjectKindItem,
ObjectID: item.ID,
TagName: tag,
})
if err != nil {
return err
}
}
for _, tag := range update.AddTags {
err = q.InsertTag(ctx, mysqlcore.InsertTagParams{
ObjectKind: tagObjectKindItem,
ObjectID: item.ID,
TagName: tag,
})
if err != nil {
return err
}
}
return tx.Commit()
}
func (r *itemRepository) Delete(ctx context.Context, item entities.Item) error {

40
ports/mysql/mysqlcore/db.go

@ -102,6 +102,12 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
if q.deleteStatStmt, err = db.PrepareContext(ctx, deleteStat); err != nil {
return nil, fmt.Errorf("error preparing query DeleteStat: %w", err)
}
if q.deleteTagStmt, err = db.PrepareContext(ctx, deleteTag); err != nil {
return nil, fmt.Errorf("error preparing query DeleteTag: %w", err)
}
if q.deleteTagByObjectStmt, err = db.PrepareContext(ctx, deleteTagByObject); err != nil {
return nil, fmt.Errorf("error preparing query DeleteTagByObject: %w", err)
}
if q.getItemStmt, err = db.PrepareContext(ctx, getItem); err != nil {
return nil, fmt.Errorf("error preparing query GetItem: %w", err)
}
@ -138,6 +144,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
if q.insertStatStmt, err = db.PrepareContext(ctx, insertStat); err != nil {
return nil, fmt.Errorf("error preparing query InsertStat: %w", err)
}
if q.insertTagStmt, err = db.PrepareContext(ctx, insertTag); err != nil {
return nil, fmt.Errorf("error preparing query InsertTag: %w", err)
}
if q.listItemStatProgressStmt, err = db.PrepareContext(ctx, listItemStatProgress); err != nil {
return nil, fmt.Errorf("error preparing query ListItemStatProgress: %w", err)
}
@ -201,6 +210,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) {
if q.listStatsStmt, err = db.PrepareContext(ctx, listStats); err != nil {
return nil, fmt.Errorf("error preparing query ListStats: %w", err)
}
if q.listTagsByObjectStmt, err = db.PrepareContext(ctx, listTagsByObject); err != nil {
return nil, fmt.Errorf("error preparing query ListTagsByObject: %w", err)
}
if q.replaceItemStatProgressStmt, err = db.PrepareContext(ctx, replaceItemStatProgress); err != nil {
return nil, fmt.Errorf("error preparing query ReplaceItemStatProgress: %w", err)
}
@ -366,6 +378,16 @@ func (q *Queries) Close() error {
err = fmt.Errorf("error closing deleteStatStmt: %w", cerr)
}
}
if q.deleteTagStmt != nil {
if cerr := q.deleteTagStmt.Close(); cerr != nil {
err = fmt.Errorf("error closing deleteTagStmt: %w", cerr)
}
}
if q.deleteTagByObjectStmt != nil {
if cerr := q.deleteTagByObjectStmt.Close(); cerr != nil {
err = fmt.Errorf("error closing deleteTagByObjectStmt: %w", cerr)
}
}
if q.getItemStmt != nil {
if cerr := q.getItemStmt.Close(); cerr != nil {
err = fmt.Errorf("error closing getItemStmt: %w", cerr)
@ -426,6 +448,11 @@ func (q *Queries) Close() error {
err = fmt.Errorf("error closing insertStatStmt: %w", cerr)
}
}
if q.insertTagStmt != nil {
if cerr := q.insertTagStmt.Close(); cerr != nil {
err = fmt.Errorf("error closing insertTagStmt: %w", cerr)
}
}
if q.listItemStatProgressStmt != nil {
if cerr := q.listItemStatProgressStmt.Close(); cerr != nil {
err = fmt.Errorf("error closing listItemStatProgressStmt: %w", cerr)
@ -531,6 +558,11 @@ func (q *Queries) Close() error {
err = fmt.Errorf("error closing listStatsStmt: %w", cerr)
}
}
if q.listTagsByObjectStmt != nil {
if cerr := q.listTagsByObjectStmt.Close(); cerr != nil {
err = fmt.Errorf("error closing listTagsByObjectStmt: %w", cerr)
}
}
if q.replaceItemStatProgressStmt != nil {
if cerr := q.replaceItemStatProgressStmt.Close(); cerr != nil {
err = fmt.Errorf("error closing replaceItemStatProgressStmt: %w", cerr)
@ -646,6 +678,8 @@ type Queries struct {
deleteSprintStmt *sql.Stmt
deleteSprintPartStmt *sql.Stmt
deleteStatStmt *sql.Stmt
deleteTagStmt *sql.Stmt
deleteTagByObjectStmt *sql.Stmt
getItemStmt *sql.Stmt
getItemStatProgressBetweenStmt *sql.Stmt
getProjectStmt *sql.Stmt
@ -658,6 +692,7 @@ type Queries struct {
insertScopeStmt *sql.Stmt
insertSprintStmt *sql.Stmt
insertStatStmt *sql.Stmt
insertTagStmt *sql.Stmt
listItemStatProgressStmt *sql.Stmt
listItemStatProgressMultiStmt *sql.Stmt
listItemsAcquiredBetweenStmt *sql.Stmt
@ -679,6 +714,7 @@ type Queries struct {
listSprintsAtStmt *sql.Stmt
listSprintsBetweenStmt *sql.Stmt
listStatsStmt *sql.Stmt
listTagsByObjectStmt *sql.Stmt
replaceItemStatProgressStmt *sql.Stmt
replaceProjectRequirementStatStmt *sql.Stmt
replaceScopeMemberStmt *sql.Stmt
@ -721,6 +757,8 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries {
deleteSprintStmt: q.deleteSprintStmt,
deleteSprintPartStmt: q.deleteSprintPartStmt,
deleteStatStmt: q.deleteStatStmt,
deleteTagStmt: q.deleteTagStmt,
deleteTagByObjectStmt: q.deleteTagByObjectStmt,
getItemStmt: q.getItemStmt,
getItemStatProgressBetweenStmt: q.getItemStatProgressBetweenStmt,
getProjectStmt: q.getProjectStmt,
@ -733,6 +771,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries {
insertScopeStmt: q.insertScopeStmt,
insertSprintStmt: q.insertSprintStmt,
insertStatStmt: q.insertStatStmt,
insertTagStmt: q.insertTagStmt,
listItemStatProgressStmt: q.listItemStatProgressStmt,
listItemStatProgressMultiStmt: q.listItemStatProgressMultiStmt,
listItemsAcquiredBetweenStmt: q.listItemsAcquiredBetweenStmt,
@ -754,6 +793,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries {
listSprintsAtStmt: q.listSprintsAtStmt,
listSprintsBetweenStmt: q.listSprintsBetweenStmt,
listStatsStmt: q.listStatsStmt,
listTagsByObjectStmt: q.listTagsByObjectStmt,
replaceItemStatProgressStmt: q.replaceItemStatProgressStmt,
replaceProjectRequirementStatStmt: q.replaceProjectRequirementStatStmt,
replaceScopeMemberStmt: q.replaceScopeMemberStmt,

6
ports/mysql/mysqlcore/models.go

@ -101,3 +101,9 @@ type Stat struct {
AllowedAmounts sqltypes.NullRawMessage
IsPrimary bool
}
type Tag struct {
ObjectKind int32
ObjectID int
TagName string
}

97
ports/mysql/mysqlcore/tag.sql.go

@ -0,0 +1,97 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.13.0
// source: tag.sql
package mysqlcore
import (
"context"
)
const deleteTag = `-- name: DeleteTag :exec
DELETE
FROM tag
WHERE object_kind = ?
AND object_id = ?
AND tag_name = ?
`
type DeleteTagParams struct {
ObjectKind int32
ObjectID int
TagName string
}
func (q *Queries) DeleteTag(ctx context.Context, arg DeleteTagParams) error {
_, err := q.exec(ctx, q.deleteTagStmt, deleteTag, arg.ObjectKind, arg.ObjectID, arg.TagName)
return err
}
const deleteTagByObject = `-- name: DeleteTagByObject :exec
DELETE
FROM tag
WHERE object_kind = ?
AND object_id = ?
`
type DeleteTagByObjectParams struct {
ObjectKind int32
ObjectID int
}
func (q *Queries) DeleteTagByObject(ctx context.Context, arg DeleteTagByObjectParams) error {
_, err := q.exec(ctx, q.deleteTagByObjectStmt, deleteTagByObject, arg.ObjectKind, arg.ObjectID)
return err
}
const insertTag = `-- name: InsertTag :exec
INSERT INTO tag (object_kind, object_id, tag_name)
VALUES (?, ?, ?)
`
type InsertTagParams struct {
ObjectKind int32
ObjectID int
TagName string
}
func (q *Queries) InsertTag(ctx context.Context, arg InsertTagParams) error {
_, err := q.exec(ctx, q.insertTagStmt, insertTag, arg.ObjectKind, arg.ObjectID, arg.TagName)
return err
}
const listTagsByObject = `-- name: ListTagsByObject :many
SELECT tag_name
FROM tag
WHERE object_kind = ?
AND object_id = ?
`
type ListTagsByObjectParams struct {
ObjectKind int32
ObjectID int
}
func (q *Queries) ListTagsByObject(ctx context.Context, arg ListTagsByObjectParams) ([]string, error) {
rows, err := q.query(ctx, q.listTagsByObjectStmt, listTagsByObject, arg.ObjectKind, arg.ObjectID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []string{}
for rows.Next() {
var tag_name string
if err := rows.Scan(&tag_name); err != nil {
return nil, err
}
items = append(items, tag_name)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

22
ports/mysql/queries/tag.sql

@ -0,0 +1,22 @@
-- name: InsertTag :exec
INSERT INTO tag (object_kind, object_id, tag_name)
VALUES (?, ?, ?);
-- name: DeleteTag :exec
DELETE
FROM tag
WHERE object_kind = ?
AND object_id = ?
AND tag_name = ?;
-- name: DeleteTagByObject :exec
DELETE
FROM tag
WHERE object_kind = ?
AND object_id = ?;
-- name: ListTagsByObject :many
SELECT tag_name
FROM tag
WHERE object_kind = ?
AND object_id = ?;

16
scripts/goose-mysql/20221119134102_create_table_tag.sql

@ -0,0 +1,16 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE tag (
object_kind TINYINT NOT NULL,
object_id INT NOT NULL,
tag_name VARCHAR(255) NOT NULL,
PRIMARY KEY (object_kind, object_id, tag_name),
UNIQUE (object_kind, tag_name, object_id)
);
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS tag;
-- +goose StatementEnd

54
usecases/items/service.go

@ -2,8 +2,10 @@ package items
import (
"context"
"fmt"
"git.aiterp.net/stufflog3/stufflog3/entities"
"git.aiterp.net/stufflog3/stufflog3/internal/genutils"
"git.aiterp.net/stufflog3/stufflog3/internal/validate"
"git.aiterp.net/stufflog3/stufflog3/models"
"git.aiterp.net/stufflog3/stufflog3/usecases/auth"
"git.aiterp.net/stufflog3/stufflog3/usecases/scopes"
@ -214,6 +216,28 @@ func (s *Service) Create(ctx context.Context, item entities.Item, stats []entiti
scope := s.Scopes.Context(ctx).Scope
user := s.Auth.GetUser(ctx)
for i, tag := range item.Tags {
if !validate.Tag(tag) {
return nil, models.BadInputError{
Object: "ItemInput",
Field: "tags",
Problem: fmt.Sprintf("Invalid tag: %s", tag),
Element: tag,
}
}
for _, prevTag := range item.Tags[:i] {
if strings.EqualFold(tag, prevTag) {
return nil, models.BadInputError{
Object: "ItemInput",
Field: "tags",
Problem: fmt.Sprintf("Duplicate tag: %s", tag),
Element: tag,
}
}
}
}
item.Name = strings.Trim(item.Name, "  \t\r\n")
if item.Name == "" {
return nil, models.BadInputError{
@ -290,6 +314,36 @@ func (s *Service) Update(ctx context.Context, id int, update models.ItemUpdate,
return nil, err
}
for _, tag := range update.AddTags {
if !validate.Tag(tag) {
return nil, models.BadInputError{
Object: "ItemInput",
Field: "addTags",
Problem: fmt.Sprintf("Invalid tag: %s", tag),
Element: tag,
}
}
if item.HasTag(tag) {
return nil, models.BadInputError{
Object: "ItemUpdate",
Field: "addTags",
Problem: fmt.Sprintf("The tag %s already exists!", tag),
Element: tag,
}
}
}
for _, tag := range update.RemoveTags {
if !item.HasTag(tag) {
return nil, models.BadInputError{
Object: "ItemUpdate",
Field: "removeTags",
Problem: fmt.Sprintf("The tag %s does not exist!", tag),
Element: tag,
}
}
}
if update.RequirementID != nil {
if *update.RequirementID > 0 {
req, _, err := s.RequirementFetcher.FetchRequirements(ctx, scope.ID, *update.RequirementID)

Loading…
Cancel
Save