Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions internal/datastore/common/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,33 @@ type RevisionUnavailableError struct {
func NewRevisionUnavailableError(err error) error {
return RevisionUnavailableError{err}
}

// SchemaNotInitializedError is returned when a datastore operation fails because the
// required database tables do not exist. This typically means that migrations have not been run.
type SchemaNotInitializedError struct {
error
}

func (err SchemaNotInitializedError) GRPCStatus() *status.Status {
// TODO: Update to use ERROR_REASON_DATASTORE_NOT_MIGRATED once authzed/api#159 is merged
return spiceerrors.WithCodeAndDetails(
err,
codes.FailedPrecondition,
spiceerrors.ForReason(
v1.ErrorReason_ERROR_REASON_UNSPECIFIED,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: please create the right error code

map[string]string{},
),
)
}

func (err SchemaNotInitializedError) Unwrap() error {
return err.error
}

// NewSchemaNotInitializedError creates a new SchemaNotInitializedError with a helpful message
// instructing the user to run migrations.
func NewSchemaNotInitializedError(underlying error) error {
return SchemaNotInitializedError{
fmt.Errorf("%w. please run \"spicedb datastore migrate\"", underlying),
}
}
55 changes: 55 additions & 0 deletions internal/datastore/common/errors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package common

import (
"fmt"
"testing"

"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
)

func TestSchemaNotInitializedError(t *testing.T) {
underlyingErr := fmt.Errorf("relation \"caveat\" does not exist (SQLSTATE 42P01)")
err := NewSchemaNotInitializedError(underlyingErr)

t.Run("error message contains migration instructions", func(t *testing.T) {
require.Contains(t, err.Error(), "spicedb datastore migrate")
// The error message now includes the underlying error first, followed by the instruction
require.Contains(t, err.Error(), "relation \"caveat\" does not exist")
})

t.Run("unwrap returns underlying error", func(t *testing.T) {
var schemaErr SchemaNotInitializedError
require.ErrorAs(t, err, &schemaErr)
require.ErrorIs(t, schemaErr.Unwrap(), underlyingErr)
})

t.Run("grpc status is FailedPrecondition", func(t *testing.T) {
var schemaErr SchemaNotInitializedError
require.ErrorAs(t, err, &schemaErr)
status := schemaErr.GRPCStatus()
require.Equal(t, codes.FailedPrecondition, status.Code())
})

t.Run("can be detected with errors.As", func(t *testing.T) {
var schemaErr SchemaNotInitializedError
require.ErrorAs(t, err, &schemaErr)
})

t.Run("wrapped error preserves chain", func(t *testing.T) {
wrappedErr := fmt.Errorf("outer: %w", err)
var schemaErr SchemaNotInitializedError
require.ErrorAs(t, wrappedErr, &schemaErr)
})

t.Run("grpc status extractable from wrapped error", func(t *testing.T) {
// This tests the scenario where SchemaNotInitializedError is wrapped
// by another fmt.Errorf (e.g., in crdb/caveat.go). The gRPC library
// uses errors.As to extract GRPCStatus from wrapped errors.
wrappedErr := fmt.Errorf("outer context: %w", err)
var schemaErr SchemaNotInitializedError
require.ErrorAs(t, wrappedErr, &schemaErr)
status := schemaErr.GRPCStatus()
require.Equal(t, codes.FailedPrecondition, status.Code())
})
}
4 changes: 1 addition & 3 deletions internal/datastore/crdb/migrations/driver.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@ import (
const (
errUnableToInstantiate = "unable to instantiate CRDBDriver: %w"

postgresMissingTableErrorCode = "42P01"

queryLoadVersion = "SELECT version_num from schema_version"
queryWriteVersion = "UPDATE schema_version SET version_num=$1 WHERE version_num=$2"
)
Expand Down Expand Up @@ -52,7 +50,7 @@ func (apd *CRDBDriver) Version(ctx context.Context) (string, error) {

if err := apd.db.QueryRow(ctx, queryLoadVersion).Scan(&loaded); err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == postgresMissingTableErrorCode {
if errors.As(err, &pgErr) && pgErr.Code == pgxcommon.PgMissingTable {
return "", nil
}
return "", fmt.Errorf("unable to load alembic revision: %w", err)
Expand Down
38 changes: 38 additions & 0 deletions internal/datastore/mysql/common/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package common

import (
"errors"

"github.com/go-sql-driver/mysql"

dscommon "github.com/authzed/spicedb/internal/datastore/common"
)

const (
// mysqlMissingTableErrorNumber is the MySQL error number for "table doesn't exist".
// This corresponds to MySQL error 1146 (ER_NO_SUCH_TABLE) with SQLSTATE 42S02.
mysqlMissingTableErrorNumber = 1146
)

// IsMissingTableError returns true if the error is a MySQL error indicating a missing table.
// This typically happens when migrations have not been run.
func IsMissingTableError(err error) bool {
var mysqlErr *mysql.MySQLError
return errors.As(err, &mysqlErr) && mysqlErr.Number == mysqlMissingTableErrorNumber
}

// WrapMissingTableError checks if the error is a missing table error and wraps it with
// a helpful message instructing the user to run migrations. If it's not a missing table error,
// it returns nil. If it's already a SchemaNotInitializedError, it returns the original error
// to preserve the wrapped error through the call chain.
func WrapMissingTableError(err error) error {
// Don't double-wrap if already a SchemaNotInitializedError - return original to preserve it
var schemaErr dscommon.SchemaNotInitializedError
if errors.As(err, &schemaErr) {
return err
}
if IsMissingTableError(err) {
return dscommon.NewSchemaNotInitializedError(err)
}
return nil
}
112 changes: 112 additions & 0 deletions internal/datastore/mysql/common/errors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package common

import (
"fmt"
"testing"

"github.com/go-sql-driver/mysql"
"github.com/stretchr/testify/require"

dscommon "github.com/authzed/spicedb/internal/datastore/common"
)

func TestIsMissingTableError(t *testing.T) {
t.Run("returns true for missing table error", func(t *testing.T) {
mysqlErr := &mysql.MySQLError{
Number: mysqlMissingTableErrorNumber,
Message: "Table 'spicedb.caveat' doesn't exist",
}
require.True(t, IsMissingTableError(mysqlErr))
})

t.Run("returns false for other mysql errors", func(t *testing.T) {
mysqlErr := &mysql.MySQLError{
Number: 1062, // Duplicate entry error
Message: "Duplicate entry '1' for key 'PRIMARY'",
}
require.False(t, IsMissingTableError(mysqlErr))
})

t.Run("returns false for non-mysql errors", func(t *testing.T) {
err := fmt.Errorf("some other error")
require.False(t, IsMissingTableError(err))
})

t.Run("returns false for nil error", func(t *testing.T) {
require.False(t, IsMissingTableError(nil))
})

t.Run("returns true for wrapped missing table error", func(t *testing.T) {
mysqlErr := &mysql.MySQLError{
Number: mysqlMissingTableErrorNumber,
Message: "Table 'spicedb.caveat' doesn't exist",
}
wrappedErr := fmt.Errorf("query failed: %w", mysqlErr)
require.True(t, IsMissingTableError(wrappedErr))
})
}

func TestWrapMissingTableError(t *testing.T) {
t.Run("wraps missing table error", func(t *testing.T) {
mysqlErr := &mysql.MySQLError{
Number: mysqlMissingTableErrorNumber,
Message: "Table 'spicedb.caveat' doesn't exist",
}
wrapped := WrapMissingTableError(mysqlErr)
require.Error(t, wrapped)

var schemaErr dscommon.SchemaNotInitializedError
require.ErrorAs(t, wrapped, &schemaErr)
require.Contains(t, wrapped.Error(), "spicedb datastore migrate")
})

t.Run("returns nil for non-missing-table errors", func(t *testing.T) {
mysqlErr := &mysql.MySQLError{
Number: 1062, // Duplicate entry error
Message: "Duplicate entry '1' for key 'PRIMARY'",
}
require.NoError(t, WrapMissingTableError(mysqlErr))
})

t.Run("returns nil for non-mysql errors", func(t *testing.T) {
err := fmt.Errorf("some other error")
require.NoError(t, WrapMissingTableError(err))
})

t.Run("returns nil for nil error", func(t *testing.T) {
require.NoError(t, WrapMissingTableError(nil))
})

t.Run("preserves original error in chain", func(t *testing.T) {
mysqlErr := &mysql.MySQLError{
Number: mysqlMissingTableErrorNumber,
Message: "Table 'spicedb.caveat' doesn't exist",
}
wrapped := WrapMissingTableError(mysqlErr)
require.Error(t, wrapped)

// The original mysql error should be accessible via unwrapping
var foundMySQLErr *mysql.MySQLError
require.ErrorAs(t, wrapped, &foundMySQLErr)
require.Equal(t, uint16(mysqlMissingTableErrorNumber), foundMySQLErr.Number)
})

t.Run("does not double-wrap already wrapped errors", func(t *testing.T) {
mysqlErr := &mysql.MySQLError{
Number: mysqlMissingTableErrorNumber,
Message: "Table 'spicedb.caveat' doesn't exist",
}
// First wrap
wrapped := WrapMissingTableError(mysqlErr)
require.Error(t, wrapped)

// Second wrap should return the already-wrapped error (preserving it through call chain)
doubleWrapped := WrapMissingTableError(wrapped)
require.Error(t, doubleWrapped)
require.Equal(t, wrapped, doubleWrapped)

// Should still be detectable as SchemaNotInitializedError
var schemaErr dscommon.SchemaNotInitializedError
require.ErrorAs(t, doubleWrapped, &schemaErr)
})
}
27 changes: 27 additions & 0 deletions internal/datastore/postgres/common/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ const (
pgReadOnlyTransaction = "25006"
pgQueryCanceled = "57014"
pgInvalidArgument = "22023"

// PgMissingTable is the Postgres error code for "relation does not exist".
// This is used to detect when migrations have not been run.
PgMissingTable = "42P01"
)

var (
Expand Down Expand Up @@ -106,3 +110,26 @@ func ConvertToWriteConstraintError(livingTupleConstraints []string, err error) e

return nil
}

// IsMissingTableError returns true if the error is a Postgres error indicating a missing table.
// This typically happens when migrations have not been run.
func IsMissingTableError(err error) bool {
var pgerr *pgconn.PgError
return errors.As(err, &pgerr) && pgerr.Code == PgMissingTable
}

// WrapMissingTableError checks if the error is a missing table error and wraps it with
// a helpful message instructing the user to run migrations. If it's not a missing table error,
// it returns nil. If it's already a SchemaNotInitializedError, it returns the original error
// to preserve the wrapped error through the call chain.
func WrapMissingTableError(err error) error {
// Don't double-wrap if already a SchemaNotInitializedError - return original to preserve it
var schemaErr dscommon.SchemaNotInitializedError
if errors.As(err, &schemaErr) {
return err
}
if IsMissingTableError(err) {
return dscommon.NewSchemaNotInitializedError(err)
}
return nil
}
Loading
Loading