349 lines
		
	
	
		
			9.7 KiB
		
	
	
	
		
			Go
		
	
	
	
			
		
		
	
	
			349 lines
		
	
	
		
			9.7 KiB
		
	
	
	
		
			Go
		
	
	
	
| package core
 | |
| 
 | |
| import (
 | |
| 	"bytes"
 | |
| 	"database/sql"
 | |
| 	"encoding/json"
 | |
| 	"errors"
 | |
| 	"fmt"
 | |
| 	"strings"
 | |
| 
 | |
| 	"github.com/pocketbase/dbx"
 | |
| 	"github.com/pocketbase/pocketbase/tools/list"
 | |
| )
 | |
| 
 | |
| const StoreKeyCachedCollections = "pbAppCachedCollections"
 | |
| 
 | |
| // CollectionQuery returns a new Collection select query.
 | |
| func (app *BaseApp) CollectionQuery() *dbx.SelectQuery {
 | |
| 	return app.ModelQuery(&Collection{})
 | |
| }
 | |
| 
 | |
| // FindCollections finds all collections by the given type(s).
 | |
| //
 | |
| // If collectionTypes is not set, it returns all collections.
 | |
| //
 | |
| // Example:
 | |
| //
 | |
| //	app.FindAllCollections() // all collections
 | |
| //	app.FindAllCollections("auth", "view") // only auth and view collections
 | |
| func (app *BaseApp) FindAllCollections(collectionTypes ...string) ([]*Collection, error) {
 | |
| 	collections := []*Collection{}
 | |
| 
 | |
| 	q := app.CollectionQuery()
 | |
| 
 | |
| 	types := list.NonzeroUniques(collectionTypes)
 | |
| 	if len(types) > 0 {
 | |
| 		q.AndWhere(dbx.In("type", list.ToInterfaceSlice(types)...))
 | |
| 	}
 | |
| 
 | |
| 	err := q.OrderBy("created ASC").All(&collections)
 | |
| 	if err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 
 | |
| 	return collections, nil
 | |
| }
 | |
| 
 | |
| // ReloadCachedCollections fetches all collections and caches them into the app store.
 | |
| func (app *BaseApp) ReloadCachedCollections() error {
 | |
| 	collections, err := app.FindAllCollections()
 | |
| 	if err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	app.Store().Set(StoreKeyCachedCollections, collections)
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| // FindCollectionByNameOrId finds a single collection by its name (case insensitive) or id.
 | |
| func (app *BaseApp) FindCollectionByNameOrId(nameOrId string) (*Collection, error) {
 | |
| 	m := &Collection{}
 | |
| 
 | |
| 	err := app.CollectionQuery().
 | |
| 		AndWhere(dbx.NewExp("[[id]]={:id} OR LOWER([[name]])={:name}", dbx.Params{
 | |
| 			"id":   nameOrId,
 | |
| 			"name": strings.ToLower(nameOrId),
 | |
| 		})).
 | |
| 		Limit(1).
 | |
| 		One(m)
 | |
| 	if err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 
 | |
| 	return m, nil
 | |
| }
 | |
| 
 | |
| // FindCachedCollectionByNameOrId is similar to [App.FindCollectionByNameOrId]
 | |
| // but retrieves the Collection from the app cache instead of making a db call.
 | |
| //
 | |
| // NB! This method is suitable for read-only Collection operations.
 | |
| //
 | |
| // Returns [sql.ErrNoRows] if no Collection is found for consistency
 | |
| // with the [App.FindCollectionByNameOrId] method.
 | |
| //
 | |
| // If you plan making changes to the returned Collection model,
 | |
| // use [App.FindCollectionByNameOrId] instead.
 | |
| //
 | |
| // Caveats:
 | |
| //
 | |
| //   - The returned Collection should be used only for read-only operations.
 | |
| //     Avoid directly modifying the returned cached Collection as it will affect
 | |
| //     the global cached value even if you don't persist the changes in the database!
 | |
| //   - If you are updating a Collection in a transaction and then call this method before commit,
 | |
| //     it'll return the cached Collection state and not the one from the uncommitted transaction.
 | |
| //   - The cache is automatically updated on collections db change (create/update/delete).
 | |
| //     To manually reload the cache you can call [App.ReloadCachedCollections()]
 | |
| func (app *BaseApp) FindCachedCollectionByNameOrId(nameOrId string) (*Collection, error) {
 | |
| 	collections, _ := app.Store().Get(StoreKeyCachedCollections).([]*Collection)
 | |
| 	if collections == nil {
 | |
| 		// cache is not initialized yet (eg. run in a system migration)
 | |
| 		return app.FindCollectionByNameOrId(nameOrId)
 | |
| 	}
 | |
| 
 | |
| 	for _, c := range collections {
 | |
| 		if strings.EqualFold(c.Name, nameOrId) || c.Id == nameOrId {
 | |
| 			return c, nil
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	return nil, sql.ErrNoRows
 | |
| }
 | |
| 
 | |
| // IsCollectionNameUnique checks that there is no existing collection
 | |
| // with the provided name (case insensitive!).
 | |
| //
 | |
| // Note: case insensitive check because the name is used also as
 | |
| // table name for the records.
 | |
| func (app *BaseApp) IsCollectionNameUnique(name string, excludeIds ...string) bool {
 | |
| 	if name == "" {
 | |
| 		return false
 | |
| 	}
 | |
| 
 | |
| 	query := app.CollectionQuery().
 | |
| 		Select("count(*)").
 | |
| 		AndWhere(dbx.NewExp("LOWER([[name]])={:name}", dbx.Params{"name": strings.ToLower(name)})).
 | |
| 		Limit(1)
 | |
| 
 | |
| 	if uniqueExcludeIds := list.NonzeroUniques(excludeIds); len(uniqueExcludeIds) > 0 {
 | |
| 		query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
 | |
| 	}
 | |
| 
 | |
| 	var exists bool
 | |
| 
 | |
| 	return query.Row(&exists) == nil && !exists
 | |
| }
 | |
| 
 | |
| // FindCollectionReferences returns information for all relation fields
 | |
| // referencing the provided collection.
 | |
| //
 | |
| // If the provided collection has reference to itself then it will be
 | |
| // also included in the result. To exclude it, pass the collection id
 | |
| // as the excludeIds argument.
 | |
| func (app *BaseApp) FindCollectionReferences(collection *Collection, excludeIds ...string) (map[*Collection][]Field, error) {
 | |
| 	collections := []*Collection{}
 | |
| 
 | |
| 	query := app.CollectionQuery()
 | |
| 
 | |
| 	if uniqueExcludeIds := list.NonzeroUniques(excludeIds); len(uniqueExcludeIds) > 0 {
 | |
| 		query.AndWhere(dbx.NotIn("id", list.ToInterfaceSlice(uniqueExcludeIds)...))
 | |
| 	}
 | |
| 
 | |
| 	if err := query.All(&collections); err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 
 | |
| 	result := map[*Collection][]Field{}
 | |
| 
 | |
| 	for _, c := range collections {
 | |
| 		for _, rawField := range c.Fields {
 | |
| 			f, ok := rawField.(*RelationField)
 | |
| 			if ok && f.CollectionId == collection.Id {
 | |
| 				result[c] = append(result[c], f)
 | |
| 			}
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	return result, nil
 | |
| }
 | |
| 
 | |
| // TruncateCollection deletes all records associated with the provided collection.
 | |
| //
 | |
| // The truncate operation is executed in a single transaction,
 | |
| // aka. either everything is deleted or none.
 | |
| //
 | |
| // Note that this method will also trigger the records related
 | |
| // cascade and file delete actions.
 | |
| func (app *BaseApp) TruncateCollection(collection *Collection) error {
 | |
| 	if collection.IsView() {
 | |
| 		return errors.New("view collections cannot be truncated since they don't store their own records.")
 | |
| 	}
 | |
| 
 | |
| 	return app.RunInTransaction(func(txApp App) error {
 | |
| 		records := make([]*Record, 0, 500)
 | |
| 
 | |
| 		for {
 | |
| 			err := txApp.RecordQuery(collection).Limit(500).All(&records)
 | |
| 			if err != nil {
 | |
| 				return err
 | |
| 			}
 | |
| 
 | |
| 			if len(records) == 0 {
 | |
| 				return nil
 | |
| 			}
 | |
| 
 | |
| 			for _, record := range records {
 | |
| 				err = txApp.Delete(record)
 | |
| 				if err != nil && !errors.Is(err, sql.ErrNoRows) {
 | |
| 					return err
 | |
| 				}
 | |
| 			}
 | |
| 
 | |
| 			records = records[:0]
 | |
| 		}
 | |
| 	})
 | |
| }
 | |
| 
 | |
| // -------------------------------------------------------------------
 | |
| 
 | |
| // saveViewCollection persists the provided View collection changes:
 | |
| //   - deletes the old related SQL view (if any)
 | |
| //   - creates a new SQL view with the latest newCollection.Options.Query
 | |
| //   - generates new feilds list  based on newCollection.Options.Query
 | |
| //   - updates newCollection.Fields based on the generated view table info and query
 | |
| //   - saves the newCollection
 | |
| //
 | |
| // This method returns an error if newCollection is not a "view".
 | |
| func saveViewCollection(app App, newCollection, oldCollection *Collection) error {
 | |
| 	if !newCollection.IsView() {
 | |
| 		return errors.New("not a view collection")
 | |
| 	}
 | |
| 
 | |
| 	return app.RunInTransaction(func(txApp App) error {
 | |
| 		query := newCollection.ViewQuery
 | |
| 
 | |
| 		// generate collection fields from the query
 | |
| 		viewFields, err := txApp.CreateViewFields(query)
 | |
| 		if err != nil {
 | |
| 			return err
 | |
| 		}
 | |
| 
 | |
| 		// delete old renamed view
 | |
| 		if oldCollection != nil {
 | |
| 			if err := txApp.DeleteView(oldCollection.Name); err != nil {
 | |
| 				return err
 | |
| 			}
 | |
| 		}
 | |
| 
 | |
| 		// wrap view query if necessary
 | |
| 		query, err = normalizeViewQueryId(txApp, query)
 | |
| 		if err != nil {
 | |
| 			return fmt.Errorf("failed to normalize view query id: %w", err)
 | |
| 		}
 | |
| 
 | |
| 		// (re)create the view
 | |
| 		if err := txApp.SaveView(newCollection.Name, query); err != nil {
 | |
| 			return err
 | |
| 		}
 | |
| 
 | |
| 		newCollection.Fields = viewFields
 | |
| 
 | |
| 		return txApp.Save(newCollection)
 | |
| 	})
 | |
| }
 | |
| 
 | |
| // normalizeViewQueryId wraps (if necessary) the provided view query
 | |
| // with a subselect to ensure that the id column is a text since
 | |
| // currently we don't support non-string model ids
 | |
| // (see https://github.com/pocketbase/pocketbase/issues/3110).
 | |
| func normalizeViewQueryId(app App, query string) (string, error) {
 | |
| 	query = strings.Trim(strings.TrimSpace(query), ";")
 | |
| 
 | |
| 	info, err := getQueryTableInfo(app, query)
 | |
| 	if err != nil {
 | |
| 		return "", err
 | |
| 	}
 | |
| 
 | |
| 	for _, row := range info {
 | |
| 		if strings.EqualFold(row.Name, FieldNameId) && strings.EqualFold(row.Type, "TEXT") {
 | |
| 			return query, nil // no wrapping needed
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	// raw parse to preserve the columns order
 | |
| 	rawParsed := new(identifiersParser)
 | |
| 	if err := rawParsed.parse(query); err != nil {
 | |
| 		return "", err
 | |
| 	}
 | |
| 
 | |
| 	columns := make([]string, 0, len(rawParsed.columns))
 | |
| 	for _, col := range rawParsed.columns {
 | |
| 		if col.alias == FieldNameId {
 | |
| 			columns = append(columns, fmt.Sprintf("CAST([[%s]] as TEXT) [[%s]]", col.alias, col.alias))
 | |
| 		} else {
 | |
| 			columns = append(columns, "[["+col.alias+"]]")
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	query = fmt.Sprintf("SELECT %s FROM (%s)", strings.Join(columns, ","), query)
 | |
| 
 | |
| 	return query, nil
 | |
| }
 | |
| 
 | |
| // resaveViewsWithChangedFields updates all view collections with changed fields.
 | |
| func resaveViewsWithChangedFields(app App, excludeIds ...string) error {
 | |
| 	collections, err := app.FindAllCollections(CollectionTypeView)
 | |
| 	if err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	return app.RunInTransaction(func(txApp App) error {
 | |
| 		for _, collection := range collections {
 | |
| 			if len(excludeIds) > 0 && list.ExistInSlice(collection.Id, excludeIds) {
 | |
| 				continue
 | |
| 			}
 | |
| 
 | |
| 			// clone the existing fields for temp modifications
 | |
| 			oldFields, err := collection.Fields.Clone()
 | |
| 			if err != nil {
 | |
| 				return err
 | |
| 			}
 | |
| 
 | |
| 			// generate new fields from the query
 | |
| 			newFields, err := txApp.CreateViewFields(collection.ViewQuery)
 | |
| 			if err != nil {
 | |
| 				return err
 | |
| 			}
 | |
| 
 | |
| 			// unset the fields' ids to exclude from the comparison
 | |
| 			for _, f := range oldFields {
 | |
| 				f.SetId("")
 | |
| 			}
 | |
| 			for _, f := range newFields {
 | |
| 				f.SetId("")
 | |
| 			}
 | |
| 
 | |
| 			encodedNewFields, err := json.Marshal(newFields)
 | |
| 			if err != nil {
 | |
| 				return err
 | |
| 			}
 | |
| 
 | |
| 			encodedOldFields, err := json.Marshal(oldFields)
 | |
| 			if err != nil {
 | |
| 				return err
 | |
| 			}
 | |
| 
 | |
| 			if bytes.EqualFold(encodedNewFields, encodedOldFields) {
 | |
| 				continue // no changes
 | |
| 			}
 | |
| 
 | |
| 			if err := saveViewCollection(txApp, collection, nil); err != nil {
 | |
| 				return err
 | |
| 			}
 | |
| 		}
 | |
| 
 | |
| 		return nil
 | |
| 	})
 | |
| }
 |