Gisle Aune
1 year ago
commit
8fc0220e51
29 changed files with 1315 additions and 0 deletions
-
2.gitignore
-
12auth/provider.go
-
64auth/service.go
-
61auth/user.go
-
37character/character.go
-
9error.go
-
17go.mod
-
26go.sum
-
86internal/generate/id.go
-
29internal/genutils/array.go
-
5internal/genutils/ptr.go
-
29internal/genutils/set.go
-
13internal/genutils/sort.go
-
70internal/genutils/update.go
-
47ports/mysql/database.go
-
19ports/mysql/migrations/20230312124831_create_table_tag.sql
-
128ports/mysql/mysqlgen/db.go
-
20ports/mysql/mysqlgen/models.go
-
147ports/mysql/mysqlgen/tag.sql.go
-
22ports/mysql/null.go
-
20ports/mysql/queries/tag.sql
-
20ports/mysql/sqlc.yaml
-
125ports/mysql/tags.go
-
11tag/repository.go
-
26tag/service.go
-
46tag/tag.go
-
110tag/tree.go
-
95tag/tree_test.go
-
19tag/update.go
@ -0,0 +1,2 @@ |
|||
.idea |
|||
.vscode |
@ -0,0 +1,12 @@ |
|||
package auth |
|||
|
|||
import ( |
|||
"context" |
|||
) |
|||
|
|||
type Provider interface { |
|||
ListUsers(ctx context.Context) ([]UserInfo, error) |
|||
LoginUser(ctx context.Context, username, password string) (*Result, error) |
|||
SetupUser(ctx context.Context, session, username, preferredUsername, newPassword string) (*UserInfo, error) |
|||
ValidateToken(ctx context.Context, token string) *UserInfo |
|||
} |
@ -0,0 +1,64 @@ |
|||
package auth |
|||
|
|||
import ( |
|||
"context" |
|||
"sync" |
|||
"time" |
|||
) |
|||
|
|||
type Service struct { |
|||
Provider Provider |
|||
|
|||
userListMutex sync.Mutex |
|||
userList []UserInfo |
|||
userMap map[string]string |
|||
userListTime time.Time |
|||
|
|||
key struct{ Stuff uint64 } |
|||
key2 struct{ Stuff2 string } |
|||
} |
|||
|
|||
func (s *Service) ValidateUser(ctx context.Context, token string) *UserInfo { |
|||
return s.Provider.ValidateToken(ctx, token) |
|||
} |
|||
|
|||
func (s *Service) AddContextUser(ctx context.Context, user UserInfo) context.Context { |
|||
return context.WithValue(ctx, &s.key, &user) |
|||
} |
|||
|
|||
func (s *Service) Users(ctx context.Context) ([]UserInfo, map[string]string, error) { |
|||
s.userListMutex.Lock() |
|||
if time.Since(s.userListTime) < time.Minute { |
|||
m := s.userMap |
|||
l := s.userList |
|||
s.userListMutex.Unlock() |
|||
return l, m, nil |
|||
} |
|||
s.userListMutex.Unlock() |
|||
|
|||
users, err := s.Provider.ListUsers(ctx) |
|||
if err != nil { |
|||
return nil, nil, err |
|||
} |
|||
|
|||
s.userListMutex.Lock() |
|||
s.userList = users |
|||
s.userMap = make(map[string]string, len(users)) |
|||
for _, user := range users { |
|||
s.userMap[user.ID] = user.Name |
|||
} |
|||
m := s.userMap |
|||
s.userListTime = time.Now() |
|||
s.userListMutex.Unlock() |
|||
|
|||
return users, m, nil |
|||
} |
|||
|
|||
func (s *Service) GetUser(ctx context.Context) *UserInfo { |
|||
v := ctx.Value(&s.key) |
|||
if v == nil { |
|||
return nil |
|||
} |
|||
|
|||
return v.(*UserInfo) |
|||
} |
@ -0,0 +1,61 @@ |
|||
package auth |
|||
|
|||
import ( |
|||
"fmt" |
|||
) |
|||
|
|||
type User struct { |
|||
ID string `json:"id"` |
|||
Name string `json:"name"` |
|||
} |
|||
|
|||
type UserInfo struct { |
|||
User |
|||
Permissions []string `json:"permissions"` |
|||
} |
|||
|
|||
type Result struct { |
|||
User *UserInfo `json:"user"` |
|||
Token string `json:"token,omitempty"` |
|||
Session string `json:"session,omitempty"` |
|||
PasswordChangeRequired bool `json:"passwordChangeRequired"` |
|||
} |
|||
|
|||
func (user *UserInfo) HasIDOrPermission(userID, subject, action string) bool { |
|||
return user.HasID(userID) || user.HasPermission(subject, action) |
|||
} |
|||
|
|||
func (user *UserInfo) HasID(id string) bool { |
|||
return user != nil && user.ID == id |
|||
} |
|||
|
|||
func (user *UserInfo) HasOpPermission(userID, subject, action string) bool { |
|||
if !user.HasID(userID) && !user.HasPermission(subject, "admin") { |
|||
return false |
|||
} |
|||
|
|||
return user.HasPermission(subject, action) |
|||
} |
|||
|
|||
func (user *UserInfo) HasPermission(subject, action string) bool { |
|||
if user == nil { |
|||
return false |
|||
} |
|||
|
|||
anyAll := "*.*" |
|||
anyAction := fmt.Sprintf("%s.*", subject) |
|||
anySubject := fmt.Sprintf("*.%s", action) |
|||
specific := fmt.Sprintf("%s.%s", subject, action) |
|||
|
|||
if action == "admin" { |
|||
anyAction = specific |
|||
} |
|||
|
|||
for _, perm := range user.Permissions { |
|||
if perm == anyAll || perm == anyAction || perm == anySubject || perm == specific { |
|||
return true |
|||
} |
|||
} |
|||
|
|||
return false |
|||
} |
@ -0,0 +1,37 @@ |
|||
package character |
|||
|
|||
import ( |
|||
"git.aiterp.net/rpdata2-take2/rpdata2/auth" |
|||
"git.aiterp.net/rpdata2-take2/rpdata2/tag" |
|||
) |
|||
|
|||
type Character struct { |
|||
ID string `json:"id"` |
|||
Name string `json:"name"` |
|||
ShortName string `json:"shortName"` |
|||
DisplayName string `json:"displayName,omitempty"` |
|||
Description string `json:"description"` |
|||
FavoriteColor string `json:"favoriteColor"` |
|||
WikiPage string `json:"wikiPage"` |
|||
|
|||
Author auth.User `json:"author"` |
|||
Attributes []Attribute `json:"attributes"` |
|||
Relations []Relation `json:"relations"` |
|||
Tags []Tag `json:"tags"` |
|||
} |
|||
|
|||
type Attribute struct { |
|||
Label string `json:"label,omitempty"` |
|||
Value string `json:"value,omitempty"` |
|||
} |
|||
|
|||
type Relation struct { |
|||
Label string `json:"label,omitempty"` |
|||
Character |
|||
} |
|||
|
|||
type Tag struct { |
|||
Label string `json:"label,omitempty"` |
|||
Restricted bool `json:"restricted,omitempty"` |
|||
tag.Tag |
|||
} |
@ -0,0 +1,9 @@ |
|||
package rpdata2 |
|||
|
|||
import "fmt" |
|||
|
|||
type NotFound string |
|||
|
|||
func (e NotFound) Error() string { |
|||
return fmt.Sprintf("%s not found", string(e)) |
|||
} |
@ -0,0 +1,17 @@ |
|||
module git.aiterp.net/rpdata2-take2/rpdata2 |
|||
|
|||
go 1.19 |
|||
|
|||
require ( |
|||
github.com/Masterminds/squirrel v1.5.3 |
|||
github.com/google/uuid v1.3.0 |
|||
github.com/stretchr/testify v1.8.2 |
|||
) |
|||
|
|||
require ( |
|||
github.com/davecgh/go-spew v1.1.1 // indirect |
|||
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect |
|||
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect |
|||
github.com/pmezard/go-difflib v1.0.0 // indirect |
|||
gopkg.in/yaml.v3 v3.0.1 // indirect |
|||
) |
@ -0,0 +1,26 @@ |
|||
github.com/Masterminds/squirrel v1.5.3 h1:YPpoceAcxuzIljlr5iWpNKaql7hLeG1KLSrhvdHpkZc= |
|||
github.com/Masterminds/squirrel v1.5.3/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= |
|||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= |
|||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= |
|||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= |
|||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= |
|||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= |
|||
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw= |
|||
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= |
|||
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= |
|||
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= |
|||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= |
|||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= |
|||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= |
|||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= |
|||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= |
|||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= |
|||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= |
|||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= |
|||
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= |
|||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= |
|||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= |
|||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= |
|||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= |
|||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= |
|||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= |
@ -0,0 +1,86 @@ |
|||
package generate |
|||
|
|||
import ( |
|||
"crypto/rand" |
|||
"encoding/binary" |
|||
mathRand "math/rand" |
|||
"strconv" |
|||
"strings" |
|||
) |
|||
|
|||
// ID generates an ID using crypto-random, falling back to math random when that fails
|
|||
// to avoid disrupting operation because of a faulty RNG.
|
|||
func ID(prefix string, length int) string { |
|||
var data [32]byte |
|||
|
|||
result := strings.Builder{} |
|||
result.Grow(length + 32) |
|||
result.WriteString(prefix) |
|||
|
|||
pos := 0 |
|||
for result.Len() < length { |
|||
if pos == 0 { |
|||
randRead(data[:]) |
|||
} |
|||
|
|||
result.WriteString(strconv.FormatUint(binary.BigEndian.Uint64(data[pos:pos+8]), 36)) |
|||
|
|||
pos = (pos + 8) % 32 |
|||
} |
|||
|
|||
return result.String()[:length] |
|||
} |
|||
|
|||
func randRead(data []byte) { |
|||
n, err := rand.Read(data) |
|||
if err != nil { |
|||
mathRand.Read(data[n:]) |
|||
} |
|||
} |
|||
|
|||
// InternalErrorID generates a long string
|
|||
func InternalErrorID() string { |
|||
return ID("ISE", 32) |
|||
} |
|||
|
|||
// TagID generates a location ID. len=8
|
|||
func TagID(longName string) string { |
|||
return friendlyID('T', longName, 8) |
|||
} |
|||
|
|||
// CharacterID generates a character ID. len=8
|
|||
func CharacterID(longName string) string { |
|||
return friendlyID('C', longName, 8) |
|||
} |
|||
|
|||
func friendlyID(prefix byte, name string, length int) string { |
|||
b := strings.Builder{} |
|||
b.Grow(4) |
|||
b.WriteByte(prefix) |
|||
for _, ch := range strings.ToLower(name) { |
|||
if ch >= 'a' && ch <= 'z' { |
|||
b.WriteRune(ch) |
|||
|
|||
if b.Len() > 3 { |
|||
break |
|||
} |
|||
} |
|||
} |
|||
|
|||
return ID(b.String(), length) |
|||
} |
|||
|
|||
// StoryID generates a story ID: len=8
|
|||
func StoryID() string { |
|||
return ID("S", 12) |
|||
} |
|||
|
|||
// PostID generates a post ID. len=12
|
|||
func PostID() string { |
|||
return ID("P", 16) |
|||
} |
|||
|
|||
// AnnotationID generates an annotation.sql ID. len=12
|
|||
func AnnotationID() string { |
|||
return ID("A", 16) |
|||
} |
@ -0,0 +1,29 @@ |
|||
package genutils |
|||
|
|||
func UpsertIntoArray[T comparable](arr []T, values ...T) []T { |
|||
outer: |
|||
for _, value := range values { |
|||
for _, value2 := range arr { |
|||
if value2 == value { |
|||
continue outer |
|||
} |
|||
} |
|||
|
|||
arr = append(arr, value) |
|||
} |
|||
|
|||
return arr |
|||
} |
|||
|
|||
func RemoveFromArray[T comparable](arr []T, values ...T) []T { |
|||
for _, value := range values { |
|||
for i, value2 := range arr { |
|||
if value2 == value { |
|||
arr = append(arr[:i], arr[i+1:]...) |
|||
break |
|||
} |
|||
} |
|||
} |
|||
|
|||
return arr |
|||
} |
@ -0,0 +1,5 @@ |
|||
package genutils |
|||
|
|||
func Ptr[T any](t T) *T { |
|||
return &t |
|||
} |
@ -0,0 +1,29 @@ |
|||
package genutils |
|||
|
|||
type Set[T comparable] struct { |
|||
m map[T]bool |
|||
a []T |
|||
} |
|||
|
|||
func (set *Set[T]) Add(values ...T) { |
|||
if set.m == nil { |
|||
set.m = make(map[T]bool, len(values)*4) |
|||
} |
|||
|
|||
for _, value := range values { |
|||
if set.m[value] { |
|||
continue |
|||
} |
|||
|
|||
set.m[value] = true |
|||
set.a = append(set.a, value) |
|||
} |
|||
} |
|||
|
|||
func (set *Set[T]) Len() int { |
|||
return len(set.a) |
|||
} |
|||
|
|||
func (set *Set[T]) Values() []T { |
|||
return set.a[:len(set.a):len(set.a)] |
|||
} |
@ -0,0 +1,13 @@ |
|||
package genutils |
|||
|
|||
import "sort" |
|||
|
|||
type Lesser[T any] interface { |
|||
Less(T) bool |
|||
} |
|||
|
|||
func SortSlice[T Lesser[T]](slice []T) { |
|||
sort.Slice(slice, func(i, j int) bool { |
|||
return slice[i].Less(slice[j]) |
|||
}) |
|||
} |
@ -0,0 +1,70 @@ |
|||
package genutils |
|||
|
|||
type hasValid interface { |
|||
Valid() bool |
|||
} |
|||
|
|||
func ApplyUpdate[T any](dst *T, value *T) { |
|||
if value != nil { |
|||
*dst = *value |
|||
} |
|||
} |
|||
|
|||
func ApplyUpdateValid[T hasValid](dst *T, value *T) { |
|||
if value != nil && (*value).Valid() { |
|||
*dst = *value |
|||
} |
|||
} |
|||
|
|||
func ApplyUpdateNonZero[T comparable](dst *T, value *T) { |
|||
var zero T |
|||
if value != nil && *value != zero { |
|||
*dst = *value |
|||
} |
|||
} |
|||
|
|||
func ApplyUpdateNilZero[T comparable](dst **T, value *T) { |
|||
if value != nil { |
|||
var zero T |
|||
|
|||
if *value == zero { |
|||
*dst = nil |
|||
} else { |
|||
valueCopy := *value |
|||
*dst = &valueCopy |
|||
} |
|||
} |
|||
} |
|||
|
|||
func ApplyMapUpdate[K comparable, V any](dst *map[K]V, src map[K]*V) { |
|||
if *dst == nil { |
|||
dst = &map[K]V{} |
|||
} |
|||
for key, value := range src { |
|||
if value != nil { |
|||
(*dst)[key] = *value |
|||
} else { |
|||
delete(*dst, key) |
|||
} |
|||
} |
|||
} |
|||
|
|||
func ApplyUpdateMapNilZero[K comparable, V comparable](dst *map[K]V, src map[K]V) { |
|||
var zero V |
|||
if *dst == nil { |
|||
dst = &map[K]V{} |
|||
} |
|||
for key, value := range src { |
|||
if value != zero { |
|||
(*dst)[key] = value |
|||
} else { |
|||
delete(*dst, key) |
|||
} |
|||
} |
|||
} |
|||
|
|||
func ApplyArrayUpdate[T comparable](arr *[]T, upsert []T, remove []T) { |
|||
*arr = make([]T, 0, len(*arr)+len(upsert)) |
|||
*arr = UpsertIntoArray(*arr, upsert...) |
|||
*arr = RemoveFromArray(*arr, remove...) |
|||
} |
@ -0,0 +1,47 @@ |
|||
package mysql |
|||
|
|||
import ( |
|||
"context" |
|||
"database/sql" |
|||
"fmt" |
|||
"git.aiterp.net/rpdata2/rpdata2-take2/ports/mysql/mysqlgen" |
|||
"git.aiterp.net/rpdata2/rpdata2-take2/tag" |
|||
"time" |
|||
) |
|||
|
|||
type Database struct { |
|||
db *sql.DB |
|||
q *mysqlgen.Queries |
|||
} |
|||
|
|||
func (db *Database) Tags() tag.Repository { |
|||
return &tagRepository{ |
|||
db: db.db, |
|||
q: db.q, |
|||
} |
|||
} |
|||
|
|||
func Connect(host string, port int, username, password, database string) (*Database, error) { |
|||
db, err := sql.Open("mysql", fmt.Sprintf( |
|||
"%s:%s@(%s:%d)/%s?parseTime=true", username, password, host, port, database, |
|||
)) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
db.SetMaxOpenConns(10) |
|||
db.SetMaxIdleConns(10) |
|||
db.SetConnMaxIdleTime(time.Minute) |
|||
|
|||
err = db.Ping() |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
q, err := mysqlgen.Prepare(context.Background(), db) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
return &Database{db: db, q: q}, nil |
|||
} |
@ -0,0 +1,19 @@ |
|||
-- +goose Up |
|||
-- +goose StatementBegin |
|||
CREATE TABLE tag |
|||
( |
|||
`id` CHAR(8) NOT NULL PRIMARY KEY, |
|||
`parent_id` CHAR(8) NULL, |
|||
`owner_id` VARCHAR(255) NOT NULL, |
|||
`name` VARCHAR(255) NOT NULL, |
|||
`kind` INT NOT NULL, |
|||
`description` TEXT NOT NULL, |
|||
`listed` BOOL NOT NULL, |
|||
`secret` BOOL NOT NULL |
|||
); |
|||
-- +goose StatementEnd |
|||
|
|||
-- +goose Down |
|||
-- +goose StatementBegin |
|||
DROP TABLE tag; |
|||
-- +goose StatementEnd |
@ -0,0 +1,128 @@ |
|||
// Code generated by sqlc. DO NOT EDIT.
|
|||
// versions:
|
|||
// sqlc v1.13.0
|
|||
|
|||
package mysqlgen |
|||
|
|||
import ( |
|||
"context" |
|||
"database/sql" |
|||
"fmt" |
|||
) |
|||
|
|||
type DBTX interface { |
|||
ExecContext(context.Context, string, ...interface{}) (sql.Result, error) |
|||
PrepareContext(context.Context, string) (*sql.Stmt, error) |
|||
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) |
|||
QueryRowContext(context.Context, string, ...interface{}) *sql.Row |
|||
} |
|||
|
|||
func New(db DBTX) *Queries { |
|||
return &Queries{db: db} |
|||
} |
|||
|
|||
func Prepare(ctx context.Context, db DBTX) (*Queries, error) { |
|||
q := Queries{db: db} |
|||
var err error |
|||
if q.deleteTagStmt, err = db.PrepareContext(ctx, deleteTag); err != nil { |
|||
return nil, fmt.Errorf("error preparing query DeleteTag: %w", err) |
|||
} |
|||
if q.findTagStmt, err = db.PrepareContext(ctx, findTag); err != nil { |
|||
return nil, fmt.Errorf("error preparing query FindTag: %w", err) |
|||
} |
|||
if q.listListedTagsStmt, err = db.PrepareContext(ctx, listListedTags); err != nil { |
|||
return nil, fmt.Errorf("error preparing query ListListedTags: %w", err) |
|||
} |
|||
if q.listTagsStmt, err = db.PrepareContext(ctx, listTags); err != nil { |
|||
return nil, fmt.Errorf("error preparing query ListTags: %w", err) |
|||
} |
|||
if q.replaceTagStmt, err = db.PrepareContext(ctx, replaceTag); err != nil { |
|||
return nil, fmt.Errorf("error preparing query ReplaceTag: %w", err) |
|||
} |
|||
return &q, nil |
|||
} |
|||
|
|||
func (q *Queries) Close() error { |
|||
var err error |
|||
if q.deleteTagStmt != nil { |
|||
if cerr := q.deleteTagStmt.Close(); cerr != nil { |
|||
err = fmt.Errorf("error closing deleteTagStmt: %w", cerr) |
|||
} |
|||
} |
|||
if q.findTagStmt != nil { |
|||
if cerr := q.findTagStmt.Close(); cerr != nil { |
|||
err = fmt.Errorf("error closing findTagStmt: %w", cerr) |
|||
} |
|||
} |
|||
if q.listListedTagsStmt != nil { |
|||
if cerr := q.listListedTagsStmt.Close(); cerr != nil { |
|||
err = fmt.Errorf("error closing listListedTagsStmt: %w", cerr) |
|||
} |
|||
} |
|||
if q.listTagsStmt != nil { |
|||
if cerr := q.listTagsStmt.Close(); cerr != nil { |
|||
err = fmt.Errorf("error closing listTagsStmt: %w", cerr) |
|||
} |
|||
} |
|||
if q.replaceTagStmt != nil { |
|||
if cerr := q.replaceTagStmt.Close(); cerr != nil { |
|||
err = fmt.Errorf("error closing replaceTagStmt: %w", cerr) |
|||
} |
|||
} |
|||
return err |
|||
} |
|||
|
|||
func (q *Queries) exec(ctx context.Context, stmt *sql.Stmt, query string, args ...interface{}) (sql.Result, error) { |
|||
switch { |
|||
case stmt != nil && q.tx != nil: |
|||
return q.tx.StmtContext(ctx, stmt).ExecContext(ctx, args...) |
|||
case stmt != nil: |
|||
return stmt.ExecContext(ctx, args...) |
|||
default: |
|||
return q.db.ExecContext(ctx, query, args...) |
|||
} |
|||
} |
|||
|
|||
func (q *Queries) query(ctx context.Context, stmt *sql.Stmt, query string, args ...interface{}) (*sql.Rows, error) { |
|||
switch { |
|||
case stmt != nil && q.tx != nil: |
|||
return q.tx.StmtContext(ctx, stmt).QueryContext(ctx, args...) |
|||
case stmt != nil: |
|||
return stmt.QueryContext(ctx, args...) |
|||
default: |
|||
return q.db.QueryContext(ctx, query, args...) |
|||
} |
|||
} |
|||
|
|||
func (q *Queries) queryRow(ctx context.Context, stmt *sql.Stmt, query string, args ...interface{}) *sql.Row { |
|||
switch { |
|||
case stmt != nil && q.tx != nil: |
|||
return q.tx.StmtContext(ctx, stmt).QueryRowContext(ctx, args...) |
|||
case stmt != nil: |
|||
return stmt.QueryRowContext(ctx, args...) |
|||
default: |
|||
return q.db.QueryRowContext(ctx, query, args...) |
|||
} |
|||
} |
|||
|
|||
type Queries struct { |
|||
db DBTX |
|||
tx *sql.Tx |
|||
deleteTagStmt *sql.Stmt |
|||
findTagStmt *sql.Stmt |
|||
listListedTagsStmt *sql.Stmt |
|||
listTagsStmt *sql.Stmt |
|||
replaceTagStmt *sql.Stmt |
|||
} |
|||
|
|||
func (q *Queries) WithTx(tx *sql.Tx) *Queries { |
|||
return &Queries{ |
|||
db: tx, |
|||
tx: tx, |
|||
deleteTagStmt: q.deleteTagStmt, |
|||
findTagStmt: q.findTagStmt, |
|||
listListedTagsStmt: q.listListedTagsStmt, |
|||
listTagsStmt: q.listTagsStmt, |
|||
replaceTagStmt: q.replaceTagStmt, |
|||
} |
|||
} |
@ -0,0 +1,20 @@ |
|||
// Code generated by sqlc. DO NOT EDIT.
|
|||
// versions:
|
|||
// sqlc v1.13.0
|
|||
|
|||
package mysqlgen |
|||
|
|||
import ( |
|||
"database/sql" |
|||
) |
|||
|
|||
type Tag struct { |
|||
ID string |
|||
ParentID sql.NullString |
|||
OwnerID string |
|||
Name string |
|||
Kind int |
|||
Description string |
|||
Listed bool |
|||
Secret bool |
|||
} |
@ -0,0 +1,147 @@ |
|||
// Code generated by sqlc. DO NOT EDIT.
|
|||
// versions:
|
|||
// sqlc v1.13.0
|
|||
// source: tag.sql
|
|||
|
|||
package mysqlgen |
|||
|
|||
import ( |
|||
"context" |
|||
"database/sql" |
|||
) |
|||
|
|||
const deleteTag = `-- name: DeleteTag :exec |
|||
DELETE FROM tag WHERE id = ? |
|||
` |
|||
|
|||
func (q *Queries) DeleteTag(ctx context.Context, id string) error { |
|||
_, err := q.exec(ctx, q.deleteTagStmt, deleteTag, id) |
|||
return err |
|||
} |
|||
|
|||
const findTag = `-- name: FindTag :one |
|||
SELECT id, parent_id, owner_id, name, kind, description, listed, secret |
|||
FROM tag |
|||
WHERE id = ? |
|||
` |
|||
|
|||
func (q *Queries) FindTag(ctx context.Context, id string) (Tag, error) { |
|||
row := q.queryRow(ctx, q.findTagStmt, findTag, id) |
|||
var i Tag |
|||
err := row.Scan( |
|||
&i.ID, |
|||
&i.ParentID, |
|||
&i.OwnerID, |
|||
&i.Name, |
|||
&i.Kind, |
|||
&i.Description, |
|||
&i.Listed, |
|||
&i.Secret, |
|||
) |
|||
return i, err |
|||
} |
|||
|
|||
const listListedTags = `-- name: ListListedTags :many |
|||
SELECT id, parent_id, owner_id, name, kind, description, listed, secret |
|||
FROM tag |
|||
WHERE listed = 1 |
|||
` |
|||
|
|||
func (q *Queries) ListListedTags(ctx context.Context) ([]Tag, error) { |
|||
rows, err := q.query(ctx, q.listListedTagsStmt, listListedTags) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
defer rows.Close() |
|||
items := []Tag{} |
|||
for rows.Next() { |
|||
var i Tag |
|||
if err := rows.Scan( |
|||
&i.ID, |
|||
&i.ParentID, |
|||
&i.OwnerID, |
|||
&i.Name, |
|||
&i.Kind, |
|||
&i.Description, |
|||
&i.Listed, |
|||
&i.Secret, |
|||
); err != nil { |
|||
return nil, err |
|||
} |
|||
items = append(items, i) |
|||
} |
|||
if err := rows.Close(); err != nil { |
|||
return nil, err |
|||
} |
|||
if err := rows.Err(); err != nil { |
|||
return nil, err |
|||
} |
|||
return items, nil |
|||
} |
|||
|
|||
const listTags = `-- name: ListTags :many |
|||
SELECT id, parent_id, owner_id, name, kind, description, listed, secret |
|||
FROM tag |
|||
` |
|||
|
|||
func (q *Queries) ListTags(ctx context.Context) ([]Tag, error) { |
|||
rows, err := q.query(ctx, q.listTagsStmt, listTags) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
defer rows.Close() |
|||
items := []Tag{} |
|||
for rows.Next() { |
|||
var i Tag |
|||
if err := rows.Scan( |
|||
&i.ID, |
|||
&i.ParentID, |
|||
&i.OwnerID, |
|||
&i.Name, |
|||
&i.Kind, |
|||
&i.Description, |
|||
&i.Listed, |
|||
&i.Secret, |
|||
); err != nil { |
|||
return nil, err |
|||
} |
|||
items = append(items, i) |
|||
} |
|||
if err := rows.Close(); err != nil { |
|||
return nil, err |
|||
} |
|||
if err := rows.Err(); err != nil { |
|||
return nil, err |
|||
} |
|||
return items, nil |
|||
} |
|||
|
|||
const replaceTag = `-- name: ReplaceTag :exec |
|||
REPLACE INTO tag (id, parent_id, owner_id, name, kind, description, listed, secret) |
|||
VALUES (?, ?, ?, ?, ?, ?, ?, ?) |
|||
` |
|||
|
|||
type ReplaceTagParams struct { |
|||
ID string |
|||
ParentID sql.NullString |
|||
OwnerID string |
|||
Name string |
|||
Kind int |
|||
Description string |
|||
Listed bool |
|||
Secret bool |
|||
} |
|||
|
|||
func (q *Queries) ReplaceTag(ctx context.Context, arg ReplaceTagParams) error { |
|||
_, err := q.exec(ctx, q.replaceTagStmt, replaceTag, |
|||
arg.ID, |
|||
arg.ParentID, |
|||
arg.OwnerID, |
|||
arg.Name, |
|||
arg.Kind, |
|||
arg.Description, |
|||
arg.Listed, |
|||
arg.Secret, |
|||
) |
|||
return err |
|||
} |
@ -0,0 +1,22 @@ |
|||
package mysql |
|||
|
|||
import ( |
|||
"database/sql" |
|||
"git.aiterp.net/rpdata2-take2/rpdata2/internal/genutils" |
|||
) |
|||
|
|||
func toNullString(s *string) sql.NullString { |
|||
if s != nil { |
|||
return sql.NullString{Valid: true, String: *s} |
|||
} else { |
|||
return sql.NullString{Valid: false} |
|||
} |
|||
} |
|||
|
|||
func fromNullString(ns sql.NullString) *string { |
|||
if ns.Valid { |
|||
return genutils.Ptr(ns.String) |
|||
} else { |
|||
return nil |
|||
} |
|||
} |
@ -0,0 +1,20 @@ |
|||
-- name: FindTag :one |
|||
SELECT * |
|||
FROM tag |
|||
WHERE id = ?; |
|||
|
|||
-- name: ListTags :many |
|||
SELECT * |
|||
FROM tag; |
|||
|
|||
-- name: ListListedTags :many |
|||
SELECT * |
|||
FROM tag |
|||
WHERE listed = 1; |
|||
|
|||
-- name: ReplaceTag :exec |
|||
REPLACE INTO tag (id, parent_id, owner_id, name, kind, description, listed, secret) |
|||
VALUES (?, ?, ?, ?, ?, ?, ?, ?); |
|||
|
|||
-- name: DeleteTag :exec |
|||
DELETE FROM tag WHERE id = ?; |
@ -0,0 +1,20 @@ |
|||
version: "1" |
|||
packages: |
|||
- name: "mysqlgen" |
|||
path: "./mysqlgen" |
|||
queries: "./queries" |
|||
schema: "./migrations" |
|||
engine: "mysql" |
|||
emit_prepared_queries: true |
|||
emit_interface: false |
|||
emit_exact_table_names: false |
|||
emit_empty_slices: true |
|||
emit_json_tags: false |
|||
overrides: |
|||
- go_type: "float64" |
|||
db_type: "float" |
|||
- go_type: "float64" |
|||
db_type: "float" |
|||
nullable: true |
|||
- go_type: "int" |
|||
db_type: "int" |
@ -0,0 +1,125 @@ |
|||
package mysql |
|||
|
|||
import ( |
|||
"context" |
|||
"database/sql" |
|||
"git.aiterp.net/rpdata2-take2/rpdata2" |
|||
"git.aiterp.net/rpdata2-take2/rpdata2/ports/mysql/mysqlgen" |
|||
"git.aiterp.net/rpdata2-take2/rpdata2/tag" |
|||
"github.com/Masterminds/squirrel" |
|||
) |
|||
|
|||
type tagRepository struct { |
|||
db *sql.DB |
|||
q *mysqlgen.Queries |
|||
} |
|||
|
|||
func (r *tagRepository) FindOne(ctx context.Context, id string) (*tag.Tag, error) { |
|||
row, err := r.q.FindTag(ctx, id) |
|||
if err != sql.ErrNoRows { |
|||
return nil, rpdata2.NotFound("Tag") |
|||
} else if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
return &tag.Tag{ |
|||
ID: row.ID, |
|||
ParentID: fromNullString(row.ParentID), |
|||
OwnerID: row.OwnerID, |
|||
Name: row.Name, |
|||
Kind: tag.Kind(row.Kind), |
|||
Description: row.Description, |
|||
Listed: row.Listed, |
|||
Secret: row.Secret, |
|||
}, nil |
|||
} |
|||
|
|||
func (r *tagRepository) FindRecursive(ctx context.Context, id string) ([]tag.Tag, error) { |
|||
return r.findRecursive(ctx, make([]tag.Tag, 0, 32), []string{id}) |
|||
} |
|||
|
|||
func (r *tagRepository) findRecursive(ctx context.Context, tags []tag.Tag, parentIDs []string) ([]tag.Tag, error) { |
|||
query, args, err := squirrel.Select("id,parent_id,owner_id,name,kind,description,listed,secret"). |
|||
From("tag"). |
|||
Where(squirrel.Eq{"parent_id": parentIDs}). |
|||
ToSql() |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
rows, err := r.db.QueryContext(ctx, query, args...) |
|||
if err != nil && err != sql.ErrNoRows { |
|||
return nil, err |
|||
} |
|||
defer rows.Close() |
|||
|
|||
nextParentIDs := make([]string, 0, 8) |
|||
for rows.Next() { |
|||
var t tag.Tag |
|||
var ns sql.NullString |
|||
|
|||
err = rows.Scan(&t.ID, &ns, &t.OwnerID, &t.Name, &t.Kind, &t.Description, &t.Listed, &t.Secret) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
t.ParentID = fromNullString(ns) |
|||
nextParentIDs = append(nextParentIDs, t.ID) |
|||
|
|||
tags = append(tags, t) |
|||
} |
|||
|
|||
if len(nextParentIDs) > 0 { |
|||
return r.findRecursive(ctx, tags, nextParentIDs) |
|||
} else { |
|||
return tags, nil |
|||
} |
|||
} |
|||
|
|||
func (r *tagRepository) List(ctx context.Context, full bool) ([]tag.Tag, error) { |
|||
var rows []mysqlgen.Tag |
|||
var err error |
|||
if full { |
|||
rows, err = r.q.ListTags(ctx) |
|||
} else { |
|||
rows, err = r.q.ListListedTags(ctx) |
|||
} |
|||
if err != nil { |
|||
if err == sql.ErrNoRows { |
|||
return []tag.Tag{}, nil |
|||
} |
|||
return nil, err |
|||
} |
|||
|
|||
res := make([]tag.Tag, 0, len(rows)) |
|||
for _, row := range rows { |
|||
res = append(res, tag.Tag{ |
|||
ID: row.ID, |
|||
ParentID: fromNullString(row.ParentID), |
|||
OwnerID: row.OwnerID, |
|||
Name: row.Name, |
|||
Kind: tag.Kind(row.Kind), |
|||
Description: row.Description, |
|||
Listed: row.Listed, |
|||
Secret: row.Secret, |
|||
}) |
|||
} |
|||
|
|||
return res, nil |
|||
} |
|||
|
|||
func (r *tagRepository) Save(ctx context.Context, tag tag.Tag) error { |
|||
return r.q.ReplaceTag(ctx, mysqlgen.ReplaceTagParams{ |
|||
ID: tag.ID, |
|||
ParentID: toNullString(tag.ParentID), |
|||
OwnerID: tag.OwnerID, |
|||
Name: tag.Name, |
|||
Kind: int(tag.Kind), |
|||
Description: tag.Description, |
|||
Listed: tag.Listed, |
|||
Secret: tag.Secret, |
|||
}) |
|||
} |
|||
|
|||
func (r *tagRepository) Delete(ctx context.Context, tag tag.Tag) error { |
|||
return r.q.DeleteTag(ctx, tag.ID) |
|||
} |
@ -0,0 +1,11 @@ |
|||
package tag |
|||
|
|||
import "context" |
|||
|
|||
type Repository interface { |
|||
FindOne(ctx context.Context, id string) (*Tag, error) |
|||
FindRecursive(ctx context.Context, id string) ([]Tag, error) |
|||
List(ctx context.Context, full bool) ([]Tag, error) |
|||
Save(ctx context.Context, tag Tag) error |
|||
Delete(ctx context.Context, tag Tag) error |
|||
} |
@ -0,0 +1,26 @@ |
|||
package tag |
|||
|
|||
import ( |
|||
"context" |
|||
"git.aiterp.net/rpdata2-take2/rpdata2" |
|||
"git.aiterp.net/rpdata2-take2/rpdata2/auth" |
|||
) |
|||
|
|||
type Service struct { |
|||
Repository Repository |
|||
Auth auth.Service |
|||
} |
|||
|
|||
func (s *Service) Find(ctx context.Context, id string) (*Node, error) { |
|||
tags, err := s.Repository.FindRecursive(ctx, id) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
root := BuildForest(tags)[0].WithoutSecret(s.Auth.GetUser(ctx)) |
|||
if root == nil { |
|||
return nil, rpdata2.NotFound("Tag") |
|||
} |
|||
|
|||
return root, nil |
|||
} |
@ -0,0 +1,46 @@ |
|||
package tag |
|||
|
|||
type Tag struct { |
|||
ID string `json:"id"` |
|||
ParentID *string `json:"parentId"` |
|||
OwnerID string `json:"ownerId"` // The user who can change the tag
|
|||
Name string `json:"name"` |
|||
Kind Kind `json:"kind"` |
|||
Description string `json:"description"` |
|||
Listed bool `json:"listed"` // Whether to list it for search
|
|||
Secret bool `json:"secret"` // Only owners can see it
|
|||
} |
|||
|
|||
type Kind int |
|||
|
|||
const ( |
|||
// TKOrganization is an organization, faction or group
|
|||
TKOrganization = 1 << iota |
|||
// TKLocation is a settlement, building, planet, etc.... Icon: Pin
|
|||
TKLocation |
|||
// TKEvent is a specific event
|
|||
TKEvent |
|||
// TKPlot is a larger plot
|
|||
TKPlot |
|||
// TKSeries is a thread of content that's not exactly a plot/event
|
|||
TKSeries |
|||
) |
|||
|
|||
/* |
|||
L Aite |
|||
L Freedom Falls |
|||
LO Miner's Respite |
|||
L Derrai Union |
|||
L Derrai |
|||
L Aroste |
|||
L Aroste |
|||
P "The Collectors" |
|||
|
|||
P "Rellis Stuff" |
|||
E Rellis' Contract |
|||
E Familial Ties |
|||
E Skipping Work |
|||
E Sala's Backlog |
|||
S Anywhere but Here |
|||
|
|||
*/ |
@ -0,0 +1,110 @@ |
|||
package tag |
|||
|
|||
import ( |
|||
"git.aiterp.net/rpdata2-take2/rpdata2/auth" |
|||
"git.aiterp.net/rpdata2-take2/rpdata2/internal/genutils" |
|||
) |
|||
|
|||
type Node struct { |
|||
Tag |
|||
|
|||
Children []Node `json:"children"` |
|||
} |
|||
|
|||
func (n Node) WithoutSecret(reqUser *auth.UserInfo) *Node { |
|||
return n.filter(func(n Node) bool { |
|||
if !n.Secret { |
|||
return false |
|||
} |
|||
|
|||
return reqUser != nil && reqUser.HasIDOrPermission(n.OwnerID, "tag", "view_secret") |
|||
}) |
|||
} |
|||
|
|||
func (n Node) WithoutUnlisted() *Node { |
|||
return n.filter(func(n Node) bool { return n.Listed }) |
|||
} |
|||
|
|||
func (n Node) filter(cb func(n Node) bool) *Node { |
|||
if !cb(n) { |
|||
return nil |
|||
} |
|||
|
|||
filtered := make([]Node, 0, len(n.Children)) |
|||
for _, child := range n.Children { |
|||
pruned := child.filter(cb) |
|||
if pruned == nil { |
|||
continue |
|||
} |
|||
|
|||
filtered = append(filtered, *pruned) |
|||
} |
|||
|
|||
n.Children = filtered |
|||
return &n |
|||
} |
|||
|
|||
func (n Node) Less(n2 Node) bool { |
|||
if n.Kind < n2.Kind { |
|||
return true |
|||
} |
|||
|
|||
return n.Name < n2.Name |
|||
} |
|||
|
|||
func BuildForest(tags []Tag) []Node { |
|||
nodes := make(map[string]*Node) |
|||
for _, tag := range tags { |
|||
nodes[tag.ID] = &Node{Tag: tag, Children: []Node{}} |
|||
} |
|||
|
|||
children := make(map[string][]*Node, len(tags)) |
|||
for _, node := range nodes { |
|||
if node.ParentID != nil { |
|||
children[*node.ParentID] = append(children[*node.ParentID], node) |
|||
} |
|||
} |
|||
|
|||
cleared := make(map[string]bool) |
|||
for len(cleared) < len(nodes) { |
|||
anyCleared := false |
|||
for _, node := range nodes { |
|||
if cleared[node.ID] { |
|||
continue |
|||
} |
|||
|
|||
good := true |
|||
for _, child := range children[node.ID] { |
|||
if !cleared[child.ID] { |
|||
good = false |
|||
break |
|||
} |
|||
} |
|||
|
|||
if good { |
|||
genutils.SortSlice(node.Children) |
|||
|
|||
cleared[node.ID] = true |
|||
anyCleared = true |
|||
if node.ParentID != nil { |
|||
nodes[*node.ParentID].Children = append(nodes[*node.ParentID].Children, *node) |
|||
} |
|||
} |
|||
} |
|||
|
|||
if !anyCleared { |
|||
panic("deadlock in tag.BuildForest") |
|||
} |
|||
} |
|||
|
|||
res := make([]Node, 0, 8) |
|||
for _, node := range nodes { |
|||
if node.ParentID == nil { |
|||
res = append(res, *node) |
|||
} |
|||
} |
|||
|
|||
genutils.SortSlice(res) |
|||
|
|||
return res |
|||
} |
@ -0,0 +1,95 @@ |
|||
package tag |
|||
|
|||
import ( |
|||
"git.aiterp.net/rpdata2-take2/rpdata2/internal/genutils" |
|||
"github.com/stretchr/testify/assert" |
|||
"testing" |
|||
) |
|||
|
|||
func printNode(t *testing.T, prefix string, node Node) { |
|||
t.Log(prefix + "[" + node.ID + "] " + node.Name) |
|||
for _, child := range node.Children { |
|||
printNode(t, prefix+" ", child) |
|||
} |
|||
} |
|||
|
|||
func TestNode_Less(t *testing.T) { |
|||
n1 := Node{Tag: Tag{Kind: TKLocation | TKOrganization, Name: "Miner's Respite"}} |
|||
n2 := Node{Tag: Tag{Kind: TKLocation | TKOrganization, Name: "Litae's Grace"}} |
|||
n3 := Node{Tag: Tag{Kind: TKOrganization, Name: "Redrock Agency"}} |
|||
n4 := Node{Tag: Tag{Kind: TKLocation, Name: "Redrock HQ"}} |
|||
|
|||
assert.True(t, n2.Less(n1)) |
|||
assert.True(t, !n1.Less(n2)) |
|||
assert.True(t, n3.Less(n4)) |
|||
} |
|||
|
|||
func TestNode_WithoutUnlisted(t *testing.T) { |
|||
before := Node{Tag{Name: "A", Listed: true}, []Node{ |
|||
{Tag{Name: "B", Listed: true}, []Node{ |
|||
{Tag{Name: "D", Listed: false}, []Node{ |
|||
{Tag{Name: "F", Listed: true}, []Node{}}, |
|||
}}, |
|||
{Tag{Name: "E", Secret: true, Listed: true}, []Node{ |
|||
{Tag{Name: "G", Listed: true}, []Node{}}, |
|||
}}, |
|||
}}, |
|||
{Tag{Name: "C", Listed: true}, []Node{}}, |
|||
}} |
|||
|
|||
after := Node{Tag{Name: "A", Listed: true}, []Node{ |
|||
{Tag{Name: "B", Listed: true}, []Node{ |
|||
{Tag{Name: "E", Secret: true, Listed: true}, []Node{ |
|||
{Tag{Name: "G", Listed: true}, []Node{}}, |
|||
}}, |
|||
}}, |
|||
{Tag{Name: "C", Listed: true}, []Node{}}, |
|||
}} |
|||
|
|||
assert.Equal(t, &after, before.WithoutUnlisted()) |
|||
} |
|||
|
|||
func TestBuildForest(t *testing.T) { |
|||
tags := []Tag{ |
|||
{ID: "T0", ParentID: genutils.Ptr("T7"), Name: "Root"}, |
|||
{ID: "T1", ParentID: genutils.Ptr("T0"), Name: "Stuff"}, |
|||
{ID: "T2", ParentID: genutils.Ptr("T0"), Name: "Things"}, |
|||
{ID: "T3", Name: "Second Root"}, |
|||
{ID: "T4", ParentID: genutils.Ptr("T2"), Name: "Items"}, |
|||
{ID: "T5", ParentID: genutils.Ptr("T1"), Name: "Objects"}, |
|||
{ID: "T6", ParentID: genutils.Ptr("T4"), Name: "Thingies"}, |
|||
{ID: "T7", Name: "Real Root"}, |
|||
{ID: "T8", ParentID: genutils.Ptr("T3"), Name: "Another Leaf"}, |
|||
{ID: "T9", ParentID: genutils.Ptr("T5"), Name: "Branch"}, |
|||
{ID: "TA", ParentID: genutils.Ptr("T9"), Name: "Leafy Leaf"}, |
|||
} |
|||
|
|||
nodes := BuildForest(tags) |
|||
for _, node := range nodes { |
|||
printNode(t, "", node) |
|||
} |
|||
|
|||
expected := []Node{ |
|||
{tags[7], []Node{ |
|||
{tags[0], []Node{ |
|||
{tags[1], []Node{ |
|||
{tags[5], []Node{ |
|||
{tags[9], []Node{ |
|||
{tags[10], []Node{}}, |
|||
}}, |
|||
}}, |
|||
}}, |
|||
{tags[2], []Node{ |
|||
{tags[4], []Node{ |
|||
{tags[6], []Node{}}, |
|||
}}, |
|||
}}, |
|||
}}, |
|||
}}, |
|||
{tags[3], []Node{ |
|||
{tags[8], []Node{}}, |
|||
}}, |
|||
} |
|||
|
|||
assert.Equal(t, expected, nodes) |
|||
} |
@ -0,0 +1,19 @@ |
|||
package tag |
|||
|
|||
import "git.aiterp.net/rpdata2-take2/rpdata2/internal/genutils" |
|||
|
|||
type Update struct { |
|||
ParentID *string `json:"parentId"` |
|||
Name *string `json:"name"` |
|||
Kind *Kind `json:"kind"` |
|||
Listed *bool `json:"listed"` |
|||
Secret *bool `json:"secret"` // Only owners can see it
|
|||
} |
|||
|
|||
func (t *Tag) ApplyUpdate(update *Update) { |
|||
genutils.ApplyUpdateNilZero(&t.ParentID, update.ParentID) |
|||
genutils.ApplyUpdate(&t.Name, update.Name) |
|||
genutils.ApplyUpdate(&t.Kind, update.Kind) |
|||
genutils.ApplyUpdate(&t.Listed, update.Listed) |
|||
genutils.ApplyUpdate(&t.Secret, update.Secret) |
|||
} |
Write
Preview
Loading…
Cancel
Save
Reference in new issue