Files
zeekay ab9da76ea8 A Base's records are a typed GraphQL schema, answered through its own router
graph.New(app, router) renders the Base's collections as GraphQL and
answers every field with a request on the Base's own router, made with
the caller's headers, so the list and view rules, tenancy, rate limits
and expand's view checks are the REST path's and there is no second
authorization. zip's engine executes it: a host mounts g.Schema and
g.Resolve with zip.MountGraphFor, so zip is bumped to v1.37.43.

- A type per base and view collection, named from the collection
  (people: BasePerson, person, people, peopleGroupBy, createPerson,
  updatePerson, upsertPerson, deletePerson), with its fields, relations
  as objects, the records pointing at it as connections
  (opportunities_via_company) and its history.
- A filter input per collection whose operators per field type are the
  where clause's: each is one leaf of core.CompileWhere, so and, or,
  not, @me, relative dates, back-relations and inSegment mean what they
  mean there, every value bound.
- orderBy, cursor connections paged through Base's pages, a totalCount
  asked only when selected, and aggregate and GroupBy from the
  aggregate route.
- Bounds: 100 a page, four relations deep refused before any request,
  50 requests a root field, and zip's 10,000 resolutions.

graph_test drives it through zip against a CRM Base: every filter type,
paging both ways, the figures, the writes as REST reads them back, the
refusals in REST's own words (a rule, a superuser-only collection,
another org's token), the budget, and type names on every object.
tests.IAM signs IAM tokens for any test, and apis' helper uses it.
2026-10-19 09:31:00 -07:00

329 lines
9.0 KiB
Go

package graph
import (
"regexp"
"slices"
"sort"
"strings"
"github.com/hanzoai/base/core"
"github.com/hanzoai/base/tools/inflector"
)
// model is one Base's collections as the graph types them: the names every
// collection, field and root answers under, decided once per schema.
type model struct {
colls []*coll // in name order
byID map[string]*coll // collection id → coll
roots map[string]root // query and mutation root fields
segments bool // the Base has a segments collection, so filters take inSegment
history bool // some collection keeps its history
writable bool // some collection takes writes
used map[string]bool // the shared types the collections reach
skipped []string // collections or fields left out, and why
}
// root is one root field: the collection it answers for and what it does.
type root struct {
c *coll
op string // one, list, groupBy, create, update, upsert, delete
}
// coll is one collection in the graph.
type coll struct {
c *core.Collection
typ string // BasePerson
single string // Person, as a mutation spells it
list string // people
one string // person
fields []*field
byName map[string]*field
backs []*back
byBack map[string]*back
write bool // a base collection: records are written
// history: the collection keeps its history, read as a field of each record
history bool
// target: some relation in the graph points here, so the RelationFilter is used
target bool
}
// back is a relation from another collection onto this one, read from the
// record it points at: `opportunities_via_company` on a company.
type back struct {
name string
from *coll
field *field
}
// kind is what a field's value is, which decides how it is typed, filtered,
// sorted and written.
type kind int
const (
kText kind = iota // text, email, url
kRich // editor: HTML a person writes
kNumber
kRating
kBool
kDate
kSelect
kRelation
kFile
kJSON
kComposite
kOther // a type the graph does not know: answered as JSON, never filtered or sorted
)
// field is one field of a collection in the graph.
type field struct {
f core.Field
name string
kind kind
multi bool
id bool // the primary key
target *coll // a relation's target, nil when it is not in the graph
comp *composite // a composite's shape
}
// The types every graph defines, which no collection's may take.
var reserved = []string{
"BaseQuery", "BaseMutation", "BaseJSON", "BaseOrder", "BasePageInfo",
"BaseAggregate", "BaseGroup", "BaseCollection",
"BaseHistoryEntry", "BaseHistoryEdge", "BaseHistoryConnection",
"BaseIDFilter", "BaseTextFilter", "BaseNumberFilter", "BaseBooleanFilter",
"BaseDateFilter", "BaseRelativeDate", "BaseSelectFilter", "BaseContainsFilter",
"BaseRelationFilter",
}
func init() {
for _, c := range composites {
reserved = append(reserved, prefix+c.typ, prefix+c.typ+"Filter", prefix+c.typ+"OrderBy", prefix+c.typ+"Input")
for _, p := range c.parts {
if p.elem != nil {
reserved = append(reserved, prefix+p.elem.typ, prefix+p.elem.typ+"Input")
}
}
}
}
// nameRegex is a GraphQL name; one starting with two underscores is the
// language's own.
var nameRegex = regexp.MustCompile(`^[_A-Za-z][_0-9A-Za-z]*$`)
func validName(s string) bool {
return nameRegex.MatchString(s) && !strings.HasPrefix(s, "__")
}
// filterWords are the keys a filter keeps for itself; a field so named is
// answered and written but not filtered.
var filterWords = []string{"and", "or", "not", "inSegment", "notInSegment"}
// build types the collections a graph serves: every base and view collection
// that is not one Base keeps for itself. An auth collection is identity, which
// IAM owns, so it is not a type here; a relation to one reads as the id it holds.
func build(collections []*core.Collection) *model {
m := &model{byID: map[string]*coll{}, roots: map[string]root{}, used: map[string]bool{}}
sorted := slices.Clone(collections)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })
taken := map[string]bool{}
for _, n := range reserved {
taken[n] = true
}
roots := map[string]bool{"collections": true}
for _, c := range sorted {
if c.System || strings.HasPrefix(c.Name, "_") || !(c.IsBase() || c.IsView()) {
continue
}
if c.Name == core.CollectionNameSegments {
m.segments = true
}
single := inflector.Camelize(inflector.Singularize(c.Name))
list := lcFirst(inflector.Camelize(c.Name))
one := lcFirst(single)
if one == list {
one += "Record"
}
typ := prefix + single
types := []string{typ, typ + "Edge", typ + "Connection", typ + "Filter", typ + "OrderBy", typ + "Input", typ + "RelationFilter"}
names := []string{list, one, list + "GroupBy"}
if c.IsBase() {
names = append(names, "create"+single, "update"+single, "upsert"+single, "delete"+single)
}
ok := validName(single) && validName(list)
for _, n := range types {
ok = ok && !taken[n]
}
for _, n := range names {
ok = ok && !roots[n]
}
if !ok {
m.skipped = append(m.skipped, c.Name+": its names are another collection's or not GraphQL names")
continue
}
for _, n := range types {
taken[n] = true
}
for _, n := range names {
roots[n] = true
}
x := &coll{
c: c, typ: typ, single: single, list: list, one: one,
byName: map[string]*field{}, byBack: map[string]*back{},
write: c.IsBase(),
history: c.IsBase() && c.History,
}
m.colls = append(m.colls, x)
m.byID[c.Id] = x
m.roots[list] = root{x, "list"}
m.roots[one] = root{x, "one"}
m.roots[list+"GroupBy"] = root{x, "groupBy"}
if x.write {
m.writable = true
m.roots["create"+single] = root{x, "create"}
m.roots["update"+single] = root{x, "update"}
m.roots["upsert"+single] = root{x, "upsert"}
m.roots["delete"+single] = root{x, "delete"}
}
}
for _, x := range m.colls {
for _, f := range x.c.Fields {
name := f.GetName()
if f.GetHidden() {
continue
}
if !validName(name) {
m.skipped = append(m.skipped, x.c.Name+"."+name+": not a GraphQL name")
continue
}
fd := &field{f: f, name: name, kind: kindOf(f)}
if mv, ok := f.(core.MultiValuer); ok {
fd.multi = mv.IsMultiple()
}
if t, ok := f.(*core.TextField); ok && t.PrimaryKey {
fd.id = true
}
if fd.kind == kComposite {
fd.comp = composites[f.Type()]
}
if rel, ok := f.(*core.RelationField); ok {
fd.target = m.byID[rel.CollectionId]
if fd.target != nil {
fd.target.target = true
}
}
x.fields = append(x.fields, fd)
x.byName[name] = fd
}
if x.history && x.byName["history"] != nil {
// the collection's own field keeps the name
x.history = false
m.skipped = append(m.skipped, x.c.Name+".history: a field of the collection is named so")
}
}
m.history = false
for _, x := range m.colls {
m.history = m.history || x.history
}
// a relation is read back from the record it points at, under the name
// Base's own expand gives it
for _, x := range m.colls {
for _, f := range x.fields {
if f.kind != kRelation || f.target == nil {
continue
}
name := x.c.Name + "_via_" + f.name
t := f.target
if !validName(name) || t.byName[name] != nil || t.byBack[name] != nil {
m.skipped = append(m.skipped, t.c.Name+"."+name+": taken")
continue
}
b := &back{name: name, from: x, field: f}
t.backs = append(t.backs, b)
t.byBack[name] = b
}
}
return m
}
func kindOf(f core.Field) kind {
switch f.Type() {
case core.FieldTypeText, core.FieldTypeEmail, core.FieldTypeURL:
return kText
case core.FieldTypeEditor:
return kRich
case core.FieldTypeNumber:
return kNumber
case core.FieldTypeRating:
return kRating
case core.FieldTypeBool:
return kBool
case core.FieldTypeDate, core.FieldTypeAutodate:
return kDate
case core.FieldTypeSelect:
return kSelect
case core.FieldTypeRelation:
return kRelation
case core.FieldTypeFile:
return kFile
case core.FieldTypeJSON:
return kJSON
}
if _, ok := composites[f.Type()]; ok {
return kComposite
}
return kOther
}
// writable reports whether a field takes a value in an Input: never a value
// Base sets itself (a date it stamps, a writer it records) or a file, which is
// uploaded as a form rather than written as JSON.
func (f *field) writable() bool {
switch {
case f.f.Type() == core.FieldTypeAutodate, f.kind == kFile:
return false
case f.kind == kComposite:
return f.comp.write
}
return true
}
// filterable reports whether a filter can name the field.
func (f *field) filterable() bool {
return f.kind != kOther && !slices.Contains(filterWords, f.name) && f.f.Type() != core.FieldTypeGeoPoint
}
// sortable reports whether an orderBy can name the field.
func (f *field) sortable() bool {
switch f.kind {
case kText, kNumber, kRating, kBool, kDate:
return true
case kSelect:
return !f.multi
case kRelation:
return !f.multi
case kComposite:
return f.comp.sortable()
}
return false
}
func lcFirst(s string) string {
if s == "" {
return s
}
return strings.ToLower(s[:1]) + s[1:]
}