Files
2026-07-13 12:31:02 +08:00

857 lines
23 KiB
Go

package core
import (
"database/sql"
"errors"
"fmt"
"io"
"regexp"
"slices"
"strings"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/tools/dbutils"
"github.com/pocketbase/pocketbase/tools/inflector"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/pocketbase/pocketbase/tools/sort"
"github.com/pocketbase/pocketbase/tools/tokenizer"
)
// DeleteView drops the specified view name.
//
// This method is a no-op if a view with the provided name doesn't exist.
//
// NB! Be aware that this method is vulnerable to SQL injection and the
// "name" argument must come only from trusted input!
func (app *BaseApp) DeleteView(name string) error {
_, err := app.DB().NewQuery(fmt.Sprintf(
"DROP VIEW IF EXISTS {{%s}}",
name,
)).Execute()
return err
}
// SaveView creates (or updates already existing) persistent SQL view.
//
// NB! Be aware that this method is vulnerable to SQL injection and the
// "selectQuery" argument must come only from trusted input!
//
// Save view is called by:
// 1. [onCollectionSaveExecute] -> app.SaveView()
// 2. [getQueryTableInfo] -> app.SaveView()
// 3. [onCollectionSaveExecute] -> [resaveViewsWithChangedFields] -> [saveViewCollection] -> app.SaveView()
// 4. [onCollectionDeleteExecute] -> [resaveViewsWithChangedFields] -> [saveViewCollection] -> app.SaveView()
func (app *BaseApp) SaveView(name string, selectQuery string) error {
return app.RunInTransaction(func(txApp App) error {
// PostgreSQL:
// Get dependent views before dropping the view and its dependencies.
// Note:
// 1. Calling `txApp.DeleteView()` will not only drop current view, but also all the dependent views.
// 2. Why do we need to drop all dependent views in PostgreSQL? Because we need to update the view,
// although there is a `CREATE OR REPLACE VIEW` statement, but it forbids us to remove any column
// from the view. So we need to drop the view. And we we drop the view, we also need to drop all
// the dependent views otherwise PostgreSQL will throw an error.
// 3. By calling `findDependentViews()` before dropping the view, we can get all the dependent views
// and recreate them after the current view is recreated.
dependentViews, err := findDependentViews(txApp, name)
if err != nil {
return err
}
for i := len(dependentViews) - 1; i >= 0; i-- {
if err := txApp.DeleteView(dependentViews[i].Name); err != nil {
return fmt.Errorf("failed to drop dependent view %q temporarily while saving view %q: %w", dependentViews[i].Name, name, err)
}
}
// delete old view (if exists)
if err := txApp.DeleteView(name); err != nil {
return err
}
selectQuery = strings.Trim(strings.TrimSpace(selectQuery), ";")
// try to loosely detect multiple inline statements
tk := tokenizer.NewFromString(selectQuery)
tk.Separators(';')
if queryParts, _ := tk.ScanAll(); len(queryParts) > 1 {
return errors.New("multiple statements are not supported")
}
// (re)create the view
//
// note: the query is wrapped in a secondary SELECT as a rudimentary
// measure to discourage multiple inline sql statements execution
viewQuery := fmt.Sprintf("CREATE VIEW {{%s}} AS SELECT * FROM (%s)", name, selectQuery)
if _, err := txApp.DB().NewQuery(viewQuery).Execute(); err != nil {
return err
}
// fetch the view table info to ensure that the view was created
// because missing tables or columns won't return an error
if _, err := txApp.TableInfo(name); err != nil {
// manually cleanup previously created view in case the func
// is called in a nested transaction and the error is discarded
txApp.DeleteView(name)
return err
}
// PostgreSQL:
// recreate the dependent views
for _, dependentView := range dependentViews {
if _, err := txApp.DB().NewQuery(dependentView.SQL).Execute(); err != nil {
return err
}
}
return nil
})
}
// CreateViewFields creates a new FieldsList from the provided select query.
//
// There are some caveats:
// - The select query must have an "id" column.
// - Wildcard ("*") columns are not supported to avoid accidentally leaking sensitive data.
func (app *BaseApp) CreateViewFields(selectQuery string) (FieldsList, error) {
result := NewFieldsList()
suggestedFields, err := parseQueryToFields(app, selectQuery)
if err != nil {
return result, err
}
// note wrap in a transaction in case the selectQuery contains
// multiple statements allowing us to rollback on any error
txErr := app.RunInTransaction(func(txApp App) error {
info, err := getQueryTableInfo(txApp, selectQuery)
if err != nil {
return err
}
var hasId bool
for _, row := range info {
if row.Name == FieldNameId {
hasId = true
}
var field Field
if f, ok := suggestedFields[row.Name]; ok {
field = f.field
} else {
field = defaultViewField(row.Name)
}
result.Add(field)
}
if !hasId {
return errors.New("missing required id column (you can use `(ROW_NUMBER() OVER()) as id` if you don't have one)")
}
return nil
})
return result, txErr
}
// FindRecordByViewFile returns the original Record of the provided view collection file.
func (app *BaseApp) FindRecordByViewFile(viewCollectionModelOrIdentifier any, fileFieldName string, filename string) (*Record, error) {
view, err := getCollectionByModelOrIdentifier(app, viewCollectionModelOrIdentifier)
if err != nil {
return nil, err
}
if !view.IsView() {
return nil, errors.New("not a view collection")
}
var findFirstNonViewQueryFileField func(int) (*queryField, error)
findFirstNonViewQueryFileField = func(level int) (*queryField, error) {
// check the level depth to prevent infinite circular recursion
// (the limit is arbitrary and may change in the future)
if level > 5 {
return nil, errors.New("reached the max recursion level of view collection file field queries")
}
queryFields, err := parseQueryToFields(app, view.ViewQuery)
if err != nil {
return nil, err
}
for _, item := range queryFields {
if item.collection == nil ||
item.original == nil ||
item.field.GetName() != fileFieldName {
continue
}
if item.collection.IsView() {
view = item.collection
fileFieldName = item.original.GetName()
return findFirstNonViewQueryFileField(level + 1)
}
return item, nil
}
return nil, errors.New("no query file field found")
}
qf, err := findFirstNonViewQueryFileField(1)
if err != nil {
return nil, err
}
cleanFieldName := inflector.Columnify(qf.original.GetName())
record := &Record{}
query := app.RecordQuery(qf.collection).Limit(1)
if opt, ok := qf.original.(MultiValuer); !ok || !opt.IsMultiple() {
query.AndWhere(dbx.HashExp{cleanFieldName: filename})
} else {
// Tip: user inner join here is much faster than using subquery by test results in PostgreSQL.
query.InnerJoin(
fmt.Sprintf(`%s as {{_je_file}}`, dbutils.JSONEach(cleanFieldName)),
dbx.HashExp{"_je_file.value": filename},
)
}
if err := query.One(record); err != nil {
return nil, err
}
return record, nil
}
// -------------------------------------------------------------------
// Raw query to schema helpers
// -------------------------------------------------------------------
type queryField struct {
// field is the final resolved field.
field Field
// collection refers to the original field's collection model.
// It could be nil if the found query field is not from a collection
collection *Collection
// original is the original found collection field.
// It could be nil if the found query field is not from a collection
original Field
}
func defaultViewField(name string) Field {
return &JSONField{
Name: name,
MaxSize: 1, // unused for views
}
}
// castRegex extracts casted type from an column expression.
// It supports both the standard `CAST(...AS...)` and the PostgreSQL specific `::` syntax.
// eg:
// 1. (SELECT MAX(id)::TEXT FROM table)::INTEGER --> INTEGER
// 2. CAST(123 AS TEXT) --> TEXT
var castRegex = regexp.MustCompile(`(?is)^cast\s*\([\s\S]*\s+as\s+(\w+)\s*\)$`)
var castRegex2 = regexp.MustCompile(`(?is)^[\s\S]*::(\w+)\s*$`)
func getCastRegex(app App) *regexp.Regexp {
if IsPostgres(app) {
return castRegex
}
return regexp.MustCompile(`(?is)^cast\s*\(.*\s+as\s+(\w+)\s*\)$`)
}
func parseQueryToFields(app App, selectQuery string) (map[string]*queryField, error) {
p := new(identifiersParser)
if err := p.parse(app, selectQuery); err != nil {
return nil, err
}
collections, err := findCollectionsByIdentifiers(app, p.tables)
if err != nil {
return nil, err
}
result := make(map[string]*queryField, len(p.columns))
var mainTable identifier
if len(p.tables) > 0 {
mainTable = p.tables[0]
}
for _, col := range p.columns {
colLower := strings.ToLower(col.original)
// pk (always assume text field for now)
if col.alias == FieldNameId {
result[col.alias] = &queryField{
field: &TextField{
Name: col.alias,
System: true,
Required: true,
PrimaryKey: true,
Pattern: `^[a-z0-9]+$`,
},
}
continue
}
// numeric aggregations
if strings.HasPrefix(colLower, "count(") || strings.HasPrefix(colLower, "total(") {
result[col.alias] = &queryField{
field: &NumberField{
Name: col.alias,
},
}
continue
}
castMatch := getCastRegex(app).FindStringSubmatch(colLower)
if castMatch == nil && IsPostgres(app) {
castMatch = castRegex2.FindStringSubmatch(colLower)
}
// numeric casts
if len(castMatch) == 2 {
if !IsPostgres(app) {
switch castMatch[1] {
case "real", "integer", "int", "decimal", "numeric":
result[col.alias] = &queryField{
field: &NumberField{
Name: col.alias,
},
}
continue
}
} else {
// PostgreSQL:
// Note: PostgreSQL's `double precision` is not supported here because it contains whitespace and won't be catched by the regex. Use `numeric` instead.
switch castMatch[1] {
case "real", "integer", "int", "smallint", "bigint", "decimal", "numeric", "serial", "smallserial", "bigserial", "double":
result[col.alias] = &queryField{
field: &NumberField{
Name: col.alias,
},
}
continue
case "text":
result[col.alias] = &queryField{
field: &TextField{
Name: col.alias,
},
}
continue
case "boolean", "bool":
result[col.alias] = &queryField{
field: &BoolField{
Name: col.alias,
},
}
continue
}
}
}
parts := strings.Split(col.original, ".")
var fieldName string
var collection *Collection
if len(parts) == 2 {
fieldName = parts[1]
collection = collections[parts[0]]
} else {
fieldName = parts[0]
collection = collections[mainTable.alias]
}
// fallback to the default field
if collection == nil {
result[col.alias] = &queryField{
field: defaultViewField(col.alias),
}
continue
}
if fieldName == "*" {
return nil, errors.New("dynamic column names are not supported")
}
// find the first field by name (case insensitive)
var field Field
for _, f := range collection.Fields {
if strings.EqualFold(f.GetName(), fieldName) {
field = f
break
}
}
// fallback to the default field
if field == nil {
result[col.alias] = &queryField{
field: defaultViewField(col.alias),
collection: collection,
}
continue
}
// convert to relation since it is an id reference
if strings.EqualFold(fieldName, FieldNameId) {
result[col.alias] = &queryField{
field: &RelationField{
Name: col.alias,
MaxSelect: 1,
CollectionId: collection.Id,
},
collection: collection,
}
continue
}
// we fetch a brand new collection object to avoid using reflection
// or having a dedicated Clone method for each field type
tempCollection, err := app.FindCollectionByNameOrId(collection.Id)
if err != nil {
return nil, err
}
clone := tempCollection.Fields.GetById(field.GetId())
if clone == nil {
return nil, fmt.Errorf("missing expected field %q (%q) in collection %q", field.GetName(), field.GetId(), tempCollection.Name)
}
// set new random id to prevent duplications if the same field is aliased multiple times
clone.SetId("_clone_" + security.PseudorandomString(4))
clone.SetName(col.alias)
result[col.alias] = &queryField{
original: field,
field: clone,
collection: collection,
}
}
return result, nil
}
func findCollectionsByIdentifiers(app App, tables []identifier) (map[string]*Collection, error) {
names := make([]any, 0, len(tables))
for _, table := range tables {
if strings.Contains(table.alias, "(") {
continue // skip expressions
}
names = append(names, table.original)
}
if len(names) == 0 {
return nil, nil
}
result := make(map[string]*Collection, len(names))
collections := make([]*Collection, 0, len(names))
err := app.CollectionQuery().
AndWhere(dbx.In("name", names...)).
All(&collections)
if err != nil {
return nil, err
}
for _, table := range tables {
for _, collection := range collections {
if collection.Name == table.original {
result[table.alias] = collection
}
}
}
return result, nil
}
// dry run the query and parse the standard column info.
func getQueryTableInfo(app App, selectQuery string) ([]*TableInfoRow, error) {
tempView := "_temp_" + security.PseudorandomString(6)
var info []*TableInfoRow
txErr := app.RunInTransaction(func(txApp App) error {
// create a temp view with the provided query
err := txApp.SaveView(tempView, selectQuery)
if err != nil {
return err
}
// extract the generated view table info
info, err = txApp.TableInfo(tempView)
return errors.Join(err, txApp.DeleteView(tempView))
})
if txErr != nil {
return nil, txErr
}
return info, nil
}
// -------------------------------------------------------------------
// Raw query identifiers parser
// -------------------------------------------------------------------
var (
joinReplaceRegex = regexp.MustCompile(`(?im)\s+(full\s+outer\s+join|left\s+outer\s+join|right\s+outer\s+join|full\s+join|cross\s+join|inner\s+join|outer\s+join|left\s+join|right\s+join|join)\s+?`)
discardReplaceRegex = regexp.MustCompile(`(?im)\s+(where|group\s+by|having|order|limit|with)\s+?`)
commentsReplaceRegex = regexp.MustCompile(`(?m)(\/\*[\s\S]*?\*\/)|(--.+$)`)
)
type identifier struct {
original string
alias string
}
type identifiersParser struct {
columns []identifier
tables []identifier
}
func (p *identifiersParser) parse(app App, selectQuery string) error {
str := strings.Trim(strings.TrimSpace(selectQuery), ";")
str = commentsReplaceRegex.ReplaceAllString(str, " ")
str = joinReplaceRegex.ReplaceAllString(str, " __pb_join__ ")
str = discardReplaceRegex.ReplaceAllString(str, " __pb_discard__ ")
tk := tokenizer.NewFromString(str)
tk.Separators(',', ' ', '\n', '\t')
tk.KeepSeparator(true)
var skip bool
var partType string
var activeBuilder *strings.Builder
var selectParts strings.Builder
var fromParts strings.Builder
var joinParts strings.Builder
for {
token, err := tk.Scan()
if err != nil {
if err != io.EOF {
return err
}
break
}
trimmed := strings.ToLower(strings.TrimSpace(token))
switch trimmed {
case "select":
skip = false
partType = "select"
activeBuilder = &selectParts
case "distinct":
continue // ignore as it is not important for the identifiers parsing
case "from":
skip = false
partType = "from"
activeBuilder = &fromParts
case "__pb_join__":
skip = false
// the previous part was also a join
if partType == "join" {
joinParts.WriteString(",")
}
partType = "join"
activeBuilder = &joinParts
case "__pb_discard__":
// skip following tokens
skip = true
default:
isJoin := partType == "join"
if isJoin && trimmed == "on" {
skip = true
}
if !skip && activeBuilder != nil {
activeBuilder.WriteString(" ")
activeBuilder.WriteString(token)
}
}
}
selects, err := extractIdentifiers(app, selectParts.String())
if err != nil {
return err
}
froms, err := extractIdentifiers(app, fromParts.String())
if err != nil {
return err
}
joins, err := extractIdentifiers(app, joinParts.String())
if err != nil {
return err
}
p.columns = selects
p.tables = froms
p.tables = append(p.tables, joins...)
return nil
}
func extractIdentifiers(app App, rawExpression string) ([]identifier, error) {
rawTk := tokenizer.NewFromString(rawExpression)
rawTk.Separators(',')
rawIdentifiers, err := rawTk.ScanAll()
if err != nil {
return nil, err
}
result := make([]identifier, 0, len(rawIdentifiers))
for _, rawIdentifier := range rawIdentifiers {
tk := tokenizer.NewFromString(rawIdentifier)
tk.Separators(' ', '\n', '\t')
parts, err := tk.ScanAll()
if err != nil {
return nil, err
}
resolved, err := identifierFromParts(app, parts)
if err != nil {
return nil, err
}
result = append(result, resolved)
}
return result, nil
}
func identifierFromParts(app App, parts []string) (identifier, error) {
if !IsPostgres(app) {
return identifierFromParts_sqlite(parts)
}
return identifierFromParts_postgres(parts)
}
// SQLite:
func identifierFromParts_sqlite(parts []string) (identifier, error) {
var result identifier
switch len(parts) {
case 3:
if !strings.EqualFold(parts[1], "as") {
return result, fmt.Errorf(`invalid identifier part - expected "as", got %v`, parts[1])
}
result.original = parts[0]
result.alias = parts[2]
case 2:
result.original = parts[0]
result.alias = parts[1]
case 1:
subParts := strings.Split(parts[0], ".")
result.original = parts[0]
result.alias = subParts[len(subParts)-1]
default:
return result, fmt.Errorf(`invalid identifier parts %v`, parts)
}
result.original = trimRawIdentifier(result.original)
// we trim the single quote even though it is not a valid column quote character
// because SQLite allows it if the context expects an identifier and not string literal
// (https://www.sqlite.org/lang_keywords.html)
result.alias = trimRawIdentifier(result.alias, "'")
return result, nil
}
// PostgreSQL:
func identifierFromParts_postgres(parts []string) (identifier, error) {
if len(parts) == 1 {
// eg: `id`, `table_1.id`
subParts := strings.Split(parts[0], ".")
alias := subParts[len(subParts)-1]
return identifierFromParts_postgres([]string{parts[0], alias})
}
// eg:
// - `count(*) count`
// - `count(*) as count`
// - `ROW_NUMBER() OVER (PARTITION BY id ORDER BY created DESC) row_number`
// - `ROW_NUMBER() OVER (PARTITION BY id ORDER BY created DESC) as row_number`
var result identifier
result.alias = parts[len(parts)-1]
result.original = strings.TrimSuffix(strings.Join(parts[:len(parts)-1], " "), " as")
result.alias = trimRawIdentifier(result.alias)
result.original = trimRawIdentifier(result.original)
return result, nil
}
func trimRawIdentifier(rawIdentifier string, extraTrimChars ...string) string {
trimChars := "`\"[];"
if len(extraTrimChars) > 0 {
trimChars += strings.Join(extraTrimChars, "")
}
parts := strings.Split(rawIdentifier, ".")
for i := range parts {
parts[i] = strings.Trim(parts[i], trimChars)
}
return strings.Join(parts, ".")
}
func findDependentViews(app App, tableOrViewName string) ([]viewDef, error) {
if !IsPostgres(app) {
return nil, nil
}
// Note: this is a PostgreSQL specific query to find all views that depend on the provided view.
// It uses the pg_views system catalog to get the view definition and then filters by the view name.
query := `
select
u.view_name,
u.table_name referenced_table_name,
v.view_definition
from information_schema.view_table_usage u
join information_schema.views v on u.view_schema = v.table_schema
and u.view_name = v.table_name
where u.table_schema = current_schema()
order by u.view_name;
`
var rows []struct {
ViewName string `db:"view_name"`
ReferencedTableName string `db:"referenced_table_name"`
ViewDefinition string `db:"view_definition"`
}
err := app.DB().NewQuery(query).All(&rows)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
// Topological sort this DAG graph
nodes := make(map[string]viewDef)
graph := make(map[string][]string)
for _, row := range rows {
if def, ok := nodes[row.ViewName]; !ok || def.SQL == "[TABLE]" {
nodes[row.ViewName] = viewDef{
Name: row.ViewName,
SQL: fmt.Sprintf(`CREATE VIEW "%s" AS %s`, row.ViewName, row.ViewDefinition),
}
}
if !slices.Contains(graph[row.ReferencedTableName], row.ViewName) {
graph[row.ReferencedTableName] = append(graph[row.ReferencedTableName], row.ViewName)
if _, ok := nodes[row.ReferencedTableName]; !ok {
nodes[row.ReferencedTableName] = viewDef{
Name: row.ReferencedTableName,
SQL: "[TABLE]",
}
}
}
}
// No one depends on the provided view
if _, ok := graph[tableOrViewName]; !ok {
return nil, nil
}
sorted, err := sort.TopologicalSortReachable(nodes, graph, tableOrViewName)
if err != nil {
return nil, err
}
// exclude self from the sorted result.
return sorted[1:], nil
}
func findAllViewsInDependencyOrder(app App) ([]viewDef, error) {
if !IsPostgres(app) {
return nil, nil
}
// Note: this is a PostgreSQL specific query to find all views that depend on the provided view.
// It uses the pg_views system catalog to get the view definition and then filters by the view name.
query := `
select
u.view_name,
u.table_name referenced_table_name,
v.view_definition
from information_schema.view_table_usage u
join information_schema.views v on u.view_schema = v.table_schema
and u.view_name = v.table_name
where u.table_schema = current_schema()
order by u.view_name;
`
var rows []struct {
ViewName string `db:"view_name"`
ReferencedTableName string `db:"referenced_table_name"`
ViewDefinition string `db:"view_definition"`
}
err := app.DB().NewQuery(query).All(&rows)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
// Topological sort this DAG graph
nodes := make(map[string]viewDef)
graph := make(map[string][]string)
for _, row := range rows {
if def, ok := nodes[row.ViewName]; !ok || def.SQL == "[TABLE]" {
nodes[row.ViewName] = viewDef{
Name: row.ViewName,
SQL: fmt.Sprintf(`CREATE VIEW "%s" AS %s`, row.ViewName, row.ViewDefinition),
}
}
if !slices.Contains(graph[row.ReferencedTableName], row.ViewName) {
graph[row.ReferencedTableName] = append(graph[row.ReferencedTableName], row.ViewName)
if _, ok := nodes[row.ReferencedTableName]; !ok {
nodes[row.ReferencedTableName] = viewDef{
Name: row.ReferencedTableName,
SQL: "[TABLE]",
}
}
}
}
sorted, err := sort.TopologicalSortAll(nodes, graph)
if err != nil {
return nil, err
}
// exclude tables from the sorted result.
var views []viewDef
for _, node := range sorted {
if node.SQL != "[TABLE]" {
views = append(views, node)
}
}
return views, nil
}