Skip to content

Commit

Permalink
feat: extract verb call metadata (#2166)
Browse files Browse the repository at this point in the history
migrates verb call extraction out of the legacy extractor. also supports
transitive calls now
  • Loading branch information
worstell authored Jul 25, 2024
1 parent 037a172 commit 9201184
Show file tree
Hide file tree
Showing 11 changed files with 399 additions and 238 deletions.
68 changes: 1 addition & 67 deletions go-runtime/compile/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import (
var (
fset = token.NewFileSet()

ftlCallFuncPath = "github.com/TBD54566975/ftl/go-runtime/ftl.Call"
ftlFSMFuncPath = "github.com/TBD54566975/ftl/go-runtime/ftl.FSM"
ftlTransitionFuncPath = "github.com/TBD54566975/ftl/go-runtime/ftl.Transition"
ftlStartFuncPath = "github.com/TBD54566975/ftl/go-runtime/ftl.Start"
Expand Down Expand Up @@ -117,76 +116,11 @@ func visitCallExpr(pctx *parseContext, node *ast.CallExpr, stack []ast.Node) {
if fn == nil {
return
}
switch fn.FullName() {
case ftlCallFuncPath:
parseCall(pctx, node, stack)

case ftlFSMFuncPath:
if fn.FullName() == ftlFSMFuncPath {
parseFSMDecl(pctx, node, stack)
}
}

func parseCall(pctx *parseContext, node *ast.CallExpr, stack []ast.Node) {
var activeFuncDecl *ast.FuncDecl
for i := len(stack) - 1; i >= 0; i-- {
if found, ok := stack[i].(*ast.FuncDecl); ok {
activeFuncDecl = found
break
}
// use element
}
if activeFuncDecl == nil {
return
}
expectedVerbName := strcase.ToLowerCamel(activeFuncDecl.Name.Name)
var activeVerb *schema.Verb
for _, decl := range pctx.module.Decls {
if aVerb, ok := decl.(*schema.Verb); ok && aVerb.Name == expectedVerbName {
activeVerb = aVerb
break
}
}
if activeVerb == nil {
return
}
if len(node.Args) != 3 {
pctx.errors.add(errorf(node, "call must have exactly three arguments"))
return
}
ref := parseVerbRef(pctx, node.Args[1])
if ref == nil {
var suffix string
var ok bool
ref, ok = parseSelectorRef(node.Args[1])
if ok && pctx.schema.Resolve(ref).Ok() {
suffix = ", does it need to be exported?"
}
if sel, ok := node.Args[1].(*ast.SelectorExpr); ok {
pctx.errors.add(errorf(node.Args[1], "call first argument must be a function but is an unresolved reference to %s.%s%s", sel.X, sel.Sel, suffix))
}
pctx.errors.add(errorf(node.Args[1], "call first argument must be a function in an ftl module%s", suffix))
return
}
activeVerb.AddCall(ref)
}

func parseSelectorRef(node ast.Expr) (*schema.Ref, bool) {
sel, ok := node.(*ast.SelectorExpr)
if !ok {
return nil, false
}
ident, ok := sel.X.(*ast.Ident)
if !ok {
return nil, false
}
return &schema.Ref{
Pos: goPosToSchemaPos(node.Pos()),
Module: ident.Name,
Name: strcase.ToLowerCamel(sel.Sel.Name),
}, true

}

func parseVerbRef(pctx *parseContext, node ast.Expr) *schema.Ref {
_, verbFn := deref[*types.Func](pctx.pkg, node)
if verbFn == nil {
Expand Down
11 changes: 8 additions & 3 deletions go-runtime/compile/schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,8 +248,13 @@ func TestExtractModuleSchemaTwo(t *testing.T) {
export verb callsTwo(two.Payload<String>) two.Payload<String>
+calls two.two
export verb callsTwoAndThree(two.Payload<String>) two.Payload<String>
+calls two.three, two.two
export verb returnsUser(Unit) two.UserResponse
export verb three(two.Payload<String>) two.Payload<String>
export verb two(two.Payload<String>) two.Payload<String>
}
`
Expand Down Expand Up @@ -547,10 +552,10 @@ func TestErrorReporting(t *testing.T) {
`31:3-3: unexpected directive "ftl:export" attached for verb, did you mean to use '//ftl:verb export' instead?`,
`37:40-40: unsupported request type "ftl/failing.Request"`,
`37:50-50: unsupported response type "ftl/failing.Response"`,
`38:16-29: call first argument must be a function but is an unresolved reference to lib.OtherFunc`,
`38:16-29: call first argument must be a function in an ftl module`,
`38:16-29: call first argument must be a function but is an unresolved reference to lib.OtherFunc, does it need to be exported?`,
`38:16-29: call first argument must be a function in an ftl module, does it need to be exported?`,
`39:2-46: call must have exactly three arguments`,
`40:16-25: call first argument must be a function in an ftl module`,
`40:16-25: call first argument must be a function in an ftl module, does it need to be exported?`,
`45:1-2: must have at most two parameters (context.Context, struct)`,
`45:69-69: unsupported response type "ftl/failing.Response"`,
`50:22-27: first parameter must be of type context.Context but is ftl/failing.Request`,
Expand Down
27 changes: 27 additions & 0 deletions go-runtime/compile/testdata/go/two/two.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,22 @@ func Two(ctx context.Context, req Payload[string]) (Payload[string], error) {
return Payload[string]{}, nil
}

//ftl:verb export
func Three(ctx context.Context, req Payload[string]) (Payload[string], error) {
return Payload[string]{}, nil
}

//ftl:verb export
func CallsTwo(ctx context.Context, req Payload[string]) (Payload[string], error) {
return ftl.Call(ctx, Two, req)
}

//ftl:verb export
func CallsTwoAndThree(ctx context.Context, req Payload[string]) (Payload[string], error) {
err := transitiveVerbCall(ctx, req)
return Payload[string]{}, err
}

//ftl:verb export
func ReturnsUser(ctx context.Context) (UserResponse, error) {
return UserResponse{
Expand Down Expand Up @@ -87,3 +98,19 @@ type ExplicitAliasAlias = lib.NonFTLType
type TransitiveAliasType lib.NonFTLType

type TransitiveAliasAlias = lib.NonFTLType

type TransitiveAlias lib.NonFTLType

func transitiveVerbCall(ctx context.Context, req Payload[string]) error {
_, err := ftl.Call(ctx, Two, req)
if err != nil {
return err
}
err = superTransitiveVerbCall(ctx, req)
return err
}

func superTransitiveVerbCall(ctx context.Context, req Payload[string]) error {
_, err := ftl.Call(ctx, Three, req)
return err
}
65 changes: 61 additions & 4 deletions go-runtime/schema/call/analyzer.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@ import (
"go/types"
"strings"

"github.com/TBD54566975/ftl/backend/schema"
"github.com/TBD54566975/ftl/backend/schema/strcase"
"github.com/TBD54566975/ftl/go-runtime/schema/common"
"github.com/TBD54566975/golang-tools/go/analysis"
"github.com/TBD54566975/golang-tools/go/analysis/passes/inspect"
"github.com/TBD54566975/golang-tools/go/ast/inspector"
)

const (
ftlCallFuncPath = "github.com/TBD54566975/ftl/go-runtime/ftl.Call"
ftlPkgPath = "github.com/TBD54566975/ftl/go-runtime/ftl"
ftlTopicHandleTypeName = "TopicHandle"
)
Expand All @@ -23,19 +26,73 @@ type Tag struct{} // Tag uniquely identifies the fact type for this extractor.
type Fact = common.DefaultFact[Tag]

func Extract(pass *analysis.Pass) (interface{}, error) {
//TODO: implement call metadata extraction (for now this just validates all calls)

in := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector) //nolint:forcetypeassert
nodeFilter := []ast.Node{
(*ast.FuncDecl)(nil),
(*ast.CallExpr)(nil),
}
var currentFunc *ast.FuncDecl
in.Preorder(nodeFilter, func(n ast.Node) {
node := n.(*ast.CallExpr) //nolint:forcetypeassert
validateCallExpr(pass, node)
switch node := n.(type) {
case *ast.FuncDecl:
currentFunc = node
case *ast.CallExpr:
validateCallExpr(pass, node)
if currentFunc == nil {
return
}
parentFuncObj, ok := common.GetObjectForNode(pass.TypesInfo, currentFunc).Get()
if !ok {
return
}
_, fn := common.Deref[*types.Func](pass, node.Fun)
if fn == nil {
return
}
if fn.FullName() == ftlCallFuncPath {
extractVerbCall(pass, parentFuncObj, node)
return
}
common.MarkFunctionCall(pass, parentFuncObj, fn)
}
})
return common.NewExtractorResult(pass), nil
}

func extractVerbCall(pass *analysis.Pass, parentFuncObj types.Object, node *ast.CallExpr) {
if len(node.Args) != 3 {
common.Errorf(pass, node, "call must have exactly three arguments")
return
}
ref := parseVerbRef(pass, node.Args[1])
if ref == nil {
if sel, ok := node.Args[1].(*ast.SelectorExpr); ok {
common.Errorf(pass, node.Args[1], "call first argument must be a function but is an unresolved "+
"reference to %s.%s, does it need to be exported?", sel.X, sel.Sel)
}
common.Errorf(pass, node.Args[1], "call first argument must be a function in an ftl module, does "+
"it need to be exported?")
return
}
common.MarkVerbCall(pass, parentFuncObj, ref)
}

func parseVerbRef(pass *analysis.Pass, node ast.Expr) *schema.Ref {
_, verbFn := common.Deref[*types.Func](pass, node)
if verbFn == nil {
return nil
}
moduleName, err := common.FtlModuleFromGoPackage(verbFn.Pkg().Path())
if err != nil {
return nil
}
return &schema.Ref{
Pos: common.GoPosToSchemaPos(pass.Fset, node.Pos()),
Module: moduleName,
Name: strcase.ToLowerCamel(verbFn.Name()),
}
}

// validateCallExpr validates all function calls
// checks if the function call is:
// - a direct verb call to an external module
Expand Down
46 changes: 44 additions & 2 deletions go-runtime/schema/common/fact.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,22 @@ type ExternalType struct{}

func (*ExternalType) schemaFactValue() {}

// FunctionCall is a fact for marking an outbound function call on a function.
type FunctionCall struct {
// The function being called.
Callee types.Object
}

func (*FunctionCall) schemaFactValue() {}

// VerbCall is a fact for marking a call to an FTL verb on a function.
type VerbCall struct {
// The verb being called.
VerbRef *schema.Ref
}

func (*VerbCall) schemaFactValue() {}

// MarkSchemaDecl marks the given object as having been extracted to the given schema decl.
func MarkSchemaDecl(pass *analysis.Pass, obj types.Object, decl schema.Decl) {
fact := newFact(pass, obj)
Expand Down Expand Up @@ -148,6 +164,20 @@ func MarkMaybeTypeEnum(pass *analysis.Pass, obj types.Object, enum *schema.Enum)
pass.ExportObjectFact(obj, fact)
}

// MarkFunctionCall marks the given object as having an outbound function call.
func MarkFunctionCall(pass *analysis.Pass, obj types.Object, callee types.Object) {
fact := newFact(pass, obj)
fact.Add(&FunctionCall{Callee: callee})
pass.ExportObjectFact(obj, fact)
}

// MarkVerbCall marks the given object as having a call to an FTL verb.
func MarkVerbCall(pass *analysis.Pass, obj types.Object, verbRef *schema.Ref) {
fact := newFact(pass, obj)
fact.Add(&VerbCall{VerbRef: verbRef})
pass.ExportObjectFact(obj, fact)
}

// GetAllFactsExtractionStatus merges schema facts inclusive of all available results and the present pass facts.
// For a given object, it provides the current extraction status.
//
Expand Down Expand Up @@ -197,9 +227,9 @@ func GetAllFactsExtractionStatus(pass *analysis.Pass) map[types.Object]SchemaFac
return facts
}

// GetAllFacts returns all facts of the provided type marked on objects, across the current pass and results from
// GetAllFactsOfType returns all facts of the provided type marked on objects, across the current pass and results from
// prior passes. If multiple of the same fact type are marked on a single object, the first fact is returned.
func GetAllFacts[T SchemaFactValue](pass *analysis.Pass) map[types.Object]T {
func GetAllFactsOfType[T SchemaFactValue](pass *analysis.Pass) map[types.Object]T {
return getFactsScoped[T](allFacts(pass))
}

Expand Down Expand Up @@ -265,6 +295,18 @@ func GetFactsForObject[T SchemaFactValue](pass *analysis.Pass, obj types.Object)
return facts
}

func GetAllFacts(pass *analysis.Pass) map[types.Object][]SchemaFactValue {
facts := make(map[types.Object][]SchemaFactValue)
for _, fact := range allFacts(pass) {
sf, ok := fact.Fact.(SchemaFact)
if !ok {
continue
}
facts[fact.Object] = sf.Get()
}
return facts
}

func allFacts(pass *analysis.Pass) []analysis.ObjectFact {
var all []analysis.ObjectFact
all = append(all, pass.AllObjectFacts()...)
Expand Down
6 changes: 3 additions & 3 deletions go-runtime/schema/enum/analyzer.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ func Extract(pass *analysis.Pass, node *ast.TypeSpec, obj types.Object) optional

func findValueEnumVariants(pass *analysis.Pass, obj types.Object) []*schema.EnumVariant {
var variants []*schema.EnumVariant
for o, fact := range common.GetAllFacts[*common.MaybeValueEnumVariant](pass) {
for o, fact := range common.GetAllFactsOfType[*common.MaybeValueEnumVariant](pass) {
if o.Type() == obj.Type() && validateVariant(pass, o, fact.Variant) {
variants = append(variants, fact.Variant)
}
Expand All @@ -79,7 +79,7 @@ func findValueEnumVariants(pass *analysis.Pass, obj types.Object) []*schema.Enum
}

func validateVariant(pass *analysis.Pass, obj types.Object, variant *schema.EnumVariant) bool {
for _, fact := range common.GetAllFacts[*common.ExtractedDecl](pass) {
for _, fact := range common.GetAllFactsOfType[*common.ExtractedDecl](pass) {
if fact.Decl == nil {
continue
}
Expand All @@ -100,7 +100,7 @@ func validateVariant(pass *analysis.Pass, obj types.Object, variant *schema.Enum

func findTypeValueVariants(pass *analysis.Pass, obj types.Object) []*schema.EnumVariant {
var variants []*schema.EnumVariant
for vObj, fact := range common.GetAllFacts[*common.MaybeTypeEnumVariant](pass) {
for vObj, fact := range common.GetAllFactsOfType[*common.MaybeTypeEnumVariant](pass) {
if fact.Parent != obj {
continue
}
Expand Down
Loading

0 comments on commit 9201184

Please sign in to comment.