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) NAMESPACE=$(shell git rev-parse --abbrev-ref HEAD)
HASH=$(shell git rev-parse HEAD) HASH=$(shell git rev-parse HEAD)
.PHONY: test swagger
build: swagger fmt build: swagger fmt
mkdir -p _build mkdir -p _build
rm -f ./_build/scn_backend rm -f ./_build/scn_backend
@ -29,7 +31,6 @@ docker: build
-t "$(DOCKER_REPO)/$(DOCKER_NAME):latest" \ -t "$(DOCKER_REPO)/$(DOCKER_NAME):latest" \
. .
.PHONY: swagger
swagger: swagger:
which swag || go install github.com/swaggo/swag/cmd/swag@latest which swag || go install github.com/swaggo/swag/cmd/swag@latest
swag init -generalInfo api/router.go --propertyStrategy snakecase --output ./swagger/ --outputTypes "json,yaml" swag init -generalInfo api/router.go --propertyStrategy snakecase --output ./swagger/ --outputTypes "json,yaml"
@ -67,7 +68,6 @@ fmt:
go fmt ./... go fmt ./...
swag fmt swag fmt
.PHONY: test
test: test:
go 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?) - (?) ack/read deliveries && return ack-count (? or not, how to query?)
- (?) "login" on website and list/search/filter messages - (?) "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 // 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 The possible values for 'selector' are:
// @Description - "owned" Return all channels of the user // @Description - "owned" Return all channels of the user
// @Description - "subscribed" Return all channels that the user is subscribing to // @Description - "subscribed" Return all channels that the user is subscribing to
// @Description - "all" Return channels that the user owns or is subscribing // @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 - "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 // @ID api-channels-list
// @Tags API-v2 // @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"` Selector *string `json:"selector" form:"selector" enums:"owned,subscribed_any,all_any,subscribed,all"`
} }
type response struct { type response struct {
Channels []models.ChannelJSON `json:"channels"` Channels []models.ChannelWithSubscriptionJSON `json:"channels"`
} }
var u uri var u uri
@ -534,40 +535,52 @@ func (h APIHandler) ListChannels(g *gin.Context) ginresp.HTTPResponse {
sel := strings.ToLower(langext.Coalesce(q.Selector, "owned")) sel := strings.ToLower(langext.Coalesce(q.Selector, "owned"))
var res []models.ChannelJSON var res []models.ChannelWithSubscriptionJSON
if sel == "owned" { if sel == "owned" {
channels, err := h.database.ListChannelsByOwner(ctx, u.UserID)
channels, err := h.database.ListChannelsByOwner(ctx, u.UserID, u.UserID)
if err != nil { if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query channels", err) 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" { } 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 { if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query channels", err) 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" { } 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 { if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query channels", err) 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" { } 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 { if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query channels", err) 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" { } 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 { if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query channels", err) 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 { } else {
return ginresp.APIError(g, 400, apierr.INVALID_ENUM_VALUE, "Invalid value for the [selector] parameter", nil) 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})) 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 // GetChannel swaggerdoc
// //
// @Summary List all channels of a user // @Summary Get a single channel
// @ID api-channels-get // @ID api-channels-get
// @Tags API-v2 // @Tags API-v2
// //
// @Param uid path int true "UserID" // @Param uid path int true "UserID"
// @Param cid path int true "ChannelID" // @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 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 401 {object} ginresp.apiError "user is not authorized / has missing permissions"
// @Failure 404 {object} ginresp.apiError "channel not found" // @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 uid path int true "UserID"
// @Param post_body body handler.CreateChannel.body false " " // @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 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 401 {object} ginresp.apiError "user is not authorized / has missing permissions"
// @Failure 409 {object} ginresp.apiError "channel already exists" // @Failure 409 {object} ginresp.apiError "channel already exists"
// @Failure 500 {object} ginresp.apiError "internal server error" // @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 { func (h APIHandler) CreateChannel(g *gin.Context) ginresp.HTTPResponse {
type uri struct { type uri struct {
UserID models.UserID `uri:"uid"` UserID models.UserID `uri:"uid"`
} }
type body struct { type body struct {
Name string `json:"name"` Name string `json:"name"`
Subscribe *bool `json:"subscribe"`
} }
var u uri 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 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 // 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 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" // @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 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 401 {object} ginresp.apiError "user is not authorized / has missing permissions"
// @Failure 404 {object} ginresp.apiError "channel not found" // @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 { if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query (updated) user", err) 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 // ListChannelMessages swaggerdoc
@ -865,28 +893,41 @@ func (h APIHandler) ListChannelMessages(g *gin.Context) ginresp.HTTPResponse {
// ListUserSubscriptions swaggerdoc // 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 // @ID api-user-subscriptions-list
// @Tags API-v2 // @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 // @Success 200 {object} handler.ListUserSubscriptions.response
// @Failure 400 {object} ginresp.apiError "supplied values/parameters cannot be parsed / are invalid" // @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 401 {object} ginresp.apiError "user is not authorized / has missing permissions"
// @Failure 500 {object} ginresp.apiError "internal server error" // @Failure 500 {object} ginresp.apiError "internal server error"
// //
// @Router /api/users/{uid}/subscriptions [GET] // @Router /api/users/{uid}/subscriptions [GET]
func (h APIHandler) ListUserSubscriptions(g *gin.Context) ginresp.HTTPResponse { func (h APIHandler) ListUserSubscriptions(g *gin.Context) ginresp.HTTPResponse {
type uri struct { type uri struct {
UserID models.UserID `uri:"uid"` 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 { type response struct {
Subscriptions []models.SubscriptionJSON `json:"subscriptions"` Subscriptions []models.SubscriptionJSON `json:"subscriptions"`
} }
var u uri 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 { if errResp != nil {
return *errResp return *errResp
} }
@ -896,14 +937,62 @@ func (h APIHandler) ListUserSubscriptions(g *gin.Context) ginresp.HTTPResponse {
return *permResp return *permResp
} }
clients, err := h.database.ListSubscriptionsByOwner(ctx, u.UserID) sel := strings.ToLower(langext.Coalesce(q.Selector, "owner_all"))
if err != nil {
return ginresp.APIError(g, 500, apierr.DATABASE_ERROR, "Failed to query channels", err) 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 // ListChannelSubscriptions swaggerdoc

View File

@ -93,18 +93,21 @@ func (db *Database) CreateChannel(ctx TxContext, userid models.UserID, name stri
}, nil }, 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) tx, err := ctx.GetOrCreateTransaction(db)
if err != nil { if err != nil {
return nil, err 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 { if err != nil {
return nil, err return nil, err
} }
data, err := models.DecodeChannels(rows) data, err := models.DecodeChannelsWithSubscription(rows)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -112,25 +115,27 @@ func (db *Database) ListChannelsByOwner(ctx TxContext, userid models.UserID) ([]
return data, nil 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) tx, err := ctx.GetOrCreateTransaction(db)
if err != nil { if err != nil {
return nil, err return nil, err
} }
confCond := "" confCond := ""
if confirmed { if confirmed != nil && *confirmed {
confCond = " AND sub.confirmed = 1" 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{ 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{
"suid": userid, "subuid": userid,
}) })
if err != nil { if err != nil {
return nil, err return nil, err
} }
data, err := models.DecodeChannels(rows) data, err := models.DecodeChannelsWithSubscription(rows)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -138,25 +143,28 @@ func (db *Database) ListChannelsBySubscriber(ctx TxContext, userid models.UserID
return data, nil 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) tx, err := ctx.GetOrCreateTransaction(db)
if err != nil { if err != nil {
return nil, err return nil, err
} }
confCond := "OR sub.subscriber_user_id = ?" confCond := ""
if confirmed { if confirmed != nil && *confirmed {
confCond = "OR (sub.subscriber_user_id = ? AND sub.confirmed = 1)" 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{ 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, "ouid": userid,
"subuid": userid,
}) })
if err != nil { if err != nil {
return nil, err return nil, err
} }
data, err := models.DecodeChannels(rows) data, err := models.DecodeChannelsWithSubscription(rows)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -164,26 +172,27 @@ func (db *Database) ListChannelsByAccess(ctx TxContext, userid models.UserID, co
return data, nil 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) tx, err := ctx.GetOrCreateTransaction(db)
if err != nil { 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{ 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, "ouid": userid,
"cid": channelid, "cid": channelid,
"subuid": userid,
}) })
if err != nil { if err != nil {
return models.Channel{}, err return models.ChannelWithSubscription{}, err
} }
client, err := models.DecodeChannel(rows) channel, err := models.DecodeChannelWithSubscription(rows)
if err != nil { 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 { func (db *Database) IncChannelMessageCounter(ctx TxContext, channel models.Channel) error {

View File

@ -2,6 +2,7 @@ package db
import ( import (
server "blackforestbytes.com/simplecloudnotifier" server "blackforestbytes.com/simplecloudnotifier"
"blackforestbytes.com/simplecloudnotifier/db/dbtools"
"blackforestbytes.com/simplecloudnotifier/db/schema" "blackforestbytes.com/simplecloudnotifier/db/schema"
"context" "context"
"database/sql" "database/sql"
@ -9,7 +10,6 @@ import (
"fmt" "fmt"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
"github.com/rs/zerolog/log"
"gogs.mikescher.com/BlackForestBytes/goext/langext" "gogs.mikescher.com/BlackForestBytes/goext/langext"
"gogs.mikescher.com/BlackForestBytes/goext/sq" "gogs.mikescher.com/BlackForestBytes/goext/sq"
"time" "time"
@ -40,7 +40,9 @@ func NewDatabase(conf server.Config) (*Database, error) {
scndb := &Database{qqdb} scndb := &Database{qqdb}
qqdb.SetListener(scndb) qqdb.AddListener(dbtools.DBLogger{})
qqdb.AddListener(dbtools.NewDBPreprocessor(scndb.db))
return scndb, nil 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) { func (db *Database) BeginTx(ctx context.Context) (sq.Tx, error) {
return db.db.BeginTransaction(ctx, sql.LevelDefault) 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 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) tx, err := ctx.GetOrCreateTransaction(db)
if err != nil { if err != nil {
return nil, err 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 { if err != nil {
return nil, err return nil, err
} }

View File

@ -186,7 +186,7 @@ func (db *Database) UpdateUserKeys(ctx TxContext, userid models.UserID, sendKey
return err 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, "sk": sendKey,
"rk": readKey, "rk": readKey,
"ak": adminKey, "ak": adminKey,

View File

@ -2,7 +2,6 @@ package db
import ( import (
"gogs.mikescher.com/BlackForestBytes/goext/langext" "gogs.mikescher.com/BlackForestBytes/goext/langext"
"strings"
"time" "time"
) )
@ -24,14 +23,3 @@ func time2DBOpt(t *time.Time) *int64 {
} }
return langext.Ptr(t.UnixMilli()) 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/mattn/go-sqlite3 v1.14.16
github.com/rs/zerolog v1.28.0 github.com/rs/zerolog v1.28.0
github.com/swaggo/swag v1.8.7 github.com/swaggo/swag v1.8.7
gogs.mikescher.com/BlackForestBytes/goext v0.0.37 gogs.mikescher.com/BlackForestBytes/goext v0.0.41
) )
require ( 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.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 h1:6XP5UOqiougzH0Xtzs5tIU4c0sAXmdMPCvGhRxqVwLU=
gogs.mikescher.com/BlackForestBytes/goext v0.0.37/go.mod h1:/u9JtMwCP68ix4R9BJ/MT0Lm+QScmqIoyYZFKBGzv9g= 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 h1:/UOmuWzQfxxo9UtlXMwuQU8CMgg1eZXqTRwkSQJWKOI=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 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= 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 { type ChannelJSON struct {
ChannelID ChannelID `json:"channel_id"` ChannelID ChannelID `json:"channel_id"`
OwnerUserID UserID `json:"owner_user_id"` OwnerUserID UserID `json:"owner_user_id"`
@ -42,6 +65,11 @@ type ChannelJSON struct {
MessagesSent int `json:"messages_sent"` MessagesSent int `json:"messages_sent"`
} }
type ChannelWithSubscriptionJSON struct {
ChannelJSON
Subscription *SubscriptionJSON `json:"subscription"`
}
type ChannelDB struct { type ChannelDB struct {
ChannelID ChannelID `db:"channel_id"` ChannelID ChannelID `db:"channel_id"`
OwnerUserID UserID `db:"owner_user_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) { func DecodeChannel(r *sqlx.Rows) (Channel, error) {
data, err := sq.ScanSingle[ChannelDB](r, true) data, err := sq.ScanSingle[ChannelDB](r, true)
if err != nil { 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 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 := "" joinClause := ""
if f.ConfirmedSubscriptionBy != nil { 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 { 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) sqlClauses := make([]string, 0)

View File

@ -1151,11 +1151,11 @@
}, },
"/api/users/{uid}/channels": { "/api/users/{uid}/channels": {
"get": { "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": [ "tags": [
"API-v2" "API-v2"
], ],
"summary": "List channels of a user (subscribed/owned)", "summary": "List channels of a user (subscribed/owned/all)",
"operationId": "api-channels-list", "operationId": "api-channels-list",
"parameters": [ "parameters": [
{ {
@ -1206,9 +1206,7 @@
} }
} }
} }
} },
},
"/api/users/{uid}/channels/": {
"post": { "post": {
"tags": [ "tags": [
"API-v2" "API-v2"
@ -1236,7 +1234,7 @@
"200": { "200": {
"description": "OK", "description": "OK",
"schema": { "schema": {
"$ref": "#/definitions/models.ChannelJSON" "$ref": "#/definitions/models.ChannelWithSubscriptionJSON"
} }
}, },
"400": { "400": {
@ -1271,7 +1269,7 @@
"tags": [ "tags": [
"API-v2" "API-v2"
], ],
"summary": "List all channels of a user", "summary": "Get a single channel",
"operationId": "api-channels-get", "operationId": "api-channels-get",
"parameters": [ "parameters": [
{ {
@ -1293,7 +1291,7 @@
"200": { "200": {
"description": "OK", "description": "OK",
"schema": { "schema": {
"$ref": "#/definitions/models.ChannelJSON" "$ref": "#/definitions/models.ChannelWithSubscriptionJSON"
} }
}, },
"400": { "400": {
@ -1364,7 +1362,7 @@
"200": { "200": {
"description": "OK", "description": "OK",
"schema": { "schema": {
"$ref": "#/definitions/models.ChannelJSON" "$ref": "#/definitions/models.ChannelWithSubscriptionJSON"
} }
}, },
"400": { "400": {
@ -1740,7 +1738,7 @@
"tags": [ "tags": [
"API-v2" "API-v2"
], ],
"summary": "List all channels of a user", "summary": "List all subscriptions of a user (incoming/owned)",
"operationId": "api-user-subscriptions-list", "operationId": "api-user-subscriptions-list",
"parameters": [ "parameters": [
{ {
@ -1749,6 +1747,21 @@
"name": "uid", "name": "uid",
"in": "path", "in": "path",
"required": true "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": { "responses": {
@ -2380,6 +2393,9 @@
"properties": { "properties": {
"name": { "name": {
"type": "string" "type": "string"
},
"subscribe": {
"type": "boolean"
} }
} }
}, },
@ -2529,7 +2545,7 @@
"channels": { "channels": {
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/definitions/models.ChannelJSON" "$ref": "#/definitions/models.ChannelWithSubscriptionJSON"
} }
} }
} }
@ -2804,7 +2820,7 @@
} }
} }
}, },
"models.ChannelJSON": { "models.ChannelWithSubscriptionJSON": {
"type": "object", "type": "object",
"properties": { "properties": {
"channel_id": { "channel_id": {
@ -2827,6 +2843,9 @@
"description": "can be nil, depending on endpoint", "description": "can be nil, depending on endpoint",
"type": "string" "type": "string"
}, },
"subscription": {
"$ref": "#/definitions/models.SubscriptionJSON"
},
"timestamp_created": { "timestamp_created": {
"type": "string" "type": "string"
}, },

View File

@ -55,6 +55,8 @@ definitions:
properties: properties:
name: name:
type: string type: string
subscribe:
type: boolean
type: object type: object
handler.CreateSubscription.body: handler.CreateSubscription.body:
properties: properties:
@ -151,7 +153,7 @@ definitions:
properties: properties:
channels: channels:
items: items:
$ref: '#/definitions/models.ChannelJSON' $ref: '#/definitions/models.ChannelWithSubscriptionJSON'
type: array type: array
type: object type: object
handler.ListClients.response: handler.ListClients.response:
@ -334,7 +336,7 @@ definitions:
suppress_send: suppress_send:
type: boolean type: boolean
type: object type: object
models.ChannelJSON: models.ChannelWithSubscriptionJSON:
properties: properties:
channel_id: channel_id:
type: integer type: integer
@ -350,6 +352,8 @@ definitions:
subscribe_key: subscribe_key:
description: can be nil, depending on endpoint description: can be nil, depending on endpoint
type: string type: string
subscription:
$ref: '#/definitions/models.SubscriptionJSON'
timestamp_created: timestamp_created:
type: string type: string
timestamp_lastsent: timestamp_lastsent:
@ -1288,11 +1292,11 @@ paths:
get: get:
description: |- description: |-
The possible values for 'selector' are: The possible values for 'selector' are:
- "owned" Return all channels of the user - "owned" Return all channels of the user
- "subscribed" Return all channels that the user is subscribing to - "subscribed" Return all channels that the user is subscribing to
- "all" Return channels that the user owns or is subscribing - "all" Return channels that the user owns or is subscribing
- "subscribed_any" Return all channels that the user is subscribing to (even unconfirmed) - "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 operationId: api-channels-list
parameters: parameters:
- description: UserID - description: UserID
@ -1328,10 +1332,9 @@ paths:
description: internal server error description: internal server error
schema: schema:
$ref: '#/definitions/ginresp.apiError' $ref: '#/definitions/ginresp.apiError'
summary: List channels of a user (subscribed/owned) summary: List channels of a user (subscribed/owned/all)
tags: tags:
- API-v2 - API-v2
/api/users/{uid}/channels/:
post: post:
operationId: api-channels-create operationId: api-channels-create
parameters: parameters:
@ -1349,7 +1352,7 @@ paths:
"200": "200":
description: OK description: OK
schema: schema:
$ref: '#/definitions/models.ChannelJSON' $ref: '#/definitions/models.ChannelWithSubscriptionJSON'
"400": "400":
description: supplied values/parameters cannot be parsed / are invalid description: supplied values/parameters cannot be parsed / are invalid
schema: schema:
@ -1387,7 +1390,7 @@ paths:
"200": "200":
description: OK description: OK
schema: schema:
$ref: '#/definitions/models.ChannelJSON' $ref: '#/definitions/models.ChannelWithSubscriptionJSON'
"400": "400":
description: supplied values/parameters cannot be parsed / are invalid description: supplied values/parameters cannot be parsed / are invalid
schema: schema:
@ -1404,7 +1407,7 @@ paths:
description: internal server error description: internal server error
schema: schema:
$ref: '#/definitions/ginresp.apiError' $ref: '#/definitions/ginresp.apiError'
summary: List all channels of a user summary: Get a single channel
tags: tags:
- API-v2 - API-v2
patch: patch:
@ -1434,7 +1437,7 @@ paths:
"200": "200":
description: OK description: OK
schema: schema:
$ref: '#/definitions/models.ChannelJSON' $ref: '#/definitions/models.ChannelWithSubscriptionJSON'
"400": "400":
description: supplied values/parameters cannot be parsed / are invalid description: supplied values/parameters cannot be parsed / are invalid
schema: schema:
@ -1693,6 +1696,18 @@ paths:
name: uid name: uid
required: true required: true
type: integer 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: responses:
"200": "200":
description: OK description: OK
@ -1710,7 +1725,7 @@ paths:
description: internal server error description: internal server error
schema: schema:
$ref: '#/definitions/ginresp.apiError' $ref: '#/definitions/ginresp.apiError'
summary: List all channels of a user summary: List all subscriptions of a user (incoming/owned)
tags: tags:
- API-v2 - API-v2
post: 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)) clist := tt.RequestAuthGet[chanlist](t, admintok, baseUrl, fmt.Sprintf("/api/users/%d/channels", uid))
tt.AssertEqual(t, "chan-count", 0, len(chan0.Channels)) tt.AssertMappedSet(t, "channels", []string{}, clist.Channels, "name")
} }
tt.RequestAuthPost[gin.H](t, admintok, baseUrl, fmt.Sprintf("/api/users/%d/channels", uid), gin.H{ 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)) 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.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{ 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)) clist := tt.RequestAuthGet[chanlist](t, admintok, baseUrl, fmt.Sprintf("/api/users/%d/channels", uid))
tt.AssertEqual(t, "chan-count", 2, len(clist.Channels)) tt.AssertMappedSet(t, "channels", []string{"asdf", "test"}, clist.Channels, "name")
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" })
} }
} }
@ -137,12 +135,37 @@ func TestChannelNameNormalization(t *testing.T) {
} }
} }
func TestListChannels(t *testing.T) {
t.SkipNow() //TODO
}
func TestListChannelsOwned(t *testing.T) { 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) { func TestListChannelsSubscribedAny(t *testing.T) {

View File

@ -2,6 +2,7 @@ package util
import ( import (
"fmt" "fmt"
"github.com/gin-gonic/gin"
"gogs.mikescher.com/BlackForestBytes/goext/langext" "gogs.mikescher.com/BlackForestBytes/goext/langext"
"reflect" "reflect"
"runtime/debug" "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", ""}, {9, true, "UniqueUnicorn", "Galaxy Quest", "2023.1", "ANDROID", "FCM_TOK_EX_010", ""},
{10, false, "", "", "", "", "", ""}, {10, false, "", "", "", "", "", ""},
{11, false, "", "", "", "", "", "ANDROID|v2|PURCHASED:PRO_TOK_002"}, {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{ 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", "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", "", 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)}, {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 { type DefData struct {
@ -286,6 +297,8 @@ func InitDefaultData(t *testing.T, ws *logic.Application) DefData {
users := make([]Userdat, 0, len(userExamples)) users := make([]Userdat, 0, len(userExamples))
// Create Users
for _, uex := range userExamples { for _, uex := range userExamples {
body := gin.H{} body := gin.H{}
if uex.WithClient { if uex.WithClient {
@ -318,6 +331,8 @@ func InitDefaultData(t *testing.T, ws *logic.Application) DefData {
}) })
} }
// Create Clients
for _, cex := range clientExamples { for _, cex := range clientExamples {
body := gin.H{} body := gin.H{}
body["agent_model"] = cex.AgentModel 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) 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 { 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 := gin.H{}
body["title"] = mex.Title body["title"] = mex.Title
body["user_id"] = users[mex.User].UID 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) 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 success = true
return DefData{User: users} return DefData{User: users}