Added a SQL-Preprocessor - this way we can unmarshal recursive structures (LEFT JOIN etc)

This commit is contained in:
Mike Schwörer 2022-12-21 18:14:13 +01:00
parent bbf7962e29
commit dbc014f819
Signed by: Mikescher
GPG Key ID: D3C7172E0A70F8CF
19 changed files with 740 additions and 162 deletions

View File

@ -5,6 +5,8 @@ PORT=9090
NAMESPACE=$(shell git rev-parse --abbrev-ref HEAD)
HASH=$(shell git rev-parse HEAD)
.PHONY: test swagger
build: swagger fmt
mkdir -p _build
rm -f ./_build/scn_backend
@ -29,7 +31,6 @@ docker: build
-t "$(DOCKER_REPO)/$(DOCKER_NAME):latest" \
.
.PHONY: swagger
swagger:
which swag || go install github.com/swaggo/swag/cmd/swag@latest
swag init -generalInfo api/router.go --propertyStrategy snakecase --output ./swagger/ --outputTypes "json,yaml"
@ -67,7 +68,6 @@ fmt:
go fmt ./...
swag fmt
.PHONY: test
test:
go test ./test/...

View File

@ -23,8 +23,6 @@
-------------------------------------------------------------------------------------------------------------------------------
- (?) return subscribtions in list-channels (?)
- (?) ack/read deliveries && return ack-count (? or not, how to query?)
- (?) "login" on website and list/search/filter messages

View File

@ -490,13 +490,14 @@ func (h APIHandler) DeleteClient(g *gin.Context) ginresp.HTTPResponse {
// ListChannels swaggerdoc
//
// @Summary List channels of a user (subscribed/owned)
// @Summary List channels of a user (subscribed/owned/all)
// @Description The possible values for 'selector' are:
// @Description - "owned" Return all channels of the user
// @Description - "subscribed" Return all channels that the user is subscribing to
// @Description - "all" Return channels that the user owns or is subscribing
// @Description - "owned" Return all channels of the user
// @Description - "subscribed" Return all channels that the user is subscribing to
// @Description - "all" Return channels that the user owns or is subscribing
// @Description - "subscribed_any" Return all channels that the user is subscribing to (even unconfirmed)
// @Description - "all_any" Return channels that the user owns or is subscribing (even unconfirmed)
// @Description - "all_any" Return channels that the user owns or is subscribing (even unconfirmed)
//
// @ID api-channels-list
// @Tags API-v2
//
@ -517,7 +518,7 @@ func (h APIHandler) ListChannels(g *gin.Context) ginresp.HTTPResponse {
Selector *string `json:"selector" form:"selector" enums:"owned,subscribed_any,all_any,subscribed,all"`
}
type response struct {
Channels []models.ChannelJSON `json:"channels"`
Channels []models.ChannelWithSubscriptionJSON `json:"channels"`
}
var u uri
@ -534,40 +535,52 @@ func (h APIHandler) ListChannels(g *gin.Context) ginresp.HTTPResponse {
sel := strings.ToLower(langext.Coalesce(q.Selector, "owned"))
var res []models.ChannelJSON
var res []models.ChannelWithSubscriptionJSON
if sel == "owned" {
channels, err := h.database.ListChannelsByOwner(ctx, u.UserID)
channels, err := h.database.ListChannelsByOwner(ctx, u.UserID, u.UserID)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query channels", err)
}
res = langext.ArrMap(channels, func(v models.Channel) models.ChannelJSON { return v.JSON(true) })
res = langext.ArrMap(channels, func(v models.ChannelWithSubscription) models.ChannelWithSubscriptionJSON { return v.JSON(true) })
} else if sel == "subscribed_any" {
channels, err := h.database.ListChannelsBySubscriber(ctx, u.UserID, false)
channels, err := h.database.ListChannelsBySubscriber(ctx, u.UserID, nil)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query channels", err)
}
res = langext.ArrMap(channels, func(v models.Channel) models.ChannelJSON { return v.JSON(false) })
res = langext.ArrMap(channels, func(v models.ChannelWithSubscription) models.ChannelWithSubscriptionJSON { return v.JSON(false) })
} else if sel == "all_any" {
channels, err := h.database.ListChannelsByAccess(ctx, u.UserID, false)
channels, err := h.database.ListChannelsByAccess(ctx, u.UserID, nil)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query channels", err)
}
res = langext.ArrMap(channels, func(v models.Channel) models.ChannelJSON { return v.JSON(false) })
res = langext.ArrMap(channels, func(v models.ChannelWithSubscription) models.ChannelWithSubscriptionJSON { return v.JSON(false) })
} else if sel == "subscribed" {
channels, err := h.database.ListChannelsBySubscriber(ctx, u.UserID, true)
channels, err := h.database.ListChannelsBySubscriber(ctx, u.UserID, langext.Ptr(true))
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query channels", err)
}
res = langext.ArrMap(channels, func(v models.Channel) models.ChannelJSON { return v.JSON(false) })
res = langext.ArrMap(channels, func(v models.ChannelWithSubscription) models.ChannelWithSubscriptionJSON { return v.JSON(false) })
} else if sel == "all" {
channels, err := h.database.ListChannelsByAccess(ctx, u.UserID, true)
channels, err := h.database.ListChannelsByAccess(ctx, u.UserID, langext.Ptr(true))
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query channels", err)
}
res = langext.ArrMap(channels, func(v models.Channel) models.ChannelJSON { return v.JSON(false) })
res = langext.ArrMap(channels, func(v models.ChannelWithSubscription) models.ChannelWithSubscriptionJSON { return v.JSON(false) })
} else {
return ginresp.APIError(g, 400, apierr.INVALID_ENUM_VALUE, "Invalid value for the [selector] parameter", nil)
}
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, response{Channels: res}))
@ -575,14 +588,14 @@ func (h APIHandler) ListChannels(g *gin.Context) ginresp.HTTPResponse {
// GetChannel swaggerdoc
//
// @Summary List all channels of a user
// @Summary Get a single channel
// @ID api-channels-get
// @Tags API-v2
//
// @Param uid path int true "UserID"
// @Param cid path int true "ChannelID"
//
// @Success 200 {object} models.ChannelJSON
// @Success 200 {object} models.ChannelWithSubscriptionJSON
// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid"
// @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions"
// @Failure 404 {object} ginresp.apiError "channel not found"
@ -626,19 +639,20 @@ func (h APIHandler) GetChannel(g *gin.Context) ginresp.HTTPResponse {
// @Param uid path int true "UserID"
// @Param post_body body handler.CreateChannel.body false " "
//
// @Success 200 {object} models.ChannelJSON
// @Success 200 {object} models.ChannelWithSubscriptionJSON
// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid"
// @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions"
// @Failure 409 {object} ginresp.apiError "channel already exists"
// @Failure 500 {object} ginresp.apiError "internal server error"
//
// @Router /api/users/{uid}/channels/ [POST]
// @Router /api/users/{uid}/channels [POST]
func (h APIHandler) CreateChannel(g *gin.Context) ginresp.HTTPResponse {
type uri struct {
UserID models.UserID `uri:"uid"`
}
type body struct {
Name string `json:"name"`
Name string `json:"name"`
Subscribe *bool `json:"subscribe"`
}
var u uri
@ -684,7 +698,21 @@ func (h APIHandler) CreateChannel(g *gin.Context) ginresp.HTTPResponse {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to create channel", err)
}
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, channel.JSON(true)))
if langext.Coalesce(b.Subscribe, true) {
sub, err := h.database.CreateSubscription(ctx, u.UserID, channel, true)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to create subscription", err)
}
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, channel.WithSubscription(langext.Ptr(sub)).JSON(true)))
} else {
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, channel.WithSubscription(nil).JSON(true)))
}
}
// UpdateChannel swaggerdoc
@ -699,7 +727,7 @@ func (h APIHandler) CreateChannel(g *gin.Context) ginresp.HTTPResponse {
// @Param subscribe_key body string false "Send `true` to create a new subscribe_key"
// @Param send_key body string false "Send `true` to create a new send_key"
//
// @Success 200 {object} models.ChannelJSON
// @Success 200 {object} models.ChannelWithSubscriptionJSON
// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid"
// @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions"
// @Failure 404 {object} ginresp.apiError "channel not found"
@ -754,12 +782,12 @@ func (h APIHandler) UpdateChannel(g *gin.Context) ginresp.HTTPResponse {
}
}
user, err := h.database.GetChannel(ctx, u.UserID, u.ChannelID)
channel, err := h.database.GetChannel(ctx, u.UserID, u.ChannelID)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query (updated) user", err)
}
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, user.JSON(true)))
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, channel.JSON(true)))
}
// ListChannelMessages swaggerdoc
@ -865,28 +893,41 @@ func (h APIHandler) ListChannelMessages(g *gin.Context) ginresp.HTTPResponse {
// ListUserSubscriptions swaggerdoc
//
// @Summary List all channels of a user
// @Summary List all subscriptions of a user (incoming/owned)
// // @Description The possible values for 'selector' are:
// // @Description - "owner_all" All subscriptions (confirmed/unconfirmed) with the user as owner (= subscriptions he can use to read channels)
// // @Description - "owner_confirmed" Confirmed subscriptions with the user as owner
// // @Description - "owner_unconfirmed" Unconfirmed (Pending) subscriptions with the user as owner
// // @Description - "incoming_all" All subscriptions (confirmed/unconfirmed) from other users to channels of this user (= incoming subscriptions and subscription requests)
// // @Description - "incoming_confirmed" Confirmed subscriptions from other users to channels of this user
// // @Description - "incoming_unconfirmed" Unconfirmed subscriptions from other users to channels of this user (= requests)
// //
// @ID api-user-subscriptions-list
// @Tags API-v2
//
// @Param uid path int true "UserID"
// @Param uid path int true "UserID"
// @Param selector query string true "Filter subscribptions (default: owner_all)" Enums(owner_all, owner_confirmed, owner_unconfirmed, incoming_all, incoming_confirmed, incoming_unconfirmed)
//
// @Success 200 {object} handler.ListUserSubscriptions.response
// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid"
// @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions"
// @Failure 500 {object} ginresp.apiError "internal server error"
// @Success 200 {object} handler.ListUserSubscriptions.response
// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid"
// @Failure 401 {object} ginresp.apiError "user is not authorized / has missing permissions"
// @Failure 500 {object} ginresp.apiError "internal server error"
//
// @Router /api/users/{uid}/subscriptions [GET]
func (h APIHandler) ListUserSubscriptions(g *gin.Context) ginresp.HTTPResponse {
type uri struct {
UserID models.UserID `uri:"uid"`
}
type query struct {
Selector *string `json:"selector" form:"selector" enums:"owner_all,owner_confirmed,owner_unconfirmed,incoming_all,incoming_confirmed,incoming_unconfirmed"`
}
type response struct {
Subscriptions []models.SubscriptionJSON `json:"subscriptions"`
}
var u uri
ctx, errResp := h.app.StartRequest(g, &u, nil, nil, nil)
var q query
ctx, errResp := h.app.StartRequest(g, &u, &q, nil, nil)
if errResp != nil {
return *errResp
}
@ -896,14 +937,62 @@ func (h APIHandler) ListUserSubscriptions(g *gin.Context) ginresp.HTTPResponse {
return *permResp
}
clients, err := h.database.ListSubscriptionsByOwner(ctx, u.UserID)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query channels", err)
sel := strings.ToLower(langext.Coalesce(q.Selector, "owner_all"))
var res []models.Subscription
var err error
if sel == "owner_all" {
res, err = h.database.ListSubscriptionsByOwner(ctx, u.UserID, nil)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query subscriptions", err)
}
} else if sel == "owner_confirmed" {
res, err = h.database.ListSubscriptionsByOwner(ctx, u.UserID, langext.Ptr(true))
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query subscriptions", err)
}
} else if sel == "owner_unconfirmed" {
res, err = h.database.ListSubscriptionsByOwner(ctx, u.UserID, langext.Ptr(false))
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query subscriptions", err)
}
} else if sel == "incoming_all" {
res, err = h.database.ListSubscriptionsBySubscriber(ctx, u.UserID, nil)
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query subscriptions", err)
}
} else if sel == "incoming_confirmed" {
res, err = h.database.ListSubscriptionsBySubscriber(ctx, u.UserID, langext.Ptr(true))
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query subscriptions", err)
}
} else if sel == "incoming_unconfirmed" {
res, err = h.database.ListSubscriptionsBySubscriber(ctx, u.UserID, langext.Ptr(false))
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query subscriptions", err)
}
} else {
return ginresp.APIError(g, 400, apierr.INVALID_ENUM_VALUE, "Invalid value for the [selector] parameter", nil)
}
res := langext.ArrMap(clients, func(v models.Subscription) models.SubscriptionJSON { return v.JSON() })
jsonres := langext.ArrMap(res, func(v models.Subscription) models.SubscriptionJSON { return v.JSON() })
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, response{Subscriptions: res}))
return ctx.FinishSuccess(ginresp.JSON(http.StatusOK, response{Subscriptions: jsonres}))
}
// ListChannelSubscriptions swaggerdoc

View File

@ -93,18 +93,21 @@ func (db *Database) CreateChannel(ctx TxContext, userid models.UserID, name stri
}, nil
}
func (db *Database) ListChannelsByOwner(ctx TxContext, userid models.UserID) ([]models.Channel, error) {
func (db *Database) ListChannelsByOwner(ctx TxContext, userid models.UserID, subUserID models.UserID) ([]models.ChannelWithSubscription, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return nil, err
}
rows, err := tx.Query(ctx, "SELECT * FROM channels WHERE owner_user_id = :ouid", sq.PP{"ouid": userid})
rows, err := tx.Query(ctx, "SELECT channels.*, sub.* FROM channels LEFT JOIN subscriptions AS sub ON channels.channel_id = sub.channel_id AND sub.subscriber_user_id = :subuid WHERE owner_user_id = :ouid", sq.PP{
"ouid": userid,
"subuid": subUserID,
})
if err != nil {
return nil, err
}
data, err := models.DecodeChannels(rows)
data, err := models.DecodeChannelsWithSubscription(rows)
if err != nil {
return nil, err
}
@ -112,25 +115,27 @@ func (db *Database) ListChannelsByOwner(ctx TxContext, userid models.UserID) ([]
return data, nil
}
func (db *Database) ListChannelsBySubscriber(ctx TxContext, userid models.UserID, confirmed bool) ([]models.Channel, error) {
func (db *Database) ListChannelsBySubscriber(ctx TxContext, userid models.UserID, confirmed *bool) ([]models.ChannelWithSubscription, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return nil, err
}
confCond := ""
if confirmed {
if confirmed != nil && *confirmed {
confCond = " AND sub.confirmed = 1"
} else if confirmed != nil && !*confirmed {
confCond = " AND sub.confirmed = 0"
}
rows, err := tx.Query(ctx, "SELECT * FROM channels LEFT JOIN subscriptions sub on channels.channel_id = sub.channel_id WHERE sub.subscriber_user_id = :suid "+confCond, sq.PP{
"suid": userid,
rows, err := tx.Query(ctx, "SELECT channels.*, sub.* FROM channels LEFT JOIN subscriptions AS sub on channels.channel_id = sub.channel_id AND sub.subscriber_user_id = :subuid WHERE sub.subscription_id IS NOT NULL "+confCond, sq.PP{
"subuid": userid,
})
if err != nil {
return nil, err
}
data, err := models.DecodeChannels(rows)
data, err := models.DecodeChannelsWithSubscription(rows)
if err != nil {
return nil, err
}
@ -138,25 +143,28 @@ func (db *Database) ListChannelsBySubscriber(ctx TxContext, userid models.UserID
return data, nil
}
func (db *Database) ListChannelsByAccess(ctx TxContext, userid models.UserID, confirmed bool) ([]models.Channel, error) {
func (db *Database) ListChannelsByAccess(ctx TxContext, userid models.UserID, confirmed *bool) ([]models.ChannelWithSubscription, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return nil, err
}
confCond := "OR sub.subscriber_user_id = ?"
if confirmed {
confCond = "OR (sub.subscriber_user_id = ? AND sub.confirmed = 1)"
confCond := ""
if confirmed != nil && *confirmed {
confCond = "OR sub.confirmed = 1"
} else if confirmed != nil && !*confirmed {
confCond = "OR sub.confirmed = 0"
}
rows, err := tx.Query(ctx, "SELECT * FROM channels LEFT JOIN subscriptions sub on channels.channel_id = sub.channel_id WHERE owner_user_id = :ouid "+confCond, sq.PP{
"ouid": userid,
rows, err := tx.Query(ctx, "SELECT channels.*, sub.* FROM channels LEFT JOIN subscriptions AS sub on channels.channel_id = sub.channel_id AND sub.subscriber_user_id = :subuid WHERE owner_user_id = :ouid "+confCond, sq.PP{
"ouid": userid,
"subuid": userid,
})
if err != nil {
return nil, err
}
data, err := models.DecodeChannels(rows)
data, err := models.DecodeChannelsWithSubscription(rows)
if err != nil {
return nil, err
}
@ -164,26 +172,27 @@ func (db *Database) ListChannelsByAccess(ctx TxContext, userid models.UserID, co
return data, nil
}
func (db *Database) GetChannel(ctx TxContext, userid models.UserID, channelid models.ChannelID) (models.Channel, error) {
func (db *Database) GetChannel(ctx TxContext, userid models.UserID, channelid models.ChannelID) (models.ChannelWithSubscription, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return models.Channel{}, err
return models.ChannelWithSubscription{}, err
}
rows, err := tx.Query(ctx, "SELECT * FROM channels WHERE owner_user_id = :ouid AND channel_id = :cid LIMIT 1", sq.PP{
"ouid": userid,
"cid": channelid,
rows, err := tx.Query(ctx, "SELECT channels.*, sub.* FROM channels LEFT JOIN subscriptions AS sub on channels.channel_id = sub.channel_id AND sub.subscriber_user_id = :subuid WHERE owner_user_id = :ouid AND channels.channel_id = :cid LIMIT 1", sq.PP{
"ouid": userid,
"cid": channelid,
"subuid": userid,
})
if err != nil {
return models.Channel{}, err
return models.ChannelWithSubscription{}, err
}
client, err := models.DecodeChannel(rows)
channel, err := models.DecodeChannelWithSubscription(rows)
if err != nil {
return models.Channel{}, err
return models.ChannelWithSubscription{}, err
}
return client, nil
return channel, nil
}
func (db *Database) IncChannelMessageCounter(ctx TxContext, channel models.Channel) error {

View File

@ -2,6 +2,7 @@ package db
import (
server "blackforestbytes.com/simplecloudnotifier"
"blackforestbytes.com/simplecloudnotifier/db/dbtools"
"blackforestbytes.com/simplecloudnotifier/db/schema"
"context"
"database/sql"
@ -9,7 +10,6 @@ import (
"fmt"
"github.com/jmoiron/sqlx"
_ "github.com/mattn/go-sqlite3"
"github.com/rs/zerolog/log"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"gogs.mikescher.com/BlackForestBytes/goext/sq"
"time"
@ -40,7 +40,9 @@ func NewDatabase(conf server.Config) (*Database, error) {
scndb := &Database{qqdb}
qqdb.SetListener(scndb)
qqdb.AddListener(dbtools.DBLogger{})
qqdb.AddListener(dbtools.NewDBPreprocessor(scndb.db))
return scndb, nil
}
@ -83,35 +85,3 @@ func (db *Database) Ping(ctx context.Context) error {
func (db *Database) BeginTx(ctx context.Context) (sq.Tx, error) {
return db.db.BeginTransaction(ctx, sql.LevelDefault)
}
func (db *Database) OnQuery(txID *uint16, sql string, _ *sq.PP) {
if txID == nil {
log.Debug().Msg(fmt.Sprintf("[SQL-QUERY] %s", fmtSQLPrint(sql)))
} else {
log.Debug().Msg(fmt.Sprintf("[SQL-TX<%d>-QUERY] %s", *txID, fmtSQLPrint(sql)))
}
}
func (db *Database) OnExec(txID *uint16, sql string, _ *sq.PP) {
if txID == nil {
log.Debug().Msg(fmt.Sprintf("[SQL-EXEC] %s", fmtSQLPrint(sql)))
} else {
log.Debug().Msg(fmt.Sprintf("[SQL-TX<%d>-EXEC] %s", *txID, fmtSQLPrint(sql)))
}
}
func (db *Database) OnPing() {
log.Debug().Msg("[SQL-PING]")
}
func (db *Database) OnTxBegin(txid uint16) {
log.Debug().Msg(fmt.Sprintf("[SQL-TX<%d>-START]", txid))
}
func (db *Database) OnTxCommit(txid uint16) {
log.Debug().Msg(fmt.Sprintf("[SQL-TX<%d>-COMMIT]", txid))
}
func (db *Database) OnTxRollback(txid uint16) {
log.Debug().Msg(fmt.Sprintf("[SQL-TX<%d>-ROLLBACK]", txid))
}

View File

@ -0,0 +1,90 @@
package dbtools
import (
"context"
"fmt"
"github.com/rs/zerolog/log"
"gogs.mikescher.com/BlackForestBytes/goext/sq"
"strings"
)
type DBLogger struct{}
func (l DBLogger) PrePing(ctx context.Context) error {
log.Debug().Msg("[SQL-PING]")
return nil
}
func (l DBLogger) PreTxBegin(ctx context.Context, txid uint16) error {
log.Debug().Msg(fmt.Sprintf("[SQL-TX<%d>-START]", txid))
return nil
}
func (l DBLogger) PreTxCommit(txid uint16) error {
log.Debug().Msg(fmt.Sprintf("[SQL-TX<%d>-COMMIT]", txid))
return nil
}
func (l DBLogger) PreTxRollback(txid uint16) error {
log.Debug().Msg(fmt.Sprintf("[SQL-TX<%d>-ROLLBACK]", txid))
return nil
}
func (l DBLogger) PreQuery(ctx context.Context, txID *uint16, sql *string, params *sq.PP) error {
if txID == nil {
log.Debug().Msg(fmt.Sprintf("[SQL-QUERY] %s", fmtSQLPrint(*sql)))
} else {
log.Debug().Msg(fmt.Sprintf("[SQL-TX<%d>-QUERY] %s", *txID, fmtSQLPrint(*sql)))
}
return nil
}
func (l DBLogger) PreExec(ctx context.Context, txID *uint16, sql *string, params *sq.PP) error {
if txID == nil {
log.Debug().Msg(fmt.Sprintf("[SQL-EXEC] %s", fmtSQLPrint(*sql)))
} else {
log.Debug().Msg(fmt.Sprintf("[SQL-TX<%d>-EXEC] %s", *txID, fmtSQLPrint(*sql)))
}
return nil
}
func (l DBLogger) PostPing(result error) {
//
}
func (l DBLogger) PostTxBegin(txid uint16, result error) {
//
}
func (l DBLogger) PostTxCommit(txid uint16, result error) {
//
}
func (l DBLogger) PostTxRollback(txid uint16, result error) {
//
}
func (l DBLogger) PostQuery(txID *uint16, sqlOriginal string, sqlReal string, params sq.PP) {
//
}
func (l DBLogger) PostExec(txID *uint16, sqlOriginal string, sqlReal string, params sq.PP) {
//
}
func fmtSQLPrint(sql string) string {
if strings.Contains(sql, ";") {
return "(...multi...)"
}
sql = strings.ReplaceAll(sql, "\r", "")
sql = strings.ReplaceAll(sql, "\n", " ")
return sql
}

View File

@ -0,0 +1,238 @@
package dbtools
import (
"context"
"errors"
"fmt"
"github.com/rs/zerolog/log"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"gogs.mikescher.com/BlackForestBytes/goext/sq"
"regexp"
"strings"
"sync"
)
//
// This is..., not good...
//
// for sq.ScanAll to work with (left-)joined tables _need_ to get column names aka "alias.column"
// But sqlite (and all other db server) only return "column" if we don't manually specify `alias.column as "alias.columnname"`
// But always specifying all columns (and their alias) would be __very__ cumbersome...
//
// The "solution" is this preprocessor, which translates queries of the form `SELECT tab1.*, tab2.* From tab1` into `SELECT tab1.col1 AS "tab1.col1", tab1.col2 AS "tab1.col2" ....`
//
// Prerequisites:
// - all aliased tables must be written as `tablename AS alias` (the variant without the AS keyword is invalid)
// - a star only expands to the (single) table in FROM. Use *, table2.* if there exists a second (joined) table
// - No weird SQL syntax, this "parser" is not very robust...
//
type DBPreprocessor struct {
db sq.DB
lock sync.Mutex
cacheColumns map[string][]string
cacheQuery map[string]string
}
var regexAlias = regexp.MustCompile("([A-Za-z_\\-0-9]+)\\s+AS\\s+([A-Za-z_\\-0-9]+)")
func NewDBPreprocessor(db sq.DB) *DBPreprocessor {
return &DBPreprocessor{
db: db,
lock: sync.Mutex{},
cacheColumns: make(map[string][]string),
cacheQuery: make(map[string]string),
}
}
func (pp *DBPreprocessor) PrePing(ctx context.Context) error {
return nil
}
func (pp *DBPreprocessor) PreTxBegin(ctx context.Context, txid uint16) error {
return nil
}
func (pp *DBPreprocessor) PreTxCommit(txid uint16) error {
return nil
}
func (pp *DBPreprocessor) PreTxRollback(txid uint16) error {
return nil
}
func (pp *DBPreprocessor) PreQuery(ctx context.Context, txID *uint16, sql *string, params *sq.PP) error {
sqlOriginal := *sql
pp.lock.Lock()
v, ok := pp.cacheQuery[sqlOriginal]
pp.lock.Unlock()
if ok {
*sql = v
return nil
}
if !strings.HasPrefix(sqlOriginal, "SELECT ") {
return nil
}
idxFrom := strings.Index(sqlOriginal, " FROM ")
if idxFrom < 0 {
return nil
}
fromTableName := strings.Split(strings.TrimSpace(sqlOriginal[idxFrom+len(" FROM"):]), " ")[0]
sels := strings.TrimSpace(sqlOriginal[len("SELECT "):idxFrom])
split := strings.Split(sels, ",")
newsel := make([]string, 0)
aliasMap := make(map[string]string)
for _, v := range regexAlias.FindAllStringSubmatch(sqlOriginal, idxFrom+len(" FROM")) {
aliasMap[strings.TrimSpace(v[2])] = strings.TrimSpace(v[1])
}
for _, expr := range split {
expr = strings.TrimSpace(expr)
if expr == "*" {
columns, err := pp.getTableColumns(ctx, fromTableName)
if err != nil {
return err
}
for _, colname := range columns {
newsel = append(newsel, fmt.Sprintf("%s.%s AS \"%s\"", fromTableName, colname, colname))
}
} else if strings.HasSuffix(expr, ".*") {
tableName := expr[0 : len(expr)-2]
if tableRealName, ok := aliasMap[tableName]; ok {
columns, err := pp.getTableColumns(ctx, tableRealName)
if err != nil {
return err
}
for _, colname := range columns {
newsel = append(newsel, fmt.Sprintf("%s.%s AS \"%s.%s\"", tableName, colname, tableName, colname))
}
} else if tableName == fromTableName {
columns, err := pp.getTableColumns(ctx, tableName)
if err != nil {
return err
}
for _, colname := range columns {
newsel = append(newsel, fmt.Sprintf("%s.%s AS \"%s\"", tableName, colname, colname))
}
} else {
columns, err := pp.getTableColumns(ctx, tableName)
if err != nil {
return err
}
for _, colname := range columns {
newsel = append(newsel, fmt.Sprintf("%s.%s AS \"%s.%s\"", tableName, colname, tableName, colname))
}
}
} else {
return nil
}
}
newSQL := "SELECT " + strings.Join(newsel, ", ") + sqlOriginal[idxFrom:]
pp.lock.Lock()
pp.cacheQuery[sqlOriginal] = newSQL
pp.lock.Unlock()
log.Debug().Msgf("Preprocessed SQL statement from '%s' --to--> '%s'", sqlOriginal, newSQL)
*sql = newSQL
return nil
}
func (pp *DBPreprocessor) PreExec(ctx context.Context, txID *uint16, sql *string, params *sq.PP) error {
return nil
}
func (pp *DBPreprocessor) PostPing(result error) {
//
}
func (pp *DBPreprocessor) PostTxBegin(txid uint16, result error) {
//
}
func (pp *DBPreprocessor) PostTxCommit(txid uint16, result error) {
//
}
func (pp *DBPreprocessor) PostTxRollback(txid uint16, result error) {
//
}
func (pp *DBPreprocessor) PostQuery(txID *uint16, sqlOriginal string, sqlReal string, params sq.PP) {
//
}
func (pp *DBPreprocessor) PostExec(txID *uint16, sqlOriginal string, sqlReal string, params sq.PP) {
//
}
func (pp *DBPreprocessor) getTableColumns(ctx context.Context, tablename string) ([]string, error) {
pp.lock.Lock()
v, ok := pp.cacheColumns[tablename]
pp.lock.Unlock()
if ok {
return v, nil
}
type res struct {
CID int64 `db:"cid"`
Name string `db:"name"`
Type string `db:"type"`
NotNull int `db:"notnull"`
DFLT *string `db:"dflt_value"`
PK int `db:"pk"`
}
rows, err := pp.db.Query(ctx, "PRAGMA table_info('"+tablename+"');", sq.PP{})
if err != nil {
return nil, err
}
resrows, err := sq.ScanAll[res](rows, true)
if err != nil {
return nil, err
}
columns := langext.ArrMap(resrows, func(v res) string { return v.Name })
if len(columns) == 0 {
return nil, errors.New("no columns in table '" + tablename + "' (table does not exist?)")
}
pp.lock.Lock()
pp.cacheColumns[tablename] = columns
pp.lock.Unlock()
return columns, nil
}

View File

@ -62,13 +62,46 @@ func (db *Database) ListSubscriptionsByChannel(ctx TxContext, channelID models.C
return data, nil
}
func (db *Database) ListSubscriptionsByOwner(ctx TxContext, ownerUserID models.UserID) ([]models.Subscription, error) {
func (db *Database) ListSubscriptionsByOwner(ctx TxContext, ownerUserID models.UserID, confirmed *bool) ([]models.Subscription, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return nil, err
}
rows, err := tx.Query(ctx, "SELECT * FROM subscriptions WHERE channel_owner_user_id = :ouid", sq.PP{"ouid": ownerUserID})
cond := ""
if confirmed != nil && *confirmed {
cond = " AND confirmed = 1"
} else if confirmed != nil && !*confirmed {
cond = " AND confirmed = 0"
}
rows, err := tx.Query(ctx, "SELECT * FROM subscriptions WHERE channel_owner_user_id = :ouid"+cond, sq.PP{"ouid": ownerUserID})
if err != nil {
return nil, err
}
data, err := models.DecodeSubscriptions(rows)
if err != nil {
return nil, err
}
return data, nil
}
func (db *Database) ListSubscriptionsBySubscriber(ctx TxContext, subscriberUserID models.UserID, confirmed *bool) ([]models.Subscription, error) {
tx, err := ctx.GetOrCreateTransaction(db)
if err != nil {
return nil, err
}
cond := ""
if confirmed != nil && *confirmed {
cond = " AND confirmed = 1"
} else if confirmed != nil && !*confirmed {
cond = " AND confirmed = 0"
}
rows, err := tx.Query(ctx, "SELECT * FROM subscriptions WHERE subscriber_user_id = :suid"+cond, sq.PP{"suid": subscriberUserID})
if err != nil {
return nil, err
}

View File

@ -186,7 +186,7 @@ func (db *Database) UpdateUserKeys(ctx TxContext, userid models.UserID, sendKey
return err
}
_, err = tx.Exec(ctx, "UPDATE users SET send_key = :sk, read_key = :rk, admin_key = :ak WHERE user_id = ?", sq.PP{
_, err = tx.Exec(ctx, "UPDATE users SET send_key = :sk, read_key = :rk, admin_key = :ak WHERE user_id = :uid", sq.PP{
"sk": sendKey,
"rk": readKey,
"ak": adminKey,

View File

@ -2,7 +2,6 @@ package db
import (
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"strings"
"time"
)
@ -24,14 +23,3 @@ func time2DBOpt(t *time.Time) *int64 {
}
return langext.Ptr(t.UnixMilli())
}
func fmtSQLPrint(sql string) string {
if strings.Contains(sql, ";") {
return "(...multi...)"
}
sql = strings.ReplaceAll(sql, "\r", "")
sql = strings.ReplaceAll(sql, "\n", " ")
return sql
}

View File

@ -8,7 +8,7 @@ require (
github.com/mattn/go-sqlite3 v1.14.16
github.com/rs/zerolog v1.28.0
github.com/swaggo/swag v1.8.7
gogs.mikescher.com/BlackForestBytes/goext v0.0.37
gogs.mikescher.com/BlackForestBytes/goext v0.0.41
)
require (

View File

@ -112,6 +112,14 @@ gogs.mikescher.com/BlackForestBytes/goext v0.0.36 h1:iOUYz2NEiObCCdBnkt8DPi1N8gH
gogs.mikescher.com/BlackForestBytes/goext v0.0.36/go.mod h1:/u9JtMwCP68ix4R9BJ/MT0Lm+QScmqIoyYZFKBGzv9g=
gogs.mikescher.com/BlackForestBytes/goext v0.0.37 h1:6XP5UOqiougzH0Xtzs5tIU4c0sAXmdMPCvGhRxqVwLU=
gogs.mikescher.com/BlackForestBytes/goext v0.0.37/go.mod h1:/u9JtMwCP68ix4R9BJ/MT0Lm+QScmqIoyYZFKBGzv9g=
gogs.mikescher.com/BlackForestBytes/goext v0.0.38 h1:P9tf0tHexcH6Q3u9pRwi4iQsOb65Lc80JvzD49f/EIY=
gogs.mikescher.com/BlackForestBytes/goext v0.0.38/go.mod h1:/u9JtMwCP68ix4R9BJ/MT0Lm+QScmqIoyYZFKBGzv9g=
gogs.mikescher.com/BlackForestBytes/goext v0.0.39 h1:96QUjPMoCzcZpA2SYUU4XXytWJq3dcsnfska88tMikU=
gogs.mikescher.com/BlackForestBytes/goext v0.0.39/go.mod h1:/u9JtMwCP68ix4R9BJ/MT0Lm+QScmqIoyYZFKBGzv9g=
gogs.mikescher.com/BlackForestBytes/goext v0.0.40 h1:wh5+IRmcMAbwJ8cK6JZvg71IiMUWuJoaKAR5dbt7FLU=
gogs.mikescher.com/BlackForestBytes/goext v0.0.40/go.mod h1:/u9JtMwCP68ix4R9BJ/MT0Lm+QScmqIoyYZFKBGzv9g=
gogs.mikescher.com/BlackForestBytes/goext v0.0.41 h1:3p/MtkHZ2gulSdizXql3VnFf2v7WpeOBCmTi0rQYCQw=
gogs.mikescher.com/BlackForestBytes/goext v0.0.41/go.mod h1:/u9JtMwCP68ix4R9BJ/MT0Lm+QScmqIoyYZFKBGzv9g=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97 h1:/UOmuWzQfxxo9UtlXMwuQU8CMgg1eZXqTRwkSQJWKOI=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s=

View File

@ -31,6 +31,29 @@ func (c Channel) JSON(includeKey bool) ChannelJSON {
}
}
func (c Channel) WithSubscription(sub *Subscription) ChannelWithSubscription {
return ChannelWithSubscription{
Channel: c,
Subscription: sub,
}
}
type ChannelWithSubscription struct {
Channel
Subscription *Subscription
}
func (c ChannelWithSubscription) JSON(includeChannelKey bool) ChannelWithSubscriptionJSON {
var sub *SubscriptionJSON = nil
if c.Subscription != nil {
sub = langext.Ptr(c.Subscription.JSON())
}
return ChannelWithSubscriptionJSON{
ChannelJSON: c.Channel.JSON(includeChannelKey),
Subscription: sub,
}
}
type ChannelJSON struct {
ChannelID ChannelID `json:"channel_id"`
OwnerUserID UserID `json:"owner_user_id"`
@ -42,6 +65,11 @@ type ChannelJSON struct {
MessagesSent int `json:"messages_sent"`
}
type ChannelWithSubscriptionJSON struct {
ChannelJSON
Subscription *SubscriptionJSON `json:"subscription"`
}
type ChannelDB struct {
ChannelID ChannelID `db:"channel_id"`
OwnerUserID UserID `db:"owner_user_id"`
@ -67,6 +95,22 @@ func (c ChannelDB) Model() Channel {
}
}
type ChannelWithSubscriptionDB struct {
ChannelDB
Subscription *SubscriptionDB `db:"sub"`
}
func (c ChannelWithSubscriptionDB) Model() ChannelWithSubscription {
var sub *Subscription = nil
if c.Subscription != nil {
sub = langext.Ptr(c.Subscription.Model())
}
return ChannelWithSubscription{
Channel: c.ChannelDB.Model(),
Subscription: sub,
}
}
func DecodeChannel(r *sqlx.Rows) (Channel, error) {
data, err := sq.ScanSingle[ChannelDB](r, true)
if err != nil {
@ -82,3 +126,19 @@ func DecodeChannels(r *sqlx.Rows) ([]Channel, error) {
}
return langext.ArrMap(data, func(v ChannelDB) Channel { return v.Model() }), nil
}
func DecodeChannelWithSubscription(r *sqlx.Rows) (ChannelWithSubscription, error) {
data, err := sq.ScanSingle[ChannelWithSubscriptionDB](r, true)
if err != nil {
return ChannelWithSubscription{}, err
}
return data.Model(), nil
}
func DecodeChannelsWithSubscription(r *sqlx.Rows) ([]ChannelWithSubscription, error) {
data, err := sq.ScanAll[ChannelWithSubscriptionDB](r, true)
if err != nil {
return nil, err
}
return langext.ArrMap(data, func(v ChannelWithSubscriptionDB) ChannelWithSubscription { return v.Model() }), nil
}

View File

@ -45,10 +45,10 @@ func (f MessageFilter) SQL() (string, string, sq.PP, error) {
joinClause := ""
if f.ConfirmedSubscriptionBy != nil {
joinClause += " LEFT JOIN subscriptions subs on messages.channel_id = subs.channel_id "
joinClause += " LEFT JOIN subscriptions AS subs on messages.channel_id = subs.channel_id "
}
if f.SearchString != nil {
joinClause += " JOIN messages_fts mfts on (mfts.rowid = messages.scn_message_id) "
joinClause += " JOIN messages_fts AS mfts on (mfts.rowid = messages.scn_message_id) "
}
sqlClauses := make([]string, 0)

View File

@ -1151,11 +1151,11 @@
},
"/api/users/{uid}/channels": {
"get": {
"description": "The possible values for 'selector' are:\n- \"owned\" Return all channels of the user\n- \"subscribed\" Return all channels that the user is subscribing to\n- \"all\" Return channels that the user owns or is subscribing\n- \"subscribed_any\" Return all channels that the user is subscribing to (even unconfirmed)\n- \"all_any\" Return channels that the user owns or is subscribing (even unconfirmed)",
"description": "The possible values for 'selector' are:\n- \"owned\" Return all channels of the user\n- \"subscribed\" Return all channels that the user is subscribing to\n- \"all\" Return channels that the user owns or is subscribing\n- \"subscribed_any\" Return all channels that the user is subscribing to (even unconfirmed)\n- \"all_any\" Return channels that the user owns or is subscribing (even unconfirmed)",
"tags": [
"API-v2"
],
"summary": "List channels of a user (subscribed/owned)",
"summary": "List channels of a user (subscribed/owned/all)",
"operationId": "api-channels-list",
"parameters": [
{
@ -1206,9 +1206,7 @@
}
}
}
}
},
"/api/users/{uid}/channels/": {
},
"post": {
"tags": [
"API-v2"
@ -1236,7 +1234,7 @@
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/models.ChannelJSON"
"$ref": "#/definitions/models.ChannelWithSubscriptionJSON"
}
},
"400": {
@ -1271,7 +1269,7 @@
"tags": [
"API-v2"
],
"summary": "List all channels of a user",
"summary": "Get a single channel",
"operationId": "api-channels-get",
"parameters": [
{
@ -1293,7 +1291,7 @@
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/models.ChannelJSON"
"$ref": "#/definitions/models.ChannelWithSubscriptionJSON"
}
},
"400": {
@ -1364,7 +1362,7 @@
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/models.ChannelJSON"
"$ref": "#/definitions/models.ChannelWithSubscriptionJSON"
}
},
"400": {
@ -1740,7 +1738,7 @@
"tags": [
"API-v2"
],
"summary": "List all channels of a user",
"summary": "List all subscriptions of a user (incoming/owned)",
"operationId": "api-user-subscriptions-list",
"parameters": [
{
@ -1749,6 +1747,21 @@
"name": "uid",
"in": "path",
"required": true
},
{
"enum": [
"owner_all",
"owner_confirmed",
"owner_unconfirmed",
"incoming_all",
"incoming_confirmed",
"incoming_unconfirmed"
],
"type": "string",
"description": "Filter subscribptions (default: owner_all)",
"name": "selector",
"in": "query",
"required": true
}
],
"responses": {
@ -2380,6 +2393,9 @@
"properties": {
"name": {
"type": "string"
},
"subscribe": {
"type": "boolean"
}
}
},
@ -2529,7 +2545,7 @@
"channels": {
"type": "array",
"items": {
"$ref": "#/definitions/models.ChannelJSON"
"$ref": "#/definitions/models.ChannelWithSubscriptionJSON"
}
}
}
@ -2804,7 +2820,7 @@
}
}
},
"models.ChannelJSON": {
"models.ChannelWithSubscriptionJSON": {
"type": "object",
"properties": {
"channel_id": {
@ -2827,6 +2843,9 @@
"description": "can be nil, depending on endpoint",
"type": "string"
},
"subscription": {
"$ref": "#/definitions/models.SubscriptionJSON"
},
"timestamp_created": {
"type": "string"
},

View File

@ -55,6 +55,8 @@ definitions:
properties:
name:
type: string
subscribe:
type: boolean
type: object
handler.CreateSubscription.body:
properties:
@ -151,7 +153,7 @@ definitions:
properties:
channels:
items:
$ref: '#/definitions/models.ChannelJSON'
$ref: '#/definitions/models.ChannelWithSubscriptionJSON'
type: array
type: object
handler.ListClients.response:
@ -334,7 +336,7 @@ definitions:
suppress_send:
type: boolean
type: object
models.ChannelJSON:
models.ChannelWithSubscriptionJSON:
properties:
channel_id:
type: integer
@ -350,6 +352,8 @@ definitions:
subscribe_key:
description: can be nil, depending on endpoint
type: string
subscription:
$ref: '#/definitions/models.SubscriptionJSON'
timestamp_created:
type: string
timestamp_lastsent:
@ -1288,11 +1292,11 @@ paths:
get:
description: |-
The possible values for 'selector' are:
- "owned" Return all channels of the user
- "subscribed" Return all channels that the user is subscribing to
- "all" Return channels that the user owns or is subscribing
- "owned" Return all channels of the user
- "subscribed" Return all channels that the user is subscribing to
- "all" Return channels that the user owns or is subscribing
- "subscribed_any" Return all channels that the user is subscribing to (even unconfirmed)
- "all_any" Return channels that the user owns or is subscribing (even unconfirmed)
- "all_any" Return channels that the user owns or is subscribing (even unconfirmed)
operationId: api-channels-list
parameters:
- description: UserID
@ -1328,10 +1332,9 @@ paths:
description: internal server error
schema:
$ref: '#/definitions/ginresp.apiError'
summary: List channels of a user (subscribed/owned)
summary: List channels of a user (subscribed/owned/all)
tags:
- API-v2
/api/users/{uid}/channels/:
post:
operationId: api-channels-create
parameters:
@ -1349,7 +1352,7 @@ paths:
"200":
description: OK
schema:
$ref: '#/definitions/models.ChannelJSON'
$ref: '#/definitions/models.ChannelWithSubscriptionJSON'
"400":
description: supplied values/parameters cannot be parsed / are invalid
schema:
@ -1387,7 +1390,7 @@ paths:
"200":
description: OK
schema:
$ref: '#/definitions/models.ChannelJSON'
$ref: '#/definitions/models.ChannelWithSubscriptionJSON'
"400":
description: supplied values/parameters cannot be parsed / are invalid
schema:
@ -1404,7 +1407,7 @@ paths:
description: internal server error
schema:
$ref: '#/definitions/ginresp.apiError'
summary: List all channels of a user
summary: Get a single channel
tags:
- API-v2
patch:
@ -1434,7 +1437,7 @@ paths:
"200":
description: OK
schema:
$ref: '#/definitions/models.ChannelJSON'
$ref: '#/definitions/models.ChannelWithSubscriptionJSON'
"400":
description: supplied values/parameters cannot be parsed / are invalid
schema:
@ -1693,6 +1696,18 @@ paths:
name: uid
required: true
type: integer
- description: 'Filter subscribptions (default: owner_all)'
enum:
- owner_all
- owner_confirmed
- owner_unconfirmed
- incoming_all
- incoming_confirmed
- incoming_unconfirmed
in: query
name: selector
required: true
type: string
responses:
"200":
description: OK
@ -1710,7 +1725,7 @@ paths:
description: internal server error
schema:
$ref: '#/definitions/ginresp.apiError'
summary: List all channels of a user
summary: List all subscriptions of a user (incoming/owned)
tags:
- API-v2
post:

View File

@ -28,8 +28,8 @@ func TestCreateChannel(t *testing.T) {
}
{
chan0 := tt.RequestAuthGet[chanlist](t, admintok, baseUrl, fmt.Sprintf("/api/users/%d/channels", uid))
tt.AssertEqual(t, "chan-count", 0, len(chan0.Channels))
clist := tt.RequestAuthGet[chanlist](t, admintok, baseUrl, fmt.Sprintf("/api/users/%d/channels", uid))
tt.AssertMappedSet(t, "channels", []string{}, clist.Channels, "name")
}
tt.RequestAuthPost[gin.H](t, admintok, baseUrl, fmt.Sprintf("/api/users/%d/channels", uid), gin.H{
@ -39,7 +39,7 @@ func TestCreateChannel(t *testing.T) {
{
clist := tt.RequestAuthGet[chanlist](t, admintok, baseUrl, fmt.Sprintf("/api/users/%d/channels", uid))
tt.AssertEqual(t, "chan.len", 1, len(clist.Channels))
tt.AssertEqual(t, "chan.name", "test", clist.Channels[0]["name"])
tt.AssertMappedSet(t, "channels", []string{"test"}, clist.Channels, "name")
}
tt.RequestAuthPost[gin.H](t, admintok, baseUrl, fmt.Sprintf("/api/users/%d/channels", uid), gin.H{
@ -48,9 +48,7 @@ func TestCreateChannel(t *testing.T) {
{
clist := tt.RequestAuthGet[chanlist](t, admintok, baseUrl, fmt.Sprintf("/api/users/%d/channels", uid))
tt.AssertEqual(t, "chan-count", 2, len(clist.Channels))
tt.AssertArrAny(t, "chan.has('asdf')", clist.Channels, func(msg gin.H) bool { return msg["name"].(string) == "asdf" })
tt.AssertArrAny(t, "chan.has('test')", clist.Channels, func(msg gin.H) bool { return msg["name"].(string) == "test" })
tt.AssertMappedSet(t, "channels", []string{"asdf", "test"}, clist.Channels, "name")
}
}
@ -137,12 +135,37 @@ func TestChannelNameNormalization(t *testing.T) {
}
}
func TestListChannels(t *testing.T) {
t.SkipNow() //TODO
}
func TestListChannelsOwned(t *testing.T) {
t.SkipNow() //TODO
ws, baseUrl, stop := tt.StartSimpleWebserver(t)
defer stop()
data := tt.InitDefaultData(t, ws)
type chanlist struct {
Channels []gin.H `json:"channels"`
}
testdata := map[int][]string{
0: {"main", "chattingchamber", "unicdhll", "promotions", "reminders"},
1: {"promotions"},
2: {},
3: {},
4: {},
5: {},
6: {},
7: {},
8: {},
9: {},
10: {},
11: {},
12: {},
13: {},
}
for k, v := range testdata {
r0 := tt.RequestAuthGet[chanlist](t, data.User[k].AdminKey, baseUrl, fmt.Sprintf("/api/users/%d/channels", data.User[k].UID))
tt.AssertMappedSet(t, fmt.Sprintf("%d->chanlist", k), v, r0.Channels, "name")
}
}
func TestListChannelsSubscribedAny(t *testing.T) {

View File

@ -2,6 +2,7 @@ package util
import (
"fmt"
"github.com/gin-gonic/gin"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"reflect"
"runtime/debug"
@ -199,3 +200,22 @@ func AssertMultiNonEmpty(t *testing.T, key string, args ...any) {
}
}
}
func AssertMappedSet[T langext.OrderedConstraint](t *testing.T, key string, expected []T, values []gin.H, objkey string) {
actual := langext.ArrMap(values, func(v gin.H) T { return v[objkey].(T) })
langext.Sort(actual)
langext.Sort(expected)
if !langext.ArrEqualsExact(actual, expected) {
t.Errorf("Value [%s] differs (%T <-> %T):\n", key, expected, actual)
t.Errorf("Actual := [%v]\n", actual)
t.Errorf("Expected := [%v]\n", expected)
t.Error(string(debug.Stack()))
t.FailNow()
}
}

View File

@ -86,6 +86,8 @@ var userExamples = []userex{
{9, true, "UniqueUnicorn", "Galaxy Quest", "2023.1", "ANDROID", "FCM_TOK_EX_010", ""},
{10, false, "", "", "", "", "", ""},
{11, false, "", "", "", "", "", "ANDROID|v2|PURCHASED:PRO_TOK_002"},
{12, true, "ChanTester1", "StarfireXX", "1.x", "IOS", "FCM_TOK_EX_012", ""},
{13, true, "ChanTester2", "StarfireXX", "1.x", "IOS", "FCM_TOK_EX_013", ""},
}
var clientExamples = []clientex{
@ -264,6 +266,15 @@ var messageExamples = []msgex{
{11, "Promotions", "192.168.0.1", P1, AKEY, "Announcing Our Annual Black Friday Sale", "Mark your calendars and get ready for the biggest sale of the year. Our annual Black Friday sale is coming soon and you won't want to miss out on the amazing deals and discounts.", 0},
{11, "Promotions", "", PX, AKEY, "Join Our VIP Club and Enjoy Exclusive Benefits", "Sign up for our VIP club and enjoy exclusive benefits like early access to sales, special offers, and personalized service. Don't miss out on this exclusive opportunity.", timeext.FromHours(2.32)},
{11, "Promotions", "", P2, SKEY, "Summer Clearance: Save Up to 75% on Your Favorite Products", "It's time for our annual summer clearance sale! Save up to 75% on your favorite products, from clothing and accessories to home decor and more.", timeext.FromHours(1.87)},
{12, "", "", P0, SKEY, "New Product Launch", "We are excited to announce the launch of our new product, the XYZ widget", 0},
{12, "chan_self_subscribed", "", P0, SKEY, "Important Update", "We have released a critical update", 0},
{12, "chan_self_unsub", "", P0, SKEY, "Reminder: Upcoming Maintenance", "", 0},
{13, "", "", P0, SKEY, "New Feature Available", "ability to schedule appointments", 0},
{13, "chan_other_nosub", "", P0, SKEY, "Account Suspended", "Please contact us", 0},
{13, "chan_other_request", "", P0, SKEY, "Invitation to Beta Test", "", 0},
{13, "chan_other_accepted", "", P0, SKEY, "New Blog Post", "Congratulations on your promotion! We are proud", 0},
}
type DefData struct {
@ -286,6 +297,8 @@ func InitDefaultData(t *testing.T, ws *logic.Application) DefData {
users := make([]Userdat, 0, len(userExamples))
// Create Users
for _, uex := range userExamples {
body := gin.H{}
if uex.WithClient {
@ -318,6 +331,8 @@ func InitDefaultData(t *testing.T, ws *logic.Application) DefData {
})
}
// Create Clients
for _, cex := range clientExamples {
body := gin.H{}
body["agent_model"] = cex.AgentModel
@ -327,15 +342,9 @@ func InitDefaultData(t *testing.T, ws *logic.Application) DefData {
RequestAuthPost[gin.H](t, users[cex.User].AdminKey, baseUrl, fmt.Sprintf("/api/users/%d/clients", users[cex.User].UID), body)
}
// Create Messages
for _, mex := range messageExamples {
//User int
//Channel string
//SenderName string
//Priority int
//Key int
//Title string
//Content string
//TSOffset time.Duration
body := gin.H{}
body["title"] = mex.Title
body["user_id"] = users[mex.User].UID
@ -364,6 +373,15 @@ func InitDefaultData(t *testing.T, ws *logic.Application) DefData {
RequestPost[gin.H](t, baseUrl, "/", body)
}
// Sub/Unsub for Users 12+13
{
//TODO User 12 unsubscribe from 12:chan_self_unsub
//TODO User 13 request-subscribe to 13:chan_other_request
//TODO User 13 request-subscribe to 13:chan_other_accepted
//TODO User 13 accept subscription from user 12 to 13:chan_other_accepted
}
success = true
return DefData{User: users}