Files
base/core/collection_validate.go
zeekay 23cf129160 Field types for people and money, and the caller's org authority in rules
Seven field types a CRM record needs, each one JSON column whose parts a
filter, a sort or an aggregate reaches by name: currency (amountMicros,
currencyCode), emails, phones, links, address, fullName and actor, plus
rating. Empty is NULL. Emails, phones and links normalize what is typed and
can keep their primary part unique. An actor is stamped by the server from
the writer the save's context states; nothing a request sends sets it.

Every field carries a label, description, icon and position, a select's
values carry a label, colour and position, and a base collection carries a
label, plural, icon, description and an opt-in history flag.

Editor HTML is sanitized before it is written.

A rule reads the caller's authority in the org it acts in:
@request.auth.admin and @request.auth.role, off the token. An IAM-mirrored
auth record is never saved, so its fields are now read off the record
rather than joined against a row that is not there, which also makes
@request.auth.org_id match for the first time. A host that opens one Base
per org names it, and a token acting in another org is refused there.
2026-10-18 20:41:15 -07:00

669 lines
19 KiB
Go

package core
import (
"context"
"fmt"
"regexp"
"strconv"
"strings"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/hanzoai/base/core/validators"
"github.com/hanzoai/base/tools/dbutils"
"github.com/hanzoai/base/tools/list"
"github.com/hanzoai/base/tools/search"
"github.com/hanzoai/base/tools/types"
"github.com/hanzoai/dbx"
)
var collectionNameRegex = regexp.MustCompile(`^\w+$`)
func onCollectionValidate(e *CollectionEvent) error {
var original *Collection
if !e.Collection.IsNew() {
original = &Collection{}
if err := e.App.ModelQuery(original).Model(e.Collection.LastSavedPK(), original); err != nil {
return fmt.Errorf("failed to fetch old collection state: %w", err)
}
}
validator := newCollectionValidator(
e.Context,
e.App,
e.Collection,
original,
)
if err := validator.run(); err != nil {
return err
}
return e.Next()
}
func newCollectionValidator(ctx context.Context, app App, new, original *Collection) *collectionValidator {
validator := &collectionValidator{
ctx: ctx,
app: app,
new: new,
original: original,
}
// load old/original collection
if validator.original == nil {
validator.original = NewCollection(validator.new.Type, "")
}
return validator
}
type collectionValidator struct {
original *Collection
new *Collection
app App
ctx context.Context
}
type optionsValidator interface {
validate(cv *collectionValidator) error
}
func (validator *collectionValidator) run() error {
if validator.original.IsNew() {
validator.new.updateGeneratedIdIfExists(validator.app)
}
// generate fields from the query (overwriting any explicit user defined fields)
if validator.new.IsView() {
validator.new.Fields, _ = validator.app.CreateViewFields(validator.new.ViewQuery)
}
// validate base fields
baseErr := validation.ValidateStruct(validator.new,
validation.Field(
&validator.new.Id,
validation.Required,
validation.When(
validator.original.IsNew(),
validation.Length(1, 100),
validation.Match(DefaultIdRegex),
validation.By(validators.UniqueId(validator.app.ConcurrentDB(), validator.new.TableName())),
).Else(
validation.By(validators.Equal(validator.original.Id)),
),
),
validation.Field(
&validator.new.System,
validation.By(validator.ensureNoSystemFlagChange),
),
validation.Field(
&validator.new.Type,
validation.Required,
validation.In(
CollectionTypeBase,
CollectionTypeAuth,
CollectionTypeView,
),
validation.By(validator.ensureNoTypeChange),
),
validation.Field(
&validator.new.Name,
validation.Required,
validation.Length(1, 255),
validation.By(checkForVia),
validation.Match(collectionNameRegex),
validation.By(validator.ensureNoSystemNameChange),
validation.By(validator.checkUniqueName),
),
validation.Field(
&validator.new.Fields,
validation.By(validator.checkFieldDuplicates),
validation.By(validator.checkMinFields),
validation.When(
!validator.new.IsView(),
validation.By(validator.ensureNoSystemFieldsChange),
validation.By(validator.ensureNoFieldsTypeChange),
),
validation.By(validator.checkFieldValidators),
),
validation.Field(
&validator.new.ListRule,
validation.By(validator.checkRule),
validation.By(validator.ensureNoSystemRuleChange(validator.original.ListRule)),
),
validation.Field(
&validator.new.ViewRule,
validation.By(validator.checkRule),
validation.By(validator.ensureNoSystemRuleChange(validator.original.ViewRule)),
),
validation.Field(
&validator.new.CreateRule,
validation.When(validator.new.IsView(), validation.Nil),
validation.By(validator.checkRule),
validation.By(validator.ensureNoSystemRuleChange(validator.original.CreateRule)),
),
validation.Field(
&validator.new.UpdateRule,
validation.When(validator.new.IsView(), validation.Nil),
validation.By(validator.checkRule),
validation.By(validator.ensureNoSystemRuleChange(validator.original.UpdateRule)),
),
validation.Field(
&validator.new.DeleteRule,
validation.When(validator.new.IsView(), validation.Nil),
validation.By(validator.checkRule),
validation.By(validator.ensureNoSystemRuleChange(validator.original.DeleteRule)),
),
validation.Field(&validator.new.Indexes, validation.By(validator.checkIndexes)),
)
optionsErr := validator.validateOptions()
return validators.JoinValidationErrors(baseErr, optionsErr)
}
func (validator *collectionValidator) checkUniqueName(value any) error {
v, _ := value.(string)
// ensure unique collection name
if !validator.app.IsCollectionNameUnique(v, validator.original.Id) {
return validation.NewError("validation_collection_name_exists", "Collection name must be unique (case insensitive).")
}
// ensure that the collection name doesn't collide with the id of ANOTHER
// collection. A collection may hold its own name as its id — NewBaseCollection
// takes the id as an optional argument and nothing forbids the two matching —
// and this lookup finds that collection itself, so without excluding it such a
// collection passed creation and could never be updated again. Every later
// schema change died on restart with the error below, which describes a
// collision with itself.
dummyCollection := &Collection{}
if validator.app.ModelQuery(dummyCollection).Model(v, dummyCollection) == nil &&
dummyCollection.Id != validator.original.Id {
return validation.NewError("validation_collection_name_id_duplicate", "The name must not match an existing collection id.")
}
// ensure that there is no existing internal table with the provided name
if validator.original.Name != v && // has changed
validator.app.IsCollectionNameUnique(v) && // is not a collection (in case it was presaved)
validator.app.HasTable(v) {
return validation.NewError("validation_collection_name_invalid", "The name shouldn't match with an existing internal table.")
}
return nil
}
func (validator *collectionValidator) ensureNoSystemNameChange(value any) error {
v, _ := value.(string)
if !validator.original.IsNew() && validator.original.System && v != validator.original.Name {
return validation.NewError("validation_collection_system_name_change", "System collection name cannot be changed.")
}
return nil
}
func (validator *collectionValidator) ensureNoSystemFlagChange(value any) error {
v, _ := value.(bool)
if !validator.original.IsNew() && v != validator.original.System {
return validation.NewError("validation_collection_system_flag_change", "System collection state cannot be changed.")
}
return nil
}
func (validator *collectionValidator) ensureNoTypeChange(value any) error {
v, _ := value.(string)
if !validator.original.IsNew() && v != validator.original.Type {
return validation.NewError("validation_collection_type_change", "Collection type cannot be changed.")
}
return nil
}
func (validator *collectionValidator) ensureNoFieldsTypeChange(value any) error {
v, ok := value.(FieldsList)
if !ok {
return validators.ErrUnsupportedValueType
}
errs := validation.Errors{}
for i, field := range v {
oldField := validator.original.Fields.GetById(field.GetId())
if oldField != nil && oldField.Type() != field.Type() {
errs[strconv.Itoa(i)] = validation.NewError(
"validation_field_type_change",
"Field type cannot be changed.",
)
}
}
if len(errs) > 0 {
return errs
}
return nil
}
func (validator *collectionValidator) checkFieldDuplicates(value any) error {
fields, ok := value.(FieldsList)
if !ok {
return validators.ErrUnsupportedValueType
}
totalFields := len(fields)
ids := make([]string, 0, totalFields)
names := make([]string, 0, totalFields)
for i, field := range fields {
if list.ExistInSlice(field.GetId(), ids) {
return validation.Errors{
strconv.Itoa(i): validation.Errors{
"id": validation.NewError(
"validation_duplicated_field_id",
fmt.Sprintf("Duplicated or invalid field id %q", field.GetId()),
),
},
}
}
// field names are used as db columns and should be case insensitive
nameLower := strings.ToLower(field.GetName())
if list.ExistInSlice(nameLower, names) {
return validation.Errors{
strconv.Itoa(i): validation.Errors{
"name": validation.NewError(
"validation_duplicated_field_name",
"Duplicated or invalid field name {{.fieldName}}",
).SetParams(map[string]any{
"fieldName": field.GetName(),
}),
},
}
}
ids = append(ids, field.GetId())
names = append(names, nameLower)
}
return nil
}
func (validator *collectionValidator) checkFieldValidators(value any) error {
fields, ok := value.(FieldsList)
if !ok {
return validators.ErrUnsupportedValueType
}
errs := validation.Errors{}
for i, field := range fields {
err := field.ValidateSettings(validator.ctx, validator.app, validator.new)
if m, ok := field.(FieldMetaHolder); ok {
err = validators.JoinValidationErrors(err, m.Meta().Validate())
}
if err != nil {
errs[strconv.Itoa(i)] = err
}
}
if len(errs) > 0 {
return errs
}
return nil
}
func (cv *collectionValidator) checkViewQuery(value any) error {
v, _ := value.(string)
if v == "" {
return nil // nothing to check
}
if _, err := cv.app.CreateViewFields(v); err != nil {
return validation.NewError(
"validation_invalid_view_query",
fmt.Sprintf("Invalid query - %s", err.Error()),
)
}
return nil
}
func (cv *collectionValidator) checkMinFields(value any) error {
fields, ok := value.(FieldsList)
if !ok {
return validators.ErrUnsupportedValueType
}
if len(fields) == 0 {
return validation.ErrRequired
}
// all collections must have an "id" PK field
idField, _ := fields.GetByName(FieldNameId).(*TextField)
if idField == nil || !idField.PrimaryKey {
return validation.NewError("validation_missing_primary_key", `Missing or invalid "id" PK field.`)
}
switch cv.new.Type {
case CollectionTypeAuth:
tokenKeyField, _ := fields.GetByName(FieldNameTokenKey).(*TextField)
if tokenKeyField == nil {
return validation.NewError("validation_missing_tokenKey_field", `System "tokenKey" field is required.`)
}
if !tokenKeyField.Hidden || !tokenKeyField.System {
return validation.Errors{FieldNameTokenKey: ErrMustBeSystemAndHidden}
}
emailField, _ := fields.GetByName(FieldNameEmail).(*EmailField)
if emailField == nil {
return validation.NewError("validation_missing_email_field", `System "email" field is required.`)
}
if !emailField.System {
return validation.Errors{FieldNameEmail: ErrMustBeSystem}
}
emailVisibilityField, _ := fields.GetByName(FieldNameEmailVisibility).(*BoolField)
if emailVisibilityField == nil {
return validation.NewError("validation_missing_emailVisibility_field", `System "emailVisibility" field is required.`)
}
if !emailVisibilityField.System {
return validation.Errors{FieldNameEmailVisibility: ErrMustBeSystem}
}
verifiedField, _ := fields.GetByName(FieldNameVerified).(*BoolField)
if verifiedField == nil {
return validation.NewError("validation_missing_verified_field", `System "verified" field is required.`)
}
if !verifiedField.System {
return validation.Errors{FieldNameVerified: ErrMustBeSystem}
}
return nil
}
return nil
}
func (validator *collectionValidator) ensureNoSystemFieldsChange(value any) error {
fields, ok := value.(FieldsList)
if !ok {
return validators.ErrUnsupportedValueType
}
if validator.original.IsNew() {
return nil // not an update
}
for _, oldField := range validator.original.Fields {
if !oldField.GetSystem() {
continue
}
newField := fields.GetById(oldField.GetId())
if newField == nil || oldField.GetName() != newField.GetName() {
return validation.NewError("validation_system_field_change", "System fields cannot be deleted or renamed.")
}
}
return nil
}
func (cv *collectionValidator) checkFieldsForUniqueIndex(value any) error {
names, ok := value.([]string)
if !ok {
return validators.ErrUnsupportedValueType
}
if len(names) == 0 {
return nil // nothing to check
}
for _, name := range names {
field := cv.new.Fields.GetByName(name)
if field == nil {
return validation.NewError("validation_missing_field", "Invalid or missing field {{.fieldName}}").
SetParams(map[string]any{"fieldName": name})
}
if _, ok := dbutils.FindSingleColumnUniqueIndex(cv.new.Indexes, name); !ok {
return validation.NewError("validation_missing_unique_constraint", "The field {{.fieldName}} doesn't have a UNIQUE constraint.").
SetParams(map[string]any{"fieldName": name})
}
}
return nil
}
// note: value could be either *string or string
func (validator *collectionValidator) checkRule(value any) error {
var vStr string
v, ok := value.(*string)
if ok {
if v != nil {
vStr = *v
}
} else {
vStr, ok = value.(string)
}
if !ok {
return validators.ErrUnsupportedValueType
}
if vStr == "" {
return nil // nothing to check
}
r := NewRecordFieldResolver(validator.app, validator.new, &RequestInfo{}, true)
_, err := search.FilterData(vStr).BuildExpr(r)
if err != nil {
return validation.NewError("validation_invalid_rule", "Invalid rule. Raw error: "+err.Error())
}
return nil
}
func (validator *collectionValidator) ensureNoSystemRuleChange(oldRule *string) validation.RuleFunc {
return func(value any) error {
if validator.original.IsNew() || !validator.original.System {
return nil // not an update of a system collection
}
rule, ok := value.(*string)
if !ok {
return validators.ErrUnsupportedValueType
}
if (rule == nil && oldRule == nil) ||
(rule != nil && oldRule != nil && *rule == *oldRule) {
return nil
}
return validation.NewError("validation_collection_system_rule_change", "System collection API rule cannot be changed.")
}
}
func (cv *collectionValidator) checkIndexes(value any) error {
indexes, _ := value.(types.JSONArray[string])
if cv.new.IsView() && len(indexes) > 0 {
return validation.NewError(
"validation_indexes_not_supported",
"View collections don't support indexes.",
)
}
duplicatedNames := make(map[string]struct{}, len(indexes))
duplicatedDefinitions := make(map[string]struct{}, len(indexes))
for i, rawIndex := range indexes {
parsed := dbutils.ParseIndex(rawIndex)
// always set a table name because it is ignored anyway in order to keep it in sync with the collection name
parsed.TableName = "validator"
if !parsed.IsValid() {
return validation.Errors{
strconv.Itoa(i): validation.NewError(
"validation_invalid_index_expression",
"Invalid CREATE INDEX expression.",
),
}
}
if _, isDuplicated := duplicatedNames[strings.ToLower(parsed.IndexName)]; isDuplicated {
return validation.Errors{
strconv.Itoa(i): validation.NewError(
"validation_duplicated_index_name",
"The index name already exists.",
),
}
}
duplicatedNames[strings.ToLower(parsed.IndexName)] = struct{}{}
// ensure that the index name is not used in another collection
var usedTblName string
_ = cv.app.ConcurrentDB().Select("tbl_name").
From(cv.app.Dialect().Catalog()).
AndWhere(dbx.HashExp{"type": "index"}).
AndWhere(dbx.NewExp("LOWER([[tbl_name]])!=LOWER({:oldName})", dbx.Params{"oldName": cv.original.Name})).
AndWhere(dbx.NewExp("LOWER([[tbl_name]])!=LOWER({:newName})", dbx.Params{"newName": cv.new.Name})).
AndWhere(dbx.NewExp("LOWER([[name]])=LOWER({:indexName})", dbx.Params{"indexName": parsed.IndexName})).
Limit(1).
Row(&usedTblName)
if usedTblName != "" {
return validation.Errors{
strconv.Itoa(i): validation.NewError(
"validation_existing_index_name",
"The index name is already used in {{.usedTableName}} collection.",
).SetParams(map[string]any{"usedTableName": usedTblName}),
}
}
// reset non-important identifiers
parsed.SchemaName = "validator"
parsed.IndexName = "validator"
parsedDef := parsed.Build(cv.app.Dialect())
if _, isDuplicated := duplicatedDefinitions[parsedDef]; isDuplicated {
return validation.Errors{
strconv.Itoa(i): validation.NewError(
"validation_duplicated_index_definition",
"The index definition already exists.",
),
}
}
duplicatedDefinitions[parsedDef] = struct{}{}
// note: we don't check the index table name because it is always
// overwritten by the SyncRecordTableSchema to allow
// easier partial modifications (eg. changing only the collection name).
// if !strings.EqualFold(parsed.TableName, form.Name) {
// return validation.Errors{
// strconv.Itoa(i): validation.NewError(
// "validation_invalid_index_table",
// fmt.Sprintf("The index table must be the same as the collection name."),
// ),
// }
// }
}
// ensure that unique indexes on system fields are not changed or removed
if !cv.original.IsNew() {
OLD_INDEXES_LOOP:
for _, oldIndex := range cv.original.Indexes {
oldParsed := dbutils.ParseIndex(oldIndex)
if !oldParsed.Unique {
continue
}
// reset collate and sort since they are not important for the unique constraint
for i := range oldParsed.Columns {
oldParsed.Columns[i].Collate = ""
oldParsed.Columns[i].Sort = ""
}
oldParsedStr := oldParsed.Build(cv.app.Dialect())
for _, column := range oldParsed.Columns {
for _, f := range cv.original.Fields {
if !f.GetSystem() || !strings.EqualFold(column.Name, f.GetName()) {
continue
}
var hasMatch bool
for _, newIndex := range cv.new.Indexes {
newParsed := dbutils.ParseIndex(newIndex)
// exclude the non-important identifiers from the check
newParsed.SchemaName = oldParsed.SchemaName
newParsed.IndexName = oldParsed.IndexName
newParsed.TableName = oldParsed.TableName
// exclude partial constraints
newParsed.Where = oldParsed.Where
// reset collate and sort
for i := range newParsed.Columns {
newParsed.Columns[i].Collate = ""
newParsed.Columns[i].Sort = ""
}
if oldParsedStr == newParsed.Build(cv.app.Dialect()) {
hasMatch = true
break
}
}
if !hasMatch {
return validation.NewError(
"validation_invalid_unique_system_field_index",
"Unique index definition on system fields ({{.fieldName}}) is invalid or missing.",
).SetParams(map[string]any{"fieldName": f.GetName()})
}
continue OLD_INDEXES_LOOP
}
}
}
}
// check for required indexes
//
// note: this is in case the indexes were removed manually when creating/importing new auth collections
// and technically it is not necessary because on app.Save() the missing indexes will be reinserted by the system collection hook
if cv.new.IsAuth() {
requiredNames := []string{FieldNameTokenKey, FieldNameEmail}
for _, name := range requiredNames {
if _, ok := dbutils.FindSingleColumnUniqueIndex(indexes, name); !ok {
return validation.NewError(
"validation_missing_required_unique_index",
`Missing required unique index for field "{{.fieldName}}".`,
).SetParams(map[string]any{"fieldName": name})
}
}
}
return nil
}
func (validator *collectionValidator) validateOptions() error {
switch validator.new.Type {
case CollectionTypeAuth:
return validator.new.collectionAuthOptions.validate(validator)
case CollectionTypeView:
return validator.new.collectionViewOptions.validate(validator)
case CollectionTypeBase:
return validator.new.collectionBaseOptions.validate(validator)
}
return nil
}