2022-07-06 23:19:05 +02:00
package daos
import (
2023-07-13 21:38:55 +02:00
"context"
2023-05-31 10:47:16 +02:00
"database/sql"
2022-07-06 23:19:05 +02:00
"errors"
"fmt"
2024-01-03 04:29:30 +02:00
"sort"
2022-07-06 23:19:05 +02:00
"strings"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
2023-05-31 10:47:16 +02:00
"github.com/pocketbase/pocketbase/resolvers"
2022-10-30 10:28:14 +02:00
"github.com/pocketbase/pocketbase/tools/inflector"
2022-07-06 23:19:05 +02:00
"github.com/pocketbase/pocketbase/tools/list"
2023-05-31 10:47:16 +02:00
"github.com/pocketbase/pocketbase/tools/search"
2022-07-18 15:26:37 +02:00
"github.com/pocketbase/pocketbase/tools/security"
2022-07-06 23:19:05 +02:00
"github.com/pocketbase/pocketbase/tools/types"
2022-10-30 10:28:14 +02:00
"github.com/spf13/cast"
2022-07-06 23:19:05 +02:00
)
2023-07-13 21:38:55 +02:00
// RecordQuery returns a new Record select query from a collection model, id or name.
//
// In case a collection id or name is provided and that collection doesn't
// actually exists, the generated query will be created with a cancelled context
// and will fail once an executor (Row(), One(), All(), etc.) is called.
func ( dao * Dao ) RecordQuery ( collectionModelOrIdentifier any ) * dbx . SelectQuery {
var tableName string
var collection * models . Collection
var collectionErr error
switch c := collectionModelOrIdentifier . ( type ) {
case * models . Collection :
collection = c
tableName = collection . Name
case models . Collection :
collection = & c
tableName = collection . Name
case string :
collection , collectionErr = dao . FindCollectionByNameOrId ( c )
if collection != nil {
tableName = collection . Name
} else {
// update with some fake table name for easier debugging
tableName = "@@__missing_" + c
}
default :
// update with some fake table name for easier debugging
tableName = "@@__invalidCollectionModelOrIdentifier"
collectionErr = errors . New ( "unsupported collection identifier, must be collection model, id or name" )
}
2022-07-06 23:19:05 +02:00
selectCols := fmt . Sprintf ( "%s.*" , dao . DB ( ) . QuoteSimpleColumnName ( tableName ) )
2023-07-13 21:38:55 +02:00
query := dao . DB ( ) . Select ( selectCols ) . From ( tableName )
// in case of an error attach a new context and cancel it immediately with the error
if collectionErr != nil {
// @todo consider changing to WithCancelCause when upgrading
// the min Go requirement to 1.20, so that we can pass the error
ctx , cancelFunc := context . WithCancel ( context . Background ( ) )
query . WithContext ( ctx )
cancelFunc ( )
}
return query . WithBuildHook ( func ( q * dbx . Query ) {
q . WithExecHook ( execLockRetry ( dao . ModelQueryTimeout , dao . MaxLockRetries ) ) .
WithOneHook ( func ( q * dbx . Query , a any , op func ( b any ) error ) error {
switch v := a . ( type ) {
case * models . Record :
if v == nil {
2023-02-22 22:20:19 +02:00
return op ( a )
}
2023-07-13 21:38:55 +02:00
row := dbx . NullStringMap { }
if err := op ( & row ) ; err != nil {
return err
}
2023-02-22 22:20:19 +02:00
2023-07-13 21:38:55 +02:00
record := models . NewRecordFromNullStringMap ( collection , row )
2023-02-22 22:20:19 +02:00
2023-07-13 21:38:55 +02:00
* v = * record
2023-02-22 22:20:19 +02:00
2023-07-13 21:38:55 +02:00
return nil
default :
return op ( a )
}
} ) .
WithAllHook ( func ( q * dbx . Query , sliceA any , op func ( sliceB any ) error ) error {
switch v := sliceA . ( type ) {
case * [ ] * models . Record :
if v == nil {
return op ( sliceA )
}
2023-02-22 22:20:19 +02:00
2023-07-13 21:38:55 +02:00
rows := [ ] dbx . NullStringMap { }
if err := op ( & rows ) ; err != nil {
return err
}
2023-02-22 22:20:19 +02:00
2023-07-13 21:38:55 +02:00
records := models . NewRecordsFromNullStringMaps ( collection , rows )
2023-02-22 22:20:19 +02:00
2023-07-13 21:38:55 +02:00
* v = records
2023-02-22 22:20:19 +02:00
2023-07-13 21:38:55 +02:00
return nil
case * [ ] models . Record :
if v == nil {
2023-02-22 22:20:19 +02:00
return op ( sliceA )
}
2023-07-13 21:38:55 +02:00
rows := [ ] dbx . NullStringMap { }
if err := op ( & rows ) ; err != nil {
return err
}
records := models . NewRecordsFromNullStringMaps ( collection , rows )
nonPointers := make ( [ ] models . Record , len ( records ) )
for i , r := range records {
nonPointers [ i ] = * r
}
* v = nonPointers
return nil
default :
return op ( sliceA )
}
} )
} )
2022-07-06 23:19:05 +02:00
}
// FindRecordById finds the Record model by its id.
func ( dao * Dao ) FindRecordById (
2022-10-30 10:28:14 +02:00
collectionNameOrId string ,
2022-07-06 23:19:05 +02:00
recordId string ,
2022-10-30 10:28:14 +02:00
optFilters ... func ( q * dbx . SelectQuery ) error ,
2022-07-06 23:19:05 +02:00
) ( * models . Record , error ) {
2022-10-30 10:28:14 +02:00
collection , err := dao . FindCollectionByNameOrId ( collectionNameOrId )
if err != nil {
return nil , err
}
2022-07-06 23:19:05 +02:00
query := dao . RecordQuery ( collection ) .
2023-02-21 16:38:12 +02:00
AndWhere ( dbx . HashExp { collection . Name + ".id" : recordId } )
2022-07-06 23:19:05 +02:00
2022-10-30 10:28:14 +02:00
for _ , filter := range optFilters {
if filter == nil {
continue
}
2022-07-06 23:19:05 +02:00
if err := filter ( query ) ; err != nil {
return nil , err
}
}
2023-02-21 16:38:12 +02:00
record := & models . Record { }
if err := query . Limit ( 1 ) . One ( record ) ; err != nil {
2022-07-06 23:19:05 +02:00
return nil , err
}
2023-02-21 16:38:12 +02:00
return record , nil
2022-07-06 23:19:05 +02:00
}
// FindRecordsByIds finds all Record models by the provided ids.
// If no records are found, returns an empty slice.
func ( dao * Dao ) FindRecordsByIds (
2022-10-30 10:28:14 +02:00
collectionNameOrId string ,
2022-07-06 23:19:05 +02:00
recordIds [ ] string ,
2022-10-30 10:28:14 +02:00
optFilters ... func ( q * dbx . SelectQuery ) error ,
2022-07-06 23:19:05 +02:00
) ( [ ] * models . Record , error ) {
2022-10-30 10:28:14 +02:00
collection , err := dao . FindCollectionByNameOrId ( collectionNameOrId )
if err != nil {
return nil , err
}
2022-07-06 23:19:05 +02:00
query := dao . RecordQuery ( collection ) .
2022-10-30 10:28:14 +02:00
AndWhere ( dbx . In (
collection . Name + ".id" ,
list . ToInterfaceSlice ( recordIds ) ... ,
) )
for _ , filter := range optFilters {
if filter == nil {
continue
}
2022-07-06 23:19:05 +02:00
if err := filter ( query ) ; err != nil {
return nil , err
}
}
2023-02-21 16:38:12 +02:00
records := make ( [ ] * models . Record , 0 , len ( recordIds ) )
if err := query . All ( & records ) ; err != nil {
2022-07-06 23:19:05 +02:00
return nil , err
}
2023-02-21 16:38:12 +02:00
return records , nil
2022-07-06 23:19:05 +02:00
}
2023-07-14 14:20:48 +02:00
// @todo consider to depricate as it may be easier to just use dao.RecordQuery()
//
2022-10-30 10:28:14 +02:00
// FindRecordsByExpr finds all records by the specified db expression.
//
// Returns all collection records if no expressions are provided.
//
// Returns an empty slice if no records are found.
2022-07-06 23:19:05 +02:00
//
// Example:
2023-01-26 00:05:20 +02:00
//
2022-10-30 10:28:14 +02:00
// expr1 := dbx.HashExp{"email": "test@example.com"}
2022-11-13 00:38:18 +02:00
// expr2 := dbx.NewExp("LOWER(username) = {:username}", dbx.Params{"username": "test"})
2022-10-30 10:28:14 +02:00
// dao.FindRecordsByExpr("example", expr1, expr2)
func ( dao * Dao ) FindRecordsByExpr ( collectionNameOrId string , exprs ... dbx . Expression ) ( [ ] * models . Record , error ) {
2023-07-18 12:41:14 +02:00
query := dao . RecordQuery ( collectionNameOrId )
2022-07-06 23:19:05 +02:00
2022-10-30 10:28:14 +02:00
// add only the non-nil expressions
for _ , expr := range exprs {
if expr != nil {
query . AndWhere ( expr )
}
}
2022-07-06 23:19:05 +02:00
2023-02-21 16:38:12 +02:00
var records [ ] * models . Record
2022-10-30 10:28:14 +02:00
2023-02-21 16:38:12 +02:00
if err := query . All ( & records ) ; err != nil {
2022-07-06 23:19:05 +02:00
return nil , err
}
2023-02-21 16:38:12 +02:00
return records , nil
2022-07-06 23:19:05 +02:00
}
// FindFirstRecordByData returns the first found record matching
// the provided key-value pair.
2023-02-18 19:33:42 +02:00
func ( dao * Dao ) FindFirstRecordByData (
collectionNameOrId string ,
key string ,
value any ,
) ( * models . Record , error ) {
2023-02-21 16:38:12 +02:00
record := & models . Record { }
2022-07-06 23:19:05 +02:00
2023-07-18 12:41:14 +02:00
err := dao . RecordQuery ( collectionNameOrId ) .
2022-10-30 10:28:14 +02:00
AndWhere ( dbx . HashExp { inflector . Columnify ( key ) : value } ) .
2022-07-06 23:19:05 +02:00
Limit ( 1 ) .
2023-02-21 16:38:12 +02:00
One ( record )
2022-07-06 23:19:05 +02:00
if err != nil {
return nil , err
}
2023-02-21 16:38:12 +02:00
return record , nil
2022-07-06 23:19:05 +02:00
}
2023-05-31 10:47:16 +02:00
// FindRecordsByFilter returns limit number of records matching the
// provided string filter.
//
2023-08-18 05:31:14 +02:00
// NB! Use the last "params" argument to bind untrusted user variables!
//
2023-05-31 10:47:16 +02:00
// The sort argument is optional and can be empty string OR the same format
// used in the web APIs, eg. "-created,title".
//
// If the limit argument is <= 0, no limit is applied to the query and
// all matching records are returned.
//
// Example:
//
2023-08-18 05:31:14 +02:00
// dao.FindRecordsByFilter(
// "posts",
// "title ~ {:title} && visible = {:visible}",
// "-created",
// 10,
// 0,
// dbx.Params{"title": "lorem ipsum", "visible": true}
// )
2023-05-31 10:47:16 +02:00
func ( dao * Dao ) FindRecordsByFilter (
collectionNameOrId string ,
filter string ,
sort string ,
limit int ,
2023-08-18 05:31:14 +02:00
offset int ,
params ... dbx . Params ,
2023-05-31 10:47:16 +02:00
) ( [ ] * models . Record , error ) {
collection , err := dao . FindCollectionByNameOrId ( collectionNameOrId )
if err != nil {
return nil , err
}
q := dao . RecordQuery ( collection )
// build a fields resolver and attach the generated conditions to the query
// ---
resolver := resolvers . NewRecordFieldResolver (
dao ,
collection , // the base collection
nil , // no request data
true , // allow searching hidden/protected fields like "email"
)
2023-08-18 05:31:14 +02:00
expr , err := search . FilterData ( filter ) . BuildExpr ( resolver , params ... )
2023-05-31 10:47:16 +02:00
if err != nil || expr == nil {
return nil , errors . New ( "invalid or empty filter expression" )
}
q . AndWhere ( expr )
if sort != "" {
for _ , sortField := range search . ParseSortFromString ( sort ) {
expr , err := sortField . BuildExpr ( resolver )
if err != nil {
return nil , err
}
if expr != "" {
q . AndOrderBy ( expr )
}
}
}
resolver . UpdateQuery ( q ) // attaches any adhoc joins and aliases
// ---
2023-08-18 05:31:14 +02:00
if offset > 0 {
q . Offset ( int64 ( offset ) )
}
2023-05-31 10:47:16 +02:00
if limit > 0 {
q . Limit ( int64 ( limit ) )
}
records := [ ] * models . Record { }
if err := q . All ( & records ) ; err != nil {
return nil , err
}
return records , nil
}
// FindFirstRecordByFilter returns the first available record matching the provided filter.
//
2023-08-18 05:31:14 +02:00
// NB! Use the last params argument to bind untrusted user variables!
2023-07-08 13:01:02 +02:00
//
2023-05-31 10:47:16 +02:00
// Example:
//
2023-08-18 05:31:14 +02:00
// dao.FindFirstRecordByFilter("posts", "slug={:slug} && status='public'", dbx.Params{"slug": "test"})
func ( dao * Dao ) FindFirstRecordByFilter (
collectionNameOrId string ,
filter string ,
params ... dbx . Params ,
) ( * models . Record , error ) {
result , err := dao . FindRecordsByFilter ( collectionNameOrId , filter , "" , 1 , 0 , params ... )
2023-05-31 10:47:16 +02:00
if err != nil {
return nil , err
}
if len ( result ) == 0 {
return nil , sql . ErrNoRows
}
return result [ 0 ] , nil
}
2022-07-06 23:19:05 +02:00
// IsRecordValueUnique checks if the provided key-value pair is a unique Record value.
//
2022-10-30 10:28:14 +02:00
// For correctness, if the collection is "auth" and the key is "username",
// the unique check will be case insensitive.
//
2022-07-06 23:19:05 +02:00
// NB! Array values (eg. from multiple select fields) are matched
// as a serialized json strings (eg. `["a","b"]`), so the value uniqueness
// depends on the elements order. Or in other words the following values
// are considered different: `[]string{"a","b"}` and `[]string{"b","a"}`
func ( dao * Dao ) IsRecordValueUnique (
2022-10-30 10:28:14 +02:00
collectionNameOrId string ,
2022-07-06 23:19:05 +02:00
key string ,
value any ,
2022-10-30 10:28:14 +02:00
excludeIds ... string ,
2022-07-06 23:19:05 +02:00
) bool {
2022-10-30 10:28:14 +02:00
collection , err := dao . FindCollectionByNameOrId ( collectionNameOrId )
if err != nil {
return false
}
2022-07-06 23:19:05 +02:00
2022-10-30 10:28:14 +02:00
var expr dbx . Expression
if collection . IsAuth ( ) && key == schema . FieldNameUsername {
expr = dbx . NewExp ( "LOWER([[" + schema . FieldNameUsername + "]])={:username}" , dbx . Params {
"username" : strings . ToLower ( cast . ToString ( value ) ) ,
} )
} else {
var normalizedVal any
switch val := value . ( type ) {
case [ ] string :
2023-03-22 17:12:44 +02:00
normalizedVal = append ( types . JsonArray [ string ] { } , val ... )
2022-10-30 10:28:14 +02:00
case [ ] any :
2023-03-22 17:12:44 +02:00
normalizedVal = append ( types . JsonArray [ any ] { } , val ... )
2022-10-30 10:28:14 +02:00
default :
normalizedVal = val
}
expr = dbx . HashExp { inflector . Columnify ( key ) : normalizedVal }
2022-07-06 23:19:05 +02:00
}
2022-10-30 10:28:14 +02:00
query := dao . RecordQuery ( collection ) .
2022-07-06 23:19:05 +02:00
Select ( "count(*)" ) .
2022-10-30 10:28:14 +02:00
AndWhere ( expr ) .
Limit ( 1 )
2023-01-07 22:25:56 +02:00
if uniqueExcludeIds := list . NonzeroUniques ( excludeIds ) ; len ( uniqueExcludeIds ) > 0 {
2022-10-30 10:28:14 +02:00
query . AndWhere ( dbx . NotIn ( collection . Name + ".id" , list . ToInterfaceSlice ( uniqueExcludeIds ) ... ) )
}
2022-07-06 23:19:05 +02:00
2022-10-30 10:28:14 +02:00
var exists bool
return query . Row ( & exists ) == nil && ! exists
2022-07-06 23:19:05 +02:00
}
2023-12-29 21:25:32 +02:00
// FindAuthRecordByToken finds the auth record associated with the provided JWT.
2022-10-30 10:28:14 +02:00
//
2023-12-29 21:25:32 +02:00
// Returns an error if the JWT is invalid, expired or not associated to an auth collection record.
2022-10-30 10:28:14 +02:00
func ( dao * Dao ) FindAuthRecordByToken ( token string , baseTokenKey string ) ( * models . Record , error ) {
unverifiedClaims , err := security . ParseUnverifiedJWT ( token )
if err != nil {
return nil , err
}
// check required claims
id , _ := unverifiedClaims [ "id" ] . ( string )
collectionId , _ := unverifiedClaims [ "collectionId" ] . ( string )
if id == "" || collectionId == "" {
2022-12-18 14:06:48 +02:00
return nil , errors . New ( "missing or invalid token claims" )
2022-07-19 12:09:54 +02:00
}
2022-10-30 10:28:14 +02:00
record , err := dao . FindRecordById ( collectionId , id )
2022-07-06 23:19:05 +02:00
if err != nil {
return nil , err
}
2022-10-30 10:28:14 +02:00
if ! record . Collection ( ) . IsAuth ( ) {
return nil , errors . New ( "The token is not associated to an auth collection record." )
}
2022-07-06 23:19:05 +02:00
2022-10-30 10:28:14 +02:00
verificationKey := record . TokenKey ( ) + baseTokenKey
2022-07-06 23:19:05 +02:00
2022-10-30 10:28:14 +02:00
// verify token signature
if _ , err := security . ParseJWT ( token , verificationKey ) ; err != nil {
return nil , err
}
2022-07-06 23:19:05 +02:00
2022-10-30 10:28:14 +02:00
return record , nil
}
// FindAuthRecordByEmail finds the auth record associated with the provided email.
//
// Returns an error if it is not an auth collection or the record is not found.
func ( dao * Dao ) FindAuthRecordByEmail ( collectionNameOrId string , email string ) ( * models . Record , error ) {
collection , err := dao . FindCollectionByNameOrId ( collectionNameOrId )
2022-12-18 14:06:48 +02:00
if err != nil {
return nil , fmt . Errorf ( "failed to fetch auth collection %q (%w)" , collectionNameOrId , err )
}
if ! collection . IsAuth ( ) {
return nil , fmt . Errorf ( "%q is not an auth collection" , collectionNameOrId )
2022-10-30 10:28:14 +02:00
}
2023-02-21 16:38:12 +02:00
record := & models . Record { }
2022-10-30 10:28:14 +02:00
err = dao . RecordQuery ( collection ) .
AndWhere ( dbx . HashExp { schema . FieldNameEmail : email } ) .
Limit ( 1 ) .
2023-02-21 16:38:12 +02:00
One ( record )
2022-10-30 10:28:14 +02:00
if err != nil {
return nil , err
}
2023-02-21 16:38:12 +02:00
return record , nil
2022-10-30 10:28:14 +02:00
}
// FindAuthRecordByUsername finds the auth record associated with the provided username (case insensitive).
//
// Returns an error if it is not an auth collection or the record is not found.
func ( dao * Dao ) FindAuthRecordByUsername ( collectionNameOrId string , username string ) ( * models . Record , error ) {
collection , err := dao . FindCollectionByNameOrId ( collectionNameOrId )
2022-12-18 14:06:48 +02:00
if err != nil {
return nil , fmt . Errorf ( "failed to fetch auth collection %q (%w)" , collectionNameOrId , err )
}
if ! collection . IsAuth ( ) {
return nil , fmt . Errorf ( "%q is not an auth collection" , collectionNameOrId )
2022-10-30 10:28:14 +02:00
}
2023-02-21 16:38:12 +02:00
record := & models . Record { }
2022-07-06 23:19:05 +02:00
2022-10-30 10:28:14 +02:00
err = dao . RecordQuery ( collection ) .
AndWhere ( dbx . NewExp ( "LOWER([[" + schema . FieldNameUsername + "]])={:username}" , dbx . Params {
"username" : strings . ToLower ( username ) ,
} ) ) .
Limit ( 1 ) .
2023-02-21 16:38:12 +02:00
One ( record )
2022-10-30 10:28:14 +02:00
if err != nil {
return nil , err
2022-07-06 23:19:05 +02:00
}
2023-02-21 16:38:12 +02:00
return record , nil
2022-10-30 10:28:14 +02:00
}
// SuggestUniqueAuthRecordUsername checks if the provided username is unique
// and return a new "unique" username with appended random numeric part
// (eg. "existingName" -> "existingName583").
//
// The same username will be returned if the provided string is already unique.
func ( dao * Dao ) SuggestUniqueAuthRecordUsername (
collectionNameOrId string ,
baseUsername string ,
excludeIds ... string ,
) string {
username := baseUsername
for i := 0 ; i < 10 ; i ++ { // max 10 attempts
isUnique := dao . IsRecordValueUnique (
collectionNameOrId ,
schema . FieldNameUsername ,
username ,
excludeIds ... ,
)
if isUnique {
break // already unique
}
username = baseUsername + security . RandomStringWithAlphabet ( 3 + i , "123456789" )
}
return username
2022-07-06 23:19:05 +02:00
}
2023-06-14 12:13:21 +02:00
// CanAccessRecord checks if a record is allowed to be accessed by the
2023-07-17 22:13:39 +02:00
// specified requestInfo and accessRule.
2023-06-14 12:13:21 +02:00
//
2023-07-17 22:13:39 +02:00
// Rule and db checks are ignored in case requestInfo.Admin is set.
2023-07-11 10:50:10 +02:00
//
// The returned error indicate that something unexpected happened during
// the check (eg. invalid rule or db error).
//
// The method always return false on invalid access rule or db error.
2023-06-14 12:13:21 +02:00
//
// Example:
//
2023-07-17 22:13:39 +02:00
// requestInfo := apis.RequestInfo(c /* echo.Context */)
2023-06-14 12:13:21 +02:00
// record, _ := dao.FindRecordById("example", "RECORD_ID")
// rule := types.Pointer("@request.auth.id != '' || status = 'public'")
2023-07-11 10:50:10 +02:00
// // ... or use one of the record collection's rule, eg. record.Collection().ViewRule
2023-06-14 12:13:21 +02:00
//
2023-07-17 22:13:39 +02:00
// if ok, _ := dao.CanAccessRecord(record, requestInfo, rule); ok { ... }
func ( dao * Dao ) CanAccessRecord ( record * models . Record , requestInfo * models . RequestInfo , accessRule * string ) ( bool , error ) {
if requestInfo . Admin != nil {
2023-06-14 12:13:21 +02:00
// admins can access everything
2023-07-11 10:50:10 +02:00
return true , nil
2023-06-14 12:13:21 +02:00
}
if accessRule == nil {
// only admins can access this record
2023-07-11 10:50:10 +02:00
return false , nil
2023-06-14 12:13:21 +02:00
}
if * accessRule == "" {
2023-07-11 10:50:10 +02:00
// empty public rule, aka. everyone can access
return true , nil
2023-06-14 12:13:21 +02:00
}
var exists bool
query := dao . RecordQuery ( record . Collection ( ) ) .
Select ( "(1)" ) .
AndWhere ( dbx . HashExp { record . Collection ( ) . Name + ".id" : record . Id } )
// parse and apply the access rule filter
2023-07-17 22:13:39 +02:00
resolver := resolvers . NewRecordFieldResolver ( dao , record . Collection ( ) , requestInfo , true )
2023-06-14 12:13:21 +02:00
expr , err := search . FilterData ( * accessRule ) . BuildExpr ( resolver )
if err != nil {
2023-07-11 10:50:10 +02:00
return false , err
2023-06-14 12:13:21 +02:00
}
resolver . UpdateQuery ( query )
query . AndWhere ( expr )
2023-07-11 10:50:10 +02:00
if err := query . Limit ( 1 ) . Row ( & exists ) ; err != nil && ! errors . Is ( err , sql . ErrNoRows ) {
return false , err
2023-06-14 12:13:21 +02:00
}
2023-07-11 10:50:10 +02:00
return exists , nil
2023-06-14 12:13:21 +02:00
}
2023-03-30 20:19:14 +02:00
// SaveRecord persists the provided Record model in the database.
//
// If record.IsNew() is true, the method will perform a create, otherwise an update.
// To explicitly mark a record for update you can use record.MarkAsNotNew().
2022-07-06 23:19:05 +02:00
func ( dao * Dao ) SaveRecord ( record * models . Record ) error {
2022-10-30 10:28:14 +02:00
if record . Collection ( ) . IsAuth ( ) {
if record . Username ( ) == "" {
2022-12-06 12:26:29 +02:00
return errors . New ( "unable to save auth record without username" )
2022-10-30 10:28:14 +02:00
}
// Cross-check that the auth record id is unique for all auth collections.
// This is to make sure that the filter `@request.auth.id` always returns a unique id.
authCollections , err := dao . FindCollectionsByType ( models . CollectionTypeAuth )
if err != nil {
2022-12-06 12:26:29 +02:00
return fmt . Errorf ( "unable to fetch the auth collections for cross-id unique check: %w" , err )
2022-10-30 10:28:14 +02:00
}
for _ , collection := range authCollections {
if record . Collection ( ) . Id == collection . Id {
continue // skip current collection (sqlite will do the check for us)
}
isUnique := dao . IsRecordValueUnique ( collection . Id , schema . FieldNameId , record . Id )
if ! isUnique {
2022-12-06 12:26:29 +02:00
return errors . New ( "the auth record ID must be unique across all auth collections" )
2022-10-30 10:28:14 +02:00
}
}
}
2022-07-06 23:19:05 +02:00
return dao . Save ( record )
}
// DeleteRecord deletes the provided Record model.
//
// This method will also cascade the delete operation to all linked
2022-12-13 09:08:54 +02:00
// relational records (delete or unset, depending on the rel settings).
2022-07-06 23:19:05 +02:00
//
// The delete operation may fail if the record is part of a required
2022-12-13 09:08:54 +02:00
// reference in another record (aka. cannot be deleted or unset).
2022-07-06 23:19:05 +02:00
func ( dao * Dao ) DeleteRecord ( record * models . Record ) error {
2022-12-08 10:40:42 +02:00
// fetch rel references (if any)
//
// note: the select is outside of the transaction to minimize
// SQLITE_BUSY errors when mixing read&write in a single transaction
refs , err := dao . FindCollectionReferences ( record . Collection ( ) )
if err != nil {
return err
2022-07-06 23:19:05 +02:00
}
2022-12-08 10:40:42 +02:00
return dao . RunInTransaction ( func ( txDao * Dao ) error {
2022-12-12 19:19:31 +02:00
// manually trigger delete on any linked external auth to ensure
2022-12-15 16:42:35 +02:00
// that the `OnModel*` hooks are triggered
2022-12-12 19:19:31 +02:00
if record . Collection ( ) . IsAuth ( ) {
2022-12-15 16:42:35 +02:00
// note: the select is outside of the transaction to minimize
// SQLITE_BUSY errors when mixing read&write in a single transaction
2022-12-12 19:19:31 +02:00
externalAuths , err := dao . FindAllExternalAuthsByRecord ( record )
if err != nil {
return err
}
for _ , auth := range externalAuths {
if err := txDao . DeleteExternalAuth ( auth ) ; err != nil {
return err
}
}
}
// delete the record before the relation references to ensure that there
// will be no "A<->B" relations to prevent deadlock when calling DeleteRecord recursively
2022-12-09 01:49:17 +02:00
if err := txDao . Delete ( record ) ; err != nil {
return err
}
2022-12-12 19:19:31 +02:00
return txDao . cascadeRecordDelete ( record , refs )
} )
}
2023-01-12 15:20:51 +02:00
// cascadeRecordDelete triggers cascade deletion for the provided references.
2022-12-12 19:19:31 +02:00
//
// NB! This method is expected to be called inside a transaction.
func ( dao * Dao ) cascadeRecordDelete ( mainRecord * models . Record , refs map [ * models . Collection ] [ ] * schema . SchemaField ) error {
uniqueJsonEachAlias := "__je__" + security . PseudorandomString ( 4 )
2024-01-03 04:29:30 +02:00
// @todo consider changing refs to a slice
//
// Sort the refs keys to ensure that the cascade events firing order is always the same.
// This is not necessary for the operation to function correctly but it helps having deterministic output during testing.
sortedRefKeys := make ( [ ] * models . Collection , 0 , len ( refs ) )
for k := range refs {
sortedRefKeys = append ( sortedRefKeys , k )
}
sort . Slice ( sortedRefKeys , func ( i , j int ) bool {
return sortedRefKeys [ i ] . Name < sortedRefKeys [ j ] . Name
} )
for _ , refCollection := range sortedRefKeys {
fields , ok := refs [ refCollection ]
if refCollection . IsView ( ) || ! ok {
continue // skip missing or view collections
2023-02-18 19:33:42 +02:00
}
2022-12-12 19:19:31 +02:00
for _ , field := range fields {
recordTableName := inflector . Columnify ( refCollection . Name )
prefixedFieldName := recordTableName + "." + inflector . Columnify ( field . Name )
2023-01-12 15:20:51 +02:00
2023-03-07 23:28:35 +02:00
query := dao . RecordQuery ( refCollection ) . Distinct ( true )
if opt , ok := field . Options . ( schema . MultiValuer ) ; ! ok || ! opt . IsMultiple ( ) {
query . AndWhere ( dbx . HashExp { prefixedFieldName : mainRecord . Id } )
} else {
query . InnerJoin ( fmt . Sprintf (
2022-12-12 19:19:31 +02:00
` json_each(CASE WHEN json_valid([[%s]]) THEN [[%s]] ELSE json_array([[%s]]) END) as {{ % s }} ` ,
prefixedFieldName , prefixedFieldName , prefixedFieldName , uniqueJsonEachAlias ,
2023-01-12 15:20:51 +02:00
) , dbx . HashExp { uniqueJsonEachAlias + ".value" : mainRecord . Id } )
2023-03-07 23:28:35 +02:00
}
2022-12-12 19:19:31 +02:00
2023-01-26 00:05:20 +02:00
if refCollection . Id == mainRecord . Collection ( ) . Id {
query . AndWhere ( dbx . Not ( dbx . HashExp { recordTableName + ".id" : mainRecord . Id } ) )
}
2023-01-12 15:20:51 +02:00
// trigger cascade for each batchSize rel items until there is none
batchSize := 4000
rows := make ( [ ] dbx . NullStringMap , 0 , batchSize )
2022-12-12 19:19:31 +02:00
for {
if err := query . Limit ( int64 ( batchSize ) ) . All ( & rows ) ; err != nil {
2022-07-06 23:19:05 +02:00
return err
}
2022-12-11 21:25:31 +02:00
total := len ( rows )
if total == 0 {
2022-12-12 19:19:31 +02:00
break
2022-12-11 21:25:31 +02:00
}
2022-07-06 23:19:05 +02:00
2023-01-12 15:20:51 +02:00
refRecords := models . NewRecordsFromNullStringMaps ( refCollection , rows )
err := dao . deleteRefRecords ( mainRecord , refRecords , field )
if err != nil {
return err
2022-07-06 23:19:05 +02:00
}
2022-12-12 19:19:31 +02:00
if total < batchSize {
break // no more items
}
2023-01-12 15:20:51 +02:00
rows = rows [ : 0 ] // keep allocated memory
2022-10-30 10:28:14 +02:00
}
}
2022-12-12 19:19:31 +02:00
}
2022-10-30 10:28:14 +02:00
2022-12-12 19:19:31 +02:00
return nil
2022-07-06 23:19:05 +02:00
}
2022-12-12 19:19:31 +02:00
// deleteRefRecords checks if related records has to be deleted (if `CascadeDelete` is set)
// OR
// just unset the record id from any relation field values (if they are not required).
//
// NB! This method is expected to be called inside a transaction.
2022-12-11 21:25:31 +02:00
func ( dao * Dao ) deleteRefRecords ( mainRecord * models . Record , refRecords [ ] * models . Record , field * schema . SchemaField ) error {
options , _ := field . Options . ( * schema . RelationOptions )
if options == nil {
return errors . New ( "relation field options are not initialized" )
}
for _ , refRecord := range refRecords {
ids := refRecord . GetStringSlice ( field . Name )
// unset the record id
for i := len ( ids ) - 1 ; i >= 0 ; i -- {
if ids [ i ] == mainRecord . Id {
ids = append ( ids [ : i ] , ids [ i + 1 : ] ... )
break
}
}
// cascade delete the reference
// (only if there are no other active references in case of multiple select)
if options . CascadeDelete && len ( ids ) == 0 {
if err := dao . DeleteRecord ( refRecord ) ; err != nil {
return err
}
// no further actions are needed (the reference is deleted)
continue
}
if field . Required && len ( ids ) == 0 {
2022-12-18 14:06:48 +02:00
return fmt . Errorf ( "the record cannot be deleted because it is part of a required reference in record %s (%s collection)" , refRecord . Id , refRecord . Collection ( ) . Name )
2022-12-11 21:25:31 +02:00
}
// save the reference changes
refRecord . Set ( field . Name , field . PrepareValue ( ids ) )
if err := dao . SaveRecord ( refRecord ) ; err != nil {
return err
}
}
return nil
}