166 lines
4.8 KiB
Go
166 lines
4.8 KiB
Go
package main
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"encoding/json"
|
||
|
|
"fmt"
|
||
|
|
"log"
|
||
|
|
"os"
|
||
|
|
"path/filepath"
|
||
|
|
|
||
|
|
"github.com/google/uuid"
|
||
|
|
)
|
||
|
|
|
||
|
|
// MigratorFixture is a minimal offline dump for dry-run without MySQL.
|
||
|
|
// It is NOT a substitute for validating against production MySQL.
|
||
|
|
type MigratorFixture struct {
|
||
|
|
Companies []struct {
|
||
|
|
ID string `json:"id"`
|
||
|
|
Name string `json:"name"`
|
||
|
|
Language string `json:"language"`
|
||
|
|
} `json:"companies"`
|
||
|
|
Users []struct {
|
||
|
|
ID string `json:"id"`
|
||
|
|
Email string `json:"email"`
|
||
|
|
Name string `json:"name"`
|
||
|
|
Active bool `json:"active"`
|
||
|
|
} `json:"users"`
|
||
|
|
AdminUsers []struct {
|
||
|
|
UserID string `json:"user_id"`
|
||
|
|
Email string `json:"email"`
|
||
|
|
} `json:"admin_users"`
|
||
|
|
Profiles []struct {
|
||
|
|
CompanyID string `json:"company_id"`
|
||
|
|
UserID string `json:"user_id"`
|
||
|
|
Role string `json:"role"`
|
||
|
|
Status string `json:"status"`
|
||
|
|
} `json:"profiles"`
|
||
|
|
XMLFeeds []struct {
|
||
|
|
ID int64 `json:"id"`
|
||
|
|
CompanyID string `json:"company_id"`
|
||
|
|
Name string `json:"name"`
|
||
|
|
FieldMappings json.RawMessage `json:"field_mappings"`
|
||
|
|
} `json:"xml_feeds"`
|
||
|
|
Files []struct {
|
||
|
|
ID int64 `json:"id"`
|
||
|
|
CompanyID string `json:"company_id"`
|
||
|
|
FileName string `json:"file_name"`
|
||
|
|
} `json:"files"`
|
||
|
|
RawProducts int `json:"raw_products_count"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func loadFixture(path string) (*MigratorFixture, error) {
|
||
|
|
b, err := os.ReadFile(path)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
var f MigratorFixture
|
||
|
|
if err := json.Unmarshal(b, &f); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
return &f, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// runFixtureDryRun remaps fixture rows and writes id-map + validation-style counts
|
||
|
|
// without connecting to MySQL or Postgres.
|
||
|
|
func runFixtureDryRun(fixturePath, mapsDir, idMapPath string) {
|
||
|
|
fx, err := loadFixture(fixturePath)
|
||
|
|
if err != nil {
|
||
|
|
log.Fatalf("fixture: %v", err)
|
||
|
|
}
|
||
|
|
if err := os.MkdirAll(mapsDir, 0o755); err != nil {
|
||
|
|
log.Fatalf("maps dir: %v", err)
|
||
|
|
}
|
||
|
|
outIDMap := idMapPath
|
||
|
|
if outIDMap == "" {
|
||
|
|
outIDMap = filepath.Join(mapsDir, "id-map.json")
|
||
|
|
}
|
||
|
|
|
||
|
|
report := map[string]int{}
|
||
|
|
companyMap := map[string]string{}
|
||
|
|
userMap := map[string]string{}
|
||
|
|
feedMap := map[string]string{}
|
||
|
|
fileMap := map[string]string{}
|
||
|
|
|
||
|
|
for _, c := range fx.Companies {
|
||
|
|
companyMap[c.ID] = uuid.New().String()
|
||
|
|
report["companies"]++
|
||
|
|
}
|
||
|
|
for _, u := range fx.Users {
|
||
|
|
userMap[u.ID] = uuid.New().String()
|
||
|
|
report["users"]++
|
||
|
|
}
|
||
|
|
for _, a := range fx.AdminUsers {
|
||
|
|
if _, ok := userMap[a.UserID]; ok {
|
||
|
|
report["admin_users"]++
|
||
|
|
} else {
|
||
|
|
report["admin_users_unmatched"]++
|
||
|
|
}
|
||
|
|
}
|
||
|
|
for _, p := range fx.Profiles {
|
||
|
|
if _, okC := companyMap[p.CompanyID]; !okC {
|
||
|
|
report["memberships_skipped"]++
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if _, okU := userMap[p.UserID]; !okU {
|
||
|
|
report["memberships_skipped"]++
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
report["memberships"]++
|
||
|
|
}
|
||
|
|
for _, f := range fx.XMLFeeds {
|
||
|
|
if _, ok := companyMap[f.CompanyID]; !ok {
|
||
|
|
report["input_feeds_skipped"]++
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
feedMap[fmt.Sprintf("%d", f.ID)] = uuid.New().String()
|
||
|
|
report["input_feeds"]++
|
||
|
|
if len(f.FieldMappings) > 0 && string(f.FieldMappings) != "null" && string(f.FieldMappings) != "{}" {
|
||
|
|
report["feed_mappings"]++
|
||
|
|
}
|
||
|
|
}
|
||
|
|
for _, f := range fx.Files {
|
||
|
|
if _, ok := companyMap[f.CompanyID]; !ok {
|
||
|
|
report["files_skipped"]++
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
fileMap[fmt.Sprintf("%d", f.ID)] = uuid.New().String()
|
||
|
|
report["files"]++
|
||
|
|
}
|
||
|
|
report["raw_products"] = fx.RawProducts
|
||
|
|
|
||
|
|
idDoc := NewIDMapDocument(userMap, companyMap, true)
|
||
|
|
idDoc.Source = "fixture"
|
||
|
|
idDoc.AttachEntityMaps(nil, nil, feedMap, nil, fileMap, report)
|
||
|
|
if err := WriteIDMap(outIDMap, idDoc); err != nil {
|
||
|
|
log.Fatalf("id-map: %v", err)
|
||
|
|
}
|
||
|
|
writeEntityMapFiles(mapsDir, companyMap, userMap, nil, nil, feedMap, nil, fileMap)
|
||
|
|
|
||
|
|
counts := []CountPair{
|
||
|
|
{Entity: "companies", MySQL: int64(len(fx.Companies)), Note: "fixture"},
|
||
|
|
{Entity: "users", MySQL: int64(len(fx.Users)), Note: "fixture; password_hash never imported"},
|
||
|
|
{Entity: "admin_users", MySQL: int64(len(fx.AdminUsers)), Note: "→ is_platform_admin"},
|
||
|
|
{Entity: "profiles", MySQL: int64(len(fx.Profiles)), Note: "→ memberships"},
|
||
|
|
{Entity: "xml_feeds", MySQL: int64(len(fx.XMLFeeds)), Note: "→ input_feeds + feed_mappings"},
|
||
|
|
{Entity: "files", MySQL: int64(len(fx.Files)), Note: "metadata only"},
|
||
|
|
{Entity: "raw_products", MySQL: int64(fx.RawProducts), Note: "count-only in fixture"},
|
||
|
|
}
|
||
|
|
v := ValidationReport{
|
||
|
|
Mode: "fixture-dry-run",
|
||
|
|
Counts: counts,
|
||
|
|
Orphans: []OrphanFinding{{
|
||
|
|
Check: "skipped_fixture",
|
||
|
|
Pass: true,
|
||
|
|
Sample: "orphan checks need live Postgres after a real load",
|
||
|
|
}},
|
||
|
|
OK: true,
|
||
|
|
}
|
||
|
|
v.OrphanSummary = summarizeOrphans(v.Orphans)
|
||
|
|
printValidation(v)
|
||
|
|
_ = writeJSON(filepath.Join(mapsDir, "validation-report.json"), v)
|
||
|
|
|
||
|
|
printMigrationReport(report, true)
|
||
|
|
fmt.Printf("wrote maps under %s (unified: %s)\n", mapsDir, outIDMap)
|
||
|
|
fmt.Println("BLOCKER: fixture mode is not a substitute for dry-run against production MySQL — set MIGRATE_MYSQL_DSN and re-run before cutover.")
|
||
|
|
}
|