From c9534965b3eaa183ba327c3afc54b902341ff1a1 Mon Sep 17 00:00:00 2001 From: Stuart Douglas Date: Fri, 26 Jul 2024 15:54:28 +1000 Subject: [PATCH] feat: add local schema diff If no remote URI is specified for schema diff it will attempt to diff the local project closes #2174 --- cmd/ftl/cmd_schema_diff.go | 57 +++++++++++++++++++++++++++++++++++-- cmd/ftl/integration_test.go | 27 ++++++++++++++++++ integration/actions.go | 18 +++++++++++- 3 files changed, 98 insertions(+), 4 deletions(-) diff --git a/cmd/ftl/cmd_schema_diff.go b/cmd/ftl/cmd_schema_diff.go index 0798609098..915ef6b6ab 100644 --- a/cmd/ftl/cmd_schema_diff.go +++ b/cmd/ftl/cmd_schema_diff.go @@ -2,9 +2,11 @@ package main import ( "context" + "errors" "fmt" "net/url" "os" + "path/filepath" "connectrpc.com/connect" "github.com/alecthomas/chroma/v2/quick" @@ -17,17 +19,28 @@ import ( "github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/v1/ftlv1connect" schemapb "github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/v1/schema" "github.com/TBD54566975/ftl/backend/schema" + "github.com/TBD54566975/ftl/buildengine" + "github.com/TBD54566975/ftl/common/projectconfig" "github.com/TBD54566975/ftl/internal/log" "github.com/TBD54566975/ftl/internal/rpc" ) type schemaDiffCmd struct { - OtherEndpoint url.URL `arg:"" help:"Other endpoint URL to compare against."` + OtherEndpoint url.URL `arg:"" help:"Other endpoint URL to compare against. If this is not specified then ftl will perform a diff against the local schema." optional:""` Color bool `help:"Enable colored output regardless of TTY."` } -func (d *schemaDiffCmd) Run(ctx context.Context, currentURL *url.URL) error { - other, err := schemaForURL(ctx, d.OtherEndpoint) +func (d *schemaDiffCmd) Run(ctx context.Context, currentURL *url.URL, projConfig projectconfig.Config) error { + var other *schema.Schema + var err error + sameModulesOnly := false + if d.OtherEndpoint.String() == "" { + sameModulesOnly = true + other, err = localSchema(ctx, projConfig) + } else { + other, err = schemaForURL(ctx, d.OtherEndpoint) + } + if err != nil { return fmt.Errorf("failed to get other schema: %w", err) } @@ -35,6 +48,19 @@ func (d *schemaDiffCmd) Run(ctx context.Context, currentURL *url.URL) error { if err != nil { return fmt.Errorf("failed to get current schema: %w", err) } + if sameModulesOnly { + tempModules := current.Modules + current.Modules = []*schema.Module{} + moduleMap := map[string]*schema.Module{} + for _, i := range tempModules { + moduleMap[i.Name] = i + } + for _, i := range other.Modules { + if mod, ok := moduleMap[i.Name]; ok { + current.Modules = append(current.Modules, mod) + } + } + } edits := myers.ComputeEdits(span.URIFromPath(""), other.String(), current.String()) diff := fmt.Sprint(gotextdiff.ToUnified(d.OtherEndpoint.String(), currentURL.String(), other.String(), edits)) @@ -57,6 +83,31 @@ func (d *schemaDiffCmd) Run(ctx context.Context, currentURL *url.URL) error { return nil } +func localSchema(ctx context.Context, projectConfig projectconfig.Config) (*schema.Schema, error) { + pb := &schema.Schema{} + found := false + tried := "" + modules, err := buildengine.DiscoverModules(ctx, projectConfig.AbsModuleDirs()) + if err != nil { + return nil, fmt.Errorf("failed to discover modules %w", err) + } + for _, moduleSettings := range modules { + path := filepath.Join(moduleSettings.Config.Abs().Dir, ".ftl", "schema.pb") + mod, err := schema.ModuleFromProtoFile(path) + if err != nil { + tried += fmt.Sprintf(" failed to read schema file %s; did you run ftl build?", path) + } else { + found = true + pb.Modules = append(pb.Modules, mod) + } + + } + if !found { + return nil, errors.New(tried) + } + return pb, nil +} + func schemaForURL(ctx context.Context, url url.URL) (*schema.Schema, error) { client := rpc.Dial(ftlv1connect.NewControllerServiceClient, url.String(), log.Error) resp, err := client.PullSchema(ctx, connect.NewRequest(&ftlv1.PullSchemaRequest{})) diff --git a/cmd/ftl/integration_test.go b/cmd/ftl/integration_test.go index 6c252825ef..1d030b4274 100644 --- a/cmd/ftl/integration_test.go +++ b/cmd/ftl/integration_test.go @@ -143,3 +143,30 @@ func testImportExport(t *testing.T, object string) { }), ) } + +func TestLocalSchemaDiff(t *testing.T) { + newVerb := ` +//ftl:verb +func NewFunction(ctx context.Context, req TimeRequest) (TimeResponse, error) { + return TimeResponse{Time: time.Now()}, nil +} +` + Run(t, "", + CopyModule("time"), + Deploy("time"), + ExecWithOutput("ftl", []string{"schema", "diff"}, func(output string) { + assert.Equal(t, "", output) + }), + EditFile("time", func(bytes []byte) []byte { + s := string(bytes) + s += newVerb + return []byte(s) + }, "time.go"), + Build("time"), + // We exit with code 1 when there is a difference + ExpectError( + ExecWithOutput("ftl", []string{"schema", "diff"}, func(output string) { + assert.Contains(t, output, "- verb newFunction(time.TimeRequest) time.TimeResponse") + }), "exit status 1"), + ) +} diff --git a/integration/actions.go b/integration/actions.go index 1a8326562c..ebfe57d400 100644 --- a/integration/actions.go +++ b/integration/actions.go @@ -23,11 +23,12 @@ import ( "github.com/kballard/go-shellquote" "github.com/otiai10/copy" + "github.com/TBD54566975/scaffolder" + ftlv1 "github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/v1" schemapb "github.com/TBD54566975/ftl/backend/protos/xyz/block/ftl/v1/schema" ftlexec "github.com/TBD54566975/ftl/internal/exec" "github.com/TBD54566975/ftl/internal/log" - "github.com/TBD54566975/scaffolder" ) // Scaffold a directory relative to the testdata directory to a directory relative to the working directory. @@ -280,6 +281,21 @@ func WriteFile(path string, content []byte) Action { } } +// EditFile edits a file in a module +func EditFile(module string, editFunc func([]byte) []byte, path ...string) Action { + return func(t testing.TB, ic TestContext) { + parts := []string{ic.workDir, module} + parts = append(parts, path...) + file := filepath.Join(parts...) + Infof("Editing %s", file) + contents, err := os.ReadFile(file) + assert.NoError(t, err) + contents = editFunc(contents) + err = os.WriteFile(file, contents, os.FileMode(0)) + assert.NoError(t, err) + } +} + type Obj map[string]any // Call a verb.