diff --git a/apps/cli-go/internal/db/diff/diff.go b/apps/cli-go/internal/db/diff/diff.go index aa286eea5b..5d2790e5b9 100644 --- a/apps/cli-go/internal/db/diff/diff.go +++ b/apps/cli-go/internal/db/diff/diff.go @@ -23,6 +23,7 @@ import ( "github.com/spf13/afero" "github.com/supabase/cli/internal/db/start" "github.com/supabase/cli/internal/utils" + configpkg "github.com/supabase/cli/pkg/config" "github.com/supabase/cli/pkg/migration" "github.com/supabase/cli/pkg/parser" ) @@ -70,7 +71,11 @@ func loadDeclaredSchemas(fsys afero.Fs) ([]string, error) { } } if schemas := utils.Config.Db.Migrations.SchemaPaths; len(schemas) > 0 { - return schemas.Files(afero.NewIOFS(fsys)) + return schemas.Files( + afero.NewIOFS(fsys), + configpkg.WithSkipEmptyGlobs(), + configpkg.WithErrorOnAllSkippedGlobs(), + ) } if exists, err := afero.DirExists(fsys, utils.SchemasDir); err != nil { return nil, errors.Errorf("failed to check schemas: %w", err) diff --git a/apps/cli-go/internal/db/diff/diff_test.go b/apps/cli-go/internal/db/diff/diff_test.go index e0df5b328a..2a6a2d4ca4 100644 --- a/apps/cli-go/internal/db/diff/diff_test.go +++ b/apps/cli-go/internal/db/diff/diff_test.go @@ -409,3 +409,52 @@ func TestLoadSchemas(t *testing.T) { assert.NoError(t, err) assert.ElementsMatch(t, expected, schemas) } + +func TestLoadSchemasSkipsEmptySchemaPathGlobs(t *testing.T) { + fsys := afero.NewMemMapFs() + matched := filepath.Join(utils.SupabaseDirPath, "schemas", "tables", "players.sql") + require.NoError(t, afero.WriteFile(fsys, matched, nil, 0644)) + utils.Config.Db.Migrations.SchemaPaths = []string{ + filepath.Join(utils.SupabaseDirPath, "schemas", "tables", "*.sql"), + filepath.Join(utils.SupabaseDirPath, "schemas", "materialized_views", "*.sql"), + } + t.Cleanup(func() { + utils.Config.Db.Migrations.SchemaPaths = nil + }) + + schemas, err := loadDeclaredSchemas(fsys) + + assert.NoError(t, err) + assert.Equal(t, []string{filepath.ToSlash(matched)}, schemas) +} + +func TestLoadSchemasErrorsOnMissingLiteralSchemaPath(t *testing.T) { + fsys := afero.NewMemMapFs() + utils.Config.Db.Migrations.SchemaPaths = []string{ + filepath.Join(utils.SupabaseDirPath, "schemas", "tables", "players.sql"), + } + t.Cleanup(func() { + utils.Config.Db.Migrations.SchemaPaths = nil + }) + + schemas, err := loadDeclaredSchemas(fsys) + + assert.ErrorContains(t, err, "no files matched pattern") + assert.Empty(t, schemas) +} + +func TestLoadSchemasErrorsWhenAllSchemaPathGlobsAreEmpty(t *testing.T) { + fsys := afero.NewMemMapFs() + utils.Config.Db.Migrations.SchemaPaths = []string{ + filepath.Join(utils.SupabaseDirPath, "schemas", "tables", "*.sql"), + filepath.Join(utils.SupabaseDirPath, "schemas", "views", "*.sql"), + } + t.Cleanup(func() { + utils.Config.Db.Migrations.SchemaPaths = nil + }) + + schemas, err := loadDeclaredSchemas(fsys) + + assert.ErrorContains(t, err, "no files matched pattern") + assert.Empty(t, schemas) +} diff --git a/apps/cli-go/pkg/config/config.go b/apps/cli-go/pkg/config/config.go index b2bf3f4a99..b9f253230a 100644 --- a/apps/cli-go/pkg/config/config.go +++ b/apps/cli-go/pkg/config/config.go @@ -97,11 +97,35 @@ func (p *RequestPolicy) UnmarshalText(text []byte) error { type Glob []string +type globOptions struct { + skipEmptyGlobs bool + errorOnAllSkipped bool +} + +type GlobOption func(*globOptions) + +func WithSkipEmptyGlobs() GlobOption { + return func(o *globOptions) { + o.skipEmptyGlobs = true + } +} + +func WithErrorOnAllSkippedGlobs() GlobOption { + return func(o *globOptions) { + o.errorOnAllSkipped = true + } +} + // Match the glob patterns in the given FS to get a deduplicated // array of all migrations files to apply in the declared order. -func (g Glob) Files(fsys fs.FS) ([]string, error) { +func (g Glob) Files(fsys fs.FS, options ...GlobOption) ([]string, error) { + opts := globOptions{} + for _, apply := range options { + apply(&opts) + } var result []string var allErrors []error + var skipped []string set := make(map[string]struct{}) for _, pattern := range g { // Glob expects / as path separator on windows @@ -109,6 +133,10 @@ func (g Glob) Files(fsys fs.FS) ([]string, error) { if err != nil { allErrors = append(allErrors, errors.Errorf("failed to glob files: %w", err)) } else if len(matches) == 0 { + if opts.skipEmptyGlobs && hasGlobMeta(pattern) { + skipped = append(skipped, pattern) + continue + } allErrors = append(allErrors, errors.Errorf("no files matched pattern: %s", pattern)) } sort.Strings(matches) @@ -121,9 +149,18 @@ func (g Glob) Files(fsys fs.FS) ([]string, error) { } } } + if opts.errorOnAllSkipped && len(result) == 0 && len(skipped) > 0 { + for _, pattern := range skipped { + allErrors = append(allErrors, errors.Errorf("no files matched pattern: %s", pattern)) + } + } return result, errors.Join(allErrors...) } +func hasGlobMeta(pattern string) bool { + return strings.ContainsAny(pattern, `*?[`) +} + // We follow these rules when adding new config: // 1. Update init_config.toml (and init_config.test.toml) with the new key, default value, and comments to explain usage. // 2. Update config struct with new field and toml tag (spelled in snake_case). diff --git a/apps/cli-go/pkg/config/config_test.go b/apps/cli-go/pkg/config/config_test.go index fa38005152..6c2697ca0e 100644 --- a/apps/cli-go/pkg/config/config_test.go +++ b/apps/cli-go/pkg/config/config_test.go @@ -676,6 +676,34 @@ func TestGlobFiles(t *testing.T) { // Validate files assert.Empty(t, files) }) + + t.Run("skips empty globs when configured", func(t *testing.T) { + fsys := fs.MapFS{ + "supabase/schemas/tables/players.sql": &fs.MapFile{}, + } + g := Glob{ + "supabase/schemas/tables/*.sql", + "supabase/schemas/materialized_views/*.sql", + } + + files, err := g.Files(fsys, WithSkipEmptyGlobs()) + + assert.NoError(t, err) + assert.Equal(t, []string{"supabase/schemas/tables/players.sql"}, files) + }) + + t.Run("errors when all skipped globs are empty and configured to fail", func(t *testing.T) { + fsys := fs.MapFS{} + g := Glob{ + "supabase/schemas/tables/*.sql", + "supabase/schemas/materialized_views/*.sql", + } + + files, err := g.Files(fsys, WithSkipEmptyGlobs(), WithErrorOnAllSkippedGlobs()) + + assert.ErrorContains(t, err, "no files matched pattern") + assert.Empty(t, files) + }) } func TestLoadFunctionImportMap(t *testing.T) {