From 6c705693a545a00f438e46b599e988f075ee5ef4 Mon Sep 17 00:00:00 2001 From: Phung Ngoc Duy Date: Fri, 14 Apr 2023 05:52:52 +0800 Subject: [PATCH] Stop using Gorm, add persistence layer components and new mechanic of domain event publishing --- LICENSE | 21 +++ docker-compose.yml | 11 ++ domain/entity/base_entity.go | 16 ++- domain/event/base_domain_event.go | 66 +++++++++- domain/event/domain_event_publisher.go | 6 +- domain/event/domain_event_publisher_test.go | 37 ------ domain/event/event_storage.go | 27 ++++ domain/event/mock_domain_event.go | 33 ----- domain/event/mock_event_handler.go | 10 -- go.mod | 16 ++- go.sum | 40 +++--- persistence/database/connection.go | 59 +++++++++ persistence/database/connection_test.go | 37 ++++++ persistence/database/database_config.go | 24 ++++ persistence/result.go | 5 + persistence/trigger/listen.go | 38 ++++++ persistence/trigger/model_hooks.go | 47 +++++++ persistence/trigger/trigger_channel.go | 26 ++++ samples/account.go | 21 +++ samples/account_created_event.go | 20 +++ samples/account_created_event_handler.go | 31 +++++ samples/account_repository.go | 65 +++++++++ samples/account_repository_test.go | 138 ++++++++++++++++++++ samples/email.go | 12 ++ samples/email_repository.go | 33 +++++ samples/entity/account.go | 5 - tests/random_test_values.go | 14 ++ types/custom_type_uuidv1.go | 19 --- 28 files changed, 743 insertions(+), 134 deletions(-) create mode 100644 LICENSE create mode 100644 docker-compose.yml delete mode 100644 domain/event/domain_event_publisher_test.go create mode 100644 domain/event/event_storage.go delete mode 100644 domain/event/mock_domain_event.go delete mode 100644 domain/event/mock_event_handler.go create mode 100644 persistence/database/connection.go create mode 100644 persistence/database/connection_test.go create mode 100644 persistence/database/database_config.go create mode 100644 persistence/result.go create mode 100644 persistence/trigger/listen.go create mode 100644 persistence/trigger/model_hooks.go create mode 100644 persistence/trigger/trigger_channel.go create mode 100644 samples/account.go create mode 100644 samples/account_created_event.go create mode 100644 samples/account_created_event_handler.go create mode 100644 samples/account_repository.go create mode 100644 samples/account_repository_test.go create mode 100644 samples/email.go create mode 100644 samples/email_repository.go delete mode 100644 samples/entity/account.go create mode 100644 tests/random_test_values.go diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..517aaf3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Duy Phung + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6cdf748 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +version: '3.8' +services: + db: + image: postgres:14.1-alpine + container_name: postgresdb + restart: always + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + ports: + - '54320:5432' \ No newline at end of file diff --git a/domain/entity/base_entity.go b/domain/entity/base_entity.go index d89d1f4..293d19a 100644 --- a/domain/entity/base_entity.go +++ b/domain/entity/base_entity.go @@ -1,9 +1,21 @@ package entity +import ( + "time" + + "github.com/tonybka/go-base-ddd/domain/event" +) + type BaseEntity struct { - ID uint + ID uint + CreatedAt time.Time + UpdatedAt time.Time } func NewBaseEntity(id uint) BaseEntity { - return BaseEntity{ID: id} + return BaseEntity{ID: id, CreatedAt: time.Now()} +} + +func (base *BaseEntity) AddEvent(tableName string, domainEvent event.IBaseDomainEvent) { + event.EventSource.AddEvent(tableName, domainEvent) } diff --git a/domain/event/base_domain_event.go b/domain/event/base_domain_event.go index 74ae91c..8f9870b 100644 --- a/domain/event/base_domain_event.go +++ b/domain/event/base_domain_event.go @@ -1,10 +1,72 @@ package event -import "time" +import ( + "encoding/json" + "time" + + "github.com/google/uuid" +) type IBaseDomainEvent interface { + ID() uuid.UUID + AggregateID() uint + Aggregate() string Name() string ToJson() (string, error) - ID() string OccurredAt() time.Time + Data() interface{} +} + +type domainEventProps struct { + ID uuid.UUID + AggregateID uint + Aggregate string + Name string + OccuredAt time.Time + Data interface{} +} + +type BaseDomainEvent struct { + props domainEventProps +} + +func NewBaseDomainEvent(aggregate string, aggregateID uint, name string, data interface{}) *BaseDomainEvent { + return &BaseDomainEvent{ + props: domainEventProps{ + ID: uuid.New(), + Aggregate: aggregate, + AggregateID: aggregateID, + Name: name, + OccuredAt: time.Now(), + Data: data, + }, + } +} + +func (event *BaseDomainEvent) ID() uuid.UUID { + return event.props.ID +} + +func (event *BaseDomainEvent) AggregateID() uint { + return event.props.AggregateID +} + +func (event *BaseDomainEvent) Aggregate() string { + return event.props.Aggregate +} + +func (event *BaseDomainEvent) ToJson() (string, error) { + b, err := json.Marshal(event.props) + if err != nil { + return "", nil + } + return string(b), nil +} + +func (event *BaseDomainEvent) OccurredAt() time.Time { + return event.props.OccuredAt +} + +func (event *BaseDomainEvent) Data() interface{} { + return event.props.Data } diff --git a/domain/event/domain_event_publisher.go b/domain/event/domain_event_publisher.go index 9e6a39a..18cb284 100644 --- a/domain/event/domain_event_publisher.go +++ b/domain/event/domain_event_publisher.go @@ -1,9 +1,5 @@ package event -import ( - "gorm.io/gorm" -) - // =---------------------- // DomainEventPublisher // =---------------------- @@ -25,7 +21,7 @@ func (publisher *DomainEventPublisher) RegisterSubscriber(event IBaseDomainEvent } // Publish notifies all registered subscribers about the given events -func (publisher *DomainEventPublisher) Publish(tx *gorm.DB, events ...IBaseDomainEvent) error { +func (publisher *DomainEventPublisher) Publish(events ...IBaseDomainEvent) error { for _, event := range events { eventName := event.Name() diff --git a/domain/event/domain_event_publisher_test.go b/domain/event/domain_event_publisher_test.go deleted file mode 100644 index ac2bd1f..0000000 --- a/domain/event/domain_event_publisher_test.go +++ /dev/null @@ -1,37 +0,0 @@ -package event - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -// TestRegisterEventHandler ensure handler registration works -func TestRegisterEventHandler(t *testing.T) { - eventPublisher := InitDomainEventPublisher() - assert.NotNil(t, eventPublisher) - - handler := &MockEventHandler{} - event := &MockDomainEventStruct{} - - eventPublisher.RegisterSubscriber(event, handler) -} - -// TestPublishDomainEvent ensure the handler was notified once event triggered -func TestPublishDomainEvent(t *testing.T) { - eventPublisher := InitDomainEventPublisher() - assert.NotNil(t, eventPublisher) - - handler := &MockEventHandler{} - event := &MockDomainEventStruct{} - - eventPublisher.RegisterSubscriber(event, handler) - - // Before notification - assert.False(t, handler.Notified) - - eventPublisher.Publish(nil, event) - - // After notification - assert.True(t, handler.Notified) -} diff --git a/domain/event/event_storage.go b/domain/event/event_storage.go new file mode 100644 index 0000000..b835960 --- /dev/null +++ b/domain/event/event_storage.go @@ -0,0 +1,27 @@ +package event + +var EventSource *EventStorage + +func init() { + if EventSource == nil { + EventSource = &EventStorage{ + pendingEvents: make(map[string][]IBaseDomainEvent), + } + } +} + +type EventStorage struct { + pendingEvents map[string][]IBaseDomainEvent +} + +func (storage *EventStorage) AddEvent(dataModel string, domainEvent IBaseDomainEvent) { + if storage.pendingEvents[dataModel] == nil { + storage.pendingEvents[dataModel] = make([]IBaseDomainEvent, 0) + } + + storage.pendingEvents[dataModel] = append(storage.pendingEvents[dataModel], domainEvent) +} + +func (storage *EventStorage) GetPendingEvents(dataModel string) []IBaseDomainEvent { + return storage.pendingEvents[dataModel] +} diff --git a/domain/event/mock_domain_event.go b/domain/event/mock_domain_event.go deleted file mode 100644 index 0c16c52..0000000 --- a/domain/event/mock_domain_event.go +++ /dev/null @@ -1,33 +0,0 @@ -package event - -import ( - "encoding/json" - "time" -) - -// MockDomainEventStruct is the simulation of Domain Event structure in domain layer that would -// be referred in persistence layer for publishing -type MockDomainEventStruct struct { - EventName string - EventID string -} - -func (event *MockDomainEventStruct) Name() string { - return event.EventName -} - -func (event *MockDomainEventStruct) ToJson() (string, error) { - jsonString, err := json.Marshal(event) - if err != nil { - return "", err - } - return string(jsonString), nil -} - -func (event *MockDomainEventStruct) ID() string { - return event.EventID -} - -func (event *MockDomainEventStruct) OccurredAt() time.Time { - return time.Now() -} diff --git a/domain/event/mock_event_handler.go b/domain/event/mock_event_handler.go deleted file mode 100644 index b80e08f..0000000 --- a/domain/event/mock_event_handler.go +++ /dev/null @@ -1,10 +0,0 @@ -package event - -type MockEventHandler struct { - Notified bool -} - -func (handler *MockEventHandler) Notify(event IBaseDomainEvent) error { - handler.Notified = true - return nil -} diff --git a/go.mod b/go.mod index 7d32bf3..d44a710 100644 --- a/go.mod +++ b/go.mod @@ -4,19 +4,23 @@ go 1.18 require ( github.com/google/uuid v1.3.0 - gorm.io/gorm v1.24.2 + github.com/jackc/pgx/v5 v5.3.1 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect - github.com/mattn/go-sqlite3 v1.14.15 // indirect + github.com/kr/text v0.2.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rogpeppe/go-internal v1.10.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) require ( - github.com/jinzhu/inflection v1.0.0 // indirect - github.com/jinzhu/now v1.1.5 // indirect - github.com/stretchr/testify v1.8.1 - gorm.io/driver/sqlite v1.4.3 + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/puddle/v2 v2.2.0 // indirect + github.com/stretchr/testify v1.8.2 + golang.org/x/crypto v0.8.0 // indirect + golang.org/x/sync v0.1.0 // indirect + golang.org/x/text v0.9.0 // indirect ) diff --git a/go.sum b/go.sum index 138c813..f889a9d 100644 --- a/go.sum +++ b/go.sum @@ -1,31 +1,41 @@ +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= -github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= -github.com/jinzhu/now v1.1.4 h1:tHnRBy1i5F2Dh8BAFxqFzxKqqvezXrL2OW1TnX+Mlas= -github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= -github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= -github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= -github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI= -github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.3.1 h1:Fcr8QJ1ZeLi5zsPZqQeUZhNhxfkkKBOgJuYkJHoBOtU= +github.com/jackc/pgx/v5 v5.3.1/go.mod h1:t3JDKnCBlYIc0ewLF0Q7B8MXmoIaBOZj/ic7iHozM/8= +github.com/jackc/puddle/v2 v2.2.0 h1:RdcDk92EJBuBS55nQMMYFXTxwstHug4jkhT5pq8VxPk= +github.com/jackc/puddle/v2 v2.2.0/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.8.0 h1:pd9TJtTueMTVQXzk8E2XESSMQDj/U7OUu0PqJqPXQjQ= +golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gorm.io/driver/sqlite v1.4.3 h1:HBBcZSDnWi5BW3B3rwvVTc510KGkBkexlOg0QrmLUuU= -gorm.io/driver/sqlite v1.4.3/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI= -gorm.io/gorm v1.24.0/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA= -gorm.io/gorm v1.24.2 h1:9wR6CFD+G8nOusLdvkZelOEhpJVwwHzpQOUM+REd6U0= -gorm.io/gorm v1.24.2/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA= diff --git a/persistence/database/connection.go b/persistence/database/connection.go new file mode 100644 index 0000000..692c995 --- /dev/null +++ b/persistence/database/connection.go @@ -0,0 +1,59 @@ +package database + +import ( + "context" + "log" + "sync" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// attemptToConnect retries to establish connection +func attemptToConnect(fn func() error, attemtps int, delay time.Duration) (err error) { + if attemtps == 0 { + attemtps = 5 + } + + for attemtps > 0 { + if err = fn(); err != nil { + time.Sleep(delay) + attemtps-- + continue + } + return nil + } + return +} + +// NewDBConnection creates new connection to database +func NewDBConnection(ctx context.Context, cfg *DatabaseConfig) (*pgxpool.Pool, error) { + const timeDelta = 2 * time.Second + var dbpool *pgxpool.Pool + var err error + + var once sync.Once + once.Do(func() { + err = attemptToConnect( + func() error { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + dbpool, err = pgxpool.New(ctx, cfg.ToConnectionURL()) + if err != nil { + return err + } + + return nil + }, + cfg.ConnectAttempts, + 5*time.Second, + ) + + if err != nil { + log.Fatal("could not connect to database after retries") + } + }) + + return dbpool, nil +} diff --git a/persistence/database/connection_test.go b/persistence/database/connection_test.go new file mode 100644 index 0000000..3f396cc --- /dev/null +++ b/persistence/database/connection_test.go @@ -0,0 +1,37 @@ +package database + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func testConfig() *DatabaseConfig { + return &DatabaseConfig{ + Host: "localhost", + Port: "54320", + Name: "postgres", + UserName: "postgres", + Password: "postgres", + } +} + +// Make sure you have run the postgresQL database from docker compose +func TestConnectDB(t *testing.T) { + dbConfig := testConfig() + assert.NotNil(t, dbConfig) + + conn, err := NewDBConnection(context.Background(), dbConfig) + assert.NoError(t, err) + assert.NotNil(t, conn) +} + +func TestBuildConnectionDSN(t *testing.T) { + dbConfig := testConfig() + dsn := dbConfig.ToConnectionURL() + + assert.Greater(t, len(dsn), 0) + assert.True(t, strings.HasPrefix(dsn, "postgres://")) +} diff --git a/persistence/database/database_config.go b/persistence/database/database_config.go new file mode 100644 index 0000000..6ac06c1 --- /dev/null +++ b/persistence/database/database_config.go @@ -0,0 +1,24 @@ +package database + +import "fmt" + +// DatabaseConfig configurations of database connection +type DatabaseConfig struct { + Host string `mapstructure:"DB_HOST"` + Port string `mapstructure:"DB_PORT"` + UserName string `mapstructure:"DB_USERNAME"` + Password string `mapstructure:"DB_PASSWORD"` + Name string `mapstructure:"DB_NAME"` + ConnectAttempts int `mapstructure:"DB_CONNECT_ATTEMPS"` +} + +func (config DatabaseConfig) ToConnectionURL() string { + return fmt.Sprintf( + "postgres://%s:%s@%s:%s/%s", + config.UserName, + config.Password, + config.Host, + config.Port, + config.Name, + ) +} diff --git a/persistence/result.go b/persistence/result.go new file mode 100644 index 0000000..8295eba --- /dev/null +++ b/persistence/result.go @@ -0,0 +1,5 @@ +package persistence + +type ResultRowId struct { + Id int +} diff --git a/persistence/trigger/listen.go b/persistence/trigger/listen.go new file mode 100644 index 0000000..aeda1ba --- /dev/null +++ b/persistence/trigger/listen.go @@ -0,0 +1,38 @@ +package trigger + +import ( + "context" + "fmt" + "os" + "strconv" + "strings" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func listenToTriggers(pool *pgxpool.Pool, callback func(uint, string, uint, string) error) { + conn, err := pool.Acquire(context.Background()) + if err != nil { + fmt.Fprintln(os.Stderr, "Error acquiring connection:", err) + os.Exit(1) + } + defer conn.Release() + + _, err = conn.Exec(context.Background(), fmt.Sprintf("listen %s", TriggerChannelInsertOrUpdate)) + if err != nil { + fmt.Fprintln(os.Stderr, "Error listening to chat channel:", err) + os.Exit(1) + } + + for { + notification, err := conn.Conn().WaitForNotification(context.Background()) + if err != nil { + fmt.Fprintln(os.Stderr, "Error waiting for notification:", err) + os.Exit(1) + } + + payloads := strings.Split(notification.Payload, ";") + rowID, _ := strconv.ParseUint(payloads[1], 10, 64) + callback(uint(notification.PID), payloads[0], uint(rowID), payloads[2]) + } +} diff --git a/persistence/trigger/model_hooks.go b/persistence/trigger/model_hooks.go new file mode 100644 index 0000000..40f9c8d --- /dev/null +++ b/persistence/trigger/model_hooks.go @@ -0,0 +1,47 @@ +package trigger + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/tonybka/go-base-ddd/domain/event" +) + +// modelHooks get called whenever there is new create-or-update event triggered +func modelHooks(pid uint, table string, rowID uint, action string) error { + pendingEvents := event.EventSource.GetPendingEvents(table) + + publisher := event.GetDomainEventPublisher() + err := publisher.Publish(pendingEvents...) + if err != nil { + return err + } + + return nil +} + +// RegisterModelHooks register listener of triggers +func RegisterModelHooks(dbPool *pgxpool.Pool, tables []string) error { + + // Create trigger function + _, err := dbPool.Query(context.Background(), SQLCreateInsertOrUpdateTrigger) + if err != nil { + return err + } + + // Create triggers for database tables + for _, table := range tables { + sql := fmt.Sprintf(sqlCreateInsertUpdateTrigger, table, table) + _, err := dbPool.Query(context.Background(), sql) + + if err != nil { + return err + } + } + + // Called at last, in goroutine + go listenToTriggers(dbPool, modelHooks) + + return nil +} diff --git a/persistence/trigger/trigger_channel.go b/persistence/trigger/trigger_channel.go new file mode 100644 index 0000000..60d326f --- /dev/null +++ b/persistence/trigger/trigger_channel.go @@ -0,0 +1,26 @@ +package trigger + +const TriggerChannelInsertOrUpdate = "create_or_update_channel" + +const SQLCreateInsertOrUpdateTrigger = ` +CREATE OR REPLACE FUNCTION create_or_update_trigger_func() +RETURNS TRIGGER AS $$ +BEGIN + IF (TG_OP = 'INSERT') THEN + PERFORM pg_notify('create_or_update_channel', TG_TABLE_NAME ||';'|| NEW.id::text ||';'|| 'inserted'); + ELSIF (TG_OP = 'UPDATE') THEN + PERFORM pg_notify('create_or_update_channel', TG_TABLE_NAME ||';'|| NEW.id::text ||';'|| 'updated'); + ELSIF (TG_OP = 'DELETE') THEN + PERFORM pg_notify('create_or_update_channel', TG_TABLE_NAME ||';'|| NEW.id::text ||';'|| 'deleted'); + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql;` + +const ( + sqlCreateInsertUpdateTrigger = ` + CREATE TRIGGER create_or_update_%s_trigger + AFTER INSERT OR UPDATE ON %s + FOR EACH ROW EXECUTE FUNCTION create_or_update_trigger_func(); + ` +) diff --git a/samples/account.go b/samples/account.go new file mode 100644 index 0000000..2c38caa --- /dev/null +++ b/samples/account.go @@ -0,0 +1,21 @@ +package account + +import ( + "github.com/jackc/pgx/v5" + "github.com/tonybka/go-base-ddd/domain/entity" +) + +const DBTblNameAccounts = "accounts" + +type Account struct { + entity.BaseEntity + AccountName string +} + +func (s *Account) ScanRow(row pgx.Row) error { + return row.Scan( + &s.ID, + &s.AccountName, + &s.CreatedAt, + ) +} diff --git a/samples/account_created_event.go b/samples/account_created_event.go new file mode 100644 index 0000000..b8c206d --- /dev/null +++ b/samples/account_created_event.go @@ -0,0 +1,20 @@ +package account + +import ( + "github.com/tonybka/go-base-ddd/domain/event" +) + +type AccountCreatedEvent struct { + *event.BaseDomainEvent +} + +func NewAccountCreatedEvent(aggregateID uint, data interface{}) *AccountCreatedEvent { + base := event.NewBaseDomainEvent("accounts", aggregateID, "event.account.created", data) + return &AccountCreatedEvent{ + BaseDomainEvent: base, + } +} + +func (event *AccountCreatedEvent) Name() string { + return "event.account.created" +} diff --git a/samples/account_created_event_handler.go b/samples/account_created_event_handler.go new file mode 100644 index 0000000..ff14008 --- /dev/null +++ b/samples/account_created_event_handler.go @@ -0,0 +1,31 @@ +package account + +import ( + "fmt" + + "github.com/tonybka/go-base-ddd/domain/event" +) + +// AccountCreatedEventHandler triggered once new account created +type AccountCreatedEventHandler struct { + accountRepo *AccountRepository +} + +func NewAccountCreatedEventHandler(accountRepo *AccountRepository) *AccountCreatedEventHandler { + return &AccountCreatedEventHandler{accountRepo: accountRepo} +} + +func (handler *AccountCreatedEventHandler) Notify(event event.IBaseDomainEvent) error { + fmt.Println("AccountCreatedEventHandler.Notify: get notified") + accountCreatedEvent := event.(*AccountCreatedEvent) + + account, err := handler.accountRepo.FindById(accountCreatedEvent.AggregateID()) + if err != nil { + fmt.Println("Cound not query account") + return err + } + + fmt.Printf("Account ID: %d\n", account.ID) + fmt.Printf("Account Name: %s\n", account.AccountName) + return nil +} diff --git a/samples/account_repository.go b/samples/account_repository.go new file mode 100644 index 0000000..61610b9 --- /dev/null +++ b/samples/account_repository.go @@ -0,0 +1,65 @@ +package account + +import ( + "context" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/tonybka/go-base-ddd/persistence" +) + +const ( + sqlCreateAccount = `INSERT INTO accounts (id, account_name) VALUES ($1, $2) RETURNING accounts.id;` + sqlQueryAccountById = `SELECT id, account_name, created_at FROM accounts WHERE id = $1;` + sqlSelectAllAccounts = `SELECT id, account_name, created_at FROM accounts;` +) + +type AccountRepository struct { + pgDBConn *pgxpool.Pool +} + +func NewAccountRepository(pg *pgxpool.Pool) *AccountRepository { + return &AccountRepository{pgDBConn: pg} +} + +// Create creates new account +func (repo *AccountRepository) Create(account Account) (*persistence.ResultRowId, error) { + accountId := &persistence.ResultRowId{} + + err := repo.pgDBConn.QueryRow(context.Background(), sqlCreateAccount, account.ID, account.AccountName).Scan(&(*accountId).Id) + if err != nil { + return nil, err + } + + return accountId, nil +} + +// FindById query account by it's identity +func (repo *AccountRepository) FindById(id uint) (*Account, error) { + account := &Account{} + + row := repo.pgDBConn.QueryRow(context.Background(), sqlQueryAccountById, id) + err := account.ScanRow(row) + if err != nil { + return nil, err + } + + return account, nil +} + +// GetAll returns all accounts in the table +func (repo *AccountRepository) GetAll() ([]*Account, error) { + var accounts []*Account + + rows, err := repo.pgDBConn.Query(context.Background(), sqlSelectAllAccounts) + if err != nil { + return nil, err + } + + for rows.Next() { + account := &Account{} + err = account.ScanRow(rows) + accounts = append(accounts, account) + } + + return accounts, nil +} diff --git a/samples/account_repository_test.go b/samples/account_repository_test.go new file mode 100644 index 0000000..26937d9 --- /dev/null +++ b/samples/account_repository_test.go @@ -0,0 +1,138 @@ +package account + +import ( + "context" + "math/rand" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + "github.com/tonybka/go-base-ddd/domain/entity" + "github.com/tonybka/go-base-ddd/domain/event" + "github.com/tonybka/go-base-ddd/persistence/database" + "github.com/tonybka/go-base-ddd/persistence/trigger" + "github.com/tonybka/go-base-ddd/tests" +) + +const createAccountTableSmt = ` +CREATE TABLE accounts +( + id serial UNIQUE, + account_name varchar(80) UNIQUE NOT NULL, + created_at timestamp NOT NULL DEFAULT now(), + PRIMARY KEY (id) +); +` + +const createEmailTableSmt = ` +CREATE TABLE emails +( + id serial UNIQUE, + mail varchar(80) UNIQUE NOT NULL, + created_at timestamp NOT NULL DEFAULT now(), + PRIMARY KEY (id) +); +` + +type AccountRepositoryTestSuite struct { + suite.Suite + accountRepo *AccountRepository + emailRepo *EmailRepository +} + +func (ts *AccountRepositoryTestSuite) SetupSuite() { + + // Init global domain publisher + event.InitDomainEventPublisher() + publisher := event.GetDomainEventPublisher() + require.NotNil(ts.T(), publisher) + + dbClient, err := database.NewDBConnection( + context.Background(), + &database.DatabaseConfig{ + Host: "localhost", + Port: "54320", + Name: "postgres", + UserName: "postgres", + Password: "postgres", + }, + ) + require.NoError(ts.T(), err) + require.NotNil(ts.T(), dbClient) + + ts.accountRepo = NewAccountRepository(dbClient) + ts.emailRepo = NewEmailRepository(dbClient) + + _, err = dbClient.Query(context.Background(), createAccountTableSmt) + require.NoError(ts.T(), err) + _, err = dbClient.Query(context.Background(), createEmailTableSmt) + require.NoError(ts.T(), err) + + err = trigger.RegisterModelHooks(dbClient, []string{DBTblNameAccounts, TableNameEmails}) + require.NoError(ts.T(), err) + + // Register handlers of domain event + accountCreatedSubscribers := []event.IDomainEvenHandler{NewAccountCreatedEventHandler(ts.accountRepo)} + publisher.RegisterSubscriber(&AccountCreatedEvent{}, accountCreatedSubscribers...) + + // Reset random seed to make sure the generated value is unique + rand.Seed(time.Now().UnixNano()) +} + +func (ts *AccountRepositoryTestSuite) TestCreateAccount() { + randId := rand.Intn(99999) + + account := Account{ + BaseEntity: entity.NewBaseEntity(uint(randId)), + AccountName: tests.RandomString(), + } + + result, err := ts.accountRepo.Create(account) + ts.NoError(err) + ts.NotNil(result) + + all, err := ts.accountRepo.GetAll() + ts.NoError(err) + ts.Greater(len(all), 0) + + queriedAccount, err := ts.accountRepo.FindById(account.ID) + ts.NoError(err) + ts.Equal(account.AccountName, queriedAccount.AccountName) + ts.Equal(account.ID, queriedAccount.ID) +} + +func (ts *AccountRepositoryTestSuite) TestCreateEmail() { + randId := rand.Intn(99999) + + email := SampleEmail{ + BaseEntity: entity.NewBaseEntity(uint(randId)), + Mail: tests.RandomString(), + } + + result, err := ts.emailRepo.Create(email) + ts.NoError(err) + ts.NotNil(result) +} + +func (ts *AccountRepositoryTestSuite) TestAccountWithEvent() { + randId := rand.Intn(99999) + + account := Account{ + BaseEntity: entity.NewBaseEntity(uint(randId)), + AccountName: tests.RandomString(), + } + + account.AddEvent(DBTblNameAccounts, NewAccountCreatedEvent(uint(randId), nil)) + + _, err := ts.accountRepo.Create(account) + ts.NoError(err) +} + +func (ts *AccountRepositoryTestSuite) TearDownSuite() { +} + +func TestSuiteRunnerAccountRepository(t *testing.T) { + ts := new(AccountRepositoryTestSuite) + suite.Run(t, ts) +} diff --git a/samples/email.go b/samples/email.go new file mode 100644 index 0000000..14639bf --- /dev/null +++ b/samples/email.go @@ -0,0 +1,12 @@ +package account + +import ( + "github.com/tonybka/go-base-ddd/domain/entity" +) + +const TableNameEmails = "emails" + +type SampleEmail struct { + entity.BaseEntity + Mail string +} diff --git a/samples/email_repository.go b/samples/email_repository.go new file mode 100644 index 0000000..e7a42b2 --- /dev/null +++ b/samples/email_repository.go @@ -0,0 +1,33 @@ +package account + +import ( + "context" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/tonybka/go-base-ddd/persistence" +) + +const ( + sqlCreateEmail = `INSERT INTO emails (id, mail) VALUES ($1, $2) RETURNING emails.id;` + sqlSelectAllEmails = `SELECT id, mail created_at FROM emails;` +) + +type EmailRepository struct { + pgDBConn *pgxpool.Pool +} + +func NewEmailRepository(pg *pgxpool.Pool) *EmailRepository { + return &EmailRepository{pgDBConn: pg} +} + +// Create creates new email +func (repo *EmailRepository) Create(email SampleEmail) (*persistence.ResultRowId, error) { + mailId := &persistence.ResultRowId{} + + err := repo.pgDBConn.QueryRow(context.Background(), sqlCreateEmail, email.ID, email.Mail).Scan(&(*mailId).Id) + if err != nil { + return nil, err + } + + return mailId, nil +} diff --git a/samples/entity/account.go b/samples/entity/account.go deleted file mode 100644 index 59ccaff..0000000 --- a/samples/entity/account.go +++ /dev/null @@ -1,5 +0,0 @@ -package entity - -// Account is an user account as sample if entity -type Account struct { -} diff --git a/tests/random_test_values.go b/tests/random_test_values.go new file mode 100644 index 0000000..f494167 --- /dev/null +++ b/tests/random_test_values.go @@ -0,0 +1,14 @@ +package tests + +import "math/rand" + +func RandomString() string { + var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") + + b := make([]rune, 10) + for i := range b { + b[i] = letterRunes[rand.Intn(len(letterRunes))] + } + + return string(b) +} diff --git a/types/custom_type_uuidv1.go b/types/custom_type_uuidv1.go index aa02604..65f238a 100644 --- a/types/custom_type_uuidv1.go +++ b/types/custom_type_uuidv1.go @@ -6,8 +6,6 @@ import ( "fmt" "github.com/google/uuid" - "gorm.io/gorm" - "gorm.io/gorm/schema" ) type CustomTypeUUIDv1 uuid.UUID @@ -22,23 +20,6 @@ func (my CustomTypeUUIDv1) String() string { return uuid.UUID(my).String() } -// GormDataType -> sets type to binary(16) -func (my CustomTypeUUIDv1) GormDataType() string { - return "binary(16)" -} - -// GormDBDataType returns gorm DB data type based on the current using database. -func (my CustomTypeUUIDv1) GormDBDataType(db *gorm.DB, field *schema.Field) string { - switch db.Dialector.Name() { - case "mysql": - return "BINARY(16)" - case "sqlite": - return "BLOB" - default: - return "" - } -} - func (my CustomTypeUUIDv1) MarshalJSON() ([]byte, error) { s := uuid.UUID(my) str := "\"" + s.String() + "\""