Skip to content
Closed
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
8 changes: 7 additions & 1 deletion .claude/skills/mendix/write-layouts/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,15 @@ layout, so repoint them first:

```sql
ALTER PAGES SET LAYOUT = MyModule.App_New WHERE LAYOUT = MyModule.App_Old;
DROP LAYOUT MyModule.App_Old;
DROP LAYOUT IF EXISTS MyModule.App_Old;
```

`IF EXISTS` keeps the script re-runnable: on a second run the layout is already
gone, and without it exec stops at the `DROP` and skips everything after it.
Every document DROP a script typically needs takes it — entity, association,
enumeration, constant, microflow, nanoflow, page, layout, snippet, menu, java
action, image collection.

To *correct* a layout rather than remove it, re-create it under the same name
(`CREATE OR REPLACE LAYOUT`): the pages stay bound by qualified name and rebind
to the new document — verified end to end, the pages go back to 0 errors.
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Added

- **`DROP <document> IF EXISTS`** (mendixlabs/mxcli#1190) — `DROP ENTITY`, `ASSOCIATION`, `ENUMERATION`, `CONSTANT`, `MICROFLOW`, `NANOFLOW`, `PAGE`, `LAYOUT`, `SNIPPET`, `MENU`, `JAVA ACTION` and `IMAGE COLLECTION` take `IF EXISTS`: a missing document is reported as `… does not exist, skipping` instead of stopping exec, so a script that removes one can run twice. Only the named document (or its module) being absent is skipped; every other error still fails the statement.

## [0.24.0] - 2026-09-24

Headline: **An element's storage GUID is the database's identity, and mxcli now treats it as one.** A production report of 28 attributes emptied across 607 rows by a single edit (mendixlabs/mxcli#1119) traced to five write paths that re-minted GUIDs — one of them moving 282 in a single module. They are fixed, and a new guard at the write choke point refuses any write that moves one: a class of data loss that leaves the model valid, `mx check` clean and `DESCRIBE` byte-identical, and surfaces only when the package meets a database that already holds data. Alongside it, `MOVE ENTITY` and `RENAME` stop leaving a project unbuildable, and four more scripts that passed every gate and failed the build are refused.
Expand Down
5 changes: 4 additions & 1 deletion cmd/mxcli/syntax/features_page.go
Original file line number Diff line number Diff line change
Expand Up @@ -512,7 +512,10 @@ CREATE PAGE Sales.Detail (Title: 'Detail', Layout: Atlas_Core.Atlas_Default) {
Keywords: []string{
"drop layout", "delete layout", "remove layout",
},
Syntax: "DROP LAYOUT Module.Name",
Syntax: "DROP LAYOUT [IF EXISTS] Module.Name\n\n" +
"-- IF EXISTS skips a layout that is already gone, so the script re-runs.\n" +
"-- The same clause works on DROP ENTITY, ASSOCIATION, ENUMERATION, CONSTANT,\n" +
"-- MICROFLOW, NANOFLOW, PAGE, SNIPPET, MENU, JAVA ACTION and IMAGE COLLECTION.",
Example: "DROP LAYOUT MyModule.App_Old;\n\n" +
"-- Pages still bound to it are named in a warning, and the drop proceeds.\n" +
"-- Left dropped, each of those pages fails the build with CE1613 — which\n" +
Expand Down
25 changes: 25 additions & 0 deletions mdl-examples/bug-tests/drop-document-if-exists.mdl
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
-- ============================================================================
-- mendixlabs/mxcli#1190 — DROP <document> IF EXISTS makes a cleanup re-runnable
-- ============================================================================
--
-- The second DROP of each pair finds nothing and is skipped with
-- "<kind> <name> does not exist, skipping" instead of stopping exec, so this
-- whole file can be executed twice against the same project.
-- ============================================================================

CREATE MODULE BugDropIfExists;

CREATE OR MODIFY PERSISTENT ENTITY BugDropIfExists.Scratch ( Note: String(100) );

CREATE OR MODIFY MICROFLOW BugDropIfExists.ACT_Scratch ()
BEGIN
LOG INFO 'scratch';
END;
/

DROP MICROFLOW IF EXISTS BugDropIfExists.ACT_Scratch;
DROP MICROFLOW IF EXISTS BugDropIfExists.ACT_Scratch;
DROP ENTITY IF EXISTS BugDropIfExists.Scratch;
DROP ENTITY IF EXISTS BugDropIfExists.Scratch;
DROP LAYOUT IF EXISTS BugDropIfExists.NeverCreated;
DROP PAGE IF EXISTS BugDropIfExists.NeverCreated;
1 change: 1 addition & 0 deletions mdl/ast/ast_association.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ func (s *CreateAssociationStmt) isStatement() {}
// DropAssociationStmt represents: DROP ASSOCIATION Module.Name
type DropAssociationStmt struct {
Name QualifiedName
DropIfExists
}

func (s *DropAssociationStmt) isStatement() {}
Expand Down
18 changes: 18 additions & 0 deletions mdl/ast/ast_drop_if_exists.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// SPDX-License-Identifier: Apache-2.0

package ast

// DropIfExists is embedded in the document DROP statements that accept
// IF EXISTS. A missing document is then skipped rather than an error, so a
// script that removes one can run twice (mendixlabs/mxcli#1190).
type DropIfExists struct {
IfExists bool
}

// SkipsMissing reports whether the statement was written with IF EXISTS.
func (d DropIfExists) SkipsMissing() bool { return d.IfExists }

// MissingSkipper is implemented by every statement that embeds DropIfExists.
type MissingSkipper interface {
SkipsMissing() bool
}
1 change: 1 addition & 0 deletions mdl/ast/ast_entity.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ func (s *CreateEntityStmt) isStatement() {}
// DropEntityStmt represents: DROP ENTITY Module.Name
type DropEntityStmt struct {
Name QualifiedName
DropIfExists
}

func (s *DropEntityStmt) isStatement() {}
Expand Down
2 changes: 2 additions & 0 deletions mdl/ast/ast_enumeration.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ const (
// DropEnumerationStmt represents: DROP ENUMERATION Module.Name
type DropEnumerationStmt struct {
Name QualifiedName
DropIfExists
}

func (s *DropEnumerationStmt) isStatement() {}
Expand All @@ -117,6 +118,7 @@ func (s *CreateConstantStmt) isStatement() {}
// DropConstantStmt represents: DROP CONSTANT Module.Name
type DropConstantStmt struct {
Name QualifiedName
DropIfExists
}

func (s *DropConstantStmt) isStatement() {}
1 change: 1 addition & 0 deletions mdl/ast/ast_imagecollection.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ func (s *CreateImageCollectionStmt) isStatement() {}
// DropImageCollectionStmt represents: DROP IMAGE COLLECTION Module.Name
type DropImageCollectionStmt struct {
Name QualifiedName
DropIfExists
}

func (s *DropImageCollectionStmt) isStatement() {}
1 change: 1 addition & 0 deletions mdl/ast/ast_javaaction.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ func (s *CreateJavaActionStmt) isStatement() {}
// DropJavaActionStmt represents: DROP JAVA ACTION Module.Name
type DropJavaActionStmt struct {
Name QualifiedName
DropIfExists
}

func (s *DropJavaActionStmt) isStatement() {}
Expand Down
2 changes: 2 additions & 0 deletions mdl/ast/ast_microflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ func (s *CreateMicroflowStmt) isStatement() {}
// DropMicroflowStmt represents: DROP MICROFLOW Module.Name
type DropMicroflowStmt struct {
Name QualifiedName
DropIfExists
}

func (s *DropMicroflowStmt) isStatement() {}
Expand Down Expand Up @@ -214,6 +215,7 @@ func (s *DropRuleStmt) isStatement() {}
// DropNanoflowStmt represents: DROP NANOFLOW Module.Name
type DropNanoflowStmt struct {
Name QualifiedName
DropIfExists
}

func (s *DropNanoflowStmt) isStatement() {}
Expand Down
1 change: 1 addition & 0 deletions mdl/ast/ast_navigation.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ func (s *CreateMenuStmt) isStatement() {}
// DropMenuStmt is `drop menu Module.Name`.
type DropMenuStmt struct {
Name QualifiedName
DropIfExists
}

func (s *DropMenuStmt) isStatement() {}
3 changes: 3 additions & 0 deletions mdl/ast/ast_page.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ type DataGridColumnDef struct {
// DropPageStmt represents: DROP PAGE Module.Name
type DropPageStmt struct {
Name QualifiedName
DropIfExists
}

func (s *DropPageStmt) isStatement() {}
Expand All @@ -57,13 +58,15 @@ func (s *DropPageStmt) isStatement() {}
// exactly that — had no headless remedy at all.
type DropLayoutStmt struct {
Name QualifiedName
DropIfExists
}

func (s *DropLayoutStmt) isStatement() {}

// DropSnippetStmt represents: DROP SNIPPET Module.Name
type DropSnippetStmt struct {
Name QualifiedName
DropIfExists
}

func (s *DropSnippetStmt) isStatement() {}
29 changes: 28 additions & 1 deletion mdl/executor/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
package executor

import (
"errors"
"fmt"
"reflect"
"strings"

"github.com/mendixlabs/mxcli/mdl/ast"
mdlerrors "github.com/mendixlabs/mxcli/mdl/errors"
Expand Down Expand Up @@ -88,7 +90,32 @@ func (r *Registry) Dispatch(ctx *ExecContext, stmt ast.Statement) error {
if h == nil {
return mdlerrors.NewUnsupported(fmt.Sprintf("unhandled statement type %T", stmt))
}
return h(ctx, stmt)
return skipMissingIfAsked(ctx, stmt, h(ctx, stmt))
}

// skipMissingIfAsked turns "<kind> not found" into a notice for a DROP written
// with IF EXISTS. Only the document the statement names counts (or its module,
// which leaves the document missing just the same): any other not-found error
// is a real failure and is returned as is.
func skipMissingIfAsked(ctx *ExecContext, stmt ast.Statement, err error) error {
skipper, ok := stmt.(ast.MissingSkipper)
if err == nil || !ok || !skipper.SkipsMissing() {
return err
}
var missing *mdlerrors.NotFoundError
if !errors.As(err, &missing) {
return err
}
kind, name := stmtDropInfo(stmt)
if missing.Kind != "module" && missing.Name != name {
return err
}
label := strings.ReplaceAll(kind, "-", " ")
if kind == "javaaction" {
label = "java action"
}
fmt.Fprintf(ctx.Output, "%s %s does not exist, skipping\n", label, name)
return nil
}

// Validate checks that every known AST statement type has a registered
Expand Down
54 changes: 54 additions & 0 deletions mdl/executor/registry_skip_missing_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// SPDX-License-Identifier: Apache-2.0

package executor

import (
"bytes"
"context"
"errors"
"strings"
"testing"

"github.com/mendixlabs/mxcli/mdl/ast"
mdlerrors "github.com/mendixlabs/mxcli/mdl/errors"
)

// DROP ... IF EXISTS skips only the document it names being missing (or its
// module); every other error still fails the statement (mendixlabs/mxcli#1190).
func TestSkipMissingIfAsked(t *testing.T) {
layout := func(ifExists bool) ast.Statement {
return &ast.DropLayoutStmt{Name: ast.QualifiedName{Module: "M", Name: "Shell"},
DropIfExists: ast.DropIfExists{IfExists: ifExists}}
}
for _, tc := range []struct {
name string
stmt ast.Statement
err error
wantErr bool
wantNote string
}{
{"named layout missing", layout(true), mdlerrors.NewNotFound("layout", "M.Shell"), false, "layout M.Shell does not exist, skipping"},
{"module missing", layout(true), mdlerrors.NewNotFound("module", "M"), false, "layout M.Shell does not exist, skipping"},
{"without IF EXISTS", layout(false), mdlerrors.NewNotFound("layout", "M.Shell"), true, ""},
{"another document missing", layout(true), mdlerrors.NewNotFound("page", "M.Other"), true, ""},
{"not a not-found", layout(true), errors.New("write failed"), true, ""},
{"success", layout(true), nil, false, ""},
{"java action label", &ast.DropJavaActionStmt{Name: ast.QualifiedName{Module: "M", Name: "Act"},
DropIfExists: ast.DropIfExists{IfExists: true}}, mdlerrors.NewNotFound("java action", "M.Act"), false,
"java action M.Act does not exist, skipping"},
} {
ctx := (&Executor{}).newExecContext(context.Background())
var out bytes.Buffer
ctx.Output = &out
err := skipMissingIfAsked(ctx, tc.stmt, tc.err)
if (err != nil) != tc.wantErr {
t.Errorf("%s: err = %v, wantErr %v", tc.name, err, tc.wantErr)
}
if tc.wantNote != "" && !strings.Contains(out.String(), tc.wantNote) {
t.Errorf("%s: output = %q, want %q", tc.name, out.String(), tc.wantNote)
}
if tc.wantNote == "" && out.Len() > 0 {
t.Errorf("%s: unexpected output %q", tc.name, out.String())
}
}
}
2 changes: 2 additions & 0 deletions mdl/executor/validate_duplicates.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,8 @@ func stmtDropInfo(stmt ast.Statement) (docType, name string) {
return "agent", s.Name.String()
case *ast.DropImageCollectionStmt:
return "image-collection", s.Name.String()
case *ast.DropMenuStmt:
return "menu", s.Name.String()
}
return "", ""
}
Expand Down
28 changes: 16 additions & 12 deletions mdl/grammar/MDLParser.g4
Original file line number Diff line number Diff line change
Expand Up @@ -467,30 +467,34 @@ createMenuStatement
: MENU_KW qualifiedName (FOLDER STRING_LITERAL)? LPAREN navMenuItemDef* RPAREN
;

// IF EXISTS on the document kinds a script actually removes, so a script that
// drops a document can run twice: without it the second run stops at the DROP
// and every later statement is skipped (mendixlabs/mxcli#1190). Same ifExists
// rule as DROP USER ROLE and ALTER ENTITY ... DROP ATTRIBUTE.
dropStatement
: DROP ENTITY qualifiedName
| DROP ASSOCIATION qualifiedName
| DROP ENUMERATION qualifiedName
| DROP CONSTANT qualifiedName
| DROP MICROFLOW qualifiedName
| DROP NANOFLOW qualifiedName
: DROP ENTITY ifExists? qualifiedName
| DROP ASSOCIATION ifExists? qualifiedName
| DROP ENUMERATION ifExists? qualifiedName
| DROP CONSTANT ifExists? qualifiedName
| DROP MICROFLOW ifExists? qualifiedName
| DROP NANOFLOW ifExists? qualifiedName
| DROP RULE qualifiedName
| DROP PAGE qualifiedName
| DROP LAYOUT qualifiedName
| DROP SNIPPET qualifiedName
| DROP MENU_KW qualifiedName
| DROP PAGE ifExists? qualifiedName
| DROP LAYOUT ifExists? qualifiedName
| DROP SNIPPET ifExists? qualifiedName
| DROP MENU_KW ifExists? qualifiedName
| DROP MODULE qualifiedName
| DROP QUEUE qualifiedName
| DROP SCHEDULED EVENT qualifiedName
| DROP REGULAR EXPRESSION qualifiedName
| DROP JAVA ACTION qualifiedName
| DROP JAVA ACTION ifExists? qualifiedName
| DROP JAVASCRIPT ACTION qualifiedName
| DROP INDEX qualifiedName ON qualifiedName
| DROP ODATA CLIENT qualifiedName
| DROP ODATA SERVICE qualifiedName
| DROP BUSINESS EVENT SERVICE qualifiedName
| DROP WORKFLOW qualifiedName
| DROP IMAGE COLLECTION qualifiedName
| DROP IMAGE COLLECTION ifExists? qualifiedName
| DROP ANNOTATION STRING_LITERAL IN identifierOrKeyword
| DROP ANNOTATION AT_KW LPAREN NUMBER_LITERAL COMMA NUMBER_LITERAL RPAREN IN identifierOrKeyword
| DROP JSON STRUCTURE qualifiedName
Expand Down
31 changes: 31 additions & 0 deletions mdl/visitor/drop_if_exists_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,34 @@ func TestDropDemoUser_IfExistsIsParsed(t *testing.T) {
}
}
}

// Document DROPs take IF EXISTS too, so a script that removes a layout or a page
// can run a second time (mendixlabs/mxcli#1190). One case per kind: the flag is
// set, and IF EXISTS is not read as part of the name.
func TestDropDocument_IfExistsIsParsed(t *testing.T) {
for _, kind := range []string{
"entity", "association", "enumeration", "constant", "microflow", "nanoflow",
"page", "layout", "snippet", "menu", "java action", "image collection",
} {
for _, withIf := range []bool{true, false} {
src := "drop " + kind + " M.Doc;"
if withIf {
src = "drop " + kind + " if exists M.Doc;"
}
prog, errs := Build(src)
if len(errs) > 0 {
t.Fatalf("%s: parse: %v", src, errs[0])
}
if len(prog.Statements) != 1 {
t.Fatalf("%s: got %d statements", src, len(prog.Statements))
}
skipper, ok := prog.Statements[0].(ast.MissingSkipper)
if !ok {
t.Fatalf("%s: %T does not take IF EXISTS", src, prog.Statements[0])
}
if skipper.SkipsMissing() != withIf {
t.Errorf("%s: SkipsMissing = %v, want %v", src, skipper.SkipsMissing(), withIf)
}
}
}
}
Loading
Loading