Skip to content

Commit

Permalink
feat: Go support for parametric types (#822)
Browse files Browse the repository at this point in the history
```
🐚 ~/dev/ftl $ ftl schema
module time {
  data TimeRequest<T> {
    data T
  }

  data TimeResponse {
    time Time
  }

  // Time returns the current time.
  verb time(time.TimeRequest<String>) time.TimeResponse
      ingress ftl GET /time
}
```

After modifying time to have a generic request and echo to use it:

```
🐚 ~/dev/ftl $ ftl call echo.echo
{"message":"Hello, anonymous!!! It is 2024-01-20 12:38:24.254176 +1100 AEDT!"}
```
  • Loading branch information
alecthomas authored Jan 20, 2024
1 parent b7cbc0e commit eb4827f
Show file tree
Hide file tree
Showing 18 changed files with 610 additions and 206 deletions.
10 changes: 9 additions & 1 deletion backend/controller/ingress/ingress.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,14 @@ func validateRequestMap(dataRef *schema.DataRef, path path, request map[string]a
return fmt.Errorf("unknown data %v", dataRef)
}

if len(dataRef.TypeParameters) > 0 {
var err error
data, err = data.Monomorphise(dataRef.TypeParameters...)
if err != nil {
return err
}
}

var errs []error
for _, field := range data.Fields {
fieldPath := append(path, "."+field.Name) //nolint:gocritic
Expand Down Expand Up @@ -324,7 +332,7 @@ func validateValue(fieldType schema.Type, path path, value any, sch *schema.Sche
}

case *schema.TypeParameter:
panic("unreachable")
panic("data structures with type parameters should be monomorphised")
}

if !typeMatches {
Expand Down
103 changes: 95 additions & 8 deletions backend/schema/data.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"strings"

"golang.design/x/reflect"
"google.golang.org/protobuf/proto"

schemapb "github.com/TBD54566975/ftl/protos/xyz/block/ftl/v1/schema"
Expand Down Expand Up @@ -31,6 +32,79 @@ func (d *Data) Scope() Scope {
return scope
}

// Monomorphise this data type with the given type arguments.
//
// Will return nil if it is not a parametric type.
//
// This will return a new Data structure with all type parameters replaced with
// the given types.
func (d *Data) Monomorphise(types ...Type) (*Data, error) {
if len(d.TypeParameters) != len(types) {
return nil, fmt.Errorf("expected %d type arguments, got %d", len(d.TypeParameters), len(types))
}
if len(d.TypeParameters) == 0 {
return nil, nil
}
names := map[string]Type{}
for i, t := range d.TypeParameters {
names[t.Name] = types[i]
}
monomorphised := reflect.DeepCopy(d)
monomorphised.TypeParameters = nil

// Because we don't have parent links in the AST allowing us to visit on
// Type and replace it on the parent, we have to do a full traversal to find
// the parents of all the Type nodes we need to replace. This will be a bit
// tricky to maintain, but it's basically any type that has parametric
// types: maps, slices, fields, etc.
err := Visit(monomorphised, func(n Node, next func() error) error {
switch n := n.(type) {
case *Map:
k, err := maybeMonomorphiseType(n.Key, names)
if err != nil {
return fmt.Errorf("%s: map key: %w", n.Key.Position(), err)
}
v, err := maybeMonomorphiseType(n.Value, names)
if err != nil {
return fmt.Errorf("%s: map value: %w", n.Value.Position(), err)
}
n.Key = k
n.Value = v

case *Array:
t, err := maybeMonomorphiseType(n.Element, names)
if err != nil {
return fmt.Errorf("%s: array element: %w", n.Element.Position(), err)
}
n.Element = t

case *Field:
t, err := maybeMonomorphiseType(n.Type, names)
if err != nil {
return fmt.Errorf("%s: field type: %w", n.Type.Position(), err)
}
n.Type = t

case *Optional:
t, err := maybeMonomorphiseType(n.Type, names)
if err != nil {
return fmt.Errorf("%s: optional type: %w", n.Type.Position(), err)
}
n.Type = t

case *Any, *Bool, *Bytes, *Data, *DataRef, *Database, Decl, *Float,
IngressPathComponent, *IngressPathLiteral, *IngressPathParameter, *Int,
Metadata, *MetadataCalls, *MetadataDatabases, *MetadataIngress, *Module,
*Schema, *String, *Time, Type, *TypeParameter, *Unit, *Verb:
}
return next()
})
if err != nil {
return nil, fmt.Errorf("%s: failed to monomorphise: %w", d.Pos, err)
}
return monomorphised, nil
}

func (d *Data) Position() Position { return d.Pos }
func (*Data) schemaDecl() {}
func (d *Data) schemaChildren() []Node {
Expand Down Expand Up @@ -72,18 +146,31 @@ func (d *Data) String() string {

func (d *Data) ToProto() proto.Message {
return &schemapb.Data{
Pos: posToProto(d.Pos),
Name: d.Name,
Fields: nodeListToProto[*schemapb.Field](d.Fields),
Comments: d.Comments,
Pos: posToProto(d.Pos),
TypeParameters: nodeListToProto[*schemapb.TypeParameter](d.TypeParameters),
Name: d.Name,
Fields: nodeListToProto[*schemapb.Field](d.Fields),
Comments: d.Comments,
}
}

func DataToSchema(s *schemapb.Data) *Data {
return &Data{
Pos: posFromProto(s.Pos),
Name: s.Name,
Fields: fieldListToSchema(s.Fields),
Comments: s.Comments,
Pos: posFromProto(s.Pos),
Name: s.Name,
TypeParameters: typeParametersToSchema(s.TypeParameters),
Fields: fieldListToSchema(s.Fields),
Comments: s.Comments,
}
}

// MonoType returns the monomorphised type of this data type if applicable, or returns the original type.
func maybeMonomorphiseType(t Type, typeParameters map[string]Type) (Type, error) {
if t, ok := t.(*TypeParameter); ok {
if tp, ok := typeParameters[t.Name]; ok {
return tp, nil
}
return nil, fmt.Errorf("%s: unknown type parameter %q", t.Position(), t.Name)
}
return t, nil
}
26 changes: 26 additions & 0 deletions backend/schema/data_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package schema

import (
"testing"

"github.com/alecthomas/assert/v2"
)

func TestMonomorphisation(t *testing.T) {
data := &Data{
Name: "Data",
TypeParameters: []*TypeParameter{{Name: "T"}},
Fields: []*Field{
{Name: "a", Type: &TypeParameter{Name: "T"}},
},
}
actual, err := data.Monomorphise(&String{})
assert.NoError(t, err)
expected := &Data{
Comments: []string{},
Name: "Data",
Fields: []*Field{{Comments: []string{}, Name: "a", Type: &String{}}},
Metadata: []Metadata{},
}
assert.Equal(t, expected, actual, assert.OmitEmpty())
}
23 changes: 11 additions & 12 deletions backend/schema/dataref.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package schema
import (
"google.golang.org/protobuf/reflect/protoreflect"

"github.com/TBD54566975/ftl/backend/common/slices"
schemapb "github.com/TBD54566975/ftl/protos/xyz/block/ftl/v1/schema"
)

Expand Down Expand Up @@ -43,7 +44,12 @@ func (d *DataRef) String() string {
}

func (d *DataRef) ToProto() protoreflect.ProtoMessage {
return &schemapb.DataRef{Pos: posToProto(d.Pos), Module: d.Module, Name: d.Name}
return &schemapb.DataRef{
Pos: posToProto(d.Pos),
Module: d.Module,
Name: d.Name,
TypeParameters: slices.Map(d.TypeParameters, typeToProto),
}
}

func (*DataRef) schemaChildren() []Node { return nil }
Expand All @@ -55,16 +61,9 @@ func ParseDataRef(ref string) (*DataRef, error) {

func DataRefFromProto(s *schemapb.DataRef) *DataRef {
return &DataRef{
Pos: posFromProto(s.Pos),
Name: s.Name,
Module: s.Module,
}
}

func dataRefToSchema(s *schemapb.DataRef) *DataRef {
return &DataRef{
Pos: posFromProto(s.Pos),
Name: s.Name,
Module: s.Module,
Pos: posFromProto(s.Pos),
Name: s.Name,
Module: s.Module,
TypeParameters: slices.Map(s.TypeParameters, typeToSchema),
}
}
5 changes: 5 additions & 0 deletions backend/schema/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ type Module struct {
var _ Node = (*Module)(nil)
var _ Decl = (*Module)(nil)

// Scope returns a scope containing all the declarations in this module.
func (m *Module) Scope() Scope {
scope := Scope{}
for _, d := range m.Decls {
Expand All @@ -40,7 +41,11 @@ func (m *Module) Scope() Scope {
return scope
}

// Resolve returns the declaration in this module with the given name, or nil
func (m *Module) Resolve(ref Ref) *ModuleDecl {
if ref.Module != "" && ref.Module != m.Name {
return nil
}
for _, d := range m.Decls {
switch d := d.(type) {
case *Data:
Expand Down
2 changes: 1 addition & 1 deletion backend/schema/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ var (
declUnion = []Decl{&Data{}, &Verb{}, &Database{}}
nonOptionalTypeUnion = []Type{
&Int{}, &Float{}, &String{}, &Bytes{}, &Bool{}, &Time{}, &Array{},
&Map{}, &DataRef{}, &Unit{}, &Any{},
&Map{}, &DataRef{}, &Unit{}, &Any{}, &TypeParameter{},
}
typeUnion = append(nonOptionalTypeUnion, &Optional{})
metadataUnion = []Metadata{&MetadataCalls{}, &MetadataIngress{}, &MetadataDatabases{}}
Expand Down
4 changes: 3 additions & 1 deletion backend/schema/protobuf_dec.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ func typeToSchema(s *schemapb.Type) Type {
// case *schemapb.Type_VerbRef:
// return verbRefToSchema(s.VerbRef)
case *schemapb.Type_DataRef:
return dataRefToSchema(s.DataRef)
return DataRefFromProto(s.DataRef)
case *schemapb.Type_Int:
return &Int{Pos: posFromProto(s.Int.Pos)}
case *schemapb.Type_Float:
Expand All @@ -60,6 +60,8 @@ func typeToSchema(s *schemapb.Type) Type {
return &Unit{Pos: posFromProto(s.Unit.Pos)}
case *schemapb.Type_Any:
return &Any{Pos: posFromProto(s.Any.Pos)}
case *schemapb.Type_Parameter:
return &TypeParameter{Pos: posFromProto(s.Parameter.Pos), Name: s.Parameter.Name}
}
panic(fmt.Sprintf("unhandled type: %T", s.Value))
}
Expand Down
2 changes: 1 addition & 1 deletion backend/schema/protobuf_enc.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ func typeToProto(t Type) *schemapb.Type {
return &schemapb.Type{Value: &schemapb.Type_Optional{Optional: t.ToProto().(*schemapb.Optional)}}

case *TypeParameter:
panic("unreachable")
return &schemapb.Type{Value: &schemapb.Type_Parameter{Parameter: t.ToProto().(*schemapb.TypeParameter)}}
}
panic(fmt.Sprintf("unhandled type: %T", t))
}
19 changes: 17 additions & 2 deletions backend/schema/typeparameter.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package schema

import "google.golang.org/protobuf/reflect/protoreflect"
import (
"google.golang.org/protobuf/reflect/protoreflect"

schemapb "github.com/TBD54566975/ftl/protos/xyz/block/ftl/v1/schema"
)

type TypeParameter struct {
Pos Position `parser:"" protobuf:"1,optional"`
Expand All @@ -15,7 +19,18 @@ func (*TypeParameter) schemaType() {}
func (t *TypeParameter) Position() Position { return t.Pos }
func (t *TypeParameter) String() string { return t.Name }
func (t *TypeParameter) ToProto() protoreflect.ProtoMessage {
panic("unimplemented")
return &schemapb.TypeParameter{Pos: posToProto(t.Pos), Name: t.Name}
}
func (t *TypeParameter) schemaChildren() []Node { return nil }
func (t *TypeParameter) schemaDecl() {}

func typeParametersToSchema(s []*schemapb.TypeParameter) []*TypeParameter {
var out []*TypeParameter
for _, n := range s {
out = append(out, &TypeParameter{
Pos: posFromProto(n.Pos),
Name: n.Name,
})
}
return out
}
12 changes: 10 additions & 2 deletions backend/schema/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,15 @@ func Validate(schema *Schema) (*Schema, error) {

case *DataRef:
if mdecl := scopes.Resolve(n.Untyped()); mdecl != nil {
switch mdecl.Decl.(type) {
switch decl := mdecl.Decl.(type) {
case *Data:
if mdecl.Module != nil {
n.Module = mdecl.Module.Name
}
if len(n.TypeParameters) != len(decl.TypeParameters) {
merr = append(merr, fmt.Errorf("%s: reference to data structure %s has %d type parameters, but %d were expected",
n.Pos, n.Name, len(n.TypeParameters), len(decl.TypeParameters)))
}

case *TypeParameter:

Expand Down Expand Up @@ -196,11 +200,15 @@ func ValidateModule(module *Module) error {

case *DataRef:
if mdecl := scopes.Resolve(n.Untyped()); mdecl != nil {
switch mdecl.Decl.(type) {
switch decl := mdecl.Decl.(type) {
case *Data:
if n.Module == "" {
n.Module = mdecl.Module.Name
}
if len(n.TypeParameters) != len(decl.TypeParameters) {
merr = append(merr, fmt.Errorf("%s: reference to data structure %s has %d type parameters, but %d were expected",
n.Pos, n.Name, len(n.TypeParameters), len(decl.TypeParameters)))
}

case *TypeParameter:

Expand Down
Loading

0 comments on commit eb4827f

Please sign in to comment.