Files
base/core/field_composite.go
zeekay a9d2f1ff46 An aggregate reads no private email, and history no removed field
Red's review. The aggregate route reads columns directly, so it gated
only hidden fields: grouping an auth collection by email, or counting
its unique emails, answered addresses the records keep private, which
the list masks and the filter resolver will not compare. core.EmailGated
is now the one rule both ask: an auth collection's email, which a caller
who is not a superuser reads only where emailVisibility is set. The
aggregate route refuses it as it refuses a hidden field.
TestAPrivateEmailIsNotAFigure is red's proof inverted: the group and the
count are refused, and fail without the change.

A record's history showed the changes of a field the collection had
since removed. An org admin who removes a field means its values to be
gone from view, so a caller who is not a superuser no longer reads
them; _history keeps the row for a superuser.

The composite uniqueness check is a read before the write, which
PostgreSQL can race; that is now said where it is done and in LLM.md,
with the fix (a unique expression index from the dialect).
2026-10-19 09:24:43 -07:00

113 lines
3.4 KiB
Go

package core
import (
"context"
"database/sql"
"errors"
"slices"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/hanzoai/base/tools/dbutils"
"github.com/hanzoai/base/tools/inflector"
"github.com/hanzoai/dbx"
)
// CompositeField is a field whose value is one JSON object of named parts: a
// currency's amount and code, a person's first and last name, an address.
//
// The column holds the whole object, NULL when the value is empty. A filter, a
// sort or an aggregate reaches one part with a dotted name (`name.lastName`),
// exactly as it reaches into a JSON field, and the parts are named here so a
// path that names no part is refused rather than read as NULL.
type CompositeField interface {
Field
// Parts names the value's parts, in the order a person reads them.
Parts() []string
}
// TextParts names the parts of a composite value that hold text a person
// searches: the ones a "contains" over the whole value reads.
type TextParts interface {
TextParts() []string
}
// IsComposite reports whether field holds a composite value.
func IsComposite(field Field) bool {
_, ok := field.(CompositeField)
return ok
}
// HasPart reports whether a composite field names part.
func HasPart(field Field, part string) bool {
c, ok := field.(CompositeField)
return ok && slices.Contains(c.Parts(), part)
}
// compositeColumnType is the column every composite value is stored in: a JSON
// document, NULL when empty.
func compositeColumnType(app App) string {
return app.Dialect().Json() + " DEFAULT NULL"
}
// defaultMaxValues is how many items a multi-item composite (emails, phones,
// links) holds when its field sets no limit; maxMaxValues is the most a field may
// allow.
const (
defaultMaxValues = 10
maxMaxValues = 100
)
// maxValuesOf reads a field's MaxValues setting.
func maxValuesOf(n int) int {
if n <= 0 {
return defaultMaxValues
}
return n
}
func validateMaxValues(n *int) *validation.FieldRules {
return validation.Field(n, validation.Min(0), validation.Max(maxMaxValues))
}
var errValueNotUnique = validation.NewError("validation_not_unique", "Value must be unique.")
// checkUniquePart refuses a value whose part is already held by another record
// of the same collection. It is how a composite's primary part (an email, a
// phone number, a URL) is kept unique: the part lives inside a JSON document, so
// no column index can say it, and an expression index is spelled differently on
// every engine.
//
// It compares case-insensitively, since every such part is matched that way by
// the people who type it.
//
// It is a read before the write, so it holds where the engine admits one writer
// (SQLite). On PostgreSQL two writers of one value at once can both pass it; a
// unique expression index from the dialect is what would close that.
func checkUniquePart(ctx context.Context, app App, record *Record, field, part, value string) error {
if value == "" {
return nil
}
table := inflector.Columnify(record.Collection().Name)
expr := dbutils.JSONExtract(app.Dialect(), inflector.Columnify(field), part)
var exists int
err := app.ConcurrentDB().
Select("(1)").
From(table).
Where(dbx.NewExp("LOWER("+expr+") = LOWER({:value})", dbx.Params{"value": value})).
AndWhere(dbx.Not(dbx.HashExp{"id": record.Id})).
Limit(1).
WithContext(ctx).
Row(&exists)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
if exists == 1 {
return errValueNotUnique
}
return nil
}