Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion cmd/mxcli/cmd_bson_dump.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ Examples:

# Save dump to file
mxcli bson dump -p app.mpr --type page --object "PgTest.MyPage" > mypage.json

# Extract raw BSON baseline for roundtrip testing
mxcli bson dump -p app.mpr --type page --object "PgTest.MyPage" --format bson > mypage.mxunit
`,
Run: func(cmd *cobra.Command, args []string) {
projectPath, _ := cmd.Flags().GetString("project")
Expand Down Expand Up @@ -136,6 +139,12 @@ Examples:
os.Exit(1)
}

if format == "bson" {
// Write raw BSON bytes to stdout (for baseline extraction)
os.Stdout.Write(obj.Contents)
return
}

if format == "ndsl" {
var doc bson.D
if err := bson.Unmarshal(obj.Contents, &doc); err != nil {
Expand Down Expand Up @@ -342,5 +351,5 @@ func init() {
bsonDumpCmd.Flags().StringP("object", "o", "", "Object qualified name to dump (e.g., Module.PageName)")
bsonDumpCmd.Flags().BoolP("list", "l", false, "List all objects of the specified type")
bsonDumpCmd.Flags().StringSliceP("compare", "c", nil, "Compare two objects: --compare Obj1,Obj2")
bsonDumpCmd.Flags().String("format", "json", "Output format: json, ndsl")
bsonDumpCmd.Flags().String("format", "json", "Output format: json, ndsl, bson (raw bytes)")
}
91 changes: 91 additions & 0 deletions cmd/mxcli/diag.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"time"

"github.com/mendixlabs/mxcli/mdl/diaglog"
"github.com/mendixlabs/mxcli/sdk/mpr"
"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -47,6 +48,18 @@ Examples:
return
}

checkUnits, _ := cmd.Flags().GetBool("check-units")
fix, _ := cmd.Flags().GetBool("fix")
if checkUnits {
projectPath, _ := cmd.Flags().GetString("project")
if projectPath == "" {
fmt.Fprintln(os.Stderr, "Error: --check-units requires -p <project.mpr>")
os.Exit(1)
}
runCheckUnits(projectPath, fix)
return
}

if tail > 0 {
runDiagTail(logDir, tail)
return
Expand All @@ -60,6 +73,8 @@ func init() {
diagCmd.Flags().Bool("log-path", false, "Print log directory path")
diagCmd.Flags().Bool("bundle", false, "Create tar.gz with logs for bug reports")
diagCmd.Flags().Int("tail", 0, "Show last N log entries")
diagCmd.Flags().Bool("check-units", false, "Check for orphan units and stale mxunit files (MPR v2)")
diagCmd.Flags().Bool("fix", false, "Auto-fix issues found by --check-units")
}

// runDiagInfo shows diagnostic summary.
Expand Down Expand Up @@ -252,3 +267,79 @@ func formatBytes(b int64) string {
}
return fmt.Sprintf("%d KB", b/1024)
}

// runCheckUnits checks for orphan units (Unit table entry without mxunit file)
// and stale mxunit files (file exists but no Unit table entry). MPR v2 only.
func runCheckUnits(mprPath string, fix bool) {
reader, err := mpr.Open(mprPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
defer reader.Close()

contentsDir := reader.ContentsDir()
if contentsDir == "" {
fmt.Println("Not an MPR v2 project (no mprcontents directory)")
return
}

// Build set of unit UUIDs from database
unitIDs, err := reader.ListAllUnitIDs()
if err != nil {
fmt.Fprintf(os.Stderr, "Error listing units: %v\n", err)
os.Exit(1)
}
unitSet := make(map[string]bool, len(unitIDs))
for _, id := range unitIDs {
unitSet[id] = true
}

// Scan mxunit files
files, err := filepath.Glob(filepath.Join(contentsDir, "*", "*", "*.mxunit"))
if err != nil {
fmt.Fprintf(os.Stderr, "Error scanning mxunit files: %v\n", err)
os.Exit(1)
}
fileSet := make(map[string]string, len(files)) // uuid → filepath
for _, f := range files {
uuid := strings.TrimSuffix(filepath.Base(f), ".mxunit")
fileSet[uuid] = f
}

// Check for orphan units (in DB but no file)
orphans := 0
for _, id := range unitIDs {
if _, ok := fileSet[id]; !ok {
fmt.Printf("ORPHAN UNIT: %s (in Unit table but no mxunit file)\n", id)
orphans++
}
}

// Check for stale files (file exists but not in DB)
stale := 0
for uuid, fpath := range fileSet {
if !unitSet[uuid] {
fmt.Printf("STALE FILE: %s\n", uuid)
stale++
if fix {
if err := os.Remove(fpath); err != nil {
fmt.Fprintf(os.Stderr, " ERROR removing: %v\n", err)
} else {
fmt.Printf(" REMOVED: %s\n", fpath)
// Clean empty parent dirs
dir2 := filepath.Dir(fpath)
os.Remove(dir2)
dir1 := filepath.Dir(dir2)
os.Remove(dir1)
}
}
}
}

fmt.Printf("\nSummary: %d units in DB, %d mxunit files, %d orphans, %d stale\n",
len(unitIDs), len(files), orphans, stale)
if stale > 0 && !fix {
fmt.Println("Run with --fix to auto-remove stale files")
}
}
18 changes: 18 additions & 0 deletions sdk/mpr/reader.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,24 @@ func (r *Reader) ContentsDir() string {
return r.contentsDir
}

// ListAllUnitIDs returns all unit UUIDs from the Unit table.
func (r *Reader) ListAllUnitIDs() ([]string, error) {
rows, err := r.db.Query("SELECT UnitID FROM Unit")
if err != nil {
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var unitID []byte
if err := rows.Scan(&unitID); err != nil {
return nil, fmt.Errorf("scanning unit ID: %w", err)
}
ids = append(ids, BlobToUUID(unitID))
}
return ids, rows.Err()
}

// ProjectVersion returns the Mendix project version information.
func (r *Reader) ProjectVersion() *version.ProjectVersion {
return r.projectVersion
Expand Down