diff --git a/docs/execution-model.md b/docs/execution-model.md index d41781d..f6dce5f 100644 --- a/docs/execution-model.md +++ b/docs/execution-model.md @@ -307,6 +307,30 @@ concurrently is. | `invariant-violation` | yes | A breach of the invariant registry; never a retry candidate | | `execution-failed` | no | Fallback for a failure outside the typed set — an operational error to investigate, not a refusal to branch on | +### Create-shape causes + +`executor.CreateShapeCauses()` enumerates the closed vocabulary of reasons the create +path refuses a desired statement by its shape; `executor.CreateShapeCauseOf(err)` reads +the cause off a refusal. Like outcome codes, a cause is a stable identity for automation +to branch on, never prose. + +| Cause | Meaning | +| --- | --- | +| `partition-of` | Attaching a partition locks a parent the absence proof does not cover | +| `inherits` | `INHERITS` binds to an existing parent the absence proof does not cover | +| `like` | `LIKE` reads an existing source table the absence proof does not cover | +| `of-type` | `OF` binds to an existing composite type the absence proof does not cover | +| `if-not-exists` | A name-only no-op cannot prove the existing relation has the requested shape or is valid | +| `concurrently` | A table born this run needs no concurrent index build | +| `duplicate-name` | The desired set claims the same relation name twice | +| `multiple-operations` | The statement and operation parse boundaries disagree about the operation count | +| `unsupported-kind` | The statement is not a create kind the create path can run | + +`concurrently`, `multiple-operations`, and `unsupported-kind` re-verify preconditions +`statement.ParseDesired` already enforces — a desired file that passed admission cannot +produce them. They are published so the vocabulary is closed, not because automation should +expect them; a consumer seeing one has a desired schema that bypassed admission. + The three cancellation codes partition one server signal, SQLSTATE `57014`, in a fixed precedence. `budget-statement-exceeded` wins when the server's cancel arrives at or past the overall budget, even if the caller's own diff --git a/pkg/executor/create.go b/pkg/executor/create.go index 0649d46..b15c9e9 100644 --- a/pkg/executor/create.go +++ b/pkg/executor/create.go @@ -60,11 +60,11 @@ var ( // named after the table. The conflict is decidable before anything // runs, so admission refuses the whole set rather than letting a // mid-run step fail after a prefix committed. - ErrDuplicateCreateName = errors.New("desired set claims the same relation name twice") + ErrDuplicateCreateName = errors.New(CreateShapeDuplicateName.Description()) // ErrPartitionOfUnsupported is returned for CREATE TABLE ... PARTITION // OF: attaching a partition takes a lock on the partitioned parent, // an existing table the absence proof says nothing about. - ErrPartitionOfUnsupported = errors.New("CREATE TABLE PARTITION OF is not supported by the create path: attaching a partition locks the partitioned parent, which the absence proof does not cover") + ErrPartitionOfUnsupported = errors.New(CreateShapePartitionOf.Description()) // ErrUnsupportedCreateStep is returned when a desired statement is not // a shape the create path can run: a plain CREATE TABLE or a plain // CREATE INDEX on the new table. CONCURRENTLY is refused deliberately — @@ -285,7 +285,7 @@ func checkCreateSteps(schema string, ds statement.DesiredSchema) ([]createStep, for _, name := range step.claims { if _, taken := claimed[name]; taken { if step.refusal == nil { - step.refusal = fmt.Errorf("%w: %q", ErrDuplicateCreateName, name) + step.refusal = &CreateShapeError{Cause: CreateShapeDuplicateName, Name: name} } continue } @@ -332,79 +332,66 @@ func checkCreateStepShape(schema, table, sql string) (createStep, error) { if len(ops) != 1 { // ParseOne admitted a single statement, so a differing op count // means the two parse boundaries disagree about the same SQL. - step.refusal = fmt.Errorf("%w: statement carries %d operations", ErrUnsupportedCreateStep, len(ops)) + step.refusal = &CreateShapeError{Cause: CreateShapeMultipleOperations} return step, nil } op := ops[0] switch st.Kind() { case statement.KindCreateTable: - step.claims, step.refusal = checkCreateTableShape(qualified, st.Table(), op) + // The table claims its own name plus the first-choice relation + // names of its constraints and column-owned sequences. ParseOne + // already admitted this SQL as a CREATE TABLE, so a failure to read + // those names means the two parse boundaries disagree: a parse + // failure like any other, not a shape, so no positional result is + // safe and the step carries no refusal. + implicit, err := statement.ImplicitRelationNames(qualified) + if err != nil { + return createStep{}, fmt.Errorf("implicit relation names: %w", err) + } + step.claims = append([]string{st.Table()}, implicit...) + step.refusal = createTableShapeRefusal(op) case statement.KindCreateIndex: - step.claims, step.refusal = checkCreateIndexShape(op) + // An explicit index name is the step's claim; an unnamed index + // claims nothing decidable because the server invents the name. + if op.Name != "" { + step.claims = []string{op.Name} + } + step.refusal = createIndexShapeRefusal(op) default: - step.refusal = fmt.Errorf("%w: kind %q", ErrUnsupportedCreateStep, st.Kind()) + step.refusal = &CreateShapeError{Cause: CreateShapeUnsupportedKind} } return step, nil } -// checkCreateTableShape refuses the CREATE TABLE clauses that bind to a -// secondary relation or type and returns the names the table will claim: -// its own plus the first-choice relation names of its constraints and -// column-owned sequences. -// The claims are returned with the refusal so a later statement colliding -// with a refused table is still reported. -func checkCreateTableShape(qualified, table string, op statement.Op) ([]string, error) { - implicit, err := statement.ImplicitRelationNames(qualified) - if err != nil { - // ParseOne already admitted this SQL as a CREATE TABLE, so a - // refusal here means the two parse boundaries disagree. - return nil, fmt.Errorf("%w: %w", ErrUnsupportedCreateStep, err) - } - return append([]string{table}, implicit...), createTableShapeRefusal(op) -} - // createTableShapeRefusal names the CREATE TABLE clause that keeps the // statement off the create path, nil when the shape is admitted. func createTableShapeRefusal(op statement.Op) error { if op.PartitionOf { - return ErrPartitionOfUnsupported + return &CreateShapeError{Cause: CreateShapePartitionOf} } if op.Inherits { - return fmt.Errorf("%w: INHERITS binds to an existing parent the absence proof does not cover", ErrUnsupportedCreateStep) + return &CreateShapeError{Cause: CreateShapeInherits} } if op.Like { - return fmt.Errorf("%w: LIKE reads an existing source table the absence proof does not cover", ErrUnsupportedCreateStep) + return &CreateShapeError{Cause: CreateShapeLike} } if op.OfType { - return fmt.Errorf("%w: OF binds to an existing composite type the absence proof does not cover", ErrUnsupportedCreateStep) + return &CreateShapeError{Cause: CreateShapeOfType} } if op.IfNotExists { - return ErrIfNotExistsUnsupported + return &CreateShapeError{Cause: CreateShapeIfNotExists} } return nil } -// checkCreateIndexShape refuses index builds that cannot run against a -// table born this run and returns the explicit index name as the step's -// claim; an unnamed index claims nothing decidable. The claim is returned -// with the refusal so a later statement colliding with a refused index is -// still reported. -func checkCreateIndexShape(op statement.Op) ([]string, error) { - var claims []string - if op.Name != "" { - claims = []string{op.Name} - } - return claims, createIndexShapeRefusal(op) -} - // createIndexShapeRefusal names the CREATE INDEX clause that keeps the // statement off the create path, nil when the shape is admitted. func createIndexShapeRefusal(op statement.Op) error { if op.Concurrent { - return fmt.Errorf("%w: a concurrent build is refused on a table born this run", ErrUnsupportedCreateStep) + return &CreateShapeError{Cause: CreateShapeConcurrently} } if op.IfNotExists { - return ErrIfNotExistsUnsupported + return &CreateShapeError{Cause: CreateShapeIfNotExists} } return nil } diff --git a/pkg/executor/create_internal_test.go b/pkg/executor/create_internal_test.go new file mode 100644 index 0000000..0e9707a --- /dev/null +++ b/pkg/executor/create_internal_test.go @@ -0,0 +1,54 @@ +// White-box tests for create-step shape checking: the refusal causes the +// create path assigns below the ParseDesired boundary, which already turns +// away the shapes that reach them, so they are provable only here. + +package executor + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCheckCreateStepShapeAssignsCause(t *testing.T) { + tests := []struct { + name string + sql string + claims []string + cause CreateShapeCause + }{ + { + name: "concurrent index build", + sql: "CREATE INDEX CONCURRENTLY t_v_idx ON t (v)", + claims: []string{"t_v_idx"}, + cause: CreateShapeConcurrently, + }, + { + name: "alter table is not a create kind", + sql: "ALTER TABLE t ADD COLUMN v int", + cause: CreateShapeUnsupportedKind, + }, + { + name: "admitted table claims its implicit names", + sql: "CREATE TABLE t (id serial PRIMARY KEY)", + claims: []string{"t", "t_id_seq", "t_pkey"}, + }, + { + name: "unnamed index claims nothing", + sql: "CREATE INDEX ON t (v)", + cause: "", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + step, err := checkCreateStepShape("app", "t", tc.sql) + require.NoError(t, err) + assert.Equal(t, tc.claims, step.claims) + assert.Equal(t, tc.cause, CreateShapeCauseOf(step.refusal)) + if tc.cause == "" { + assert.NoError(t, step.refusal) + } + }) + } +} diff --git a/pkg/executor/create_shape.go b/pkg/executor/create_shape.go new file mode 100644 index 0000000..1d726ca --- /dev/null +++ b/pkg/executor/create_shape.go @@ -0,0 +1,134 @@ +// This file is the create path's shape-refusal vocabulary: every reason the +// create path refuses a desired statement by its shape maps to exactly one +// flat kebab-case CreateShapeCause, the same treatment code.go gives +// executor outcomes. A plan or verdict consumer branches on the cause — +// never on error prose — and each cause owns its one human sentence, so the +// sentinel errors, the typed refusal, and every renderer read the same text. + +package executor + +import ( + "errors" + "fmt" +) + +// CreateShapeCause is the stable identity of why the create path refuses a +// desired statement's shape. Automation branches on it, never on error text. +// Existing values never change meaning; new refusals add new values. +type CreateShapeCause string + +// The causes for which the create path can refuse a desired statement. +const ( + // CreateShapePartitionOf refuses attaching a partition because it locks + // the partitioned parent, which the absence proof does not cover. + CreateShapePartitionOf CreateShapeCause = "partition-of" + // CreateShapeInherits refuses binding to an existing parent because the + // absence proof does not cover it. + CreateShapeInherits CreateShapeCause = "inherits" + // CreateShapeLike refuses reading an existing source table because the + // absence proof does not cover it. + CreateShapeLike CreateShapeCause = "like" + // CreateShapeOfType refuses binding to an existing composite type because + // the absence proof does not cover it. + CreateShapeOfType CreateShapeCause = "of-type" + // CreateShapeIfNotExists refuses a name-only no-op because it cannot prove + // the existing relation has the requested shape or is valid. + CreateShapeIfNotExists CreateShapeCause = "if-not-exists" + // CreateShapeConcurrently refuses a concurrent build because a table born + // this run has no traffic to protect and a plain build cannot leave an + // invalid index behind a failure. + CreateShapeConcurrently CreateShapeCause = "concurrently" + // CreateShapeDuplicateName refuses a desired set that claims the same + // relation name twice before any statement runs. + CreateShapeDuplicateName CreateShapeCause = "duplicate-name" + // CreateShapeMultipleOperations refuses a statement when the statement and + // operation parse boundaries disagree about its operation count. + CreateShapeMultipleOperations CreateShapeCause = "multiple-operations" + // CreateShapeUnsupportedKind refuses a statement kind outside the plain + // CREATE TABLE and CREATE INDEX shapes the create path can run. + CreateShapeUnsupportedKind CreateShapeCause = "unsupported-kind" +) + +// CreateShapeCauses returns the closed set of create-shape refusal causes. +func CreateShapeCauses() []CreateShapeCause { + return []CreateShapeCause{ + CreateShapePartitionOf, + CreateShapeInherits, + CreateShapeLike, + CreateShapeOfType, + CreateShapeIfNotExists, + CreateShapeConcurrently, + CreateShapeDuplicateName, + CreateShapeMultipleOperations, + CreateShapeUnsupportedKind, + } +} + +// Description returns the human-facing sentence for a create-shape cause. +func (c CreateShapeCause) Description() string { + switch c { + case CreateShapePartitionOf: + return "CREATE TABLE PARTITION OF is not supported by the create path: attaching a partition locks the partitioned parent, which the absence proof does not cover" + case CreateShapeInherits: + return "INHERITS binds to an existing parent the absence proof does not cover" + case CreateShapeLike: + return "LIKE reads an existing source table the absence proof does not cover" + case CreateShapeOfType: + return "OF binds to an existing composite type the absence proof does not cover" + case CreateShapeIfNotExists: + return "IF NOT EXISTS is not supported: a name-only no-op cannot prove the existing relation is the requested one, or even valid" + case CreateShapeConcurrently: + return "a concurrent build is refused on a table born this run" + case CreateShapeDuplicateName: + return "desired set claims the same relation name twice" + case CreateShapeMultipleOperations: + return "statement carries multiple operations" + case CreateShapeUnsupportedKind: + return "statement kind is not supported by the create path" + default: + return "unknown create-shape refusal" + } +} + +// CreateShapeError identifies a create-shape refusal. Name is populated only +// when Cause is CreateShapeDuplicateName. +type CreateShapeError struct { + Cause CreateShapeCause + Name string +} + +// Error renders the create-shape refusal for a human reader. +func (e *CreateShapeError) Error() string { + if e.Cause == CreateShapeDuplicateName && e.Name != "" { + return fmt.Sprintf("%s: %q", e.Cause.Description(), e.Name) + } + return e.Cause.Description() +} + +// Unwrap exposes the existing sentinel boundary for errors.Is callers. +func (e *CreateShapeError) Unwrap() error { + switch e.Cause { + case CreateShapePartitionOf: + return ErrPartitionOfUnsupported + case CreateShapeIfNotExists: + return ErrIfNotExistsUnsupported + case CreateShapeDuplicateName: + return ErrDuplicateCreateName + case CreateShapeInherits, CreateShapeLike, CreateShapeOfType, CreateShapeConcurrently, + CreateShapeMultipleOperations, CreateShapeUnsupportedKind: + return ErrUnsupportedCreateStep + default: + return nil + } +} + +// CreateShapeCauseOf returns the create-shape cause carried by err, or the +// empty cause when err is nil or carries no create-shape refusal. Wrappers, +// including a *SequenceStepError naming the failed step, are read through. +func CreateShapeCauseOf(err error) CreateShapeCause { + var shapeErr *CreateShapeError + if errors.As(err, &shapeErr) { + return shapeErr.Cause + } + return "" +} diff --git a/pkg/executor/create_shape_test.go b/pkg/executor/create_shape_test.go new file mode 100644 index 0000000..2c618b0 --- /dev/null +++ b/pkg/executor/create_shape_test.go @@ -0,0 +1,136 @@ +package executor_test + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/block/pg-sprite/pkg/executor" + "github.com/block/pg-sprite/pkg/statement" +) + +func TestCreateShapeRefusalCauses(t *testing.T) { + tests := []struct { + name string + sql string + position int + cause executor.CreateShapeCause + sentinel error + code executor.Code + }{ + {name: "partition of", sql: "CREATE TABLE t PARTITION OF parent FOR VALUES FROM (1) TO (2)", cause: executor.CreateShapePartitionOf, sentinel: executor.ErrPartitionOfUnsupported, code: executor.CodePartitionOfUnsupported}, + {name: "inherits", sql: "CREATE TABLE t (id int) INHERITS (parent)", cause: executor.CreateShapeInherits, sentinel: executor.ErrUnsupportedCreateStep, code: executor.CodeUnsupportedCreateStep}, + {name: "like", sql: "CREATE TABLE t (LIKE source)", cause: executor.CreateShapeLike, sentinel: executor.ErrUnsupportedCreateStep, code: executor.CodeUnsupportedCreateStep}, + {name: "of type", sql: "CREATE TABLE t OF source_type", cause: executor.CreateShapeOfType, sentinel: executor.ErrUnsupportedCreateStep, code: executor.CodeUnsupportedCreateStep}, + {name: "table if not exists", sql: "CREATE TABLE IF NOT EXISTS t (id int)", cause: executor.CreateShapeIfNotExists, sentinel: executor.ErrIfNotExistsUnsupported, code: executor.CodeIfNotExistsUnsupported}, + {name: "index if not exists", sql: "CREATE TABLE t (id int); CREATE INDEX IF NOT EXISTS t_id ON t (id)", position: 1, cause: executor.CreateShapeIfNotExists, sentinel: executor.ErrIfNotExistsUnsupported, code: executor.CodeIfNotExistsUnsupported}, + {name: "duplicate name", sql: "CREATE TABLE t (id int PRIMARY KEY); CREATE INDEX t_pkey ON t (id)", position: 1, cause: executor.CreateShapeDuplicateName, sentinel: executor.ErrDuplicateCreateName, code: executor.CodeDuplicateCreateName}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ds, err := statement.ParseDesired(tc.sql) + require.NoError(t, err) + refusals, err := executor.CreateShapeRefusals("app", ds) + require.NoError(t, err) + require.Error(t, refusals[tc.position]) + assert.Equal(t, tc.cause, executor.CreateShapeCauseOf(refusals[tc.position])) + assert.ErrorIs(t, refusals[tc.position], tc.sentinel) + assert.Equal(t, tc.code, executor.OutcomeCode(refusals[tc.position])) + }) + } +} + +// The duplicate-name refusal names the colliding relation, so an operator +// reading the CLI sees which name the desired set claimed twice. +func TestCreateShapeRefusalNamesDuplicate(t *testing.T) { + ds, err := statement.ParseDesired("CREATE TABLE t (id int PRIMARY KEY); CREATE INDEX t_pkey ON t (id)") + require.NoError(t, err) + refusals, err := executor.CreateShapeRefusals("app", ds) + require.NoError(t, err) + require.Error(t, refusals[1]) + assert.Equal(t, executor.CreateShapeDuplicateName.Description()+`: "t_pkey"`, refusals[1].Error()) +} + +// Every published cause maps to a sentinel, an outcome code, and its own +// sentence. The table is checked against CreateShapeCauses() in both +// directions, so a cause added to the vocabulary without a mapping — or a +// Description or Unwrap arm left to fall through to the default — fails here +// rather than surfacing as "unknown create-shape refusal" with the +// execution-failed code at runtime. +func TestCreateShapeErrorMappings(t *testing.T) { + // keyword is the fragment that identifies the cause's own sentence, so + // two causes cannot silently swap prose. + tests := map[executor.CreateShapeCause]struct { + sentinel error + code executor.Code + keyword string + }{ + executor.CreateShapePartitionOf: {executor.ErrPartitionOfUnsupported, executor.CodePartitionOfUnsupported, "PARTITION OF"}, + executor.CreateShapeInherits: {executor.ErrUnsupportedCreateStep, executor.CodeUnsupportedCreateStep, "INHERITS"}, + executor.CreateShapeLike: {executor.ErrUnsupportedCreateStep, executor.CodeUnsupportedCreateStep, "LIKE"}, + executor.CreateShapeOfType: {executor.ErrUnsupportedCreateStep, executor.CodeUnsupportedCreateStep, "OF binds"}, + executor.CreateShapeIfNotExists: {executor.ErrIfNotExistsUnsupported, executor.CodeIfNotExistsUnsupported, "IF NOT EXISTS"}, + executor.CreateShapeConcurrently: {executor.ErrUnsupportedCreateStep, executor.CodeUnsupportedCreateStep, "concurrent build"}, + executor.CreateShapeDuplicateName: {executor.ErrDuplicateCreateName, executor.CodeDuplicateCreateName, "same relation name twice"}, + executor.CreateShapeMultipleOperations: {executor.ErrUnsupportedCreateStep, executor.CodeUnsupportedCreateStep, "multiple operations"}, + executor.CreateShapeUnsupportedKind: {executor.ErrUnsupportedCreateStep, executor.CodeUnsupportedCreateStep, "statement kind"}, + } + causes := executor.CreateShapeCauses() + require.Len(t, tests, len(causes), "every cause in CreateShapeCauses() needs a mapping row") + + unknown := executor.CreateShapeCause("unknown").Description() + for _, cause := range causes { + t.Run(string(cause), func(t *testing.T) { + tc, mapped := tests[cause] + require.True(t, mapped, "cause %q has no mapping row", cause) + + err := &executor.CreateShapeError{Cause: cause} + require.NotNil(t, err.Unwrap(), "Unwrap falls through to the default arm") + assert.ErrorIs(t, err, tc.sentinel) + assert.Equal(t, tc.code, executor.OutcomeCode(err)) + assert.Equal(t, cause, executor.CreateShapeCauseOf(err)) + + description := cause.Description() + assert.NotEqual(t, unknown, description, "Description falls through to the default arm") + assert.Contains(t, description, tc.keyword) + assert.Equal(t, description, err.Error(), "a refusal without a name renders exactly its sentence") + }) + } +} + +// Only the duplicate-name refusal carries a relation name; the name is +// rendered after the sentence, and other causes ignore a stray name. +func TestCreateShapeErrorRendersName(t *testing.T) { + named := &executor.CreateShapeError{Cause: executor.CreateShapeDuplicateName, Name: "t_pkey"} + assert.Equal(t, executor.CreateShapeDuplicateName.Description()+`: "t_pkey"`, named.Error()) + + unnamed := &executor.CreateShapeError{Cause: executor.CreateShapeDuplicateName} + assert.Equal(t, executor.CreateShapeDuplicateName.Description(), unnamed.Error()) + + other := &executor.CreateShapeError{Cause: executor.CreateShapeLike, Name: "t_pkey"} + assert.Equal(t, executor.CreateShapeLike.Description(), other.Error()) +} + +func TestCreateShapeCauseOf(t *testing.T) { + assert.Empty(t, executor.CreateShapeCauseOf(nil)) + assert.Empty(t, executor.CreateShapeCauseOf(errors.New("unrelated"))) + + shapeErr := &executor.CreateShapeError{Cause: executor.CreateShapeLike} + stepErr := &executor.SequenceStepError{Step: 1, Total: 1, Err: shapeErr} + assert.Equal(t, executor.CreateShapeLike, executor.CreateShapeCauseOf(stepErr)) +} + +func TestCreateShapeCauseDescriptionsAreDistinct(t *testing.T) { + seen := make(map[string]executor.CreateShapeCause) + for _, cause := range executor.CreateShapeCauses() { + description := cause.Description() + assert.NotEmpty(t, description) + if previous, exists := seen[description]; exists { + assert.Fail(t, "duplicate description", "%q and %q share %q", previous, cause, description) + } + seen[description] = cause + } + assert.NotEmpty(t, executor.CreateShapeCause("unknown").Description()) +} diff --git a/pkg/executor/docs_test.go b/pkg/executor/docs_test.go index 4ff1739..4bb54b3 100644 --- a/pkg/executor/docs_test.go +++ b/pkg/executor/docs_test.go @@ -44,6 +44,18 @@ func TestDocNamesEveryOutcomeCode(t *testing.T) { } } +// Every create-shape cause automation can branch on must be named in the +// execution model so the documented vocabulary cannot drift from the code. +func TestDocNamesEveryCreateShapeCause(t *testing.T) { + raw, err := os.ReadFile(executionModelDoc) + require.NoError(t, err) + doc := string(raw) + for _, cause := range executor.CreateShapeCauses() { + assert.Contains(t, doc, fmt.Sprintf("`%s`", cause), + "docs/execution-model.md does not name create-shape cause %q", cause) + } +} + // The doc's Permanent column is Code.Permanent(): an adapter author reading // the table and one calling the method must reach the same retry class for // every code. @@ -107,9 +119,50 @@ func TestCodesEnumerateEveryDeclaredCode(t *testing.T) { assert.Equal(t, declared, enumerated, "Codes() must enumerate exactly the declared Code constants") } +// The closed set is complete: every CreateShapeCause constant declared in +// create_shape.go is enumerated by CreateShapeCauses(). +func TestCreateShapeCausesEnumerateEveryDeclaredCause(t *testing.T) { + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, "create_shape.go", nil, parser.SkipObjectResolution) + require.NoError(t, err) + + declared := make(map[executor.CreateShapeCause]struct{}) + for _, decl := range file.Decls { + gen, ok := decl.(*ast.GenDecl) + if !ok || gen.Tok != token.CONST { + continue + } + for _, spec := range gen.Specs { + vs, ok := spec.(*ast.ValueSpec) + if !ok || !isNamedType(vs.Type, "CreateShapeCause") { + continue + } + for _, value := range vs.Values { + lit, ok := value.(*ast.BasicLit) + require.True(t, ok && lit.Kind == token.STRING, "CreateShapeCause constants are string literals") + unquoted, err := strconv.Unquote(lit.Value) + require.NoError(t, err) + declared[executor.CreateShapeCause(unquoted)] = struct{}{} + } + } + } + require.NotEmpty(t, declared, "create_shape.go declares the CreateShapeCause constants") + + enumerated := make(map[executor.CreateShapeCause]struct{}) + for _, cause := range executor.CreateShapeCauses() { + enumerated[cause] = struct{}{} + } + assert.Equal(t, declared, enumerated, + "CreateShapeCauses() must enumerate exactly the declared CreateShapeCause constants") +} + func isCodeType(expr ast.Expr) bool { + return isNamedType(expr, "Code") +} + +func isNamedType(expr ast.Expr, name string) bool { ident, ok := expr.(*ast.Ident) - return ok && ident.Name == "Code" + return ok && ident.Name == name } // The closed set has no duplicates: a code pasted twice would silently diff --git a/pkg/executor/native.go b/pkg/executor/native.go index 7a9d11a..1a230dd 100644 --- a/pkg/executor/native.go +++ b/pkg/executor/native.go @@ -52,7 +52,7 @@ var ( // while an unrelated relation — or, for a concurrent build, an // invalid index — owns that name, so an executor could report // success over a relation it cannot vouch for. - ErrIfNotExistsUnsupported = errors.New("IF NOT EXISTS is not supported: a name-only no-op cannot prove the existing relation is the requested one, or even valid") + ErrIfNotExistsUnsupported = errors.New(CreateShapeIfNotExists.Description()) // ErrInvalidIndexBuildInFlight is returned (inside an *InvalidIndexError // carrying the builder's PID) when the invalid index under the // requested name is another backend's concurrent build still in