diff --git a/internal/link/link.go b/internal/link/link.go index 0c2aa1de4..fd2d57b43 100644 --- a/internal/link/link.go +++ b/internal/link/link.go @@ -1,7 +1,6 @@ package link import ( - "bytes" "context" "fmt" "os" @@ -9,7 +8,6 @@ import ( "strings" "sync" - "github.com/BurntSushi/toml" "github.com/go-errors/errors" "github.com/jackc/pgconn" "github.com/jackc/pgx/v4" @@ -25,7 +23,7 @@ import ( ) func Run(ctx context.Context, projectRef string, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { - original := toTomlBytes(map[string]interface{}{ + original := cliConfig.ToTomlBytes(map[string]interface{}{ "api": utils.Config.Api, "db": utils.Config.Db, }) @@ -60,7 +58,7 @@ func Run(ctx context.Context, projectRef string, fsys afero.Fs, options ...func( fmt.Fprintln(os.Stdout, "Finished "+utils.Aqua("supabase link")+".") // 4. Suggest config update - updated := toTomlBytes(map[string]interface{}{ + updated := cliConfig.ToTomlBytes(map[string]interface{}{ "api": utils.Config.Api, "db": utils.Config.Db, }) @@ -72,16 +70,6 @@ func Run(ctx context.Context, projectRef string, fsys afero.Fs, options ...func( return nil } -func toTomlBytes(config any) []byte { - var buf bytes.Buffer - enc := toml.NewEncoder(&buf) - enc.Indent = "" - if err := enc.Encode(config); err != nil { - fmt.Fprintln(utils.GetDebugLogger(), "failed to marshal toml config:", err) - } - return buf.Bytes() -} - func LinkServices(ctx context.Context, projectRef, anonKey string, fsys afero.Fs) { // Ignore non-fatal errors linking services var wg sync.WaitGroup diff --git a/pkg/config/api.go b/pkg/config/api.go new file mode 100644 index 000000000..f502b9281 --- /dev/null +++ b/pkg/config/api.go @@ -0,0 +1,72 @@ +package config + +import ( + "strings" + + v1API "github.com/supabase/cli/pkg/api" +) + +type ( + api struct { + Enabled bool `toml:"enabled"` + Image string `toml:"-"` + KongImage string `toml:"-"` + Port uint16 `toml:"port"` + Schemas []string `toml:"schemas"` + ExtraSearchPath []string `toml:"extra_search_path"` + MaxRows uint `toml:"max_rows"` + Tls tlsKong `toml:"tls"` + // TODO: replace [auth|studio].api_url + ExternalUrl string `toml:"external_url"` + } + + tlsKong struct { + Enabled bool `toml:"enabled"` + } +) + +func (a *api) ToUpdatePostgrestConfigBody() v1API.UpdatePostgrestConfigBody { + body := v1API.UpdatePostgrestConfigBody{} + + // Convert Schemas to a comma-separated string + if len(a.Schemas) > 0 { + schemas := strings.Join(a.Schemas, ",") + body.DbSchema = &schemas + } + + // Convert ExtraSearchPath to a comma-separated string + if len(a.ExtraSearchPath) > 0 { + extraSearchPath := strings.Join(a.ExtraSearchPath, ",") + body.DbExtraSearchPath = &extraSearchPath + } + + // Convert MaxRows to int pointer + if a.MaxRows > 0 { + maxRows := int(a.MaxRows) + body.MaxRows = &maxRows + } + + // Note: DbPool is not present in the Api struct, so it's not set here + return body +} + +func (a *api) FromRemoteApiConfig(remoteConfig v1API.PostgrestConfigWithJWTSecretResponse) api { + result := *a + // Update Schemas if present in remoteConfig + result.Schemas = strings.Split(remoteConfig.DbSchema, ",") + + // Update ExtraSearchPath if present in remoteConfig + result.ExtraSearchPath = strings.Split(remoteConfig.DbExtraSearchPath, ",") + + // Update MaxRows if present in remoteConfig + result.MaxRows = uint(remoteConfig.MaxRows) + + return result +} + +func (a *api) DiffWithRemote(remoteConfig v1API.PostgrestConfigWithJWTSecretResponse) []byte { + // Convert the config values into easily comparable remoteConfig values + currentValue := ToTomlBytes(a) + remoteCompare := ToTomlBytes(a.FromRemoteApiConfig(remoteConfig)) + return Diff("remote[api]", remoteCompare, "local[api]", currentValue) +} diff --git a/pkg/config/api_test.go b/pkg/config/api_test.go new file mode 100644 index 000000000..881cd4893 --- /dev/null +++ b/pkg/config/api_test.go @@ -0,0 +1,77 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" + v1API "github.com/supabase/cli/pkg/api" +) + +func TestApiToUpdatePostgrestConfigBody(t *testing.T) { + t.Run("converts all fields correctly", func(t *testing.T) { + api := &api{ + Schemas: []string{"public", "private"}, + ExtraSearchPath: []string{"extensions", "public"}, + MaxRows: 1000, + } + + body := api.ToUpdatePostgrestConfigBody() + + assert.Equal(t, "public,private", *body.DbSchema) + assert.Equal(t, "extensions,public", *body.DbExtraSearchPath) + assert.Equal(t, 1000, *body.MaxRows) + }) + + t.Run("handles empty fields", func(t *testing.T) { + api := &api{} + + body := api.ToUpdatePostgrestConfigBody() + + assert.Nil(t, body.DbSchema) + assert.Nil(t, body.DbExtraSearchPath) + assert.Nil(t, body.MaxRows) + }) +} + +func TestApiDiffWithRemote(t *testing.T) { + t.Run("detects differences", func(t *testing.T) { + api := &api{ + Schemas: []string{"public", "private"}, + ExtraSearchPath: []string{"extensions", "public"}, + MaxRows: 1000, + } + + remoteConfig := v1API.PostgrestConfigWithJWTSecretResponse{ + DbSchema: "public", + DbExtraSearchPath: "public", + MaxRows: 500, + } + + diff := api.DiffWithRemote(remoteConfig) + + assert.Contains(t, string(diff), "-schemas = [\"public\"]") + assert.Contains(t, string(diff), "+schemas = [\"public\", \"private\"]") + assert.Contains(t, string(diff), "-extra_search_path = [\"public\"]") + assert.Contains(t, string(diff), "+extra_search_path = [\"extensions\", \"public\"]") + assert.Contains(t, string(diff), "-max_rows = 500") + assert.Contains(t, string(diff), "+max_rows = 1000") + }) + + t.Run("handles no differences", func(t *testing.T) { + api := &api{ + Schemas: []string{"public"}, + ExtraSearchPath: []string{"public"}, + MaxRows: 500, + } + + remoteConfig := v1API.PostgrestConfigWithJWTSecretResponse{ + DbSchema: "public", + DbExtraSearchPath: "public", + MaxRows: 500, + } + + diff := api.DiffWithRemote(remoteConfig) + + assert.Empty(t, diff) + }) +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 80270124c..e00fc5b8e 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -145,23 +145,6 @@ type ( Remotes map[string]baseConfig `toml:"-"` } - api struct { - Enabled bool `toml:"enabled"` - Image string `toml:"-"` - KongImage string `toml:"-"` - Port uint16 `toml:"port"` - Schemas []string `toml:"schemas"` - ExtraSearchPath []string `toml:"extra_search_path"` - MaxRows uint `toml:"max_rows"` - Tls tlsKong `toml:"tls"` - // TODO: replace [auth|studio].api_url - ExternalUrl string `toml:"external_url"` - } - - tlsKong struct { - Enabled bool `toml:"enabled"` - } - db struct { Image string `toml:"-"` Port uint16 `toml:"port"` @@ -1322,3 +1305,13 @@ func (a *auth) ResolveJWKS(ctx context.Context) (string, error) { return string(jwksEncoded), nil } + +func ToTomlBytes(config any) []byte { + var buf bytes.Buffer + enc := toml.NewEncoder(&buf) + enc.Indent = "" + if err := enc.Encode(config); err != nil { + fmt.Fprintln(os.Stderr, "failed to marshal toml config:", err) + } + return buf.Bytes() +} diff --git a/pkg/config/diff.go b/pkg/config/diff.go new file mode 100644 index 000000000..04aa76ed4 --- /dev/null +++ b/pkg/config/diff.go @@ -0,0 +1,261 @@ +// Copyright 2022 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package config + +import ( + "bytes" + "fmt" + "sort" + "strings" +) + +// A pair is a pair of values tracked for both the x and y side of a diff. +// It is typically a pair of line indexes. +type pair struct{ x, y int } + +// Diff returns an anchored diff of the two texts old and new +// in the “unified diff” format. If old and new are identical, +// Diff returns a nil slice (no output). +// +// Unix diff implementations typically look for a diff with +// the smallest number of lines inserted and removed, +// which can in the worst case take time quadratic in the +// number of lines in the texts. As a result, many implementations +// either can be made to run for a long time or cut off the search +// after a predetermined amount of work. +// +// In contrast, this implementation looks for a diff with the +// smallest number of “unique” lines inserted and removed, +// where unique means a line that appears just once in both old and new. +// We call this an “anchored diff” because the unique lines anchor +// the chosen matching regions. An anchored diff is usually clearer +// than a standard diff, because the algorithm does not try to +// reuse unrelated blank lines or closing braces. +// The algorithm also guarantees to run in O(n log n) time +// instead of the standard O(n²) time. +// +// Some systems call this approach a “patience diff,” named for +// the “patience sorting” algorithm, itself named for a solitaire card game. +// We avoid that name for two reasons. First, the name has been used +// for a few different variants of the algorithm, so it is imprecise. +// Second, the name is frequently interpreted as meaning that you have +// to wait longer (to be patient) for the diff, meaning that it is a slower algorithm, +// when in fact the algorithm is faster than the standard one. +func Diff(oldName string, old []byte, newName string, new []byte) []byte { + if bytes.Equal(old, new) { + return nil + } + x := lines(old) + y := lines(new) + + // Print diff header. + var out bytes.Buffer + fmt.Fprintf(&out, "diff %s %s\n", oldName, newName) + fmt.Fprintf(&out, "--- %s\n", oldName) + fmt.Fprintf(&out, "+++ %s\n", newName) + + // Loop over matches to consider, + // expanding each match to include surrounding lines, + // and then printing diff chunks. + // To avoid setup/teardown cases outside the loop, + // tgs returns a leading {0,0} and trailing {len(x), len(y)} pair + // in the sequence of matches. + var ( + done pair // printed up to x[:done.x] and y[:done.y] + chunk pair // start lines of current chunk + count pair // number of lines from each side in current chunk + ctext []string // lines for current chunk + ) + for _, m := range tgs(x, y) { + if m.x < done.x { + // Already handled scanning forward from earlier match. + continue + } + + // Expand matching lines as far as possible, + // establishing that x[start.x:end.x] == y[start.y:end.y]. + // Note that on the first (or last) iteration we may (or definitely do) + // have an empty match: start.x==end.x and start.y==end.y. + start := m + for start.x > done.x && start.y > done.y && x[start.x-1] == y[start.y-1] { + start.x-- + start.y-- + } + end := m + for end.x < len(x) && end.y < len(y) && x[end.x] == y[end.y] { + end.x++ + end.y++ + } + + // Emit the mismatched lines before start into this chunk. + // (No effect on first sentinel iteration, when start = {0,0}.) + for _, s := range x[done.x:start.x] { + ctext = append(ctext, "-"+s) + count.x++ + } + for _, s := range y[done.y:start.y] { + ctext = append(ctext, "+"+s) + count.y++ + } + + // If we're not at EOF and have too few common lines, + // the chunk includes all the common lines and continues. + const C = 3 // number of context lines + if (end.x < len(x) || end.y < len(y)) && + (end.x-start.x < C || (len(ctext) > 0 && end.x-start.x < 2*C)) { + for _, s := range x[start.x:end.x] { + ctext = append(ctext, " "+s) + count.x++ + count.y++ + } + done = end + continue + } + + // End chunk with common lines for context. + if len(ctext) > 0 { + n := end.x - start.x + if n > C { + n = C + } + for _, s := range x[start.x : start.x+n] { + ctext = append(ctext, " "+s) + count.x++ + count.y++ + } + done = pair{start.x + n, start.y + n} + + // Format and emit chunk. + // Convert line numbers to 1-indexed. + // Special case: empty file shows up as 0,0 not 1,0. + if count.x > 0 { + chunk.x++ + } + if count.y > 0 { + chunk.y++ + } + fmt.Fprintf(&out, "@@ -%d,%d +%d,%d @@\n", chunk.x, count.x, chunk.y, count.y) + for _, s := range ctext { + out.WriteString(s) + } + count.x = 0 + count.y = 0 + ctext = ctext[:0] + } + + // If we reached EOF, we're done. + if end.x >= len(x) && end.y >= len(y) { + break + } + + // Otherwise start a new chunk. + chunk = pair{end.x - C, end.y - C} + for _, s := range x[chunk.x:end.x] { + ctext = append(ctext, " "+s) + count.x++ + count.y++ + } + done = end + } + + return out.Bytes() +} + +// lines returns the lines in the file x, including newlines. +// If the file does not end in a newline, one is supplied +// along with a warning about the missing newline. +func lines(x []byte) []string { + l := strings.SplitAfter(string(x), "\n") + if l[len(l)-1] == "" { + l = l[:len(l)-1] + } else { + // Treat last line as having a message about the missing newline attached, + // using the same text as BSD/GNU diff (including the leading backslash). + l[len(l)-1] += "\n\\ No newline at end of file\n" + } + return l +} + +// tgs returns the pairs of indexes of the longest common subsequence +// of unique lines in x and y, where a unique line is one that appears +// once in x and once in y. +// +// The longest common subsequence algorithm is as described in +// Thomas G. Szymanski, “A Special Case of the Maximal Common +// Subsequence Problem,” Princeton TR #170 (January 1975), +// available at https://research.swtch.com/tgs170.pdf. +func tgs(x, y []string) []pair { + // Count the number of times each string appears in a and b. + // We only care about 0, 1, many, counted as 0, -1, -2 + // for the x side and 0, -4, -8 for the y side. + // Using negative numbers now lets us distinguish positive line numbers later. + m := make(map[string]int) + for _, s := range x { + if c := m[s]; c > -2 { + m[s] = c - 1 + } + } + for _, s := range y { + if c := m[s]; c > -8 { + m[s] = c - 4 + } + } + + // Now unique strings can be identified by m[s] = -1+-4. + // + // Gather the indexes of those strings in x and y, building: + // xi[i] = increasing indexes of unique strings in x. + // yi[i] = increasing indexes of unique strings in y. + // inv[i] = index j such that x[xi[i]] = y[yi[j]]. + var xi, yi, inv []int + for i, s := range y { + if m[s] == -1+-4 { + m[s] = len(yi) + yi = append(yi, i) + } + } + for i, s := range x { + if j, ok := m[s]; ok && j >= 0 { + xi = append(xi, i) + inv = append(inv, j) + } + } + + // Apply Algorithm A from Szymanski's paper. + // In those terms, A = J = inv and B = [0, n). + // We add sentinel pairs {0,0}, and {len(x),len(y)} + // to the returned sequence, to help the processing loop. + J := inv + n := len(xi) + T := make([]int, n) + L := make([]int, n) + for i := range T { + T[i] = n + 1 + } + for i := 0; i < n; i++ { + k := sort.Search(n, func(k int) bool { + return T[k] >= J[i] + }) + T[k] = J[i] + L[i] = k + 1 + } + k := 0 + for _, v := range L { + if k < v { + k = v + } + } + seq := make([]pair, 2+k) + seq[1+k] = pair{len(x), len(y)} // sentinel at end + lastj := n + for i := n - 1; i >= 0; i-- { + if L[i] == k && J[i] < lastj { + seq[k] = pair{xi[i], yi[J[i]]} + k-- + } + } + seq[0] = pair{0, 0} // sentinel at start + return seq +}