cargo-platform/migrations/generatemodel.go
teguh nugroho 216825cb2f
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
coba last
2026-08-22 21:20:28 +07:00

821 lines
15 KiB
Go

package migrations
import (
"database/sql"
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strings"
"gorm.io/gen"
"gorm.io/gen/field"
"gorm.io/gorm"
)
// ForeignKeyMeta menyimpan metadata Foreign Key dari PostgreSQL.
type ForeignKeyMeta struct {
ConstraintName string
TableName string
ColumnName string
RefTable string
RefColumn string
Ordinal int
}
// GenerateModel membuat model GORM dari seluruh tabel public.
func GenerateModel(table *string, db *gorm.DB) {
currentDir, err := os.Getwd()
if err != nil {
log.Fatalf(
"Gagal mendapatkan working directory: %v",
err,
)
}
// Folder sementara untuk generated query.
queryOutPath := filepath.Join(
currentDir,
"internal",
"domain",
"query_utils",
)
// Folder model.
modelOutPath := filepath.Join(
currentDir,
"internal",
"domain",
)
if err := os.MkdirAll(queryOutPath, os.ModePerm); err != nil {
log.Fatalf(
"Gagal membuat folder query_utils: %v",
err,
)
}
if err := os.MkdirAll(modelOutPath, os.ModePerm); err != nil {
log.Fatalf(
"Gagal membuat folder domain: %v",
err,
)
}
// ---------------------------------------------------------
// SQL DB
// ---------------------------------------------------------
sqlDB, err := db.DB()
if err != nil {
log.Fatalf(
"Gagal mengambil instance sql.DB: %v",
err,
)
}
// ---------------------------------------------------------
// Foreign Keys
// ---------------------------------------------------------
fkMetas := fetchForeignKeys(sqlDB)
log.Printf(
"Berhasil mendeteksi %d kolom Foreign Key fisik di database.",
len(fkMetas),
)
// ---------------------------------------------------------
// GORM Gen
// ---------------------------------------------------------
g := gen.NewGenerator(gen.Config{
OutPath: queryOutPath,
ModelPkgPath: modelOutPath,
Mode: gen.WithoutContext,
FieldWithIndexTag: true,
FieldWithTypeTag: true,
FieldNullable: true,
FieldSignable: true,
})
g.UseDB(db)
// ---------------------------------------------------------
// Get tables
// ---------------------------------------------------------
rows, err := sqlDB.Query(`
SELECT
table_name
FROM information_schema.tables
WHERE
table_schema = 'public'
AND table_type = 'BASE TABLE'
ORDER BY table_name
`)
if err != nil {
log.Fatalf(
"Gagal mengambil daftar tabel: %v",
err,
)
}
defer rows.Close()
var tableNames []string
for rows.Next() {
var tName string
if err := rows.Scan(&tName); err != nil {
log.Printf(
"Gagal membaca nama tabel: %v",
err,
)
continue
}
// Jangan generate tabel internal GORM.
if strings.HasPrefix(tName, "gorm_") {
continue
}
tableNames = append(
tableNames,
tName,
)
}
if err := rows.Err(); err != nil {
log.Fatalf(
"Gagal membaca daftar tabel: %v",
err,
)
}
log.Printf(
"Berhasil menemukan %d tabel.",
len(tableNames),
)
// ---------------------------------------------------------
// Group FK
//
// Satu constraint = satu relationship.
//
// Ini penting untuk composite FK.
// ---------------------------------------------------------
fkGroups := groupForeignKeys(fkMetas)
// ---------------------------------------------------------
// Relationship options per table
// ---------------------------------------------------------
tableRelationOpts := make(
map[string][]gen.ModelOpt,
)
// Digunakan untuk mencegah relationship yang benar-benar
// identik dibuat lebih dari satu kali.
//
// key:
//
// source table
// relation type
// property
// target table
// foreign keys
// references
//
seenRelations := make(
map[string]bool,
)
// ---------------------------------------------------------
// Build relationships
// ---------------------------------------------------------
for _, group := range fkGroups {
if len(group) == 0 {
continue
}
// Pastikan urutan composite FK konsisten.
sort.Slice(
group,
func(i, j int) bool {
return group[i].Ordinal < group[j].Ordinal
},
)
first := group[0]
// Pastikan source dan target table memang ada.
if !containsString(
tableNames,
first.TableName,
) {
log.Printf(
"WARNING: source table %s tidak ditemukan.",
first.TableName,
)
continue
}
if !containsString(
tableNames,
first.RefTable,
) {
log.Printf(
"WARNING: referenced table %s tidak ditemukan.",
first.RefTable,
)
continue
}
// -----------------------------------------------------
// Collect FK columns
// -----------------------------------------------------
foreignKeys := make(
[]string,
0,
len(group),
)
references := make(
[]string,
0,
len(group),
)
for _, fk := range group {
foreignKeys = append(
foreignKeys,
fk.ColumnName,
)
references = append(
references,
fk.RefColumn,
)
}
// -----------------------------------------------------
// BELONGS TO
// -----------------------------------------------------
belongsProperty := belongsToPropertyName(first)
belongsRelationKey := buildRelationKey(
first.TableName,
"belongs_to",
belongsProperty,
first.RefTable,
foreignKeys,
references,
)
if !seenRelations[belongsRelationKey] {
seenRelations[belongsRelationKey] = true
log.Printf(
"BELONGS TO: %s (%s) -> %s (%s) [%s]",
first.TableName,
strings.Join(foreignKeys, ", "),
first.RefTable,
strings.Join(references, ", "),
belongsProperty,
)
relation := gen.FieldRelate(
field.BelongsTo,
belongsProperty,
g.GenerateModel(first.RefTable),
&field.RelateConfig{
GORMTag: field.GormTag{
"foreignKey": foreignKeys,
"references": references,
},
},
)
tableRelationOpts[first.TableName] =
append(
tableRelationOpts[first.TableName],
relation,
)
}
// -----------------------------------------------------
// HAS MANY
// -----------------------------------------------------
hasManyProperty := hasManyPropertyName(first)
hasManyRelationKey := buildRelationKey(
first.RefTable,
"has_many",
hasManyProperty,
first.TableName,
foreignKeys,
references,
)
if !seenRelations[hasManyRelationKey] {
seenRelations[hasManyRelationKey] = true
log.Printf(
"HAS MANY: %s -> %s (%s) [%s]",
first.RefTable,
first.TableName,
strings.Join(foreignKeys, ", "),
hasManyProperty,
)
relation := gen.FieldRelate(
field.HasMany,
hasManyProperty,
g.GenerateModel(first.TableName),
&field.RelateConfig{
GORMTag: field.GormTag{
"foreignKey": foreignKeys,
"references": references,
},
},
)
tableRelationOpts[first.RefTable] =
append(
tableRelationOpts[first.RefTable],
relation,
)
}
}
// ---------------------------------------------------------
// Generate final models
// ---------------------------------------------------------
var finalModels []any
// Simpan generated model berdasarkan table.
//
// Kita tidak menggunakan gen.Model karena type tersebut
// memang tidak diekspos oleh gorm/gen.
//
// GenerateModel tetap dipanggil di sini untuk menghasilkan
// final model dengan relation options.
//
// Tidak ada kebutuhan untuk menyimpan return type-nya.
for _, tName := range tableNames {
opts := tableRelationOpts[tName]
compiledModel := g.GenerateModel(
tName,
opts...,
)
finalModels = append(
finalModels,
compiledModel,
)
}
// ---------------------------------------------------------
// ApplyBasic
// ---------------------------------------------------------
if table != nil && *table != "" {
targetTable := strings.TrimSpace(*table)
if !containsString(
tableNames,
targetTable,
) {
log.Fatalf(
"Tabel %s tidak ditemukan di database!",
targetTable,
)
}
// Generate model khusus table yang diminta.
specModel := g.GenerateModel(
targetTable,
tableRelationOpts[targetTable]...,
)
g.ApplyBasic(specModel)
log.Printf(
"Hanya generate model: %s",
targetTable,
)
} else {
g.ApplyBasic(
finalModels...,
)
log.Printf(
"Generate seluruh %d model.",
len(finalModels),
)
}
// ---------------------------------------------------------
// Execute
// ---------------------------------------------------------
g.Execute()
// ---------------------------------------------------------
// Remove query utility
// ---------------------------------------------------------
if err := os.RemoveAll(queryOutPath); err != nil {
log.Printf(
"Peringatan: gagal menghapus query_utils: %v",
err,
)
}
// ---------------------------------------------------------
// Rename .gen.go -> .go
// ---------------------------------------------------------
if err := renameGeneratedFiles(modelOutPath); err != nil {
log.Printf(
"Peringatan: gagal mengubah nama file model: %v",
err,
)
}
log.Println(
"Selesai! Model berhasil dibuat.",
)
}
// -------------------------------------------------------------
// Group Foreign Keys
// -------------------------------------------------------------
//
// Satu constraint_name dianggap sebagai satu relationship.
//
// Contoh:
//
// FK:
// company_id -> company.id
// user_id -> user.id
//
// Jika berbeda constraint:
//
// fk_order_company
// fk_order_user
//
// maka akan menjadi dua relationship.
//
// Composite FK:
//
// FK (company_id, order_id)
// -> orders (company_id, id)
//
// menjadi satu relationship.
//
// -------------------------------------------------------------
func groupForeignKeys(
metas []ForeignKeyMeta,
) [][]ForeignKeyMeta {
groups := make(
map[string][]ForeignKeyMeta,
)
for _, meta := range metas {
key := fmt.Sprintf(
"%s|%s",
meta.TableName,
meta.ConstraintName,
)
groups[key] = append(
groups[key],
meta,
)
}
result := make(
[][]ForeignKeyMeta,
0,
len(groups),
)
for _, group := range groups {
sort.Slice(
group,
func(i, j int) bool {
return group[i].Ordinal < group[j].Ordinal
},
)
result = append(
result,
group,
)
}
// Untuk deterministic output.
sort.Slice(
result,
func(i, j int) bool {
if result[i][0].TableName != result[j][0].TableName {
return result[i][0].TableName <
result[j][0].TableName
}
return result[i][0].ConstraintName <
result[j][0].ConstraintName
},
)
return result
}
// -------------------------------------------------------------
// Build unique relation key
// -------------------------------------------------------------
func buildRelationKey(
sourceTable string,
relationType string,
propertyName string,
targetTable string,
foreignKeys []string,
references []string,
) string {
return strings.Join(
[]string{
sourceTable,
relationType,
propertyName,
targetTable,
strings.Join(foreignKeys, ","),
strings.Join(references, ","),
},
"|",
)
}
// -------------------------------------------------------------
// BelongsTo property name
// -------------------------------------------------------------
//
// user_id
// -> User_Relation
//
// customer_id
// -> Customer_Relation
//
// company_id
// -> Company_Relation
//
// -------------------------------------------------------------
func belongsToPropertyName(fk ForeignKeyMeta) string {
name := strings.TrimSuffix(
fk.ColumnName,
"_id",
)
if name == "" || name == fk.ColumnName {
name = fk.RefTable
}
return snakeToPascalCase(name) + "_Relation"
}
// -------------------------------------------------------------
// HasMany property name
// -------------------------------------------------------------
//
// orders -> Orders_Relation
//
// -------------------------------------------------------------
func hasManyPropertyName(fk ForeignKeyMeta) string {
tableName := snakeToPascalCase(fk.TableName)
columnName := strings.TrimSuffix(
fk.ColumnName,
"_id",
)
columnName = snakeToPascalCase(columnName)
return tableName +
"_By" +
columnName +
"_Relation"
}
// -------------------------------------------------------------
// Fetch Foreign Keys
// -------------------------------------------------------------
func fetchForeignKeys(
db *sql.DB,
) []ForeignKeyMeta {
query := `
SELECT
tc.constraint_name,
kcu.table_name,
kcu.column_name,
ccu.table_name AS referenced_table_name,
ccu.column_name AS referenced_column_name,
kcu.ordinal_position
FROM information_schema.table_constraints AS tc
JOIN information_schema.key_column_usage AS kcu
ON tc.constraint_name = kcu.constraint_name
AND tc.table_schema = kcu.table_schema
JOIN information_schema.referential_constraints AS rc
ON tc.constraint_name = rc.constraint_name
AND tc.table_schema = rc.constraint_schema
JOIN information_schema.key_column_usage AS ccu
ON rc.unique_constraint_name = ccu.constraint_name
AND rc.unique_constraint_schema = ccu.table_schema
AND kcu.ordinal_position = ccu.ordinal_position
WHERE
tc.constraint_type = 'FOREIGN KEY'
AND tc.table_schema = 'public'
ORDER BY
kcu.table_name,
tc.constraint_name,
kcu.ordinal_position
`
rows, err := db.Query(query)
if err != nil {
log.Printf(
"Gagal membaca metadata Foreign Key: %v",
err,
)
return nil
}
defer rows.Close()
var metas []ForeignKeyMeta
for rows.Next() {
var m ForeignKeyMeta
err := rows.Scan(
&m.ConstraintName,
&m.TableName,
&m.ColumnName,
&m.RefTable,
&m.RefColumn,
&m.Ordinal,
)
if err != nil {
log.Printf(
"Gagal membaca metadata Foreign Key: %v",
err,
)
continue
}
metas = append(
metas,
m,
)
}
if err := rows.Err(); err != nil {
log.Printf(
"Error membaca Foreign Key rows: %v",
err,
)
}
return metas
}
// -------------------------------------------------------------
// Rename generated files
// -------------------------------------------------------------
func renameGeneratedFiles(
dir string,
) error {
files, err := os.ReadDir(dir)
if err != nil {
return err
}
for _, file := range files {
if file.IsDir() {
continue
}
if !strings.HasSuffix(
file.Name(),
".gen.go",
) {
continue
}
oldPath := filepath.Join(
dir,
file.Name(),
)
newName := strings.TrimSuffix(
file.Name(),
".gen.go",
) + ".go"
newPath := filepath.Join(
dir,
newName,
)
if err := os.Rename(
oldPath,
newPath,
); err != nil {
return err
}
}
return nil
}
// -------------------------------------------------------------
// Contains string
// -------------------------------------------------------------
func containsString(
items []string,
target string,
) bool {
for _, item := range items {
if item == target {
return true
}
}
return false
}
// -------------------------------------------------------------
// snake_case -> PascalCase
// -------------------------------------------------------------
func snakeToPascalCase(
s string,
) string {
parts := strings.Split(
s,
"_",
)
for i, part := range parts {
if len(part) == 0 {
continue
}
parts[i] =
strings.ToUpper(part[:1]) +
part[1:]
}
return strings.Join(
parts,
"",
)
}