Skip to content

Commit 08a0af1

Browse files
getevoclaude
andcommitted
Add SoftDeletedAt type with proper GORM soft-delete integration
Implement custom SoftDeletedAt type (sql.NullTime-based) that registers QueryClauses/UpdateClauses/DeleteClauses with GORM's schema parser, so embedding types.SoftDelete in a model causes db.Delete() to run: UPDATE SET deleted_at=NOW(), deleted=1 instead of a hard DELETE, and all SELECT/UPDATE queries automatically gain WHERE deleted_at IS NULL. Key fix: GORM v1.30+ clause.Set.MergeClause replaces rather than appends, so both SET assignments must be passed in a single AddClause call. Also update SoftDelete helper methods (Time, Set, After, Before, Equal, IsZero, Unix, Format) to use SoftDeletedAt.Valid/.Time instead of the old *time.Time nil-check pattern. Revert leftover softDelete:flag tag from lib/model/model.go (no-op in v2). Add sqlite-based tests verifying soft/hard delete and query filtering. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent ccecc2e commit 08a0af1

File tree

6 files changed

+382
-29
lines changed

6 files changed

+382
-29
lines changed

go.mod

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ require (
3535
gopkg.in/yaml.v3 v3.0.1
3636
gorm.io/driver/mysql v1.5.7
3737
gorm.io/driver/postgres v1.6.0
38-
gorm.io/gorm v1.25.11
38+
gorm.io/gorm v1.30.0
3939
)
4040

4141
require (
@@ -70,6 +70,7 @@ require (
7070
github.com/mattn/go-colorable v0.1.13 // indirect
7171
github.com/mattn/go-isatty v0.0.20 // indirect
7272
github.com/mattn/go-runewidth v0.0.16 // indirect
73+
github.com/mattn/go-sqlite3 v1.14.22 // indirect
7374
github.com/nats-io/nkeys v0.4.6 // indirect
7475
github.com/nats-io/nuid v1.0.1 // indirect
7576
github.com/pierrec/lz4/v4 v4.1.18 // indirect
@@ -82,4 +83,5 @@ require (
8283
github.com/valyala/tcplisten v1.0.0 // indirect
8384
golang.org/x/sync v0.10.0 // indirect
8485
golang.org/x/sys v0.28.0 // indirect
86+
gorm.io/driver/sqlite v1.6.0 // indirect
8587
)

go.sum

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
114114
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
115115
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
116116
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
117+
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
118+
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
117119
github.com/nats-io/nats.go v1.31.0 h1:/WFBHEc/dOKBF6qf1TZhrdEfTmOZ5JzdJ+Y3m6Y/p7E=
118120
github.com/nats-io/nats.go v1.31.0/go.mod h1:di3Bm5MLsoB4Bx61CBTsxuarI36WbhAwOm8QrW39+i8=
119121
github.com/nats-io/nkeys v0.4.6 h1:IzVe95ru2CT6ta874rt9saQRkWfe2nFj1NtvYSLqMzY=
@@ -220,6 +222,10 @@ gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
220222
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
221223
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
222224
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
225+
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
226+
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
223227
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
224228
gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg=
225229
gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
230+
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
231+
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=

lib/db/types/basic.go

Lines changed: 32 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -138,9 +138,14 @@ func NowUpdatedAt() UpdatedAt {
138138

139139
// SoftDelete provides soft delete functionality in GORM.
140140
// It includes a `Deleted` flag and a `DeletedAt` timestamp.
141+
//
142+
// GORM integration: embedding SoftDelete in a model causes GORM to intercept
143+
// db.Delete() calls and run UPDATE SET deleted=1, deleted_at=NOW() instead of
144+
// a hard DELETE. Queries automatically filter with WHERE deleted=0. Use
145+
// db.Unscoped() to bypass the filter and see or hard-delete records.
141146
type SoftDelete struct {
142-
Deleted bool `gorm:"column:deleted;default:0;index" json:"deleted"`
143-
DeletedAt *time.Time `gorm:"column:deleted_at;nullable" json:"deleted_at"`
147+
Deleted bool `gorm:"column:deleted;default:0;index" json:"deleted"`
148+
DeletedAt SoftDeletedAt `gorm:"column:deleted_at" json:"deleted_at"`
144149
}
145150

146151
// IsDeleted checks if the entity has been marked as deleted.
@@ -155,10 +160,9 @@ func (o *SoftDelete) IsDeleted() bool {
155160
func (o *SoftDelete) SetDeleted(v bool) {
156161
o.Deleted = v
157162
if v {
158-
now := time.Now()
159-
o.DeletedAt = &now
163+
o.DeletedAt = SoftDeletedAt{Valid: true, Time: time.Now()}
160164
} else {
161-
o.DeletedAt = nil
165+
o.DeletedAt = SoftDeletedAt{Valid: false}
162166
}
163167
}
164168

@@ -172,60 +176,61 @@ func (o *SoftDelete) Delete() {
172176
o.SetDeleted(true)
173177
}
174178

175-
// Time returns the underlying time.Time value, or zero time if DeletedAt is nil
179+
// Time returns the underlying time.Time value, or zero time if DeletedAt is not set.
176180
func (o SoftDelete) Time() time.Time {
177-
if o.DeletedAt == nil {
181+
if !o.DeletedAt.Valid {
178182
return time.Time{}
179183
}
180-
return *o.DeletedAt
184+
return o.DeletedAt.Time
181185
}
182186

183-
// Set sets the DeletedAt timestamp
187+
// Set sets the DeletedAt timestamp.
184188
func (o *SoftDelete) Set(t time.Time) {
185-
o.DeletedAt = &t
189+
o.DeletedAt = SoftDeletedAt{Valid: true, Time: t}
186190
}
187191

188-
// After reports whether the DeletedAt time is after u (returns false if DeletedAt is nil)
192+
// After reports whether the DeletedAt time is after u (returns false if DeletedAt is not set).
189193
func (o SoftDelete) After(u time.Time) bool {
190-
if o.DeletedAt == nil {
194+
if !o.DeletedAt.Valid {
191195
return false
192196
}
193-
return o.DeletedAt.After(u)
197+
return o.DeletedAt.Time.After(u)
194198
}
195199

196-
// Before reports whether the DeletedAt time is before u (returns false if DeletedAt is nil)
200+
// Before reports whether the DeletedAt time is before u (returns false if DeletedAt is not set).
197201
func (o SoftDelete) Before(u time.Time) bool {
198-
if o.DeletedAt == nil {
202+
if !o.DeletedAt.Valid {
199203
return false
200204
}
201-
return o.DeletedAt.Before(u)
205+
return o.DeletedAt.Time.Before(u)
202206
}
203207

204-
// Equal reports whether the DeletedAt time is equal to u (returns false if DeletedAt is nil)
208+
// Equal reports whether the DeletedAt time is equal to u (returns false if DeletedAt is not set).
205209
func (o SoftDelete) Equal(u time.Time) bool {
206-
if o.DeletedAt == nil {
210+
if !o.DeletedAt.Valid {
207211
return false
208212
}
209-
return o.DeletedAt.Equal(u)
213+
return o.DeletedAt.Time.Equal(u)
210214
}
211215

212-
// IsZero reports whether the DeletedAt is nil or represents the zero time instant
216+
// IsZero reports whether the DeletedAt is not set or represents the zero time instant.
213217
func (o SoftDelete) IsZero() bool {
214-
return o.DeletedAt == nil || o.DeletedAt.IsZero()
218+
return !o.DeletedAt.Valid || o.DeletedAt.Time.IsZero()
215219
}
216220

217-
// Unix returns the local time corresponding to the given Unix time (returns 0 if DeletedAt is nil)
221+
// Unix returns the Unix timestamp (returns 0 if DeletedAt is not set).
218222
func (o SoftDelete) Unix() int64 {
219-
if o.DeletedAt == nil {
223+
if !o.DeletedAt.Valid {
220224
return 0
221225
}
222-
return o.DeletedAt.Unix()
226+
return o.DeletedAt.Time.Unix()
223227
}
224228

225-
// Format returns a textual representation of the time value formatted according to the layout (returns empty string if DeletedAt is nil)
229+
// Format returns a textual representation of the time value formatted according to the layout
230+
// (returns empty string if DeletedAt is not set).
226231
func (o SoftDelete) Format(layout string) string {
227-
if o.DeletedAt == nil {
232+
if !o.DeletedAt.Valid {
228233
return ""
229234
}
230-
return o.DeletedAt.Format(layout)
235+
return o.DeletedAt.Time.Format(layout)
231236
}

lib/db/types/soft_delete.go

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
package types
2+
3+
import (
4+
"database/sql"
5+
"database/sql/driver"
6+
"encoding/json"
7+
"reflect"
8+
"time"
9+
10+
"gorm.io/gorm"
11+
"gorm.io/gorm/clause"
12+
"gorm.io/gorm/schema"
13+
)
14+
15+
// SoftDeletedAt is a nullable time type used as the DeletedAt field in
16+
// types.SoftDelete. It implements GORM's QueryClauses, UpdateClauses, and
17+
// DeleteClauses interfaces so that:
18+
//
19+
// - SELECT/UPDATE queries automatically gain WHERE deleted_at IS NULL.
20+
// - db.Delete() is converted to UPDATE SET deleted_at=NOW(), deleted=1
21+
// instead of issuing a hard DELETE.
22+
//
23+
// Use db.Unscoped() to bypass the filter and see or hard-delete records.
24+
type SoftDeletedAt sql.NullTime
25+
26+
// Scan implements the sql.Scanner interface.
27+
func (n *SoftDeletedAt) Scan(value interface{}) error {
28+
return (*sql.NullTime)(n).Scan(value)
29+
}
30+
31+
// Value implements the driver.Valuer interface.
32+
func (n SoftDeletedAt) Value() (driver.Value, error) {
33+
if !n.Valid {
34+
return nil, nil
35+
}
36+
return n.Time, nil
37+
}
38+
39+
// MarshalJSON implements json.Marshaler.
40+
func (n SoftDeletedAt) MarshalJSON() ([]byte, error) {
41+
if n.Valid {
42+
return json.Marshal(n.Time)
43+
}
44+
return json.Marshal(nil)
45+
}
46+
47+
// UnmarshalJSON implements json.Unmarshaler.
48+
func (n *SoftDeletedAt) UnmarshalJSON(b []byte) error {
49+
if string(b) == "null" {
50+
n.Valid = false
51+
return nil
52+
}
53+
err := json.Unmarshal(b, &n.Time)
54+
if err == nil {
55+
n.Valid = true
56+
}
57+
return err
58+
}
59+
60+
// GormDataType returns the GORM column data type.
61+
func (SoftDeletedAt) GormDataType() string {
62+
return "datetime"
63+
}
64+
65+
// QueryClauses implements schema.QueryClausesInterface.
66+
// GORM calls this during schema parsing and registers the returned clauses
67+
// so that all SELECT statements gain WHERE deleted_at IS NULL automatically.
68+
func (SoftDeletedAt) QueryClauses(f *schema.Field) []clause.Interface {
69+
return []clause.Interface{softDeleteQueryClause{Field: f}}
70+
}
71+
72+
// UpdateClauses implements schema.UpdateClausesInterface.
73+
// Ensures the WHERE deleted_at IS NULL filter is also applied on UPDATE.
74+
func (SoftDeletedAt) UpdateClauses(f *schema.Field) []clause.Interface {
75+
return []clause.Interface{softDeleteUpdateClause{Field: f}}
76+
}
77+
78+
// DeleteClauses implements schema.DeleteClausesInterface.
79+
// Converts DELETE into UPDATE SET deleted_at=NOW(), deleted=1.
80+
func (SoftDeletedAt) DeleteClauses(f *schema.Field) []clause.Interface {
81+
return []clause.Interface{softDeleteDeleteClause{Field: f}}
82+
}
83+
84+
// NullTime returns the underlying sql.NullTime.
85+
func (n SoftDeletedAt) NullTime() sql.NullTime {
86+
return sql.NullTime(n)
87+
}
88+
89+
// TimeValue returns the time.Time value and whether it is valid (non-NULL).
90+
func (n SoftDeletedAt) TimeValue() (time.Time, bool) {
91+
return n.Time, n.Valid
92+
}
93+
94+
// ---- query clause -------------------------------------------------------
95+
96+
type softDeleteQueryClause struct {
97+
Field *schema.Field
98+
}
99+
100+
func (softDeleteQueryClause) Name() string { return "" }
101+
func (softDeleteQueryClause) Build(clause.Builder) {}
102+
func (softDeleteQueryClause) MergeClause(*clause.Clause) {}
103+
104+
func (sd softDeleteQueryClause) ModifyStatement(stmt *gorm.Statement) {
105+
if _, ok := stmt.Clauses["soft_delete_enabled"]; ok || stmt.Statement.Unscoped {
106+
return
107+
}
108+
109+
// Wrap existing OR conditions in AND to avoid logic errors when combined
110+
// with the soft-delete predicate (mirrors gorm.SoftDeleteQueryClause).
111+
if c, ok := stmt.Clauses["WHERE"]; ok {
112+
if where, ok := c.Expression.(clause.Where); ok && len(where.Exprs) >= 1 {
113+
for _, expr := range where.Exprs {
114+
if orCond, ok := expr.(clause.OrConditions); ok && len(orCond.Exprs) == 1 {
115+
where.Exprs = []clause.Expression{clause.And(where.Exprs...)}
116+
c.Expression = where
117+
stmt.Clauses["WHERE"] = c
118+
break
119+
}
120+
}
121+
}
122+
}
123+
124+
stmt.AddClause(clause.Where{Exprs: []clause.Expression{
125+
clause.Eq{Column: clause.Column{Table: clause.CurrentTable, Name: sd.Field.DBName}, Value: nil},
126+
}})
127+
stmt.Clauses["soft_delete_enabled"] = clause.Clause{}
128+
}
129+
130+
// ---- update clause -------------------------------------------------------
131+
132+
type softDeleteUpdateClause struct {
133+
Field *schema.Field
134+
}
135+
136+
func (softDeleteUpdateClause) Name() string { return "" }
137+
func (softDeleteUpdateClause) Build(clause.Builder) {}
138+
func (softDeleteUpdateClause) MergeClause(*clause.Clause) {}
139+
140+
func (sd softDeleteUpdateClause) ModifyStatement(stmt *gorm.Statement) {
141+
if stmt.SQL.Len() == 0 && !stmt.Statement.Unscoped {
142+
softDeleteQueryClause{Field: sd.Field}.ModifyStatement(stmt)
143+
}
144+
}
145+
146+
// ---- delete clause -------------------------------------------------------
147+
148+
type softDeleteDeleteClause struct {
149+
Field *schema.Field
150+
}
151+
152+
func (softDeleteDeleteClause) Name() string { return "" }
153+
func (softDeleteDeleteClause) Build(clause.Builder) {}
154+
func (softDeleteDeleteClause) MergeClause(*clause.Clause) {}
155+
156+
func (sd softDeleteDeleteClause) ModifyStatement(stmt *gorm.Statement) {
157+
if stmt.SQL.Len() != 0 || stmt.Statement.Unscoped {
158+
return
159+
}
160+
161+
curTime := stmt.DB.NowFunc()
162+
nowSDA := SoftDeletedAt{Valid: true, Time: curTime}
163+
164+
// Build a single SET clause with both assignments.
165+
// NOTE: In GORM v1.30+ clause.Set.MergeClause replaces rather than
166+
// appends, so we must pass both assignments in one AddClause call.
167+
set := clause.Set{{Column: clause.Column{Name: sd.Field.DBName}, Value: nowSDA}}
168+
if deletedField := sd.Field.Schema.LookUpField("Deleted"); deletedField != nil {
169+
set = append(set, clause.Assignment{Column: clause.Column{Name: deletedField.DBName}, Value: true})
170+
stmt.SetColumn(deletedField.DBName, true, true)
171+
}
172+
stmt.AddClause(set)
173+
stmt.SetColumn(sd.Field.DBName, nowSDA, true)
174+
175+
if stmt.Schema != nil {
176+
_, queryValues := schema.GetIdentityFieldValuesMap(stmt.Context, stmt.ReflectValue, stmt.Schema.PrimaryFields)
177+
column, values := schema.ToQueryValues(stmt.Table, stmt.Schema.PrimaryFieldDBNames, queryValues)
178+
179+
if len(values) > 0 {
180+
stmt.AddClause(clause.Where{Exprs: []clause.Expression{clause.IN{Column: column, Values: values}}})
181+
}
182+
183+
if stmt.ReflectValue.CanAddr() && stmt.Dest != stmt.Model && stmt.Model != nil {
184+
_, queryValues = schema.GetIdentityFieldValuesMap(stmt.Context, reflect.ValueOf(stmt.Model), stmt.Schema.PrimaryFields)
185+
column, values = schema.ToQueryValues(stmt.Table, stmt.Schema.PrimaryFieldDBNames, queryValues)
186+
187+
if len(values) > 0 {
188+
stmt.AddClause(clause.Where{Exprs: []clause.Expression{clause.IN{Column: column, Values: values}}})
189+
}
190+
}
191+
}
192+
193+
softDeleteQueryClause{Field: sd.Field}.ModifyStatement(stmt)
194+
stmt.AddClauseIfNotExists(clause.Update{})
195+
stmt.Build(stmt.DB.Callback().Update().Clauses...)
196+
}

0 commit comments

Comments
 (0)