-
Notifications
You must be signed in to change notification settings - Fork 17
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Refactor: Use UUID for Primary Keys Across All Tables #180
Comments
@diyor28 Below is a proposed implementation plan. This is based on an analysis of the codebase by Cursor.ai using Claude 3.7 (Latest model). Happy to discuss approach and specifics. UUID v7 Implementation PlanOverviewThis document outlines the plan to migrate the database primary key strategy from sequential integers (SERIAL8) to UUID v7. UUID v7 is a time-ordered UUID format that provides several advantages over sequential integers, including:
Current ImplementationThe current implementation uses PostgreSQL's SERIAL8 (BIGSERIAL) data type for most tables, which creates auto-incrementing 64-bit integer primary keys. Some tables already use UUIDs, but they use the default The codebase already uses the Implementation ApproachInstead of using a PostgreSQL function to generate UUID v7 values, we will use Go code to generate the UUIDs. This approach has several advantages:
The Implementation Steps1. Create UUID Utility PackageCreate a new utility package in // pkg/uuidutil/uuid.go
package uuidutil
import (
"github.com/google/uuid"
)
// NewV7 generates a new UUID v7 (time-ordered)
func NewV7() uuid.UUID {
id, err := uuid.NewV7()
if err != nil {
// Fall back to v4 if v7 generation fails for any reason
id, _ = uuid.NewRandom()
}
return id
}
// NewV7String generates a new UUID v7 and returns it as a string
func NewV7String() string {
return NewV7().String()
}
// Parse parses a UUID string into a UUID object
func Parse(s string) (uuid.UUID, error) {
return uuid.Parse(s)
}
// MustParse parses a UUID string into a UUID object, panicking on error
func MustParse(s string) uuid.UUID {
return uuid.MustParse(s)
} 2. Go Code Changes2.1 Model ChangesUpdate all model structs in all modules to use UUID strings instead of uint for ID fields: // modules/core/infrastructure/persistence/models/models.go
type Upload struct {
ID string // Changed from uint to string (UUID)
Hash string
Path string
Name string
Size int
Mimetype string
Type string
CreatedAt time.Time
UpdatedAt time.Time
}
// Similar changes for all other model structs 2.2 Domain Entity ChangesUpdate all domain entity interfaces and implementations across all modules to use UUID strings instead of uint: // modules/core/domain/entities/upload/upload.go
type Upload interface {
ID() string // Changed from uint to string (UUID)
Type() UploadType
Hash() string
Path() string
Name() string
Size() Size
IsImage() bool
PreviewURL() string
URL() *url.URL
Mimetype() *mimetype.MIME
CreatedAt() time.Time
UpdatedAt() time.Time
}
// Update the implementation struct
type upload struct {
id string // Changed from uint to string (UUID)
hash string
path string
name string
size Size
_type UploadType
mimetype *mimetype.MIME
createdAt time.Time
updatedAt time.Time
}
// Update the constructor functions
func New(
hash, path, name string,
size int,
mimetype *mimetype.MIME,
_type UploadType,
) Upload {
return NewWithID(
uuidutil.NewV7String(), // Generate UUID v7 here
hash, path, name,
size,
mimetype,
_type,
time.Now(),
time.Now(),
)
}
func NewWithID(
id string, // Changed from uint to string (UUID)
hash, path, name string,
size int,
mimetype *mimetype.MIME,
_type UploadType,
createdAt, updatedAt time.Time,
) Upload {
// ...
} 2.3 Repository ChangesUpdate all repository interfaces and implementations to use UUID strings: // modules/core/domain/entities/upload/upload_repository.go
type FindParams struct {
ID string // Changed from uint to string (UUID)
Hash string
Limit int
Offset int
SortBy SortBy
Search string
Type UploadType
Mimetype *mimetype.MIME
}
type Repository interface {
Count(ctx context.Context) (int64, error)
GetAll(ctx context.Context) ([]Upload, error)
GetPaginated(ctx context.Context, params *FindParams) ([]Upload, error)
GetByID(ctx context.Context, id string) (Upload, error) // Changed from uint to string (UUID)
GetByHash(ctx context.Context, hash string) (Upload, error)
Create(ctx context.Context, data Upload) (Upload, error)
Update(ctx context.Context, data Upload) error
Delete(ctx context.Context, id string) error // Changed from uint to string (UUID)
} Update repository implementations: // modules/core/infrastructure/persistence/upload_repository.go
func (g *GormUploadRepository) GetByID(ctx context.Context, id string) (upload.Upload, error) {
// Update query to use UUID comparison
query := selectUploadQuery + " WHERE uuid = $1"
uploads, err := g.queryUploads(ctx, query, id)
// ...
}
func (g *GormUploadRepository) Create(ctx context.Context, data upload.Upload) (upload.Upload, error) {
pool, err := composables.UseTx(ctx)
if err != nil {
return nil, err
}
// No need to generate UUID here as it's already generated in the domain entity constructor
_, err = pool.Exec(ctx, insertUploadQuery,
data.ID(), // Use the UUID generated in the domain entity
data.Hash(),
data.Path(),
data.Name(),
data.Size().Bytes(),
data.Type().String(),
data.Mimetype().String(),
data.CreatedAt(),
data.UpdatedAt(),
)
if err != nil {
return nil, err
}
return data, nil
}
func (g *GormUploadRepository) Delete(ctx context.Context, id string) error {
// Update query to use UUID
pool, err := composables.UseTx(ctx)
if err != nil {
return err
}
_, err = pool.Exec(ctx, deleteUploadQuery, id)
return err
}
// Similar changes for other repository methods 2.4 SQL Query ChangesUpdate all SQL queries to handle UUID values: // modules/core/infrastructure/persistence/upload_repository.go
const (
selectUploadQuery = `SELECT uuid, hash, path, name, size, type, mimetype, created_at, updated_at FROM uploads`
countUploadsQuery = `SELECT COUNT(*) FROM uploads`
insertUploadQuery = `INSERT INTO uploads (uuid, hash, path, name, size, type, mimetype, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`
updatedUploadQuery = `UPDATE uploads
SET hash = $1,
path = $2,
name = $3,
size = $4,
type = $5,
mimetype = $6,
updated_at = $7
WHERE uuid = $8`
deleteUploadQuery = `DELETE FROM uploads WHERE uuid = $1`
) 2.5 Mapper ChangesUpdate all mapper functions across the codebase to handle UUID strings instead of uint: // modules/core/infrastructure/persistence/core_mappers.go
func ToDomainUser(dbUser *models.User, dbUpload *models.Upload, roles []role.Role, groupIDs []uuid.UUID) (user.User, error) {
var avatar upload.Upload
if dbUpload != nil {
avatar = ToDomainUpload(dbUpload)
}
email, err := internet.NewEmail(dbUser.Email)
if err != nil {
return nil, err
}
options := []user.Option{
user.WithID(dbUser.ID), // ID is now a string
// Other options...
}
// ...
}
func ToDomainUpload(dbUpload *models.Upload) upload.Upload {
// Changed from parsing uint to using string directly
return upload.NewWithID(
dbUpload.ID,
dbUpload.Hash,
dbUpload.Path,
dbUpload.Name,
dbUpload.Size,
mimetype.Detect([]byte{}),
upload.UploadType(dbUpload.Type),
dbUpload.CreatedAt,
dbUpload.UpdatedAt,
)
} // modules/core/interfaces/graph/mappers/user_mapper.go
func UserToGraphModel(u user.User) *model.User {
return &model.User{
ID: u.ID(), // Changed from int64(u.ID()) to u.ID()
Email: u.Email().Value(),
FirstName: u.FirstName(),
LastName: u.LastName(),
UILanguage: string(u.UILanguage()),
CreatedAt: u.CreatedAt(),
UpdatedAt: u.UpdatedAt(),
}
} // modules/core/presentation/mappers/mappers.go
func UserToViewModel(entity user.User) *viewmodels.User {
var avatar *viewmodels.Upload
if entity.Avatar() != nil {
avatar = UploadToViewModel(entity.Avatar())
}
return &viewmodels.User{
ID: entity.ID(), // Changed from strconv.FormatUint to direct string
FirstName: entity.FirstName(),
LastName: entity.LastName(),
MiddleName: entity.MiddleName(),
Email: entity.Email().Value(),
Avatar: avatar,
UILanguage: string(entity.UILanguage()),
LastAction: entity.LastAction().Format(time.RFC3339),
CreatedAt: entity.CreatedAt().Format(time.RFC3339),
UpdatedAt: entity.UpdatedAt().Format(time.RFC3339),
Roles: mapping.MapViewModels(entity.Roles(), RoleToViewModel),
AvatarID: entity.AvatarID(), // Changed from strconv.Itoa to direct string
}
}
func UploadToViewModel(entity upload.Upload) *viewmodels.Upload {
return &viewmodels.Upload{
ID: entity.ID(), // Changed from strconv.FormatUint to direct string
Hash: entity.Hash(),
URL: entity.PreviewURL(),
Mimetype: entity.Mimetype().String(),
Size: entity.Size().String(),
CreatedAt: entity.CreatedAt().Format(time.RFC3339),
UpdatedAt: entity.UpdatedAt().Format(time.RFC3339),
}
} 2.6 Templ File ChangesUpdate all .templ files where IDs are used to handle UUID strings: // modules/core/presentation/templates/pages/users/edit.templ
// Example of form input changes to handle UUID
// Before
<input type="hidden" name="ID" value={ strconv.FormatUint(uint64(props.User.ID), 10) }/>
// After
<input type="hidden" name="ID" value={ props.User.ID }/>
// Before (in JavaScript)
const userId = parseInt(document.getElementById("user-id").value);
// After (in JavaScript)
const userId = document.getElementById("user-id").value; // modules/core/presentation/templates/pages/roles/permissions.templ
// Example of link href changes
// Before
<a href={fmt.Sprintf("/users/%d", user.ID)}>
// After
<a href={fmt.Sprintf("/users/%s", user.ID)}> 3. Service Layer ChangesUpdate all service methods that handle entity IDs to use string instead of uint: // Update service method signatures
func (s *uploadService) GetByID(ctx context.Context, id string) (upload.Upload, error) {
// ...
}
func (s *uploadService) Delete(ctx context.Context, id string) error {
// ...
} 4. API/Handler ChangesUpdate all API handlers and request/response DTOs to use string IDs: // Update DTO structs
type UploadDTO struct {
ID string `json:"id"`
Hash string `json:"hash"`
Path string `json:"path"`
Name string `json:"name"`
Size int `json:"size"`
Mimetype string `json:"mimetype"`
Type string `json:"type"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Update handler methods
func (h *uploadHandler) GetByID(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
// No need to parse as uint anymore
upload, err := h.service.GetByID(r.Context(), id)
// ...
} 5. GraphQL Schema ChangesUpdate all GraphQL schemas to use ID scalar for primary keys: type Upload {
id: ID!
hash: String!
path: String!
name: String!
size: Int!
mimetype: String!
type: String!
createdAt: DateTime!
updatedAt: DateTime!
} Modules to ModifyBased on the codebase analysis, the following modules contain entities that need to be updated: Core Module
Warehouse Module
CRM Module
Finance Module
HRM Module
BiChat Module
Files to ModifyNew Files to Create
Domain Entity Files
Repository Files
Service Files
API/Handler Files
GraphQL Files
Mapper Files
Template Files
Implementation ConsiderationsPerformance
Compatibility
Rollout Strategy
TestingCreate comprehensive tests to verify:
ConclusionMigrating to UUID v7 primary keys will improve the system's scalability and security. The time-ordered nature of UUID v7 provides performance benefits over random UUIDs while maintaining the advantages of distributed ID generation. By using Go code for UUID generation instead of a database function, we ensure consistency across the application and simplify the implementation. The migration will require changes to domain models, repositories, services, and API layers, but the benefits of using UUID v7 will outweigh the implementation effort in the long term. |
@twinguy LGTM, except for domain entities I'd rather use uuid.UUID instead of string. Also don't see the need for UUID v7. It takes up more space while not providing any real benefits for us |
Description
Refactor the database schema to use UUIDs as primary keys instead of auto-incremented integers across all tables. This change enhances flexibility at the application level, improves data uniqueness across distributed systems, and allows for safer merging of records between environments.
Rationale
The text was updated successfully, but these errors were encountered: