diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2448f20..d8d0e9d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [main, dev] + branches: [main] pull_request: branches: [main, dev] diff --git a/Makefile b/Makefile index 5f3900a..b7909e0 100644 --- a/Makefile +++ b/Makefile @@ -1,19 +1,30 @@ -.PHONY: help lint test test-verbose test-coverage test-coverage-html test-clean +.PHONY: + help + lint + test-all + test-verbose + test-coverage + test-coverage-html + test-clean + install-pre-push-hook + uninstall-pre-push-hook help: @echo "Available commands:" - @echo " help - Show this help message" - @echo " lint - Run golangci-lint on the codebase" - @echo " test - Run all tests" - @echo " test-verbose - Run all tests with verbose output" - @echo " test-coverage - Run all tests with coverage report" - @echo " test-coverage-html - Run all tests and generate HTML coverage report" - @echo " test-clean - Clean test cache and run tests" + @echo " help - Show this help message" + @echo " lint - Run golangci-lint on the codebase" + @echo " test - Run all tests" + @echo " test-verbose - Run all tests with verbose output" + @echo " test-coverage - Run all tests with coverage report" + @echo " test-coverage-html - Run all tests and generate HTML coverage report" + @echo " test-clean - Clean test cache and run tests" + @echo " install-pre-push-hook - Install the pre-push git hook" + @echo " uninstall-pre-push-hook - Uninstall the pre-push git hook" lint: golangci-lint run ./... -test: +test-all: @echo "Running all tests..." go test ./test/... @@ -34,3 +45,15 @@ test-coverage-html: test-clean: @echo "Cleaning test cache and running tests..." go clean -testcache && go test -v ./test/... + +install-pre-push-hook: + @echo "Installing pre-push git hook..." + @mkdir -p .git/hooks + @cp scripts/git-pre-push.sh .git/hooks/pre-push + @chmod +x .git/hooks/pre-push + @echo "Pre-push hook installed successfully!" + +uninstall-pre-push-hook: + @echo "Uninstalling pre-push git hook..." + @rm -f .git/hooks/pre-push + @echo "Pre-push hook uninstalled successfully!" diff --git a/gorm_crud_repository.go b/gorm_repository.go similarity index 67% rename from gorm_crud_repository.go rename to gorm_repository.go index fb893c4..3d0327e 100644 --- a/gorm_crud_repository.go +++ b/gorm_repository.go @@ -8,10 +8,10 @@ import ( "gorm.io/gorm" ) -// CRUDRepository defines a generic interface for basic CRUD operations on entities of type T. +// Repository defines a generic interface for basic CRUD operations on entities of type T. // It provides standard database operations with context support and transaction awareness. // The interface abstracts the underlying database implementation for easier testing and flexibility. -type CRUDRepository[T any] interface { +type Repository[T any] interface { // Insert creates a new record in the database. Insert(ctx context.Context, model T) (T, error) // FindAll retrieves multiple records based on the specification. @@ -34,30 +34,31 @@ type Specification[T any] struct { Model T // Model with fields set for WHERE conditions PreloadRelations []string // Relations to eager load ForUpdate bool // Whether to use SELECT ... FOR UPDATE + DeletedFilter DeletedFilter } -// NewCRUDRepository creates a new CRUD repository implementation using GORM. +// NewRepository creates a new CRUD repository implementation using GORM. // The repository provides transaction-aware database operations for the specified entity type T. -func NewCRUDRepository[T any](db *gorm.DB) CRUDRepository[T] { +func NewRepository[T any](db *gorm.DB) Repository[T] { var zero T - if reflect.TypeOf(zero).Kind() == reflect.Ptr { - panic("CRUDRepository does not support pointer types for T") + if typ := reflect.TypeOf(zero); typ != nil && typ.Kind() == reflect.Ptr { + panic("Repository does not support pointer types for T") } - return &crudRepositoryGorm[T]{db} + return &gormRepository[T]{db} } -type crudRepositoryGorm[T any] struct { +type gormRepository[T any] struct { db *gorm.DB } -func (cr *crudRepositoryGorm[T]) Insert(ctx context.Context, model T) (T, error) { +func (gr *gormRepository[T]) Insert(ctx context.Context, model T) (T, error) { var zero T - if err := cr.checkZeroValue(model); err != nil { + if err := gr.checkZeroValue(model); err != nil { return zero, err } - db, err := cr.GetGormInstance(ctx) + db, err := gr.GetGormInstance(ctx) if err != nil { return zero, err } @@ -69,10 +70,10 @@ func (cr *crudRepositoryGorm[T]) Insert(ctx context.Context, model T) (T, error) return model, nil } -func (cr *crudRepositoryGorm[T]) FindAll(ctx context.Context, spec Specification[T]) ([]T, error) { +func (gr *gormRepository[T]) FindAll(ctx context.Context, spec Specification[T]) ([]T, error) { var models []T - db, err := cr.GetGormInstance(ctx) + db, err := gr.GetGormInstance(ctx) if err != nil { return nil, err } @@ -82,6 +83,7 @@ func (cr *crudRepositoryGorm[T]) FindAll(ctx context.Context, spec Specification DefaultOrder(), PreloadRelations(spec.PreloadRelations), ForUpdate(spec.ForUpdate), + spec.DeletedFilter.WhereDeleted(), ). Find(&models). Error @@ -93,10 +95,10 @@ func (cr *crudRepositoryGorm[T]) FindAll(ctx context.Context, spec Specification return models, nil } -func (cr *crudRepositoryGorm[T]) FindFirst(ctx context.Context, spec Specification[T]) (T, error) { +func (gr *gormRepository[T]) FindFirst(ctx context.Context, spec Specification[T]) (T, error) { var model T - db, err := cr.GetGormInstance(ctx) + db, err := gr.GetGormInstance(ctx) if err != nil { return model, err } @@ -106,6 +108,7 @@ func (cr *crudRepositoryGorm[T]) FindFirst(ctx context.Context, spec Specificati DefaultOrder(), PreloadRelations(spec.PreloadRelations), ForUpdate(spec.ForUpdate), + spec.DeletedFilter.WhereDeleted(), ). First(&model). Error @@ -120,14 +123,14 @@ func (cr *crudRepositoryGorm[T]) FindFirst(ctx context.Context, spec Specificati return model, nil } -func (cr *crudRepositoryGorm[T]) Update(ctx context.Context, model T) (T, error) { +func (gr *gormRepository[T]) Update(ctx context.Context, model T) (T, error) { var zero T - if err := cr.checkZeroValue(model); err != nil { + if err := gr.checkZeroValue(model); err != nil { return zero, err } - db, err := cr.GetGormInstance(ctx) + db, err := gr.GetGormInstance(ctx) if err != nil { return zero, err } @@ -139,12 +142,12 @@ func (cr *crudRepositoryGorm[T]) Update(ctx context.Context, model T) (T, error) return model, nil } -func (cr *crudRepositoryGorm[T]) Delete(ctx context.Context, model T) error { - if err := cr.checkZeroValue(model); err != nil { +func (gr *gormRepository[T]) Delete(ctx context.Context, model T) error { + if err := gr.checkZeroValue(model); err != nil { return err } - db, err := cr.GetGormInstance(ctx) + db, err := gr.GetGormInstance(ctx) if err != nil { return err } @@ -156,12 +159,12 @@ func (cr *crudRepositoryGorm[T]) Delete(ctx context.Context, model T) error { return nil } -func (cr *crudRepositoryGorm[T]) BatchInsert(ctx context.Context, models []T) ([]T, error) { +func (gr *gormRepository[T]) BatchInsert(ctx context.Context, models []T) ([]T, error) { if len(models) < 1 { return nil, eris.Errorf("inserted models cannot be empty") } - db, err := cr.GetGormInstance(ctx) + db, err := gr.GetGormInstance(ctx) if err != nil { return nil, err } @@ -173,7 +176,7 @@ func (cr *crudRepositoryGorm[T]) BatchInsert(ctx context.Context, models []T) ([ return models, nil } -func (cr *crudRepositoryGorm[T]) checkZeroValue(model T) error { +func (gr *gormRepository[T]) checkZeroValue(model T) error { if reflect.DeepEqual(model, *new(T)) { return eris.New("model cannot be zero value") } @@ -181,7 +184,7 @@ func (cr *crudRepositoryGorm[T]) checkZeroValue(model T) error { return nil } -func (cr *crudRepositoryGorm[T]) GetGormInstance(ctx context.Context) (*gorm.DB, error) { +func (gr *gormRepository[T]) GetGormInstance(ctx context.Context) (*gorm.DB, error) { tx, err := GetTxFromContext(ctx) if err != nil { return nil, err @@ -190,5 +193,5 @@ func (cr *crudRepositoryGorm[T]) GetGormInstance(ctx context.Context) (*gorm.DB, return tx, nil } - return cr.db.WithContext(ctx), nil + return gr.db.WithContext(ctx), nil } diff --git a/gorm_scopes.go b/gorm_scopes.go index 146e7ff..e4011c2 100644 --- a/gorm_scopes.go +++ b/gorm_scopes.go @@ -106,3 +106,22 @@ func ForUpdate(enable bool) func(*gorm.DB) *gorm.DB { return db } } + +type DeletedFilter struct { + filterType internal.DeletedFilterType +} + +func (df *DeletedFilter) WhereDeleted() func(*gorm.DB) *gorm.DB { + return func(d *gorm.DB) *gorm.DB { + if df.filterType == nil { + return d + } + return df.filterType.WhereDeleted()(d) + } +} + +var ( + ExcludeDeleted DeletedFilter = DeletedFilter{internal.ExcludeDeleted{}} + IncludeDeleted DeletedFilter = DeletedFilter{internal.IncludeDeleted{}} + OnlyDeleted DeletedFilter = DeletedFilter{internal.OnlyDeleted{}} +) diff --git a/internal/gorm_scopes.go b/internal/gorm_scopes.go new file mode 100644 index 0000000..f336b68 --- /dev/null +++ b/internal/gorm_scopes.go @@ -0,0 +1,31 @@ +package internal + +import "gorm.io/gorm" + +type DeletedFilterType interface { + WhereDeleted() func(*gorm.DB) *gorm.DB +} + +type ExcludeDeleted struct{} + +func (ed ExcludeDeleted) WhereDeleted() func(*gorm.DB) *gorm.DB { + return func(d *gorm.DB) *gorm.DB { + return d.Where("deleted_at IS NULL") + } +} + +type IncludeDeleted struct{} + +func (id IncludeDeleted) WhereDeleted() func(*gorm.DB) *gorm.DB { + return func(d *gorm.DB) *gorm.DB { + return d + } +} + +type OnlyDeleted struct{} + +func (od OnlyDeleted) WhereDeleted() func(*gorm.DB) *gorm.DB { + return func(d *gorm.DB) *gorm.DB { + return d.Where("deleted_at IS NOT NULL") + } +} diff --git a/scripts/git-pre-push.sh b/scripts/git-pre-push.sh new file mode 100644 index 0000000..1c6b6f2 --- /dev/null +++ b/scripts/git-pre-push.sh @@ -0,0 +1,21 @@ +#!/bin/sh +# +# Pre-push hook that runs linting and tests before allowing push + +echo "Running pre-push checks..." + +# Run linting +echo "\n=== Running linting ===" +if ! make lint; then + echo "āŒ Linting failed! Please fix the issues before pushing." + exit 1 +fi + +# Run tests +echo "\n=== Running tests ===" +if ! make test-all; then + echo "āŒ Tests failed! Please fix the test issues before pushing." + exit 1 +fi + +echo "\nāœ… All checks passed! Pushing can continue...\n" diff --git a/test/crud_repository_test.go b/test/repository_test.go similarity index 90% rename from test/crud_repository_test.go rename to test/repository_test.go index 78deb33..a7be787 100644 --- a/test/crud_repository_test.go +++ b/test/repository_test.go @@ -34,16 +34,16 @@ func setupTestDB(t *testing.T) *gorm.DB { return db } -func TestNewCRUDRepository(t *testing.T) { +func TestNewRepository(t *testing.T) { db := setupTestDB(t) - repo := crud.NewCRUDRepository[TestModel](db) + repo := crud.NewRepository[TestModel](db) - assert.NotNil(t, repo, "NewCRUDRepository should not return nil") + assert.NotNil(t, repo, "NewRepository should not return nil") } -func TestCRUDRepository_Insert(t *testing.T) { +func TestRepository_Insert(t *testing.T) { db := setupTestDB(t) - repo := crud.NewCRUDRepository[TestModel](db) + repo := crud.NewRepository[TestModel](db) ctx := context.Background() t.Run("successful insert", func(t *testing.T) { @@ -68,9 +68,9 @@ func TestCRUDRepository_Insert(t *testing.T) { }) } -func TestCRUDRepository_FindAll(t *testing.T) { +func TestRepository_FindAll(t *testing.T) { db := setupTestDB(t) - repo := crud.NewCRUDRepository[TestModel](db) + repo := crud.NewRepository[TestModel](db) ctx := context.Background() // Insert test data @@ -102,9 +102,9 @@ func TestCRUDRepository_FindAll(t *testing.T) { }) } -func TestCRUDRepository_FindFirst(t *testing.T) { +func TestRepository_FindFirst(t *testing.T) { db := setupTestDB(t) - repo := crud.NewCRUDRepository[TestModel](db) + repo := crud.NewRepository[TestModel](db) ctx := context.Background() testModel := TestModel{Name: "Alice", Email: "alice@example.com", Age: 25} @@ -131,9 +131,9 @@ func TestCRUDRepository_FindFirst(t *testing.T) { }) } -func TestCRUDRepository_Update(t *testing.T) { +func TestRepository_Update(t *testing.T) { db := setupTestDB(t) - repo := crud.NewCRUDRepository[TestModel](db) + repo := crud.NewRepository[TestModel](db) ctx := context.Background() testModel := TestModel{Name: "Alice", Email: "alice@example.com", Age: 25} @@ -162,9 +162,9 @@ func TestCRUDRepository_Update(t *testing.T) { }) } -func TestCRUDRepository_Delete(t *testing.T) { +func TestRepository_Delete(t *testing.T) { db := setupTestDB(t) - repo := crud.NewCRUDRepository[TestModel](db) + repo := crud.NewRepository[TestModel](db) ctx := context.Background() testModel := TestModel{Name: "Alice", Email: "alice@example.com", Age: 25} @@ -190,9 +190,9 @@ func TestCRUDRepository_Delete(t *testing.T) { }) } -func TestCRUDRepository_BatchInsert(t *testing.T) { +func TestRepository_BatchInsert(t *testing.T) { db := setupTestDB(t) - repo := crud.NewCRUDRepository[TestModel](db) + repo := crud.NewRepository[TestModel](db) ctx := context.Background() t.Run("successful batch insert", func(t *testing.T) { @@ -221,9 +221,9 @@ func TestCRUDRepository_BatchInsert(t *testing.T) { }) } -func TestCRUDRepository_GetGormInstance(t *testing.T) { +func TestRepository_GetGormInstance(t *testing.T) { db := setupTestDB(t) - repo := crud.NewCRUDRepository[TestModel](db) + repo := crud.NewRepository[TestModel](db) ctx := context.Background() instance, err := repo.GetGormInstance(ctx) diff --git a/test/scopes_test.go b/test/scopes_test.go index e278624..06d1cfa 100644 --- a/test/scopes_test.go +++ b/test/scopes_test.go @@ -11,13 +11,22 @@ import ( "gorm.io/gorm/logger" ) +// SoftDeleteModel for testing soft delete functionality +type SoftDeleteModel struct { + ID uint `gorm:"primaryKey"` + Name string `gorm:"not null"` + DeletedAt gorm.DeletedAt `gorm:"index"` + CreatedAt time.Time + UpdatedAt time.Time +} + func setupScopesTestDB(t *testing.T) *gorm.DB { db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) assert.NoError(t, err, "Failed to connect to test database") - err = db.AutoMigrate(&TestModel{}) + err = db.AutoMigrate(&TestModel{}, &SoftDeleteModel{}) assert.NoError(t, err, "Failed to migrate test models") return db @@ -271,3 +280,46 @@ func TestForUpdate(t *testing.T) { }) } } +func TestDeletedFilter(t *testing.T) { + db := setupScopesTestDB(t) + + // Insert test data + models := []SoftDeleteModel{ + {Name: "Active1"}, + {Name: "Active2"}, + {Name: "ToDelete1"}, + {Name: "ToDelete2"}, + } + err := db.Create(&models).Error + assert.NoError(t, err, "Failed to create test data") + + // Soft delete some records + err = db.Delete(&models[2]).Error + assert.NoError(t, err, "Failed to soft delete record") + err = db.Delete(&models[3]).Error + assert.NoError(t, err, "Failed to soft delete record") + + tests := []struct { + name string + filter crud.DeletedFilter + useUnscoped bool + wantCount int + }{ + {"exclude deleted", crud.ExcludeDeleted, false, 2}, + {"include deleted", crud.IncludeDeleted, true, 4}, + {"only deleted", crud.OnlyDeleted, true, 2}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var results []SoftDeleteModel + query := db.Scopes(tt.filter.WhereDeleted()) + if tt.useUnscoped { + query = query.Unscoped() + } + err := query.Find(&results).Error + assert.NoError(t, err, "DeletedFilter should not return error") + assert.Len(t, results, tt.wantCount, "DeletedFilter should return expected number of records") + }) + } +} diff --git a/test/transactor_test.go b/test/transactor_test.go index a874190..8661002 100644 --- a/test/transactor_test.go +++ b/test/transactor_test.go @@ -91,7 +91,7 @@ func TestTransactor_Rollback(t *testing.T) { func TestTransactor_WithinTransaction_Success(t *testing.T) { db := setupTransactorTestDB(t) transactor := crud.NewTransactor(db) - repo := crud.NewCRUDRepository[TestModel](db) + repo := crud.NewRepository[TestModel](db) ctx := context.Background() var insertedID uint @@ -127,7 +127,7 @@ func TestTransactor_WithinTransaction_Success(t *testing.T) { func TestTransactor_WithinTransaction_Rollback(t *testing.T) { db := setupTransactorTestDB(t) transactor := crud.NewTransactor(db) - repo := crud.NewCRUDRepository[TestModel](db) + repo := crud.NewRepository[TestModel](db) ctx := context.Background() var insertedID uint @@ -164,7 +164,7 @@ func TestTransactor_WithinTransaction_Rollback(t *testing.T) { func TestTransactor_WithinTransaction_Nested(t *testing.T) { db := setupTransactorTestDB(t) transactor := crud.NewTransactor(db) - repo := crud.NewCRUDRepository[TestModel](db) + repo := crud.NewRepository[TestModel](db) ctx := context.Background() var outerID, innerID uint