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
7 changes: 4 additions & 3 deletions cmd/mxcli/syntax/features_domain_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,15 +88,16 @@ func init() {

Register(SyntaxFeature{
Path: "domain-model.entity.alter",
Summary: "ALTER ENTITY: add/rename/modify/drop attributes, indexes, documentation, event handlers",
Summary: "ALTER ENTITY: add/rename/modify/drop attributes, indexes, documentation, event handlers; ALTER ENTITIES for the bulk add",
Keywords: []string{
"alter entity", "modify entity", "add attribute",
"drop attribute", "rename attribute", "add index",
"event handler", "documentation",
"if not exists", "if exists", "idempotent",
"alter entities", "bulk", "every entity", "all entities", "where persistent",
},
Syntax: "ALTER ENTITY Module.Name ADD ATTRIBUTE [IF NOT EXISTS] AttrName: Type [constraints];\nALTER ENTITY Module.Name DROP ATTRIBUTE [IF EXISTS] AttrName;\nALTER ENTITY Module.Name RENAME ATTRIBUTE OldName TO NewName;\nALTER ENTITY Module.Name MODIFY ATTRIBUTE AttrName Type [DEFAULT val];\nALTER ENTITY Module.Name DROP DEFAULT ON ATTRIBUTE AttrName;\nALTER ENTITY Module.Name ADD INDEX [name] [ON] (attr1, attr2);\nALTER ENTITY Module.Name SET DOCUMENTATION 'text';\nALTER ENTITY Module.Name SET POSITION (x, y);\nALTER ENTITY Module.Name ADD EVENT HANDLER ON BEFORE COMMIT CALL Module.MF RAISE ERROR;\n\nSET POSITION places the entity in the domain-model editor, and CREATE ENTITY\ntakes the same thing as an @Position(x, y) annotation. Both are the box's\nCENTRE, not its top-left corner. An entity created without one takes the next\nslot in a wrapping grid, which is a default rather than a layout: to arrange a\nwhole module from its association graph, run 'mxcli layout -p app.mpr'\n(--dry-run first; it replaces positions you set by hand).\n\nMODIFY ATTRIBUTE always takes a type — restate it even when you are only\nchanging the default. There is no 'MODIFY ATTRIBUTE X SET DEFAULT v' form:\nSET would be read as the type name. Use DROP DEFAULT to clear one.\n\nIF NOT EXISTS / IF EXISTS make the add/drop a no-op (skipped, not an error)\nwhen the attribute is already present / already gone — so a domain script\nre-runs cleanly. For a whole script, 'mxcli exec --continue-on-error' reports\neach failed statement and keeps going instead of halting at the first.\n\nRENAME ATTRIBUTE also rewrites every reference to the attribute: the stored\nqualified names (microflow create/change members, page widgets, the entity's own\nvalidation and access rules) AND the bare steps inside XPath constraints, which\nare resolved to their owning entity first so another entity's identically-named\nattribute is left alone. A constraint that cannot be resolved is reported and\nleft unchanged, never guessed at. Uses inside microflow expressions ($obj/Attr)\nare free text and are NOT rewritten; mxbuild reports those as CE0117.",
Example: "ALTER ENTITY Shop.Customer ADD ATTRIBUTE Phone: String(20);\nALTER ENTITY Shop.Customer ADD ATTRIBUTE IF NOT EXISTS Phone: String(20); -- re-runnable\nALTER ENTITY Shop.Customer DROP ATTRIBUTE IF EXISTS OldField; -- re-runnable\nALTER ENTITY Shop.Customer RENAME ATTRIBUTE Email TO EmailAddress;\nALTER ENTITY Shop.Customer MODIFY ATTRIBUTE Phone String(30) DEFAULT ''; -- type restated\nALTER ENTITY Shop.Customer DROP DEFAULT ON ATTRIBUTE Phone; -- clear a default\nALTER ENTITY Shop.Customer ADD INDEX ON (EmailAddress);\nALTER ENTITY Shop.Customer\n ADD EVENT HANDLER ON BEFORE COMMIT CALL Shop.Validate($currentObject) RAISE ERROR;",
Syntax: "ALTER ENTITY Module.Name ADD ATTRIBUTE [IF NOT EXISTS] AttrName: Type [constraints];\nALTER ENTITY Module.Name DROP ATTRIBUTE [IF EXISTS] AttrName;\nALTER ENTITY Module.Name RENAME ATTRIBUTE OldName TO NewName;\nALTER ENTITY Module.Name MODIFY ATTRIBUTE AttrName Type [DEFAULT val];\nALTER ENTITY Module.Name DROP DEFAULT ON ATTRIBUTE AttrName;\nALTER ENTITY Module.Name ADD INDEX [name] [ON] (attr1, attr2);\nALTER ENTITY Module.Name SET DOCUMENTATION 'text';\nALTER ENTITY Module.Name SET POSITION (x, y);\nALTER ENTITY Module.Name ADD EVENT HANDLER ON BEFORE COMMIT CALL Module.MF RAISE ERROR;\nALTER ENTITIES [IN Module] ADD ATTRIBUTE [IF NOT EXISTS] AttrName: Type [, ...]\n [WHERE PERSISTENT | WHERE NON-PERSISTENT];\n\nALTER ENTITIES is the bulk form: one statement applied to every entity in a\nmodule instead of one statement per entity. Only ADD ATTRIBUTE is offered --\nDROP and RENAME aimed at a set are destructive by a typo, and SET POSITION on\nevery entity is meaningless. Pair it with IF NOT EXISTS so the script re-runs.\n\nWHERE filters by persistence, using the same words CREATE ENTITY uses. A VIEW\nentity matches NEITHER: its rows come from an OQL query, so it is not the\npersistent/non-persistent distinction this filter means.\n\nWITHOUT IN, the sweep covers the whole project but SKIPS System and every\nMarketplace module, reporting which -- an upgrade replaces those modules and\nwould take the attribute with it. Naming a module with IN is taken as meaning\nit, so a deliberate edit there is still possible.\n\nSET POSITION places the entity in the domain-model editor, and CREATE ENTITY\ntakes the same thing as an @Position(x, y) annotation. Both are the box's\nCENTRE, not its top-left corner. An entity created without one takes the next\nslot in a wrapping grid, which is a default rather than a layout: to arrange a\nwhole module from its association graph, run 'mxcli layout -p app.mpr'\n(--dry-run first; it replaces positions you set by hand).\n\nMODIFY ATTRIBUTE always takes a type — restate it even when you are only\nchanging the default. There is no 'MODIFY ATTRIBUTE X SET DEFAULT v' form:\nSET would be read as the type name. Use DROP DEFAULT to clear one.\n\nIF NOT EXISTS / IF EXISTS make the add/drop a no-op (skipped, not an error)\nwhen the attribute is already present / already gone — so a domain script\nre-runs cleanly. For a whole script, 'mxcli exec --continue-on-error' reports\neach failed statement and keeps going instead of halting at the first.\n\nRENAME ATTRIBUTE also rewrites every reference to the attribute: the stored\nqualified names (microflow create/change members, page widgets, the entity's own\nvalidation and access rules) AND the bare steps inside XPath constraints, which\nare resolved to their owning entity first so another entity's identically-named\nattribute is left alone. A constraint that cannot be resolved is reported and\nleft unchanged, never guessed at. Uses inside microflow expressions ($obj/Attr)\nare free text and are NOT rewritten; mxbuild reports those as CE0117.",
Example: "ALTER ENTITY Shop.Customer ADD ATTRIBUTE Phone: String(20);\nALTER ENTITY Shop.Customer ADD ATTRIBUTE IF NOT EXISTS Phone: String(20); -- re-runnable\nALTER ENTITY Shop.Customer DROP ATTRIBUTE IF EXISTS OldField; -- re-runnable\nALTER ENTITY Shop.Customer RENAME ATTRIBUTE Email TO EmailAddress;\nALTER ENTITY Shop.Customer MODIFY ATTRIBUTE Phone String(30) DEFAULT ''; -- type restated\nALTER ENTITY Shop.Customer DROP DEFAULT ON ATTRIBUTE Phone; -- clear a default\nALTER ENTITY Shop.Customer ADD INDEX ON (EmailAddress);\nALTER ENTITY Shop.Customer\n ADD EVENT HANDLER ON BEFORE COMMIT CALL Shop.Validate($currentObject) RAISE ERROR;\n\n-- give every persistent entity in a module an audit trail, in one statement\nALTER ENTITIES IN Shop\n ADD ATTRIBUTE IF NOT EXISTS CreatedDate: AutoCreatedDate,\n ADD ATTRIBUTE IF NOT EXISTS ChangedDate: AutoChangedDate\n WHERE PERSISTENT;",
SeeAlso: []string{"domain-model.entity.create", "domain-model.entity.attributes"},
})

Expand Down
1 change: 1 addition & 0 deletions docs/01-project/MDL_QUICK_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ Modifies an existing entity without full replacement.
| Set position | `alter entity Module.Name set position (100, 200);` | Canvas position |
| Add system attribute | `alter entity Module.Name add attribute owner: autoowner;` | Same syntax as regular attributes |
| Drop system attribute | `alter entity Module.Name drop attribute owner;` | Drop by system attribute name |
| Add attribute to every entity | `alter entities [in Module] add attribute [if not exists] attr: type [, ...] [where persistent\|non-persistent];` | The bulk form — one statement instead of one per entity. **ADD ATTRIBUTE only**: drop/rename aimed at a set are destructive by a typo. A **view** entity matches neither persistence filter. **Without `in`**, the sweep skips System and every Marketplace module (and says which) — an upgrade replaces those and would take the attribute with it |

> **Re-running domain scripts.** `IF NOT EXISTS` / `IF EXISTS` make an individual
> create/add/drop a no-op when already applied — accepted on `create entity`,
Expand Down
28 changes: 28 additions & 0 deletions mdl-examples/doctype-tests/01-domain-model-examples.mdl
Original file line number Diff line number Diff line change
Expand Up @@ -1881,6 +1881,34 @@ type ReferenceSet
owner both;
/

-- ############################################################################
-- LEVEL 13: BULK ALTER — one statement for a whole module
-- ############################################################################
--
-- ALTER ENTITIES replaces one ALTER ENTITY per entity. Only ADD ATTRIBUTE is
-- offered: drop and rename aimed at a set are destructive by a typo.
--
-- IF NOT EXISTS is what makes it re-runnable, and this file is re-run on every
-- doctype pass — the second run must skip rather than fail.

-- Give every persistent entity in the module an audit trail.
alter entities in DmTest
add attribute if not exists AuditCreated: AutoCreatedDate,
add attribute if not exists AuditChanged: AutoChangedDate
where persistent;
/

-- The non-persistent half of the module, addressed on its own.
alter entities in DmTest
add attribute if not exists ScratchNote: String(40)
where non-persistent;
/

-- No filter: every entity in the module, view entities included.
alter entities in DmTest
add attribute if not exists BulkTouched: Boolean;
/

-- Verify generalized entities appear in listings
show entities in DmTest;
describe entity DmTest.Attachment;
Expand Down
29 changes: 29 additions & 0 deletions mdl/ast/ast_entity.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,35 @@ type AlterEntityStmt struct {

func (s *AlterEntityStmt) isStatement() {}

// EntityPersistenceFilter narrows ALTER ENTITIES to one persistence kind.
type EntityPersistenceFilter int

const (
// EntityFilterAll is the absence of a WHERE clause.
EntityFilterAll EntityPersistenceFilter = iota
EntityFilterPersistent
EntityFilterNonPersistent
)

// AlterEntitiesStmt is the bulk form: one statement applied to every entity in
// a module, rather than one statement per entity.
//
// It carries a LIST of AlterEntityStmt rather than its own operation fields.
// The executor resolves the target set and then runs each action through the
// single-entity path unchanged, so every domain rule, refusal and idempotence
// guard that applies to ALTER ENTITY applies here for free and cannot drift.
type AlterEntitiesStmt struct {
// Module is the module to sweep. Empty means every module in the project.
Module string
// Filter narrows the set by persistence; EntityFilterAll means no WHERE.
Filter EntityPersistenceFilter
// Actions are applied to each matched entity, in order. Each carries a
// zero Name; the executor fills it in per entity.
Actions []*AlterEntityStmt
}

func (s *AlterEntitiesStmt) isStatement() {}

// ============================================================================
// View Entity Statements
// ============================================================================
Expand Down
207 changes: 207 additions & 0 deletions mdl/executor/cmd_entities_bulk.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
// SPDX-License-Identifier: Apache-2.0

package executor

import (
"fmt"
"sort"
"strings"

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

// execAlterEntities handles the bulk form:
//
// ALTER ENTITIES [IN <module>] ADD ATTRIBUTE ... [WHERE PERSISTENT|NON-PERSISTENT]
//
// It resolves the target set and then runs each action through execAlterEntity
// unchanged. That delegation is the whole design: the single-entity path
// already carries the reserved-word refusals, the access-rule reconciliation,
// the IF NOT EXISTS skip and the write elision, and a second implementation
// would have to be kept in step with all of it. One statement per entity is
// what this replaces, not the code that executes one.
func execAlterEntities(ctx *ExecContext, s *ast.AlterEntitiesStmt) error {
if !ctx.Connected() {
return mdlerrors.NewNotConnected()
}

targets, err := resolveBulkEntityTargets(ctx, s)
if err != nil {
return err
}
if len(targets) == 0 {
// Not an error: a module whose entities all match already, or a filter
// that excludes everything, is a legitimate no-op in a re-run script.
fmt.Fprintf(ctx.Output, "No entities matched%s\n", bulkScopeSuffix(s))
return nil
}

for _, qn := range targets {
for _, action := range s.Actions {
// Copy per entity: execAlterEntity reads Name, and the parsed
// action is shared across every target.
perEntity := *action
perEntity.Name = qn
if err := execAlterEntity(ctx, &perEntity); err != nil {
return fmt.Errorf("%s: %w", qn, err)
}
}
}

fmt.Fprintf(ctx.Output, "Altered %d entit%s%s\n",
len(targets), plural(len(targets), "y", "ies"), bulkScopeSuffix(s))
return nil
}

// resolveBulkEntityTargets returns the qualified names the statement applies
// to, sorted, so a run is deterministic and its output diffable.
func resolveBulkEntityTargets(ctx *ExecContext, s *ast.AlterEntitiesStmt) ([]ast.QualifiedName, error) {
modules, err := ctx.Backend.ListModules()
if err != nil {
return nil, mdlerrors.NewBackend("list modules", err)
}

var out []ast.QualifiedName
var skipped []string
// qualified name -> its generalization's qualified name ("" at the root)
parentOf := map[string]string{}
matchedModule := false
for _, m := range modules {
if s.Module != "" && !strings.EqualFold(m.Name, s.Module) {
continue
}
matchedModule = true

// Sweeping the whole project must not edit System or a Marketplace
// module: an upgrade replaces the module and the attribute goes with
// it, so the write is silently undone later rather than refused now.
// Naming the module explicitly is taken as meaning it -- the same
// division `mxcli layout` makes.
if s.Module == "" && isUneditableModule(m) {
skipped = append(skipped, m.Name)
continue
}

dm, err := ctx.Backend.GetDomainModel(m.ID)
if err != nil {
return nil, mdlerrors.NewBackend("get domain model", err)
}
if dm == nil {
continue
}
for _, e := range dm.Entities {
if !entityMatchesFilter(e, s.Filter) {
continue
}
out = append(out, ast.QualifiedName{Module: m.Name, Name: e.Name})
parentOf[m.Name+"."+e.Name] = e.GeneralizationRef
}
}

if s.Module != "" && !matchedModule {
return nil, mdlerrors.NewNotFoundMsg("module", s.Module,
fmt.Sprintf("module not found: %s", s.Module))
}

if len(skipped) > 0 {
sort.Strings(skipped)
fmt.Fprintf(ctx.Output, "Skipped %d System/Marketplace module(s): %s\n",
len(skipped), strings.Join(skipped, ", "))
}

out = dropInheritedTargets(out, parentOf)

sort.Slice(out, func(i, j int) bool {
if out[i].Module != out[j].Module {
return out[i].Module < out[j].Module
}
return out[i].Name < out[j].Name
})
return out, nil
}

// entityMatchesFilter applies the WHERE clause. A view entity is neither
// persistent nor non-persistent in the sense this filter means -- its rows come
// from an OQL query -- so it is excluded from both, rather than silently
// treated as one of them.
func entityMatchesFilter(e *domainmodel.Entity, f ast.EntityPersistenceFilter) bool {
if e == nil {
return false
}
// A view entity is never a target, with or without a filter: its columns
// are defined by its OQL select list, and adding an attribute gives
// CE6770 "View Entity is out of sync with the OQL Query".
if e.Source != "" || e.OqlQuery != "" {
return false
}
switch f {
case ast.EntityFilterPersistent:
return e.Persistable
case ast.EntityFilterNonPersistent:
return !e.Persistable
default:
return true
}
}

func bulkScopeSuffix(s *ast.AlterEntitiesStmt) string {
scope := ""
if s.Module != "" {
scope = " in " + s.Module
}
switch s.Filter {
case ast.EntityFilterPersistent:
scope += " (persistent only)"
case ast.EntityFilterNonPersistent:
scope += " (non-persistent only)"
}
return scope
}

// isUneditableModule reports a module whose contents an upgrade replaces.
func isUneditableModule(m *model.Module) bool {
if m == nil {
return false
}
if m.Name == "System" {
return true
}
return m.FromAppStore || strings.TrimSpace(m.AppStoreGuid) != ""
}

// dropInheritedTargets removes an entity whose ANCESTOR is also a target.
//
// Mendix refuses an attribute name that appears on both a generalization and
// its specialization -- CE0069 "Duplicate member name" -- and adding it to the
// parent is what the author wants anyway, since the child inherits it. Without
// this, ALTER ENTITIES over a module with any inheritance produced a project
// that would not build, which is how DmTest.Vehicle/Truck/PassengerCar caught
// it. The child is dropped rather than the parent: dropping the parent would
// leave the attribute off entities that have no specialization.
func dropInheritedTargets(targets []ast.QualifiedName, parentOf map[string]string) []ast.QualifiedName {
inSet := make(map[string]bool, len(targets))
for _, t := range targets {
inSet[t.Module+"."+t.Name] = true
}

kept := targets[:0]
for _, t := range targets {
covered := false
// Walk up the chain; a cycle is impossible in a loadable model, but
// bound the walk anyway rather than trust that.
for p, hops := parentOf[t.Module+"."+t.Name], 0; p != "" && hops < 64; hops++ {
if inSet[p] {
covered = true
break
}
p = parentOf[p]
}
if !covered {
kept = append(kept, t)
}
}
return kept
}
Loading
Loading