diff --git a/.gitignore b/.gitignore index 0d8eeda..65d20bd 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,7 @@ reg-room-service !cmd/** target **/*.swp -config.yaml +config*.yaml *.http **/*.jar api-generator diff --git a/go.mod b/go.mod index b1440a0..2b43c6b 100644 --- a/go.mod +++ b/go.mod @@ -5,8 +5,10 @@ go 1.21 require ( github.com/StephanHCB/go-autumn-logging v0.3.0 github.com/StephanHCB/go-autumn-logging-zerolog v0.5.0 + github.com/d4l3k/messagediff v1.2.1 github.com/go-chi/chi/v5 v5.0.10 github.com/golang-jwt/jwt/v4 v4.5.0 + github.com/google/uuid v1.3.1 github.com/pkg/errors v0.9.1 github.com/rs/zerolog v1.31.0 github.com/stretchr/testify v1.8.4 diff --git a/go.sum b/go.sum index 1dec637..6a45228 100644 --- a/go.sum +++ b/go.sum @@ -5,6 +5,8 @@ github.com/StephanHCB/go-autumn-logging-zerolog v0.5.0/go.mod h1:Hspu94dHAKtgjMA github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/d4l3k/messagediff v1.2.1 h1:ZcAIMYsUg0EAp9X+tt8/enBE/Q8Yd5kzPynLyKptt9U= +github.com/d4l3k/messagediff v1.2.1/go.mod h1:Oozbb1TVXFac9FtSIxHBMnBCq2qeH/2KkEQxENCrlLo= 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/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk= @@ -14,6 +16,8 @@ github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9 github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= +github.com/google/uuid v1.3.1/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.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= diff --git a/internal/entity/base.go b/internal/entity/base.go index 6f0ab93..0a03be2 100644 --- a/internal/entity/base.go +++ b/internal/entity/base.go @@ -1,10 +1,14 @@ package entity -import "time" +import ( + "time" + + "gorm.io/gorm" +) type Base struct { ID string `gorm:"primaryKey; type:varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;NOT NULL"` CreatedAt time.Time UpdatedAt time.Time - DeletedAt *time.Time `sql:"index"` + DeletedAt gorm.DeletedAt `gorm:"index"` } diff --git a/internal/entity/group.go b/internal/entity/group.go index 72078e5..6dc0fd9 100644 --- a/internal/entity/group.go +++ b/internal/entity/group.go @@ -24,6 +24,8 @@ type Group struct { type GroupMember struct { Member + // TODO references to get integrity check! + // GroupID references the group to which the member belongs (or has been invited) GroupID string `gorm:"type:varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;NOT NULL;index:room_group_member_grpid"` diff --git a/internal/entity/history.go b/internal/entity/history.go index 7e62495..0e77ee4 100644 --- a/internal/entity/history.go +++ b/internal/entity/history.go @@ -5,7 +5,8 @@ import "gorm.io/gorm" type History struct { gorm.Model Entity string `gorm:"type:varchar(80) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;NOT NULL;index:att_histories_entity_idx"` // the name (type) of the entity - EntityId uint `gorm:"NOT NULL;index:att_histories_entity_idx"` // the pk of the entity + EntityId string `gorm:"type:varchar(80) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;NOT NULL;index:att_histories_entity_idx"` // the pk of the entity + Operation string `gorm:"type:varchar(80) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;NOT NULL"` // update / delete / undelete RequestId string `gorm:"type:varchar(8) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci"` // optional request id that triggered the change Identity string `gorm:"type:varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;NOT NULL;index:att_histories_identity_idx"` // the subject that triggered the change Diff string `gorm:"type:text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci"` diff --git a/internal/entity/member.go b/internal/entity/member.go index 9d6ac96..ca44543 100644 --- a/internal/entity/member.go +++ b/internal/entity/member.go @@ -1,11 +1,17 @@ package entity -import "gorm.io/gorm" +import ( + "time" +) type Member struct { - // This contains ID = the badge number of the attendee (an attendee can only either be in a - // group or invited, and can only ever be in one room at the same time. - gorm.Model + // ID contains the badge number of the attendee (an attendee can only either be in a + // group or invited, and can only ever be in one room at the same time). + ID uint `gorm:"primarykey"` + CreatedAt time.Time + UpdatedAt time.Time + + // intentionally not supplying DeletedAt -- don't want soft delete // Nickname caches the nickname of the attendee Nickname string `gorm:"type:varchar(80) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;NOT NULL"` diff --git a/internal/entity/room.go b/internal/entity/room.go index ab60ada..012df99 100644 --- a/internal/entity/room.go +++ b/internal/entity/room.go @@ -19,6 +19,8 @@ type Room struct { type RoomMember struct { Member + // TODO references to get integrity check! + // RoomID references the room to which the attendee belongs RoomID string `gorm:"type:varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;NOT NULL;index:room_room_member_roomid"` } diff --git a/internal/repository/database/dbrepo/constructors.go b/internal/repository/database/dbrepo/constructors.go index f12b85f..bd3b034 100644 --- a/internal/repository/database/dbrepo/constructors.go +++ b/internal/repository/database/dbrepo/constructors.go @@ -1 +1,57 @@ package dbrepo + +import ( + "context" + "strings" + + aulogging "github.com/StephanHCB/go-autumn-logging" + + "github.com/eurofurence/reg-room-service/internal/repository/database" + "github.com/eurofurence/reg-room-service/internal/repository/database/historizeddb" + "github.com/eurofurence/reg-room-service/internal/repository/database/inmemorydb" + "github.com/eurofurence/reg-room-service/internal/repository/database/mysqldb" +) + +var activeRepository database.Repository + +func SetRepository(repository database.Repository) { + activeRepository = repository +} + +func GetRepository() database.Repository { + if activeRepository == nil { + aulogging.Logger.NoCtx().Error().Print("You must Open() the database before using it. This is an error in your implementation.") + panic("You must Open() the database before using it. This is an error in your implementation.") + } + return activeRepository +} + +func Open(ctx context.Context, variant string, mysqlConnectString string) error { + var r database.Repository + if variant == "mysql" { + aulogging.Info(ctx, "Opening mysql database...") + r = historizeddb.New(mysqldb.New(mysqlConnectString)) + } else { + aulogging.Warn(ctx, "Opening inmemory database (not useful for production!)...") + r = historizeddb.New(inmemorydb.New()) + } + err := r.Open(ctx) + SetRepository(r) + return err +} + +func Close(ctx context.Context) { + aulogging.Info(ctx, "Closing database...") + GetRepository().Close(ctx) + SetRepository(nil) +} + +func Migrate(ctx context.Context) error { + aulogging.Info(ctx, "Migrating database...") + return GetRepository().Migrate(ctx) +} + +func MysqlConnectString(username string, password string, databaseName string, parameters []string) string { + return username + ":" + password + "@" + + databaseName + "?" + strings.Join(parameters, "&") +} diff --git a/internal/repository/database/historizeddb/implementation.go b/internal/repository/database/historizeddb/implementation.go new file mode 100644 index 0000000..2c0ca0b --- /dev/null +++ b/internal/repository/database/historizeddb/implementation.go @@ -0,0 +1,286 @@ +package historizeddb + +import ( + "context" + "errors" + "fmt" + + "github.com/d4l3k/messagediff" + + "github.com/eurofurence/reg-room-service/internal/entity" + "github.com/eurofurence/reg-room-service/internal/repository/database" +) + +// TODO need request id and identity from context for history, see at bottom of file!!! + +type HistorizingRepository struct { + wrappedRepository database.Repository +} + +func New(wrappedRepository database.Repository) database.Repository { + return &HistorizingRepository{wrappedRepository: wrappedRepository} +} + +func (r *HistorizingRepository) Open(ctx context.Context) error { + return r.wrappedRepository.Open(ctx) +} + +func (r *HistorizingRepository) Close(ctx context.Context) { + r.wrappedRepository.Close(ctx) +} + +func (r *HistorizingRepository) Migrate(ctx context.Context) error { + return r.wrappedRepository.Migrate(ctx) +} + +type entityType string + +const ( + typeGroup entityType = "Group" + typeGroupMember entityType = "GroupMember" + typeRoom entityType = "Room" + typeRoomMember entityType = "RoomMember" +) + +type operationType string + +const ( + opUpdate operationType = "update" + opDelete operationType = "delete" + opUndelete operationType = "undelete" +) + +// group + +func (r *HistorizingRepository) GetGroups(ctx context.Context) ([]*entity.Group, error) { + return r.wrappedRepository.GetGroups(ctx) +} + +func (r *HistorizingRepository) AddGroup(ctx context.Context, group *entity.Group) (string, error) { + return r.wrappedRepository.AddGroup(ctx, group) +} + +func (r *HistorizingRepository) UpdateGroup(ctx context.Context, group *entity.Group) error { + oldVersion, err := r.wrappedRepository.GetGroupByID(ctx, group.ID) + if err != nil { + return err + } + + // hide always present diff in times + oldVersion.CreatedAt = group.CreatedAt + oldVersion.UpdatedAt = group.UpdatedAt + + histEntry := diffReverse(ctx, oldVersion, group, typeGroup, group.ID, opUpdate) + + err = r.wrappedRepository.RecordHistory(ctx, histEntry) + if err != nil { + return err + } + + return r.wrappedRepository.UpdateGroup(ctx, group) +} + +func (r *HistorizingRepository) GetGroupByID(ctx context.Context, id string) (*entity.Group, error) { + return r.wrappedRepository.GetGroupByID(ctx, id) +} + +func (r *HistorizingRepository) SoftDeleteGroupByID(ctx context.Context, id string) error { + histEntry := noDiffRecord(ctx, typeGroup, id, opDelete) + + if err := r.wrappedRepository.RecordHistory(ctx, histEntry); err != nil { + return err + } + + return r.wrappedRepository.SoftDeleteGroupByID(ctx, id) +} + +func (r *HistorizingRepository) UndeleteGroupByID(ctx context.Context, id string) error { + histEntry := noDiffRecord(ctx, typeGroup, id, opUndelete) + + if err := r.wrappedRepository.RecordHistory(ctx, histEntry); err != nil { + return err + } + + return r.wrappedRepository.UndeleteGroupByID(ctx, id) +} + +// group members + +func (r *HistorizingRepository) NewEmptyGroupMembership(ctx context.Context, groupID string, attendeeID uint) *entity.GroupMember { + return r.wrappedRepository.NewEmptyGroupMembership(ctx, groupID, attendeeID) +} + +func (r *HistorizingRepository) GetGroupMembershipByAttendeeID(ctx context.Context, attendeeID uint) (*entity.GroupMember, error) { + return r.wrappedRepository.GetGroupMembershipByAttendeeID(ctx, attendeeID) +} + +func (r *HistorizingRepository) GetGroupMembersByGroupID(ctx context.Context, groupID string) ([]*entity.GroupMember, error) { + return r.wrappedRepository.GetGroupMembersByGroupID(ctx, groupID) +} + +func (r *HistorizingRepository) AddGroupMembership(ctx context.Context, gm *entity.GroupMember) error { + return r.wrappedRepository.AddGroupMembership(ctx, gm) +} + +func (r *HistorizingRepository) UpdateGroupMembership(ctx context.Context, gm *entity.GroupMember) error { + oldVersion, err := r.wrappedRepository.GetGroupMembershipByAttendeeID(ctx, gm.ID) + if err != nil { + return err + } + + // hide always present diff in times + oldVersion.CreatedAt = gm.CreatedAt + oldVersion.UpdatedAt = gm.UpdatedAt + + histEntry := diffReverse(ctx, oldVersion, gm, typeGroupMember, fmt.Sprintf("%d", gm.ID), opUpdate) + + err = r.wrappedRepository.RecordHistory(ctx, histEntry) + if err != nil { + return err + } + + return r.wrappedRepository.UpdateGroupMembership(ctx, gm) +} + +func (r *HistorizingRepository) DeleteGroupMembership(ctx context.Context, attendeeID uint) error { + histEntry := noDiffRecord(ctx, typeGroupMember, fmt.Sprintf("%d", attendeeID), opDelete) + + if err := r.wrappedRepository.RecordHistory(ctx, histEntry); err != nil { + return err + } + + return r.wrappedRepository.DeleteGroupMembership(ctx, attendeeID) +} + +// room + +func (r *HistorizingRepository) GetRooms(ctx context.Context) ([]*entity.Room, error) { + return r.wrappedRepository.GetRooms(ctx) +} + +func (r *HistorizingRepository) AddRoom(ctx context.Context, room *entity.Room) (string, error) { + return r.wrappedRepository.AddRoom(ctx, room) +} + +func (r *HistorizingRepository) UpdateRoom(ctx context.Context, room *entity.Room) error { + oldVersion, err := r.wrappedRepository.GetRoomByID(ctx, room.ID) + if err != nil { + return err + } + + // hide always present diff in times + oldVersion.CreatedAt = room.CreatedAt + oldVersion.UpdatedAt = room.UpdatedAt + + histEntry := diffReverse(ctx, oldVersion, room, typeRoom, room.ID, opUpdate) + + err = r.wrappedRepository.RecordHistory(ctx, histEntry) + if err != nil { + return err + } + + return r.wrappedRepository.UpdateRoom(ctx, room) +} + +func (r *HistorizingRepository) GetRoomByID(ctx context.Context, id string) (*entity.Room, error) { + return r.wrappedRepository.GetRoomByID(ctx, id) +} + +func (r *HistorizingRepository) SoftDeleteRoomByID(ctx context.Context, id string) error { + histEntry := noDiffRecord(ctx, typeRoom, id, opDelete) + + if err := r.wrappedRepository.RecordHistory(ctx, histEntry); err != nil { + return err + } + + return r.wrappedRepository.SoftDeleteRoomByID(ctx, id) +} + +func (r *HistorizingRepository) UndeleteRoomByID(ctx context.Context, id string) error { + histEntry := noDiffRecord(ctx, typeRoom, id, opUndelete) + + if err := r.wrappedRepository.RecordHistory(ctx, histEntry); err != nil { + return err + } + + return r.wrappedRepository.UndeleteRoomByID(ctx, id) +} + +// room members + +func (r *HistorizingRepository) NewEmptyRoomMembership(ctx context.Context, roomID string, attendeeID uint) *entity.RoomMember { + return r.wrappedRepository.NewEmptyRoomMembership(ctx, roomID, attendeeID) +} + +func (r *HistorizingRepository) GetRoomMembershipByAttendeeID(ctx context.Context, attendeeID uint) (*entity.RoomMember, error) { + return r.wrappedRepository.GetRoomMembershipByAttendeeID(ctx, attendeeID) +} + +func (r *HistorizingRepository) GetRoomMembersByRoomID(ctx context.Context, roomID string) ([]*entity.RoomMember, error) { + return r.wrappedRepository.GetRoomMembersByRoomID(ctx, roomID) +} + +func (r *HistorizingRepository) AddRoomMembership(ctx context.Context, rm *entity.RoomMember) error { + return r.wrappedRepository.AddRoomMembership(ctx, rm) +} + +func (r *HistorizingRepository) UpdateRoomMembership(ctx context.Context, rm *entity.RoomMember) error { + oldVersion, err := r.wrappedRepository.GetRoomMembershipByAttendeeID(ctx, rm.ID) + if err != nil { + return err + } + + // hide always present diff in times + oldVersion.CreatedAt = rm.CreatedAt + oldVersion.UpdatedAt = rm.UpdatedAt + + histEntry := diffReverse(ctx, oldVersion, rm, typeRoomMember, fmt.Sprintf("%d", rm.ID), opUpdate) + + err = r.wrappedRepository.RecordHistory(ctx, histEntry) + if err != nil { + return err + } + + return r.wrappedRepository.UpdateRoomMembership(ctx, rm) +} + +func (r *HistorizingRepository) DeleteRoomMembership(ctx context.Context, attendeeID uint) error { + histEntry := noDiffRecord(ctx, typeRoomMember, fmt.Sprintf("%d", attendeeID), opDelete) + + if err := r.wrappedRepository.RecordHistory(ctx, histEntry); err != nil { + return err + } + + return r.wrappedRepository.DeleteRoomMembership(ctx, attendeeID) +} + +// --- history --- + +func (r *HistorizingRepository) RecordHistory(ctx context.Context, h *entity.History) error { + // it is an error to call this from the outside. From the inside use wrappedRepository.RecordHistory to bypass the error + return errors.New("not allowed to directly manipulate history") +} + +func diffReverse[T any](_ context.Context, oldVersion *T, newVersion *T, entityName entityType, entityID string, operation operationType) *entity.History { + // we diff reverse so the OLD value is printed in the diffs. The new value is in the database now. + histEntry := &entity.History{ + Entity: string(entityName), + EntityId: entityID, + Operation: string(operation), + RequestId: "", // TODO ctxvalues.RequestId(ctx), + Identity: "", // TODO ctxvalues.Subject(ctx), + } + diff, _ := messagediff.PrettyDiff(*newVersion, *oldVersion) + histEntry.Diff = diff + return histEntry +} + +func noDiffRecord(_ context.Context, entityName entityType, entityID string, operation operationType) *entity.History { + return &entity.History{ + Entity: string(entityName), + EntityId: entityID, + Operation: string(operation), + RequestId: "", // TODO ctxvalues.RequestId(ctx), + Identity: "", // TODO ctxvalues.Subject(ctx), + } +} diff --git a/internal/repository/database/inmemorydb/implementation.go b/internal/repository/database/inmemorydb/implementation.go new file mode 100644 index 0000000..b79b279 --- /dev/null +++ b/internal/repository/database/inmemorydb/implementation.go @@ -0,0 +1,377 @@ +package inmemorydb + +import ( + "context" + "fmt" + "sync/atomic" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" + + "github.com/eurofurence/reg-room-service/internal/entity" + "github.com/eurofurence/reg-room-service/internal/repository/database" +) + +type IMGroup struct { + Group entity.Group // intentionally not a pointer so assignment makes a copy + Members []entity.GroupMember // intentionally not pointers so assignment makes a copy +} + +type IMRoom struct { + Room entity.Room // intentionally not a pointer so assignment makes a copy + Members []entity.RoomMember // intentionally not pointers so assignment makes a copy +} + +type InMemoryRepository struct { + groups map[string]*IMGroup + rooms map[string]*IMRoom + history map[uint]*entity.History + idSequence uint32 + Now func() time.Time +} + +func New() database.Repository { + return &InMemoryRepository{ + Now: time.Now, + } +} + +func (r *InMemoryRepository) Open(_ context.Context) error { + r.groups = make(map[string]*IMGroup) + r.rooms = make(map[string]*IMRoom) + r.history = make(map[uint]*entity.History) + return nil +} + +func (r *InMemoryRepository) Close(_ context.Context) { + r.groups = nil + r.rooms = nil + r.history = nil +} + +func (r *InMemoryRepository) Migrate(_ context.Context) error { + // nothing to do + return nil +} + +// groups + +func (r *InMemoryRepository) GetGroups(_ context.Context) ([]*entity.Group, error) { + result := make([]*entity.Group, 0) + for _, grp := range r.groups { + if !grp.Group.DeletedAt.Valid { + grpCopy := grp.Group + result = append(result, &grpCopy) + } + } + return result, nil +} + +func (r *InMemoryRepository) AddGroup(_ context.Context, group *entity.Group) (string, error) { + group.ID = uuid.NewString() + r.groups[group.ID] = &IMGroup{Group: *group} + return group.ID, nil +} + +func (r *InMemoryRepository) UpdateGroup(_ context.Context, group *entity.Group) error { + if _, ok := r.groups[group.ID]; ok { + r.groups[group.ID] = &IMGroup{Group: *group} // this makes a copy + return nil + } else { + return gorm.ErrRecordNotFound + } +} + +func (r *InMemoryRepository) GetGroupByID(_ context.Context, id string) (*entity.Group, error) { + // allow deleted so history and undelete work + if result, ok := r.groups[id]; ok { + grpCopy := result.Group + return &grpCopy, nil + } else { + return &entity.Group{}, gorm.ErrRecordNotFound + } +} + +func (r *InMemoryRepository) SoftDeleteGroupByID(_ context.Context, id string) error { + if result, ok := r.groups[id]; ok { + result.Group.DeletedAt = gorm.DeletedAt{ + Time: r.Now(), + Valid: true, + } + return nil + } else { + return gorm.ErrRecordNotFound + } +} + +func (r *InMemoryRepository) UndeleteGroupByID(_ context.Context, id string) error { + if result, ok := r.groups[id]; ok { + result.Group.DeletedAt = gorm.DeletedAt{ + Time: r.Now(), + Valid: false, + } + return nil + } else { + return gorm.ErrRecordNotFound + } +} + +// group members + +func (r *InMemoryRepository) NewEmptyGroupMembership(_ context.Context, groupID string, attendeeID uint) *entity.GroupMember { + var m entity.GroupMember + m.ID = attendeeID + m.GroupID = groupID + m.IsInvite = true // default to invite because that's the usual starting point + return &m +} + +func (r *InMemoryRepository) GetGroupMembershipByAttendeeID(_ context.Context, attendeeID uint) (*entity.GroupMember, error) { + for _, grp := range r.groups { + for _, gm := range grp.Members { + if gm.ID == attendeeID { + gmCopy := gm + return &gmCopy, nil + } + } + } + defaultValue := entity.GroupMember{} + defaultValue.ID = attendeeID + return &defaultValue, gorm.ErrRecordNotFound +} + +func (r *InMemoryRepository) GetGroupMembersByGroupID(_ context.Context, groupID string) ([]*entity.GroupMember, error) { + if grp, ok := r.groups[groupID]; ok { + result := make([]*entity.GroupMember, len(grp.Members)) + for i := range grp.Members { + cpMem := grp.Members[i] + result[i] = &cpMem + } + return result, nil + } else { + return []*entity.GroupMember{}, gorm.ErrRecordNotFound + } +} + +func (r *InMemoryRepository) AddGroupMembership(ctx context.Context, gm *entity.GroupMember) error { + _, err := r.GetGroupMembershipByAttendeeID(ctx, gm.ID) + if err != gorm.ErrRecordNotFound { + return gorm.ErrDuplicatedKey + } + if grp, ok := r.groups[gm.GroupID]; ok { + grp.Members = append(grp.Members, *gm) + return nil + } else { + return gorm.ErrForeignKeyViolated + } +} + +func (r *InMemoryRepository) UpdateGroupMembership(ctx context.Context, gm *entity.GroupMember) error { + _, err := r.GetGroupMembershipByAttendeeID(ctx, gm.ID) + if err != nil { + return err + } + if grp, ok := r.groups[gm.GroupID]; ok { + updatedMembers := make([]entity.GroupMember, len(grp.Members)) + for i, m := range grp.Members { + if m.ID == gm.ID { + updatedMembers[i] = *gm + } else { + updatedMembers[i] = grp.Members[i] + } + } + grp.Members = updatedMembers + return nil + } else { + return fmt.Errorf("internal error - this should not happen, we just read the group") + } +} + +func (r *InMemoryRepository) DeleteGroupMembership(ctx context.Context, attendeeID uint) error { + current, err := r.GetGroupMembershipByAttendeeID(ctx, attendeeID) + if err != nil { + return err + } + if grp, ok := r.groups[current.GroupID]; ok { + updatedMembers := make([]entity.GroupMember, 0) + for _, m := range grp.Members { + if m.ID != attendeeID { + updatedMembers = append(updatedMembers, m) + } + } + grp.Members = updatedMembers + return nil + } else { + return fmt.Errorf("internal error - this should not happen, we just read the group") + } +} + +// rooms + +func (r *InMemoryRepository) GetRooms(ctx context.Context) ([]*entity.Room, error) { + result := make([]*entity.Room, 0) + for _, rm := range r.rooms { + if !rm.Room.DeletedAt.Valid { + rmCopy := rm.Room + result = append(result, &rmCopy) + } + } + return result, nil +} + +func (r *InMemoryRepository) AddRoom(ctx context.Context, room *entity.Room) (string, error) { + room.ID = uuid.NewString() + r.rooms[room.ID] = &IMRoom{Room: *room} + return room.ID, nil +} + +func (r *InMemoryRepository) UpdateRoom(ctx context.Context, room *entity.Room) error { + if _, ok := r.rooms[room.ID]; ok { + r.rooms[room.ID] = &IMRoom{Room: *room} // this makes a copy + return nil + } else { + return gorm.ErrRecordNotFound + } +} + +func (r *InMemoryRepository) GetRoomByID(ctx context.Context, id string) (*entity.Room, error) { + // allow deleted so history and undelete work + if result, ok := r.rooms[id]; ok { + grpCopy := result.Room + return &grpCopy, nil + } else { + return &entity.Room{}, gorm.ErrRecordNotFound + } +} + +func (r *InMemoryRepository) SoftDeleteRoomByID(ctx context.Context, id string) error { + if result, ok := r.rooms[id]; ok { + result.Room.DeletedAt = gorm.DeletedAt{ + Time: r.Now(), + Valid: true, + } + return nil + } else { + return gorm.ErrRecordNotFound + } +} + +func (r *InMemoryRepository) UndeleteRoomByID(ctx context.Context, id string) error { + if result, ok := r.rooms[id]; ok { + result.Room.DeletedAt = gorm.DeletedAt{ + Time: r.Now(), + Valid: false, + } + return nil + } else { + return gorm.ErrRecordNotFound + } +} + +// room members + +func (r *InMemoryRepository) NewEmptyRoomMembership(_ context.Context, roomID string, attendeeID uint) *entity.RoomMember { + var m entity.RoomMember + m.ID = attendeeID + m.RoomID = roomID + return &m +} + +func (r *InMemoryRepository) GetRoomMembershipByAttendeeID(_ context.Context, attendeeID uint) (*entity.RoomMember, error) { + for _, room := range r.rooms { + for _, mem := range room.Members { + if mem.ID == attendeeID { + rmCopy := mem + return &rmCopy, nil + } + } + } + defaultValue := entity.RoomMember{} + defaultValue.ID = attendeeID + return &defaultValue, gorm.ErrRecordNotFound +} + +func (r *InMemoryRepository) GetRoomMembersByRoomID(ctx context.Context, roomID string) ([]*entity.RoomMember, error) { + if rm, ok := r.rooms[roomID]; ok { + result := make([]*entity.RoomMember, len(rm.Members)) + for i := range rm.Members { + cpRoom := rm.Members[i] + result[i] = &cpRoom + } + return result, nil + } else { + return []*entity.RoomMember{}, gorm.ErrRecordNotFound + } +} + +func (r *InMemoryRepository) AddRoomMembership(ctx context.Context, rm *entity.RoomMember) error { + _, err := r.GetRoomMembershipByAttendeeID(ctx, rm.ID) + if err != gorm.ErrRecordNotFound { + return gorm.ErrDuplicatedKey + } + if room, ok := r.rooms[rm.RoomID]; ok { + room.Members = append(room.Members, *rm) + return nil + } else { + return gorm.ErrForeignKeyViolated + } +} + +func (r *InMemoryRepository) UpdateRoomMembership(ctx context.Context, rm *entity.RoomMember) error { + _, err := r.GetRoomMembershipByAttendeeID(ctx, rm.ID) + if err != nil { + return err + } + if room, ok := r.rooms[rm.RoomID]; ok { + updatedMembers := make([]entity.RoomMember, len(room.Members)) + for i, m := range room.Members { + if m.ID == rm.ID { + updatedMembers[i] = *rm + } else { + updatedMembers[i] = room.Members[i] + } + } + room.Members = updatedMembers + return nil + } else { + return fmt.Errorf("internal error - this should not happen, we just read the room") + } +} + +func (r *InMemoryRepository) DeleteRoomMembership(ctx context.Context, attendeeID uint) error { + current, err := r.GetRoomMembershipByAttendeeID(ctx, attendeeID) + if err != nil { + return err + } + if grp, ok := r.rooms[current.RoomID]; ok { + updatedMembers := make([]entity.RoomMember, 0) + for _, m := range grp.Members { + if m.ID != attendeeID { + updatedMembers = append(updatedMembers, m) + } + } + grp.Members = updatedMembers + return nil + } else { + return fmt.Errorf("internal error - this should not happen, we just read the room") + } +} + +// history + +func (r *InMemoryRepository) RecordHistory(_ context.Context, h *entity.History) error { + newID := uint(atomic.AddUint32(&r.idSequence, 1)) + h.ID = newID + r.history[newID] = h + return nil +} + +// GetHistoryByID is only offered for testing, and only on the in memory db. +func (r *InMemoryRepository) GetHistoryByID(_ context.Context, id uint) (*entity.History, error) { + if h, ok := r.history[id]; ok { + return h, nil + } else { + return &entity.History{}, fmt.Errorf("cannot get history entry %d - not present", id) + } +} diff --git a/internal/repository/database/interface.go b/internal/repository/database/interface.go index 405c8a1..1fadb4d 100644 --- a/internal/repository/database/interface.go +++ b/internal/repository/database/interface.go @@ -11,9 +11,11 @@ type Repository interface { Close(ctx context.Context) Migrate(ctx context.Context) error - AddGroup(ctx context.Context, g *entity.Group) (string, error) - UpdateGroup(ctx context.Context, g *entity.Group) error - GetGroupByID(ctx context.Context, id string) (*entity.Group, error) + // GetGroups returns all non-soft-deleted groups. + GetGroups(ctx context.Context) ([]*entity.Group, error) + AddGroup(ctx context.Context, group *entity.Group) (string, error) + UpdateGroup(ctx context.Context, group *entity.Group) error + GetGroupByID(ctx context.Context, id string) (*entity.Group, error) // may return soft deleted entities! SoftDeleteGroupByID(ctx context.Context, id string) error UndeleteGroupByID(ctx context.Context, id string) error @@ -21,14 +23,16 @@ type Repository interface { // groupID and attendeeID. NewEmptyGroupMembership(ctx context.Context, groupID string, attendeeID uint) *entity.GroupMember GetGroupMembershipByAttendeeID(ctx context.Context, attendeeID uint) (*entity.GroupMember, error) - GetGroupMembersByGroupID(ctx context.Context, groupID string) ([]entity.GroupMember, error) + GetGroupMembersByGroupID(ctx context.Context, groupID string) ([]*entity.GroupMember, error) AddGroupMembership(ctx context.Context, gm *entity.GroupMember) error UpdateGroupMembership(ctx context.Context, gm *entity.GroupMember) error DeleteGroupMembership(ctx context.Context, attendeeID uint) error - AddRoom(ctx context.Context, g *entity.Room) (string, error) - UpdateRoom(ctx context.Context, g *entity.Room) error - GetRoomByID(ctx context.Context, id string) (*entity.Room, error) + // GetRooms returns all non-soft-deleted rooms. + GetRooms(ctx context.Context) ([]*entity.Room, error) + AddRoom(ctx context.Context, room *entity.Room) (string, error) + UpdateRoom(ctx context.Context, room *entity.Room) error + GetRoomByID(ctx context.Context, id string) (*entity.Room, error) // may return soft deleted entities! SoftDeleteRoomByID(ctx context.Context, id string) error UndeleteRoomByID(ctx context.Context, id string) error @@ -36,9 +40,9 @@ type Repository interface { // RoomID and attendeeID. NewEmptyRoomMembership(ctx context.Context, roomID string, attendeeID uint) *entity.RoomMember GetRoomMembershipByAttendeeID(ctx context.Context, attendeeID uint) (*entity.RoomMember, error) - GetRoomMembersByRoomID(ctx context.Context, roomID string) ([]entity.RoomMember, error) - AddRoomMembership(ctx context.Context, gm *entity.RoomMember) error - UpdateRoomMembership(ctx context.Context, gm *entity.RoomMember) error + GetRoomMembersByRoomID(ctx context.Context, roomID string) ([]*entity.RoomMember, error) + AddRoomMembership(ctx context.Context, rm *entity.RoomMember) error + UpdateRoomMembership(ctx context.Context, rm *entity.RoomMember) error DeleteRoomMembership(ctx context.Context, attendeeID uint) error RecordHistory(ctx context.Context, h *entity.History) error diff --git a/internal/repository/database/mysqldb/implementation.go b/internal/repository/database/mysqldb/implementation.go index c4ece58..a1388c8 100644 --- a/internal/repository/database/mysqldb/implementation.go +++ b/internal/repository/database/mysqldb/implementation.go @@ -2,9 +2,13 @@ package mysqldb import ( "context" + "database/sql" "errors" + "fmt" "time" + "github.com/google/uuid" + aulogging "github.com/StephanHCB/go-autumn-logging" "gorm.io/driver/mysql" "gorm.io/gorm" @@ -21,7 +25,7 @@ type MysqlRepository struct { Now func() time.Time } -func Create(connectString string) database.Repository { +func New(connectString string) database.Repository { return &MysqlRepository{ Now: time.Now, connectString: connectString, @@ -73,18 +77,74 @@ func (r *MysqlRepository) Migrate(ctx context.Context) error { aulogging.ErrorErrf(ctx, err, "failed to migrate mysql db: %s", err.Error()) return err } + + err = r.createConstraintIfNotExists(ctx, "room_group_members", "room_group_members_groupid_fk", + "group_id", "room_groups", "id") + if err != nil { + aulogging.ErrorErrf(ctx, err, "failed to check or create group fk constraint during migration: %s", err.Error()) + return err + } + + err = r.createConstraintIfNotExists(ctx, "room_room_members", "room_room_members_roomid_fk", + "room_id", "room_rooms", "id") + if err != nil { + aulogging.ErrorErrf(ctx, err, "failed to check or create group fk constraint during migration: %s", err.Error()) + return err + } + + return nil +} + +func (r *MysqlRepository) createConstraintIfNotExists(_ context.Context, + tableName string, constraintName string, fieldName string, + referencesTable string, referencesField string, +) error { + // gorm does not support creating a foreign key constraint without having the referenced data structure + // in the entity. Which keeps unnecessarily loading rooms/groups over and over given the design of our API... + + db, err := r.db.DB() + if err != nil { + return err + } + + existsQuery := fmt.Sprintf(`SELECT count(*) as found FROM information_schema.table_constraints +WHERE table_name='%s' AND constraint_name='%s'`, tableName, constraintName) + + var found int + err = db.QueryRow(existsQuery).Scan(&found) + if err != nil { + return err + } + + if found == 0 { + createQuery := fmt.Sprintf(`ALTER TABLE %s +ADD CONSTRAINT %s + FOREIGN KEY (%s) +REFERENCES %s (%s)`, tableName, constraintName, fieldName, referencesTable, referencesField) + + _, err = db.Exec(createQuery) + if err != nil { + return err + } + } + return nil } const groupDesc = "group" -func (r *MysqlRepository) AddGroup(ctx context.Context, g *entity.Group) (string, error) { - err := add[entity.Group](ctx, r.db, g, groupDesc) - return g.ID, err +func (r *MysqlRepository) GetGroups(ctx context.Context) ([]*entity.Group, error) { + return getAllNonDeleted[entity.Group](ctx, r.db, groupDesc) +} + +func (r *MysqlRepository) AddGroup(ctx context.Context, group *entity.Group) (string, error) { + group.ID = uuid.NewString() + err := add[entity.Group](ctx, r.db, group, groupDesc) + return group.ID, err } -func (r *MysqlRepository) UpdateGroup(ctx context.Context, g *entity.Group) error { - return update[entity.Group](ctx, r.db, g, groupDesc) +func (r *MysqlRepository) UpdateGroup(ctx context.Context, group *entity.Group) error { + return update[entity.Group](ctx, r.db, group, groupDesc) } func (r *MysqlRepository) GetGroupByID(ctx context.Context, id string) (*entity.Group, error) { @@ -115,7 +175,7 @@ func (r *MysqlRepository) GetGroupMembershipByAttendeeID(ctx context.Context, at return getMembershipByAttendeeID[entity.GroupMember](ctx, r.db, attendeeID, &m, groupMembershipDesc) } -func (r *MysqlRepository) GetGroupMembersByGroupID(ctx context.Context, groupID string) ([]entity.GroupMember, error) { +func (r *MysqlRepository) GetGroupMembersByGroupID(ctx context.Context, groupID string) ([]*entity.GroupMember, error) { return selectMembersBy[entity.GroupMember](ctx, r.db, &entity.GroupMember{GroupID: groupID}, groupMembershipDesc) } @@ -133,7 +193,12 @@ func (r *MysqlRepository) DeleteGroupMembership(ctx context.Context, attendeeID const roomDesc = "room" +func (r *MysqlRepository) GetRooms(ctx context.Context) ([]*entity.Room, error) { + return getAllNonDeleted[entity.Room](ctx, r.db, roomDesc) +} + func (r *MysqlRepository) AddRoom(ctx context.Context, room *entity.Room) (string, error) { + room.ID = uuid.NewString() err := add[entity.Room](ctx, r.db, room, roomDesc) return room.ID, err } @@ -169,7 +234,7 @@ func (r *MysqlRepository) GetRoomMembershipByAttendeeID(ctx context.Context, att return getMembershipByAttendeeID[entity.RoomMember](ctx, r.db, attendeeID, &m, roomMembershipDesc) } -func (r *MysqlRepository) GetRoomMembersByRoomID(ctx context.Context, roomID string) ([]entity.RoomMember, error) { +func (r *MysqlRepository) GetRoomMembersByRoomID(ctx context.Context, roomID string) ([]*entity.RoomMember, error) { return selectMembersBy[entity.RoomMember](ctx, r.db, &entity.RoomMember{RoomID: roomID}, roomMembershipDesc) } @@ -199,6 +264,14 @@ type anyMemberCollection interface { entity.Group | entity.Room } +func getAllNonDeleted[E anyMemberCollection]( + ctx context.Context, + db *gorm.DB, + logDescription string, +) ([]*E, error) { + return selectBy[E](ctx, db, nil, logDescription) +} + func add[E anyMemberCollection]( ctx context.Context, db *gorm.DB, @@ -311,37 +384,8 @@ func selectMembersBy[E anyMembership]( db *gorm.DB, condition *E, logDescription string, -) ([]E, error) { - var table E - rows, err := db.Model(&table).Where(condition).Rows() - if err != nil { - aulogging.WarnErrf(ctx, err, "mysql error during %s select: %s", logDescription, err.Error()) - return make([]E, 0), err - } - defer func() { - err := rows.Close() - if err != nil { - aulogging.WarnErrf(ctx, err, "mysql error during %s result set close: %s", logDescription, err.Error()) - } - }() - - result := make([]E, 0) - for rows.Next() { - var sc E - err := db.ScanRows(rows, &sc) - if err != nil { - aulogging.WarnErrf(ctx, err, "mysql error during %s read: %s", logDescription, err.Error()) - return make([]E, 0), err - } - - result = append(result, sc) - } - if err := rows.Err(); err != nil { - aulogging.WarnErrf(ctx, err, "mysql error during %s result set processing: %s", logDescription, err.Error()) - return make([]E, 0), err - } - - return result, nil +) ([]*E, error) { + return selectBy[E](ctx, db, condition, logDescription) } func addMembership[E anyMembership]( @@ -389,3 +433,50 @@ func deleteMembership[E anyMembership]( } return nil } + +// even more low level + +func selectBy[E any]( + ctx context.Context, + db *gorm.DB, + condition *E, + logDescription string, +) ([]*E, error) { + var table E + var rows *sql.Rows + var err error + + if condition == nil { + rows, err = db.Model(&table).Rows() // all non-deleted rows + } else { + rows, err = db.Model(&table).Where(condition).Rows() // matching non-deleted rows + } + if err != nil { + aulogging.WarnErrf(ctx, err, "mysql error during %s select: %s", logDescription, err.Error()) + return make([]*E, 0), err + } + defer func() { + err := rows.Close() + if err != nil { + aulogging.WarnErrf(ctx, err, "mysql error during %s result set close: %s", logDescription, err.Error()) + } + }() + + result := make([]*E, 0) + for rows.Next() { + var sc E + err := db.ScanRows(rows, &sc) + if err != nil { + aulogging.WarnErrf(ctx, err, "mysql error during %s read: %s", logDescription, err.Error()) + return make([]*E, 0), err + } + + result = append(result, &sc) + } + if err := rows.Err(); err != nil { + aulogging.WarnErrf(ctx, err, "mysql error during %s result set processing: %s", logDescription, err.Error()) + return make([]*E, 0), err + } + + return result, nil +}