mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
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.
329 lines
9.0 KiB
Go
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:]
|
|
}
|