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
2 changes: 2 additions & 0 deletions .claude/skills/fix-issue.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ to the symptom table below, so the next similar issue costs fewer reads.

| Symptom | Root cause layer | First file to open | Fix pattern |
|---------|-----------------|-------------------|-------------|
| `create or modify external entities` silently resets a per-entity setting the user had changed (e.g. allow-create-change-locally) | `applyExternalEntityFields` stamps every field on both the create and the update path, so anything not derivable from the OData contract was overwritten with a default | `mdl/executor/cmd_contract.go` (`applyExternalEntityFields`) | Separate contract-derived fields (Countable/Creatable/Deletable/Skip/Top — refresh from metadata) from local modelling choices (CreateChangeLocally — leave alone; a new entity arrives zero-valued, which is Mendix's default). Issue #782 |
| An **external (OData) entity** loses its remote settings on any read-modify-write — `describe external entity` says `is not an external entity (source: )`, and `alter entity … set allow_create_change_locally = true` reports success but the flag stays off. Works under `--engine legacy` | `entityFromGen` recognised only `DomainModels$OqlViewEntitySource`, so the three `Rest$OData*` sources read back as no source at all: `Source` empty, every remote field zeroed. The write path was fine — it switches on `e.Source`, which the read never populated | `mdl/backend/modelsdk/domainmodel.go` (`entityFromGen`'s source switch, `odataKeyFromGen`) | Mirror the legacy parser (`sdk/mpr/parser_domainmodel.go`) for all three flavours: RemoteEntitySource (capabilities + CreateChangeLocally + key), EntityTypeSource (type name + IsOpen + key), PrimitiveCollectionEntitySource (service only). `Updatable` has no gen accessor and the writer does not emit it — leaving it zero is symmetric. **Check the read side first when a write-path field "does not stick"**: a switch on a field the read never fills looks like a write bug. Repro `mdl-examples/bug-tests/782-external-entity-create-change-locally.mdl`. Issue #782 |
| A pluggable widget's datasource (or child widget, or client action) is **silently dropped at write time** — `exec` prints `Created page` but the widget lands with the piece missing, and only a `log` line mentions `not yet supported — rerun with MXCLI_ENGINE=legacy` | The converter *does* return an error, but it travels through `widgetobj.ChildSerializer`, whose methods return BSON with no error channel (the `TODO(shared-types)` in `mdl/backend/widgetobj/builder.go`), so the caller logged it and returned nil | `mdl/backend/modelsdk/widget_pluggable_write.go` (`recordChildSerializeErr`, `takeChildSerializeErr`) + the drains in `page_write.go` / `snippet_write.go` | Record the failure in a package-level accumulator and drain it at every page/snippet write entry point, so the statement fails instead of the write succeeding with data missing (ADR-0004: refuse, don't drop). The real fix is the deferred `ChildSerializer` interface change; until then, any **new** write entry point that builds pluggable widgets must drain too. Repro `mdl-examples/bug-tests/795b-flow-datasource-context-entity.mdl` |
| `describe page` reports the wrong context entity under a data container bound to a microflow/nanoflow — `-- Context: $currentObject (Module.GetOrders)` names the *flow* instead of the entity it returns | `widget.EntityContext = widget.DataSource.Reference` is correct for a database source (reference *is* the entity) and wrong for a flow source (reference is the flow's qualified name) | `mdl/executor/cmd_pages_describe_flowcontext.go` (`dataSourceEntityContext`, `flowReturnEntity`) + the five assignment sites in `cmd_pages_describe_parse.go` | Resolve the flow's return type via `ListMicroflows`/`ListNanoflows` + `getHierarchy().GetQualifiedName`, taking the entity from an Object/List return type; fall back to the reference when the flow is unresolvable or returns a scalar, so the result is never worse than before. Note `GetRawUnitByName` is unimplemented on the modelsdk engine — the list+hierarchy path is the one that works |
| `describe page` omits a **pluggable** widget's `DataSource` when it is bound to a microflow (`datagrid g1 {` with no DataSource), while a `database from` source describes fine — re-applying the output recreates the grid unbound | A `Forms$MicroflowSource` stores the name in the nested `Forms$MicroflowSettings` (`MicroflowSettings` → `Microflow`), which is what the write path and Studio Pro emit; the reader looked up a top-level `Microflow` key, got `""`, and returned no datasource. The describe *formatter* was correct all along — read bug only | `mdl/executor/cmd_pages_describe_pluggable.go` (`microflowSourceRef`, `nanoflowSourceRef`, `extractDataGrid2DataSource`, `extractGalleryDataSource`, `parseCustomWidgetDataSource`) | Read the nested settings with a top-level fallback, via one shared helper — there were four divergent copies of this lookup and two were wrong. `Forms$NanoflowSource` was missing entirely from the DataGrid2/Gallery switches; add it alongside. Do **not** touch `CustomWidgets$CustomWidgetNanoflowSource` (a different metamodel type whose `Nanoflow` really is top-level) or the `Forms$MicroflowAction` reads (actions, not datasources). Repro `mdl-examples/bug-tests/795-datagrid-microflow-datasource-describe.mdl`. Issue #795 |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
-- Bug #782: External Entities — allow_create_change_locally doesn't work
--
-- Symptom: after importing entities from a consumed OData service, enabling
-- "Allow creating and changing objects locally" left the flag at false.
--
-- Two independent causes, both fixed:
--
-- (1) READ. entityFromGen (mdl/backend/modelsdk/domainmodel.go) recognised only
-- DomainModels$OqlViewEntitySource, so an OData external entity read back
-- with an empty Source and every remote field zeroed. `describe external
-- entity` rejected it outright ("is not an external entity (source: )"), and
-- `alter entity … set allow_create_change_locally = true` wrote the flag onto
-- a model that no longer knew the entity was external, so it was lost. The
-- legacy engine parsed all three OData source flavours, so this was a
-- modelsdk-engine gap — and modelsdk is the default engine.
--
-- (2) RE-IMPORT. applyExternalEntityFields (mdl/executor/cmd_contract.go) stamped
-- CreateChangeLocally = false on every import, including updates of existing
-- entities, so `create or modify external entities` reset whatever the user
-- had set. Unlike Countable/Creatable/Deletable it cannot be derived from the
-- service contract — it is a local modelling choice — so it is now left alone
-- on the top-level branch.
--
-- Manual verification (needs a project; the metadata file ships with the repo):
--
-- cp mdl-examples/odata-local-metadata/sample-metadata.xml /path/to/app/
-- mxcli exec 782-external-entity-create-change-locally.mdl -p app.mpr
--
-- Expect the final DESCRIBE to report `AllowCreateChangeLocally: Yes`. Before the
-- fix the first DESCRIBE errored with "is not an external entity", and after the
-- re-import the flag was back to No.
--
-- Adjust MetadataUrl below to wherever sample-metadata.xml sits.

create module Issue782;
create module role Issue782.User;

create constant Issue782.SvcUrl
type string
default 'https://services.odata.org/V4/Northwind/Northwind.svc/';

create odata client Issue782.Sample (
MetadataUrl: './mdl-examples/odata-local-metadata/sample-metadata.xml',
ServiceUrl: '@Issue782.SvcUrl'
);

create external entities from Issue782.Sample into Issue782;

-- (1) The entity must read back as external at all.
describe external entity Issue782.Products;

-- The reported flow: turn the flag on, and it must stick.
alter entity Issue782.Products set allow_create_change_locally = true;
describe external entity Issue782.Products;

-- (2) A re-import refreshes the contract-derived capabilities but must not reset
-- the local-changes flag.
create or modify external entities from Issue782.Sample into Issue782;
describe external entity Issue782.Products;
73 changes: 69 additions & 4 deletions mdl/backend/modelsdk/domainmodel.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (

"github.com/mendixlabs/mxcli/modelsdk/element"
genDm "github.com/mendixlabs/mxcli/modelsdk/gen/domainmodels"
genRest "github.com/mendixlabs/mxcli/modelsdk/gen/rest"
genTexts "github.com/mendixlabs/mxcli/modelsdk/gen/texts"
"github.com/mendixlabs/mxcli/modelsdk/mprread"

Expand Down Expand Up @@ -160,14 +161,52 @@ func entityFromGen(e *genDm.Entity) *domainmodel.Entity {

out.Location = parseLocation(e.Location())

// View entities carry an OqlViewEntitySource referencing their source
// document by qualified name; surface it so read-modify-write paths (e.g.
// MOVE ENTITY, which must reparent the source doc) can see it.
if src, ok := e.Source().(*genDm.OqlViewEntitySource); ok {
// An entity's Source says where its data comes from. Surface each flavour so
// read-modify-write paths (MOVE ENTITY reparenting a source doc, ALTER ENTITY
// flipping a remote capability) can see it — without this an external entity
// reads back looking local, DESCRIBE EXTERNAL ENTITY rejects it, and an update
// rebuilds it with no source at all (mendixlabs/mxcli#782). Mirrors the legacy
// parser in sdk/mpr/parser_domainmodel.go.
switch src := e.Source().(type) {
case *genDm.OqlViewEntitySource:
out.Source = "DomainModels$OqlViewEntitySource"
out.SourceObjectID = model.ID(src.ID())
out.SourceDocumentRef = src.SourceDocumentQualifiedName()
out.OqlQuery = src.Oql()

case *genRest.ODataRemoteEntitySource:
// Top-level external entity: has its own entity set, so it carries the
// CRUD/paging capabilities and the local-changes flag.
out.Source = "Rest$ODataRemoteEntitySource"
out.SourceObjectID = model.ID(src.ID())
out.RemoteServiceName = src.SourceDocumentQualifiedName()
out.RemoteEntitySet = src.EntitySet()
out.RemoteEntityName = src.RemoteName()
out.Countable = src.Countable()
out.Creatable = src.Creatable()
out.Deletable = src.Deletable()
out.SkipSupported = src.SkipSupported()
out.TopSupported = src.TopSupported()
out.CreateChangeLocally = src.CreateChangeLocally()
out.RemoteKeyParts = odataKeyFromGen(src.Key())
// Updatable has no gen accessor because the storage type has no such
// field — updatability is per attribute (Rest$ODataMappedValue). The
// write path does not emit it either, so leaving it zero is symmetric.

case *genRest.ODataEntityTypeSource:
// Derived / abstract / contained target type: no entity set, no capabilities.
out.Source = "Rest$ODataEntityTypeSource"
out.SourceObjectID = model.ID(src.ID())
out.RemoteServiceName = src.SourceDocumentQualifiedName()
out.RemoteEntityName = src.EntityTypeName()
out.IsOpen = src.IsOpen()
out.RemoteKeyParts = odataKeyFromGen(src.Key())

case *genRest.ODataPrimitiveCollectionEntitySource:
// NPE generated for a Collection(Edm.*) property; carries only the service.
out.Source = "Rest$ODataPrimitiveCollectionEntitySource"
out.SourceObjectID = model.ID(src.ID())
out.RemoteServiceName = src.SourceDocumentQualifiedName()
}

for _, el := range e.AttributesItems() {
Expand Down Expand Up @@ -341,6 +380,32 @@ func indexFromGen(idx *genDm.Index) *domainmodel.Index {

// attributeTypeFromGen is the reverse of attributeTypeToGen: a gen attribute-type
// element back to a domainmodel.AttributeType (with Length / enumeration ref).
// odataKeyFromGen converts a Rest$ODataKey to the semantic remote-key parts. The
// inverse of odataKeyToGen in domainmodel_write.go.
func odataKeyFromGen(key element.Element) []*domainmodel.RemoteKeyPart {
k, ok := key.(*genRest.ODataKey)
if !ok || k == nil {
return nil
}
var parts []*domainmodel.RemoteKeyPart
for _, el := range k.PartsItems() {
p, ok := el.(*genRest.ODataKeyPart)
if !ok {
continue
}
kp := &domainmodel.RemoteKeyPart{
Name: p.EntityKeyPartName(),
RemoteName: p.Name(),
RemoteType: p.RemoteType(),
}
if t := p.Type(); t != nil {
kp.Type = attributeTypeFromGen(t)
}
parts = append(parts, kp)
}
return parts
}

func attributeTypeFromGen(t element.Element) domainmodel.AttributeType {
switch at := t.(type) {
case *genDm.StringAttributeType:
Expand Down
Loading
Loading