Skip to content
Closed
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
99 changes: 99 additions & 0 deletions pkg/dbsql/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// Copyright © 2026 Kaleido, Inc.
//
// SPDX-License-Identifier: Apache-2.0
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package dbsql

import "errors"

// SQLSTATE class 23 (integrity constraint violation) codes
const (
SQLStateForeignKeyViolation = "23503"
SQLStateUniqueViolation = "23505"
)

// ConstraintViolation describes an integrity constraint violation reported by the database
type ConstraintViolation struct {
// SQLState is the SQLSTATE code identifying the kind of violation, e.g. SQLStateUniqueViolation
SQLState string
// Constraint is the name of the violated constraint, or empty if the driver does not expose it
Constraint string
}

// sqlStateError is implemented by driver errors that expose the SQLSTATE code - notably *pq.Error (lib/pq)
// and *pgconn.PgError (pgx). Duck-typing it here avoids a dependency on any particular driver.
type sqlStateError interface {
SQLState() string
}

// SQLStateConstraintViolationClassifier is the default ConstraintViolationClassifier, matching any error in the
// chain that reports a SQLSTATE unique or foreign key violation. It cannot determine the constraint name.
func SQLStateConstraintViolationClassifier(err error) *ConstraintViolation {
var sqlErr sqlStateError
if errors.As(err, &sqlErr) {
switch code := sqlErr.SQLState(); code {
case SQLStateUniqueViolation, SQLStateForeignKeyViolation:
return &ConstraintViolation{SQLState: code}
}
}
return nil
}

// IsUniqueViolation reports whether err was caused by a unique constraint violation. The error may be wrapped -
// for example the MsgDBInsertFailed error returned from Insert/InsertTx wraps the underlying driver error - and
// the whole chain is inspected.
//
// This lets callers rely on a UNIQUE index in the database to enforce uniqueness, and map the resulting error.
//
// If one or more constraint names are supplied, only a violation of one of those constraints is a match.
// Constraint names are only available when the provider supplies a ConstraintViolationClassifier that extracts
// them; if the name cannot be determined the result is false, so callers never mistake an unrelated constraint
// for the one they asked about.
func (s *Database) IsUniqueViolation(err error, constraints ...string) bool {
return s.isConstraintViolation(err, SQLStateUniqueViolation, constraints)
}

// IsForeignKeyViolation reports whether err was caused by a foreign key constraint violation - an insert or
// update referencing a row that does not exist, or a delete of a row that is still referenced. Wrapping and
// constraint name matching behave as for IsUniqueViolation.
func (s *Database) IsForeignKeyViolation(err error, constraints ...string) bool {
return s.isConstraintViolation(err, SQLStateForeignKeyViolation, constraints)
}

func (s *Database) isConstraintViolation(err error, sqlState string, constraints []string) bool {
if err == nil {
return false
}
classify := s.features.ConstraintViolationClassifier
if classify == nil {
classify = SQLStateConstraintViolationClassifier
}
violation := classify(err)
if violation == nil || violation.SQLState != sqlState {
return false
}
if len(constraints) == 0 {
return true
}
if violation.Constraint == "" {
return false
}
for _, c := range constraints {
if c == violation.Constraint {
return true
}
}
return false
}
205 changes: 205 additions & 0 deletions pkg/dbsql/errors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
// Copyright © 2026 Kaleido, Inc.
//
// SPDX-License-Identifier: Apache-2.0
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package dbsql

import (
"context"
"fmt"
"testing"

sq "github.com/Masterminds/squirrel"
"github.com/hyperledger-firefly/common/pkg/fftypes"
"github.com/hyperledger-firefly/common/pkg/i18n"
sqlite3driver "github.com/mattn/go-sqlite3"
"github.com/pkg/errors"
"github.com/stretchr/testify/assert"
)

// fakeSQLStateError mimics the shape of *pq.Error / *pgconn.PgError without depending on a Postgres driver
type fakeSQLStateError struct {
code string
}

func (e *fakeSQLStateError) Error() string { return "pq: fake error " + e.code }
func (e *fakeSQLStateError) SQLState() string { return e.code }

func TestConstraintViolationSQLStateFallback(t *testing.T) {
// No provider classifier configured, so the SQLSTATE fallback is used
s := &Database{}

assert.False(t, s.IsUniqueViolation(nil))
assert.False(t, s.IsForeignKeyViolation(nil))
assert.False(t, s.IsUniqueViolation(fmt.Errorf("pop")))
assert.False(t, s.IsForeignKeyViolation(fmt.Errorf("pop")))
assert.False(t, s.IsUniqueViolation(&fakeSQLStateError{code: "23502"})) // not-null violation
assert.False(t, s.IsForeignKeyViolation(&fakeSQLStateError{code: "23502"}))

uniqueErr := &fakeSQLStateError{code: SQLStateUniqueViolation}
fkErr := &fakeSQLStateError{code: SQLStateForeignKeyViolation}
assert.True(t, s.IsUniqueViolation(uniqueErr))
assert.False(t, s.IsForeignKeyViolation(uniqueErr))
assert.True(t, s.IsForeignKeyViolation(fkErr))
assert.False(t, s.IsUniqueViolation(fkErr))

// Wrapped the way InsertTx wraps driver errors, and wrapped again by a caller
wrapped := i18n.WrapError(context.Background(), uniqueErr, i18n.MsgDBInsertFailed)
assert.Regexp(t, "FF00177", wrapped)
assert.True(t, s.IsUniqueViolation(wrapped))
assert.True(t, s.IsUniqueViolation(errors.Wrap(wrapped, "outer")))
assert.True(t, s.IsForeignKeyViolation(i18n.WrapError(context.Background(), fkErr, i18n.MsgDBDeleteFailed)))

// The fallback cannot identify the constraint, so a request for a specific constraint fails closed
assert.False(t, s.IsUniqueViolation(wrapped, "my_constraint"))
assert.False(t, s.IsForeignKeyViolation(fkErr, "my_fk"))
}

func TestConstraintViolationProviderClassifier(t *testing.T) {
uniqueErr := fmt.Errorf("duplicate key value violates unique constraint \"crudables_id\"")
fkErr := fmt.Errorf("insert or update on table \"linkables\" violates foreign key constraint \"linkables_crud_id_fkey\"")
otherErr := fmt.Errorf("pop")
s := &Database{
features: SQLFeatures{
ConstraintViolationClassifier: func(err error) *ConstraintViolation {
switch {
case errors.Is(err, uniqueErr):
return &ConstraintViolation{SQLState: SQLStateUniqueViolation, Constraint: "crudables_id"}
case errors.Is(err, fkErr):
return &ConstraintViolation{SQLState: SQLStateForeignKeyViolation, Constraint: "linkables_crud_id_fkey"}
}
return nil
},
},
}

assert.False(t, s.IsUniqueViolation(nil))
assert.False(t, s.IsUniqueViolation(otherErr))
assert.False(t, s.IsUniqueViolation(otherErr, "crudables_id"))
assert.False(t, s.IsForeignKeyViolation(otherErr))

assert.True(t, s.IsUniqueViolation(uniqueErr))
assert.True(t, s.IsUniqueViolation(errors.Wrap(uniqueErr, "wrapped"), "crudables_id"))
assert.True(t, s.IsUniqueViolation(uniqueErr, "some_other_index", "crudables_id"))
assert.False(t, s.IsUniqueViolation(uniqueErr, "some_other_index"))
assert.False(t, s.IsForeignKeyViolation(uniqueErr))

assert.True(t, s.IsForeignKeyViolation(fkErr))
assert.True(t, s.IsForeignKeyViolation(fkErr, "linkables_crud_id_fkey"))
assert.False(t, s.IsForeignKeyViolation(fkErr, "some_other_fkey"))
assert.False(t, s.IsUniqueViolation(fkErr))
}

func TestConstraintViolationThroughInsertTx(t *testing.T) {
// Mock provider has no classifier, so this exercises the SQLSTATE fallback through the real
// InsertTx error wrapping path
s, mdb := NewMockProvider().UTInit()
mdb.ExpectBegin()
ctx, tx, _, err := s.BeginOrUseTx(context.Background())
assert.NoError(t, err)
sb := sq.Insert("table").Columns("col1").Values("val1")

mdb.ExpectExec("INSERT.*").WillReturnError(&fakeSQLStateError{code: SQLStateUniqueViolation})
_, err = s.InsertTx(ctx, "table1", tx, sb, nil)
assert.Regexp(t, "FF00177", err)
assert.True(t, s.IsUniqueViolation(err))
assert.False(t, s.IsForeignKeyViolation(err))

mdb.ExpectExec("INSERT.*").WillReturnError(&fakeSQLStateError{code: SQLStateForeignKeyViolation})
_, err = s.InsertTx(ctx, "table1", tx, sb, nil)
assert.Regexp(t, "FF00177", err)
assert.True(t, s.IsForeignKeyViolation(err))
assert.False(t, s.IsUniqueViolation(err))

// A different failure is not misreported
mdb.ExpectExec("INSERT.*").WillReturnError(fmt.Errorf("pop"))
_, err = s.InsertTx(ctx, "table1", tx, sb, nil)
assert.Regexp(t, "FF00177", err)
assert.False(t, s.IsUniqueViolation(err))
assert.False(t, s.IsForeignKeyViolation(err))
}

func TestUniqueViolationSQLiteEnd2End(t *testing.T) {
sql, done := newSQLiteTestProvider(t)
defer done()
ctx := context.Background()

collection := newCRUDCollection(sql.db, "ns1")
c1 := &TestCRUDable{
ResourceBase: ResourceBase{ID: fftypes.NewUUID()},
NS: ptrTo("ns1"),
Name: ptrTo("bob"),
}
err := collection.Insert(ctx, c1)
assert.NoError(t, err)

// Second insert of the same (ns, id) violates the crudables_id unique index
err = collection.Insert(ctx, c1)
assert.Regexp(t, "FF00177", err)
assert.True(t, sql.db.IsUniqueViolation(err))
assert.False(t, sql.db.IsForeignKeyViolation(err))
// SQLite does not report the constraint name, so asking for one fails closed
assert.False(t, sql.db.IsUniqueViolation(err, "crudables_id"))

// A row that violates nothing is fine
c2 := &TestCRUDable{
ResourceBase: ResourceBase{ID: fftypes.NewUUID()},
NS: ptrTo("ns1"),
Name: ptrTo("sally"),
}
assert.NoError(t, collection.Insert(ctx, c2))
}

func TestForeignKeyViolationSQLiteEnd2End(t *testing.T) {
sql, done := newSQLiteTestProvider(t)
defer done()
ctx := context.Background()

// SQLite has foreign key enforcement off by default, and the test schema has no FKs - set one up
_, err := sql.db.db.ExecContext(ctx, "PRAGMA foreign_keys = ON")
assert.NoError(t, err)
_, err = sql.db.db.ExecContext(ctx, `CREATE TABLE fk_children (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
crud_seq INTEGER NOT NULL REFERENCES crudables(seq)
)`)
assert.NoError(t, err)

ctx, tx, autoCommit, err := sql.db.BeginOrUseTx(ctx)
assert.NoError(t, err)
defer sql.db.RollbackTx(ctx, tx, autoCommit)

// Referencing a parent row that does not exist
_, err = sql.db.InsertTx(ctx, "fk_children", tx, sq.Insert("fk_children").Columns("crud_seq").Values(999999), nil)
assert.Regexp(t, "FF00177", err)
assert.True(t, sql.db.IsForeignKeyViolation(err))
assert.False(t, sql.db.IsUniqueViolation(err))
assert.False(t, sql.db.IsForeignKeyViolation(err, "some_fkey"))
}

func TestSQLiteConstraintViolationClassifier(t *testing.T) {
v := sqliteConstraintViolationClassifier(sqlite3driver.Error{Code: sqlite3driver.ErrConstraint, ExtendedCode: sqlite3driver.ErrConstraintUnique})
assert.Equal(t, &ConstraintViolation{SQLState: SQLStateUniqueViolation}, v)

v = sqliteConstraintViolationClassifier(errors.Wrap(sqlite3driver.Error{Code: sqlite3driver.ErrConstraint, ExtendedCode: sqlite3driver.ErrConstraintPrimaryKey}, "wrapped"))
assert.Equal(t, &ConstraintViolation{SQLState: SQLStateUniqueViolation}, v)

v = sqliteConstraintViolationClassifier(sqlite3driver.Error{Code: sqlite3driver.ErrConstraint, ExtendedCode: sqlite3driver.ErrConstraintForeignKey})
assert.Equal(t, &ConstraintViolation{SQLState: SQLStateForeignKeyViolation}, v)

// Other constraint types (e.g. NOT NULL) and non-constraint errors are not classified
assert.Nil(t, sqliteConstraintViolationClassifier(sqlite3driver.Error{Code: sqlite3driver.ErrConstraint, ExtendedCode: sqlite3driver.ErrConstraintNotNull}))
assert.Nil(t, sqliteConstraintViolationClassifier(sqlite3driver.Error{Code: sqlite3driver.ErrBusy}))
assert.Nil(t, sqliteConstraintViolationClassifier(fmt.Errorf("pop")))
}
6 changes: 6 additions & 0 deletions pkg/dbsql/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ type SQLFeatures struct {
// When >0 and MultiRowInsert is true, InsertMany will chunk rows so that
// rows*columns never exceeds this limit. PostgreSQL's wire protocol limit is 65535.
MaxPlaceholders int
// ConstraintViolationClassifier lets the provider classify a driver error as an integrity constraint violation,
// including the name of the violated constraint where the driver exposes it. Return nil for any other error.
// When unset, Database.IsUniqueViolation / IsForeignKeyViolation fall back to SQLStateConstraintViolationClassifier,
// which checks for a SQLState() method on the error (implemented by both lib/pq and pgx) but cannot identify
// the constraint name.
ConstraintViolationClassifier func(err error) *ConstraintViolation
}

func DefaultSQLProviderFeatures() SQLFeatures {
Expand Down
21 changes: 19 additions & 2 deletions pkg/dbsql/provider_sqlitego.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,15 @@ package dbsql
import (
"context"
"database/sql"
"errors"

sq "github.com/Masterminds/squirrel"
migratedb "github.com/golang-migrate/migrate/v4/database"
"github.com/golang-migrate/migrate/v4/database/sqlite3"
"github.com/hyperledger-firefly/common/pkg/config"

// Import SQLite driver
_ "github.com/mattn/go-sqlite3"
// SQLite driver - also used to classify constraint errors
sqlite3driver "github.com/mattn/go-sqlite3"
)

func InitSQLiteConfig(conf config.Section) {
Expand Down Expand Up @@ -60,6 +61,7 @@ func (p *sqLiteProvider) Features() SQLFeatures {
features := DefaultSQLProviderFeatures()
features.PlaceholderFormat = sq.Dollar
features.UseILIKE = false // Not supported
features.ConstraintViolationClassifier = sqliteConstraintViolationClassifier
return features
}

Expand All @@ -75,3 +77,18 @@ func (p *sqLiteProvider) Open(url string) (*sql.DB, error) {
func (p *sqLiteProvider) GetMigrationDriver(db *sql.DB) (migratedb.Driver, error) {
return sqlite3.WithInstance(db, &sqlite3.Config{})
}

// sqliteConstraintViolationClassifier maps SQLite's extended constraint error codes onto the equivalent SQLSTATE.
// SQLite does not report the constraint name, only the column list in the message text.
func sqliteConstraintViolationClassifier(err error) *ConstraintViolation {
var sqliteErr sqlite3driver.Error
if errors.As(err, &sqliteErr) {
switch sqliteErr.ExtendedCode {
case sqlite3driver.ErrConstraintUnique, sqlite3driver.ErrConstraintPrimaryKey:
return &ConstraintViolation{SQLState: SQLStateUniqueViolation}
case sqlite3driver.ErrConstraintForeignKey:
return &ConstraintViolation{SQLState: SQLStateForeignKeyViolation}
}
}
return nil
}
6 changes: 6 additions & 0 deletions pkg/i18n/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ type ffError struct {
status int
}

// Unwrap exposes the wrapped error so that errors.Is / errors.As can inspect the full chain, including
// the original cause passed to WrapError (for example a database driver error)
func (ffe *ffError) Unwrap() error {
return ffe.error
}

func (ffe *ffError) MessageKey() ErrorMessageKey {
return ffe.msgKey
}
Expand Down
Loading