Skip to content

Commit 30d96e5

Browse files
committed
enhanced error handling
1 parent f76ff03 commit 30d96e5

8 files changed

Lines changed: 254 additions & 2 deletions

File tree

io/errx/errors.go

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
package errx
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"strings"
7+
)
8+
9+
var (
10+
// ErrMissingIdentity indicates SQL generation/execution cannot proceed because
11+
// identity (primary key) columns were not detected.
12+
ErrMissingIdentity = errors.New("missing identity")
13+
14+
// ErrDuplicateKey indicates an insert/update violated a unique constraint.
15+
// Note: many drivers return opaque error types; use IsDuplicateKey to detect.
16+
ErrDuplicateKey = errors.New("duplicate key")
17+
18+
// ErrConstraint indicates a generic constraint violation (FK/CK/NOT NULL/etc).
19+
ErrConstraint = errors.New("constraint violation")
20+
)
21+
22+
// Error carries structured context while remaining compatible with errors.Is().
23+
type Error struct {
24+
Kind error
25+
Op string
26+
Table string
27+
Columns []string
28+
IdentityIndex int
29+
Cause error
30+
}
31+
32+
func (e *Error) Error() string {
33+
sb := &strings.Builder{}
34+
sb.WriteString("sqlx")
35+
if e.Op != "" {
36+
sb.WriteString(" ")
37+
sb.WriteString(e.Op)
38+
}
39+
sb.WriteString(": ")
40+
if e.Kind != nil {
41+
sb.WriteString(e.Kind.Error())
42+
} else {
43+
sb.WriteString("error")
44+
}
45+
if e.Table != "" {
46+
sb.WriteString(" table=")
47+
sb.WriteString(e.Table)
48+
}
49+
if e.IdentityIndex != 0 {
50+
sb.WriteString(fmt.Sprintf(" identityIndex=%d", e.IdentityIndex))
51+
}
52+
if len(e.Columns) > 0 {
53+
sb.WriteString(" columns=[")
54+
sb.WriteString(strings.Join(e.Columns, ","))
55+
sb.WriteString("]")
56+
}
57+
if e.Cause != nil {
58+
sb.WriteString(": ")
59+
sb.WriteString(e.Cause.Error())
60+
}
61+
return sb.String()
62+
}
63+
64+
func (e *Error) Unwrap() error { return e.Cause }
65+
66+
func (e *Error) Is(target error) bool {
67+
if target == nil {
68+
return false
69+
}
70+
if e.Kind != nil && target == e.Kind {
71+
return true
72+
}
73+
if e.Cause != nil {
74+
return errors.Is(e.Cause, target)
75+
}
76+
return false
77+
}
78+
79+
func MissingIdentity(op, table string, columns []string, identityIndex int) error {
80+
return &Error{
81+
Kind: ErrMissingIdentity,
82+
Op: op,
83+
Table: table,
84+
Columns: columns,
85+
IdentityIndex: identityIndex,
86+
}
87+
}
88+
89+
func DuplicateKey(op, table string, cause error) error {
90+
return &Error{
91+
Kind: ErrDuplicateKey,
92+
Op: op,
93+
Table: table,
94+
Cause: cause,
95+
}
96+
}
97+
98+
func Constraint(op, table string, cause error) error {
99+
return &Error{
100+
Kind: ErrConstraint,
101+
Op: op,
102+
Table: table,
103+
Cause: cause,
104+
}
105+
}
106+
107+
func IsMissingIdentity(err error) bool { return errors.Is(err, ErrMissingIdentity) }
108+
109+
func IsDuplicateKey(err error) bool {
110+
if errors.Is(err, ErrDuplicateKey) {
111+
return true
112+
}
113+
msg := strings.ToLower(errString(err))
114+
return strings.Contains(msg, "unique constraint") ||
115+
strings.Contains(msg, "duplicate key") ||
116+
strings.Contains(msg, "duplicate entry")
117+
}
118+
119+
func IsConstraint(err error) bool {
120+
if errors.Is(err, ErrConstraint) {
121+
return true
122+
}
123+
msg := strings.ToLower(errString(err))
124+
return strings.Contains(msg, "constraint failed") ||
125+
strings.Contains(msg, "violates") ||
126+
strings.Contains(msg, "not null constraint") ||
127+
strings.Contains(msg, "foreign key constraint") ||
128+
strings.Contains(msg, "check constraint")
129+
}
130+
131+
func errString(err error) string {
132+
if err == nil {
133+
return ""
134+
}
135+
return err.Error()
136+
}

io/errx/errors_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package errx
2+
3+
import (
4+
"errors"
5+
"testing"
6+
)
7+
8+
func TestIsDuplicateKey(t *testing.T) {
9+
if !IsDuplicateKey(errors.New("constraint failed: UNIQUE constraint failed: user_oauth_token.user_id, user_oauth_token.provider (1555)")) {
10+
t.Fatalf("expected duplicate key match")
11+
}
12+
}
13+
14+
func TestIsConstraint(t *testing.T) {
15+
if !IsConstraint(errors.New("constraint failed: NOT NULL constraint failed: foo.bar (1299)")) {
16+
t.Fatalf("expected constraint match")
17+
}
18+
}
19+
20+
func TestWrappedErrors_Is(t *testing.T) {
21+
dup := DuplicateKey("insert", "foo", errors.New("duplicate key value violates unique constraint"))
22+
if !errors.Is(dup, ErrDuplicateKey) {
23+
t.Fatalf("expected errors.Is(ErrDuplicateKey)")
24+
}
25+
missing := MissingIdentity("update", "foo", []string{"c1", "c2"}, 0)
26+
if !errors.Is(missing, ErrMissingIdentity) {
27+
t.Fatalf("expected errors.Is(ErrMissingIdentity)")
28+
}
29+
}

io/insert/session.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"github.com/viant/sqlx/io"
88
"github.com/viant/sqlx/io/config"
9+
"github.com/viant/sqlx/io/errx"
910
"github.com/viant/sqlx/metadata/info/dialect"
1011
"github.com/viant/sqlx/metadata/sink"
1112
"github.com/viant/sqlx/option"
@@ -163,6 +164,12 @@ func (s *session) flush(ctx context.Context, values []interface{}, identities []
163164
}
164165
result, err := s.stmt.ExecContext(ctx, values...)
165166
if err != nil {
167+
if errx.IsDuplicateKey(err) {
168+
return 0, 0, errx.DuplicateKey("insert", s.TableName, err)
169+
}
170+
if errx.IsConstraint(err) {
171+
return 0, 0, errx.Constraint("insert", s.TableName, err)
172+
}
166173
return 0, 0, err
167174
}
168175

@@ -197,6 +204,12 @@ func (s *session) flushQuery(ctx context.Context, values []interface{}, identiti
197204
var rowsAffected, newLastInsertedID int64
198205
rows, err := s.stmt.QueryContext(ctx, values...)
199206
if err != nil {
207+
if errx.IsDuplicateKey(err) {
208+
return 0, 0, errx.DuplicateKey("insert", s.TableName, err)
209+
}
210+
if errx.IsConstraint(err) {
211+
return 0, 0, errx.Constraint("insert", s.TableName, err)
212+
}
200213
return 0, 0, err
201214
}
202215
defer io.RunWithError(rows.Close, &err)

io/read/reader.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -352,7 +352,7 @@ func (r *Reader) ensureStmt(ctx context.Context) error {
352352
fmt.Println(r.query)
353353
}
354354
if err != nil {
355-
return err
355+
return fmt.Errorf("failed to prepare context: %w", err)
356356
}
357357

358358
r.stmt = stmt

io/update/service_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,3 +337,60 @@ outer:
337337
assertly.AssertValues(t, actual, testCase.expect)
338338
}
339339
}
340+
341+
func TestService_Exec_CompositePK(t *testing.T) {
342+
type SocialToken struct {
343+
UserID string `sqlx:"user_id,primaryKey" validate:"required"`
344+
Provider string `sqlx:"provider,primaryKey" validate:"required"`
345+
EncToken string `sqlx:"enc_token" validate:"required"`
346+
}
347+
348+
description := "Update with composite primary key"
349+
driver := "sqlite3"
350+
dsn := "/tmp/sqllite.db"
351+
table := "social_tokens"
352+
353+
initSQL := []string{
354+
"DROP TABLE IF EXISTS social_tokens",
355+
`CREATE TABLE social_tokens (
356+
user_id TEXT NOT NULL,
357+
provider TEXT NOT NULL,
358+
enc_token TEXT,
359+
PRIMARY KEY (user_id, provider)
360+
)`,
361+
`INSERT INTO social_tokens (user_id, provider, enc_token) VALUES ('u1','google','old-token')`,
362+
`INSERT INTO social_tokens (user_id, provider, enc_token) VALUES ('u1','github','keep-me')`,
363+
}
364+
365+
db, err := sql.Open(driver, dsn)
366+
if !assert.Nil(t, err, description) {
367+
return
368+
}
369+
for _, SQL := range initSQL {
370+
_, err := db.Exec(SQL)
371+
if !assert.Nil(t, err, description) {
372+
return
373+
}
374+
}
375+
376+
updater, err := update.New(context.TODO(), db, table)
377+
if !assert.Nil(t, err, description) {
378+
return
379+
}
380+
381+
rec := &SocialToken{UserID: "u1", Provider: "google", EncToken: "new-token"}
382+
affected, err := updater.Exec(context.TODO(), []interface{}{rec})
383+
assert.Nil(t, err, description)
384+
assert.EqualValues(t, 1, affected, description)
385+
386+
// Verify targeted row updated
387+
var enc string
388+
err = db.QueryRow(`SELECT enc_token FROM social_tokens WHERE user_id = ? AND provider = ?`, "u1", "google").Scan(&enc)
389+
assert.Nil(t, err, description)
390+
assert.Equal(t, "new-token", enc, description)
391+
392+
// Verify other row with same user but different provider unchanged
393+
err = db.QueryRow(`SELECT enc_token FROM social_tokens WHERE user_id = ? AND provider = ?`, "u1", "github").Scan(&enc)
394+
assert.Nil(t, err, description)
395+
assert.Equal(t, "keep-me", enc, description)
396+
}

io/update/session.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"github.com/viant/sqlx/io"
88
"github.com/viant/sqlx/io/config"
9+
"github.com/viant/sqlx/io/errx"
910
"github.com/viant/sqlx/option"
1011
"reflect"
1112
)
@@ -91,6 +92,12 @@ func (s *session) update(ctx context.Context, record interface{}) (int64, error)
9192
placeholders = s.setMarker.Placeholders(record, placeholders)
9293
result, err := s.stmt.ExecContext(ctx, placeholders...)
9394
if err != nil {
95+
if errx.IsDuplicateKey(err) {
96+
return 0, errx.DuplicateKey("update", s.TableName, err)
97+
}
98+
if errx.IsConstraint(err) {
99+
return 0, errx.Constraint("update", s.TableName, err)
100+
}
94101
return 0, err
95102
}
96103
affected, _ := result.RowsAffected()

io/update/sql.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package update
33
import (
44
"bytes"
55
"fmt"
6+
"github.com/viant/sqlx/io/errx"
67
"github.com/viant/sqlx/metadata/info"
78
"github.com/viant/sqlx/option"
89
"github.com/viant/xunsafe"
@@ -55,7 +56,7 @@ func NewBuilder(table string, columns []string, identityIndex int, dialect *info
5556
return nil, fmt.Errorf("columns were empty")
5657
}
5758
if identityIndex <= 0 {
58-
return nil, fmt.Errorf("identity index was empty")
59+
return nil, errx.MissingIdentity("update", table, columns, identityIndex)
5960
}
6061
var fragments = make([]string, len(columns))
6162
getter := dialect.PlaceholderGetter()

io/update/sql_test.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package update
22

33
import (
4+
"errors"
45
"github.com/stretchr/testify/assert"
6+
"github.com/viant/sqlx/io/errx"
57
"github.com/viant/sqlx/metadata/info"
68
"testing"
79
)
@@ -36,3 +38,10 @@ func TestUpdate_Build(t *testing.T) {
3638
}
3739

3840
}
41+
42+
func TestUpdate_NewBuilder_MissingIdentity(t *testing.T) {
43+
builder, err := NewBuilder("foo", []string{"c1", "c2"}, 0, &info.Dialect{Placeholder: "?"})
44+
assert.Nil(t, builder)
45+
assert.True(t, errors.Is(err, errx.ErrMissingIdentity))
46+
assert.True(t, errx.IsMissingIdentity(err))
47+
}

0 commit comments

Comments
 (0)