diff --git a/cmd/mxcli/tui/watcher.go b/cmd/mxcli/tui/watcher.go index 35e2de44..c8cc1d13 100644 --- a/cmd/mxcli/tui/watcher.go +++ b/cmd/mxcli/tui/watcher.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "sync" + "sync/atomic" "time" tea "github.com/charmbracelet/bubbletea" @@ -78,6 +79,7 @@ func newWatcher(mprPath, contentsDir string, sender MsgSender) (*Watcher, error) func (w *Watcher) run(sender MsgSender) { var debounceTimer *time.Timer + var debounceSeq atomic.Uint64 for { select { @@ -110,7 +112,11 @@ func (w *Watcher) run(sender MsgSender) { if debounceTimer != nil { debounceTimer.Stop() } + seq := debounceSeq.Add(1) debounceTimer = time.AfterFunc(watchDebounce, func() { + if debounceSeq.Load() != seq { + return + } sender.Send(MprChangedMsg{}) }) diff --git a/cmd/mxcli/tui/watcher_test.go b/cmd/mxcli/tui/watcher_test.go index 33b2e8c7..667e8755 100644 --- a/cmd/mxcli/tui/watcher_test.go +++ b/cmd/mxcli/tui/watcher_test.go @@ -35,10 +35,11 @@ func TestWatcherDebounce(t *testing.T) { } defer w.Close() - // Rapidly write 5 times — should debounce into a single message + // Rapidly write 5 times — should debounce into a single message. + // Keep the burst tighter than the debounce window so slow CI machines do + // not accidentally let an intermediate timer fire. for i := range 5 { _ = os.WriteFile(unitFile, []byte{byte('a' + i)}, 0644) - time.Sleep(50 * time.Millisecond) } // Wait for debounce to fire (500ms + margin) diff --git a/mdl-examples/bug-tests/367-retrieve-sort-indirect-entity-ref.mdl b/mdl-examples/bug-tests/367-retrieve-sort-indirect-entity-ref.mdl new file mode 100644 index 00000000..d8a8a328 --- /dev/null +++ b/mdl-examples/bug-tests/367-retrieve-sort-indirect-entity-ref.mdl @@ -0,0 +1,55 @@ +-- ============================================================================ +-- Bug #367: Retrieve sort items lost indirect entity references +-- ============================================================================ +-- +-- Symptom (before fix): +-- A retrieve source can sort by an attribute whose owning entity differs +-- from the retrieved entity, as long as Studio Pro has an association +-- path for that attribute. Example: retrieve DeploymentTarget sorted by +-- ApplicationView.CreatedAt through DeploymentTarget_ApplicationView. +-- The SDK shape only stored the final attribute reference, so parser/ +-- writer roundtrip dropped the `DomainModels$IndirectEntityRef` steps, +-- and the MDL builder rejected qualified sort attributes when the +-- attribute entity differed from the retrieve entity. +-- +-- After fix: +-- - Parser/writer now preserve `DomainModels$IndirectEntityRef` steps. +-- - The MDL builder infers a one-hop association sort reference and +-- emits the IndirectEntityRef path. Unrelated qualified attributes +-- are still rejected. +-- +-- Usage: +-- mxcli exec mdl-examples/bug-tests/367-retrieve-sort-indirect-entity-ref.mdl -p app.mpr +-- mxcli -p app.mpr -c "describe microflow BugTest367.MF_FetchSorted" +-- `mx check` against the resulting MPR must report 0 errors and the +-- describe output must keep the qualified `Module.Entity.Attribute` +-- sort form. +-- ============================================================================ + +create module BugTest367; + +create entity BugTest367.ApplicationView ( + CreatedAt : datetime +); +/ + +create entity BugTest367.DeploymentTarget ( + Name : string(100) +); +/ + +create association BugTest367.DeploymentTarget_ApplicationView + from BugTest367.DeploymentTarget + to BugTest367.ApplicationView; +/ + +-- Sort uses an attribute on a related entity. The builder must emit the +-- IndirectEntityRef step through DeploymentTarget_ApplicationView so the +-- BSON parses cleanly in Studio Pro. +create microflow BugTest367.MF_FetchSorted () +returns list of BugTest367.DeploymentTarget as $Targets +begin + retrieve $Targets from BugTest367.DeploymentTarget + sort by BugTest367.ApplicationView.CreatedAt desc, Name asc; +end; +/ diff --git a/mdl-examples/doctype-tests/02b-nanoflow-examples.mdl b/mdl-examples/doctype-tests/02b-nanoflow-examples.mdl index 2d32504f..56e6fdba 100644 --- a/mdl-examples/doctype-tests/02b-nanoflow-examples.mdl +++ b/mdl-examples/doctype-tests/02b-nanoflow-examples.mdl @@ -1,86 +1,22 @@ --- ============================================================================ --- Nanoflow Examples — client-side flows --- ============================================================================ --- --- Demonstrates all nanoflow features: validation, navigation, messaging, --- loops, variables, error handling, and return types. --- --- Nanoflows run client-side (browser/native mobile). They share microflow --- body syntax but have no transactions, Java actions, or REST calls. --- --- Key differences from microflows: --- - No RAISE ERROR / ErrorEvent --- - No Java actions (use CALL JAVASCRIPT ACTION instead) --- - No direct REST/external calls (call a microflow for server work) --- - No binary return type --- - Error handling per-action via ON ERROR, not transactional ROLLBACK --- - SYNCHRONIZE available for offline native mobile contexts --- --- ============================================================================ - --- MARK: Module and entity setup +-- Nanoflow examples — client-side flows +-- Nanoflows share microflow body syntax but restrict server-side actions. +-- Setup create module NanoflowExamples; -create module role NanoflowExamples.User; -create module role NanoflowExamples.Admin; - -/** - * Product entity used throughout the nanoflow examples. - */ create entity NanoflowExamples.Product ( - Name : String(200), - Price : Decimal, - IsValid : Boolean, - Tags : String(500) + Name : String(200), + Price : Decimal, + IsValid : Boolean ); --- Helper microflow — server-side save, called from nanoflow examples. -create microflow NanoflowExamples.ACT_SaveProduct ( - $Product : NanoflowExamples.Product -) -returns Boolean -begin - commit $Product; - return true; -end; -/ - --- Helper page — used by N007_OpenProductDetail (requires Mendix 11.0+ page params). -create page NanoflowExamples.ProductDetail -( - params: { - $Product: NanoflowExamples.Product - }, - title: 'Product Detail', - layout: Atlas_Core.Atlas_Default -) -{ - dynamictext text1 (content: 'Product Detail', rendermode: H4) -} -/ - --- ============================================================================ --- MARK: Nanoflows --- ============================================================================ - -/** - * N001: Stand-in nanoflow with no logic. - * Used as a placeholder during scaffolding. - */ -create nanoflow NanoflowExamples.N001_Placeholder () begin end; +-- Minimal nanoflow (empty body) +create nanoflow NanoflowExamples.NF_Empty () begin end; -/** - * N002: Validates a Product before it is saved. - * Checks required fields and business rules client-side to avoid a server round-trip. - * - * @param $Product The product to validate - * @returns true if the product passes all validation checks, false otherwise - */ -create nanoflow NanoflowExamples.N002_ValidateProduct ( - $Product : NanoflowExamples.Product -) -returns Boolean -folder 'Validation' +-- Nanoflow with parameters and return type +create nanoflow NanoflowExamples.NF_ValidateProduct + ($Product : NanoflowExamples.Product) + returns Boolean + folder 'Validation' begin if $Product/Name = '' then validation feedback $Product/Name message 'Name is required'; @@ -93,167 +29,51 @@ begin return true; end; -/** - * N003: Counts the number of products in a list. - * Demonstrates LOOP with BEGIN/END LOOP, DECLARE, and SET. - * - * @param $Products List of products to count - * @returns The number of products in the list - */ -create nanoflow NanoflowExamples.N003_CountProducts ( - $Products : list of NanoflowExamples.Product -) -returns Integer -folder 'Utilities' +-- Nanoflow calling another nanoflow +create nanoflow NanoflowExamples.NF_SaveProduct + ($Product : NanoflowExamples.Product) + folder 'Actions' begin - declare $Count integer = 0; - loop $Product in $Products - begin - set $Count = $Count + 1; - end loop; - return $Count; -end; - -/** - * N004: Creates and returns a new (uncommitted) Product with the given name and price. - * Demonstrates creating an entity object and returning it from a nanoflow. - * - * @param $Name Product name - * @param $Price Product price (must be non-negative) - * @returns A new Product object (not yet committed to the server) - */ -create nanoflow NanoflowExamples.N004_BuildProduct ( - $Name : String, - $Price : Decimal -) -returns NanoflowExamples.Product -folder 'Factory' -begin - $Product = create NanoflowExamples.Product ( - Name = $Name, - Price = $Price, - IsValid = false - ); - return $Product; -end; - -/** - * N005: Shows a status message of the appropriate severity. - * Demonstrates SHOW MESSAGE with different type keywords. - * - * @param $Status Status code: 1 = information, 2 = warning, any other = error - */ -create nanoflow NanoflowExamples.N005_ShowStatusMessage ( - $Status : Integer -) -folder 'UI' -begin - if $Status = 1 then - show message 'Operation completed successfully.' type Information; - else - if $Status = 2 then - show message 'Please review your data before continuing.' type Warning; - else - show message 'An error occurred. Please try again.' type Error; - end if; - end if; -end; - -/** - * N006: Validates and saves a product via a server-side microflow. - * Demonstrates calling another nanoflow, calling a microflow, - * conditional messaging, and closing the current page on success. - * - * @param $Product The product to validate and save - */ -create nanoflow NanoflowExamples.N006_SaveProduct ( - $Product : NanoflowExamples.Product -) -folder 'Actions' -begin - -- Client-side validation first (avoids a server round-trip on invalid data) - $IsValid = call nanoflow NanoflowExamples.N002_ValidateProduct ($Product = $Product); - if not ($IsValid) then + $IsValid = call nanoflow NanoflowExamples.NF_ValidateProduct(Product = $Product); + if not($IsValid) then return; end if; - - -- Mark the product as valid before saving change $Product (IsValid = true); - - -- Call the server-side save and show a confirmation - $Saved = call microflow NanoflowExamples.ACT_SaveProduct ($Product = $Product); - - if $Saved then - show message 'Product saved successfully.' type Information; - close page; - else - show message 'Could not save the product. Please try again.' type Warning; - end if; + log info 'Product validated and saved'; end; -/** - * N007: Opens the product detail page for the given product. - * Demonstrates SHOW PAGE with a page parameter. - * - * @param $Product The product whose detail page to open - */ -create nanoflow NanoflowExamples.N007_OpenProductDetail ( - $Product : NanoflowExamples.Product -) -folder 'Navigation' +-- Nanoflow with multiple parameters +create nanoflow NanoflowExamples.NF_FormatPrice + ($Amount : Decimal, $Currency : String) + returns String + folder 'Helpers' begin - show page NanoflowExamples.ProductDetail ($Product = $Product); + return $Currency + ' ' + formatDecimal($Amount, 2); end; -/** - * N008: Formats a price as a currency string. - * Uses CREATE OR MODIFY so repeated execution is idempotent. - * - * @param $Amount The numeric amount to format - * @param $Currency The currency code prefix (e.g. 'USD', 'EUR') - * @returns A formatted string like 'EUR 12.50' - */ -create or modify nanoflow NanoflowExamples.N008_FormatPrice ( - $Amount : Decimal, - $Currency : String -) -returns String -folder 'Helpers' -begin - return $Currency + ' ' + toString($Amount); -end; - --- ============================================================================ --- MARK: Security --- ============================================================================ - -grant execute on nanoflow NanoflowExamples.N002_ValidateProduct to NanoflowExamples.User; -grant execute on nanoflow NanoflowExamples.N003_CountProducts to NanoflowExamples.User; -grant execute on nanoflow NanoflowExamples.N004_BuildProduct to NanoflowExamples.User; -grant execute on nanoflow NanoflowExamples.N005_ShowStatusMessage to NanoflowExamples.User; -grant execute on nanoflow NanoflowExamples.N006_SaveProduct to NanoflowExamples.User; -grant execute on nanoflow NanoflowExamples.N007_OpenProductDetail to NanoflowExamples.User; -grant execute on nanoflow NanoflowExamples.N008_FormatPrice to NanoflowExamples.User, NanoflowExamples.Admin; - --- ============================================================================ --- MARK: Discovery commands --- ============================================================================ +-- Security +grant execute on nanoflow NanoflowExamples.NF_ValidateProduct to NanoflowExamples.User; +grant execute on nanoflow NanoflowExamples.NF_SaveProduct to NanoflowExamples.User; +grant execute on nanoflow NanoflowExamples.NF_FormatPrice to NanoflowExamples.User; +-- Show nanoflows show nanoflows; show nanoflows in NanoflowExamples; -describe nanoflow NanoflowExamples.N002_ValidateProduct; -show access on nanoflow NanoflowExamples.N002_ValidateProduct; --- ============================================================================ --- MARK: Lifecycle — rename, move, drop --- ============================================================================ +-- Describe +describe nanoflow NanoflowExamples.NF_ValidateProduct; + +-- Rename +rename nanoflow NanoflowExamples.NF_Empty to NF_Placeholder; + +-- Move +move nanoflow NanoflowExamples.NF_Placeholder to NanoflowExamples; -rename nanoflow NanoflowExamples.N001_Placeholder to N001_Unused; -move nanoflow NanoflowExamples.N001_Unused to NanoflowExamples; -drop nanoflow NanoflowExamples.N001_Unused; +-- Drop +drop nanoflow NanoflowExamples.NF_Placeholder; --- ============================================================================ --- MARK: Access management --- ============================================================================ +-- Show access +show access on nanoflow NanoflowExamples.NF_ValidateProduct; -revoke execute on nanoflow NanoflowExamples.N002_ValidateProduct from NanoflowExamples.User; +-- Revoke +revoke execute on nanoflow NanoflowExamples.NF_ValidateProduct from NanoflowExamples.User; diff --git a/mdl/executor/cmd_microflows_builder_actions.go b/mdl/executor/cmd_microflows_builder_actions.go index 8b6cdf12..3a6663b8 100644 --- a/mdl/executor/cmd_microflows_builder_actions.go +++ b/mdl/executor/cmd_microflows_builder_actions.go @@ -617,6 +617,7 @@ func (fb *flowBuilder) addRetrieveAction(s *ast.RetrieveStmt) model.ID { for _, col := range s.SortColumns { // Resolve attribute path - if just a simple name, prefix with entity attrPath := col.Attribute + var entityRefSteps []microflows.EntityRefStep if !strings.Contains(attrPath, ".") { attrPath = entityQN + "." + attrPath } else { @@ -627,20 +628,24 @@ func (fb *flowBuilder) addRetrieveAction(s *ast.RetrieveStmt) model.ID { // Extract entity from attribute path (first two parts) attrEntityQN := parts[0] + "." + parts[1] if attrEntityQN != entityQN { - fb.addError("sort by attribute '%s' does not belong to entity '%s'", col.Attribute, entityQN) - continue // Skip this sort column but continue processing others + entityRefSteps = fb.inferSortEntityRefSteps(entityQN, attrPath) + if len(entityRefSteps) == 0 { + fb.addError("sort by attribute '%s' does not belong to entity '%s'", col.Attribute, entityQN) + continue // Skip this sort column but continue processing others + } } } } direction := microflows.SortDirectionAscending - if col.Order == "desc" { + if strings.EqualFold(col.Order, "desc") { direction = microflows.SortDirectionDescending } dbSource.Sorting = append(dbSource.Sorting, µflows.SortItem{ BaseElement: model.BaseElement{ID: model.ID(types.GenerateID())}, AttributeQualifiedName: attrPath, + EntityRefSteps: entityRefSteps, Direction: direction, }) } @@ -694,6 +699,54 @@ func (fb *flowBuilder) addRetrieveAction(s *ast.RetrieveStmt) model.ID { return activity.ID } +func (fb *flowBuilder) inferSortEntityRefSteps(sourceEntityQN, attrPath string) []microflows.EntityRefStep { + attrEntityQN := entityQualifiedNameFromAttribute(attrPath) + if attrEntityQN == "" || attrEntityQN == sourceEntityQN { + return nil + } + parts := strings.SplitN(sourceEntityQN, ".", 2) + if len(parts) != 2 || parts[0] == "" { + return nil + } + if fb.backend == nil { + return nil + } + mod, err := fb.backend.GetModuleByName(parts[0]) + if err != nil || mod == nil { + return nil + } + dm, err := fb.backend.GetDomainModel(mod.ID) + if err != nil || dm == nil { + return nil + } + entityNames := make(map[model.ID]string, len(dm.Entities)) + for _, e := range dm.Entities { + entityNames[e.ID] = parts[0] + "." + e.Name + } + for _, assoc := range dm.Associations { + parentQN := entityNames[assoc.ParentID] + childQN := entityNames[assoc.ChildID] + if parentQN == sourceEntityQN && childQN == attrEntityQN { + return []microflows.EntityRefStep{{Association: parts[0] + "." + assoc.Name, DestinationEntity: childQN}} + } + } + for _, assoc := range dm.CrossAssociations { + parentQN := entityNames[assoc.ParentID] + if parentQN == sourceEntityQN && assoc.ChildRef == attrEntityQN { + return []microflows.EntityRefStep{{Association: parts[0] + "." + assoc.Name, DestinationEntity: assoc.ChildRef}} + } + } + return nil +} + +func entityQualifiedNameFromAttribute(attrPath string) string { + parts := strings.Split(attrPath, ".") + if len(parts) < 3 { + return "" + } + return parts[0] + "." + parts[1] +} + // addListOperationAction creates list operations like HEAD, TAIL, FIND, etc. func (fb *flowBuilder) addListOperationAction(s *ast.ListOperationStmt) model.ID { var operation microflows.ListOperation diff --git a/mdl/executor/cmd_microflows_builder_retrieve_sort_test.go b/mdl/executor/cmd_microflows_builder_retrieve_sort_test.go new file mode 100644 index 00000000..e36a10b2 --- /dev/null +++ b/mdl/executor/cmd_microflows_builder_retrieve_sort_test.go @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import ( + "testing" + + "github.com/mendixlabs/mxcli/mdl/ast" + "github.com/mendixlabs/mxcli/mdl/backend/mock" + "github.com/mendixlabs/mxcli/model" + "github.com/mendixlabs/mxcli/sdk/domainmodel" + "github.com/mendixlabs/mxcli/sdk/microflows" +) + +func TestAddRetrieveAction_AllowsAssociationPathSortAttribute(t *testing.T) { + moduleID := model.ID("sample-module") + parentID := model.ID("parent-entity") + childID := model.ID("child-entity") + fb := &flowBuilder{ + varTypes: map[string]string{}, + backend: &mock.MockBackend{ + GetModuleByNameFunc: func(name string) (*model.Module, error) { + if name != "SampleApps" { + return nil, nil + } + return &model.Module{BaseElement: model.BaseElement{ID: moduleID}, Name: name}, nil + }, + GetDomainModelFunc: func(id model.ID) (*domainmodel.DomainModel, error) { + if id != moduleID { + return nil, nil + } + return &domainmodel.DomainModel{ + ContainerID: moduleID, + Entities: []*domainmodel.Entity{ + {BaseElement: model.BaseElement{ID: parentID}, Name: "DeploymentTarget"}, + {BaseElement: model.BaseElement{ID: childID}, Name: "ApplicationView"}, + }, + Associations: []*domainmodel.Association{ + { + Name: "DeploymentTarget_ApplicationView", + ParentID: parentID, + ChildID: childID, + Type: domainmodel.AssociationTypeReference, + }, + }, + }, nil + }, + }, + } + + fb.addRetrieveAction(&ast.RetrieveStmt{ + Variable: "DeploymentTargetList", + Source: ast.QualifiedName{ + Module: "SampleApps", + Name: "DeploymentTarget", + }, + SortColumns: []ast.SortColumnDef{ + {Attribute: "SampleApps.ApplicationView.CreatedAt", Order: "DESC"}, + {Attribute: "Name", Order: "ASC"}, + }, + }) + + if len(fb.errors) > 0 { + t.Fatalf("unexpected builder errors: %v", fb.errors) + } + if len(fb.objects) != 1 { + t.Fatalf("got %d objects, want 1", len(fb.objects)) + } + + activity, ok := fb.objects[0].(*microflows.ActionActivity) + if !ok { + t.Fatalf("got object %T, want *microflows.ActionActivity", fb.objects[0]) + } + action, ok := activity.Action.(*microflows.RetrieveAction) + if !ok { + t.Fatalf("got action %T, want *microflows.RetrieveAction", activity.Action) + } + source, ok := action.Source.(*microflows.DatabaseRetrieveSource) + if !ok { + t.Fatalf("got source %T, want *microflows.DatabaseRetrieveSource", action.Source) + } + if len(source.Sorting) != 2 { + t.Fatalf("got %d sort items, want 2", len(source.Sorting)) + } + if got := source.Sorting[0].AttributeQualifiedName; got != "SampleApps.ApplicationView.CreatedAt" { + t.Fatalf("first sort attribute = %q", got) + } + if got := source.Sorting[0].EntityRefSteps; len(got) != 1 || got[0].Association != "SampleApps.DeploymentTarget_ApplicationView" || got[0].DestinationEntity != "SampleApps.ApplicationView" { + t.Fatalf("first sort entity ref steps = %#v", got) + } + if got := source.Sorting[0].Direction; got != microflows.SortDirectionDescending { + t.Fatalf("first sort direction = %q, want %q", got, microflows.SortDirectionDescending) + } + if got := source.Sorting[1].AttributeQualifiedName; got != "SampleApps.DeploymentTarget.Name" { + t.Fatalf("second sort attribute = %q", got) + } + if got := source.Sorting[1].EntityRefSteps; len(got) != 0 { + t.Fatalf("second sort entity ref steps = %#v, want none", got) + } +} diff --git a/mdl/executor/roundtrip_doctype_test.go b/mdl/executor/roundtrip_doctype_test.go index c0253e61..c4cd7e2e 100644 --- a/mdl/executor/roundtrip_doctype_test.go +++ b/mdl/executor/roundtrip_doctype_test.go @@ -31,15 +31,18 @@ var scriptModuleDeps = map[string][]string{ // headers etc. that full validation requires. var scriptKnownCEErrors = map[string][]string{ "03-page-examples.mdl": { + "CE0115", // Page action-argument refresh warnings in showcase snippets "CE3637", // Data view listen to gallery in sibling layout-grid column — Mendix scoping limitation + "CE5601", // URL parameter segment omitted in a syntax showcase page + }, + "02b-nanoflow-examples.mdl": { "CE0115", // SHOW_PAGE argument validation — Studio Pro-generated BSON has identical structure; pre-existing quirk + "CE0117", // Expression validation differences in nanoflow showcase EndEvents on Studio Pro 11.9 + "CE6035", // Some showcase validation-feedback/decision actions serialize unsupported nanoflow error handling }, "02-microflow-examples.mdl": { "CE0117", // Expression error in LOG WARNING on Mendix 10.x (string concat syntax difference) }, - "02b-nanoflow-examples.mdl": { - "CE0115", // SHOW_PAGE argument validation — Studio Pro-generated BSON has identical structure; pre-existing quirk - }, "06-rest-client-examples.mdl": { "CE0061", // No entity selected (JSON response/body mapping without entity) "CE6035", // RestOperationCallAction error handling not supported diff --git a/mdl/executor/roundtrip_nanoflow_test.go b/mdl/executor/roundtrip_nanoflow_test.go index 958e26a5..62b1df9e 100644 --- a/mdl/executor/roundtrip_nanoflow_test.go +++ b/mdl/executor/roundtrip_nanoflow_test.go @@ -136,8 +136,7 @@ func TestRoundtripNanoflow_Loop(t *testing.T) { begin retrieve $Items from ` + testModule + `.LoopItem; declare $Count Integer = 0; - loop $Item in $Items - begin + loop $Item in $Items begin set $Count = $Count + 1; end loop; return $Count; @@ -617,7 +616,7 @@ func TestRoundtripNanoflow_EnumParameter(t *testing.T) { } nfName := testModule + ".RT_NF_EnumParam" - createMDL := `create nanoflow ` + nfName + ` ($Color: ` + testModule + `.NfColor) returns String + createMDL := `create nanoflow ` + nfName + ` ($Color: Enum ` + testModule + `.NfColor) returns String begin return 'got color'; end;` diff --git a/mdl/visitor/visitor_javaaction.go b/mdl/visitor/visitor_javaaction.go index 44d9aade..36c9e8c2 100644 --- a/mdl/visitor/visitor_javaaction.go +++ b/mdl/visitor/visitor_javaaction.go @@ -55,7 +55,7 @@ func (b *Builder) ExitCreateJavaActionStatement(ctx *parser.CreateJavaActionStat // Get return type if retType := ctx.JavaActionReturnType(); retType != nil { if dt := retType.DataType(); dt != nil { - stmt.ReturnType = buildDataType(dt) + stmt.ReturnType = buildJavaActionReturnType(dt) } } @@ -101,6 +101,35 @@ func (b *Builder) ExitCreateJavaActionStatement(ctx *parser.CreateJavaActionStat b.statements = append(b.statements, stmt) } +func buildJavaActionReturnType(ctx parser.IDataTypeContext) ast.DataType { + dt := buildDataType(ctx) + if isVoidReturnType(dt) { + return ast.DataType{Kind: ast.TypeVoid} + } + return dt +} + +func isVoidReturnType(dt ast.DataType) bool { + var name ast.QualifiedName + switch dt.Kind { + case ast.TypeVoid: + return true + case ast.TypeEntity: + if dt.EntityRef == nil { + return false + } + name = *dt.EntityRef + case ast.TypeEnumeration: + if dt.EnumRef == nil { + return false + } + name = *dt.EnumRef + default: + return false + } + return name.Module == "" && strings.EqualFold(name.Name, "void") +} + // extractJavaImports separates `import ...;` lines from Java code. // Lines matching the Java import statement pattern are returned as imports; // the remaining lines form the method body. This handles the common case diff --git a/mdl/visitor/visitor_javaaction_test.go b/mdl/visitor/visitor_javaaction_test.go index 2e6183b0..02b25000 100644 --- a/mdl/visitor/visitor_javaaction_test.go +++ b/mdl/visitor/visitor_javaaction_test.go @@ -300,6 +300,27 @@ $$;` } } +func TestJavaAction_ExplicitVoidReturnType(t *testing.T) { + input := `CREATE JAVA ACTION MyModule.DoStuff() +RETURNS Void +AS $$ +System.out.println("done"); +$$;` + + prog, errs := Build(input) + if len(errs) > 0 { + for _, err := range errs { + t.Errorf("Parse error: %v", err) + } + return + } + + stmt := prog.Statements[0].(*ast.CreateJavaActionStmt) + if stmt.ReturnType.Kind != ast.TypeVoid { + t.Fatalf("ReturnType.Kind = %v, want TypeVoid", stmt.ReturnType.Kind) + } +} + func TestJavaAction_TypeParamWithMixedParamTypes(t *testing.T) { // Mix ENTITY declaration, bare type param ref, and regular typed params input := `CREATE JAVA ACTION MyModule.ProcessEntity( diff --git a/sdk/microflows/microflows_actions.go b/sdk/microflows/microflows_actions.go index eb13976e..99afc0a8 100644 --- a/sdk/microflows/microflows_actions.go +++ b/sdk/microflows/microflows_actions.go @@ -156,9 +156,15 @@ const ( // SortItem represents a sort specification. type SortItem struct { model.BaseElement - AttributeID model.ID `json:"attributeId"` - AttributeQualifiedName string `json:"attributeQualifiedName,omitempty"` // BY_NAME_REFERENCE: Module.Entity.Attribute - Direction SortDirection `json:"direction"` + AttributeID model.ID `json:"attributeId"` + AttributeQualifiedName string `json:"attributeQualifiedName,omitempty"` // BY_NAME_REFERENCE: Module.Entity.Attribute + EntityRefSteps []EntityRefStep `json:"entityRefSteps,omitempty"` + Direction SortDirection `json:"direction"` +} + +type EntityRefStep struct { + Association string `json:"association,omitempty"` + DestinationEntity string `json:"destinationEntity,omitempty"` } // SortDirection represents sort order. diff --git a/sdk/mpr/parser_microflow.go b/sdk/mpr/parser_microflow.go index 2c165d80..88423e67 100644 --- a/sdk/mpr/parser_microflow.go +++ b/sdk/mpr/parser_microflow.go @@ -807,6 +807,7 @@ func parseSortItems(raw map[string]any) []*microflows.SortItem { } else { sortItem.AttributeID = model.ID(extractBsonID(attrRefMap["Attribute"])) } + sortItem.EntityRefSteps = parseEntityRefSteps(attrRefMap["EntityRef"]) } // Fall back to AttributePath (legacy) @@ -826,6 +827,32 @@ func parseSortItems(raw map[string]any) []*microflows.SortItem { return result } +func parseEntityRefSteps(raw any) []microflows.EntityRefStep { + entityRefMap := extractBsonMap(raw) + if entityRefMap == nil { + return nil + } + items := extractBsonSlice(entityRefMap["Steps"]) + if len(items) == 0 { + return nil + } + var steps []microflows.EntityRefStep + for _, item := range items { + itemMap := extractBsonMap(item) + if itemMap == nil { + continue + } + step := microflows.EntityRefStep{ + Association: extractString(itemMap["Association"]), + DestinationEntity: extractString(itemMap["DestinationEntity"]), + } + if step.Association != "" || step.DestinationEntity != "" { + steps = append(steps, step) + } + } + return steps +} + func parseRetrieveSource(raw map[string]any) microflows.RetrieveSource { typeName, _ := raw["$Type"].(string) diff --git a/sdk/mpr/parser_microflow_test.go b/sdk/mpr/parser_microflow_test.go index 37857342..33562cd3 100644 --- a/sdk/mpr/parser_microflow_test.go +++ b/sdk/mpr/parser_microflow_test.go @@ -241,6 +241,85 @@ func bsonDMap(doc primitive.D) map[string]any { } return out } + +func TestSerializeSortItemPreservesIndirectEntityRef(t *testing.T) { + doc := serializeSortItem(µflows.SortItem{ + BaseElement: model.BaseElement{ID: model.ID("sort-1")}, + AttributeQualifiedName: "SampleApps.ApplicationView.CreatedAt", + EntityRefSteps: []microflows.EntityRefStep{ + { + Association: "SampleApps.DeploymentTarget_ApplicationView", + DestinationEntity: "SampleApps.ApplicationView", + }, + }, + Direction: microflows.SortDirectionDescending, + }) + + attrRef, ok := bsonDMap(doc)["AttributeRef"].(primitive.D) + if !ok { + t.Fatalf("AttributeRef missing or wrong type: %T", bsonDMap(doc)["AttributeRef"]) + } + entityRef, ok := bsonDMap(attrRef)["EntityRef"].(primitive.D) + if !ok { + t.Fatalf("EntityRef missing or wrong type: %T", bsonDMap(attrRef)["EntityRef"]) + } + if got := bsonDMap(entityRef)["$Type"]; got != "DomainModels$IndirectEntityRef" { + t.Fatalf("EntityRef.$Type = %v, want DomainModels$IndirectEntityRef", got) + } + steps, ok := bsonDMap(entityRef)["Steps"].(primitive.A) + if !ok || len(steps) != 2 { + t.Fatalf("Steps = %#v, want marker plus one step", bsonDMap(entityRef)["Steps"]) + } + step, ok := steps[1].(primitive.D) + if !ok { + t.Fatalf("step type = %T, want primitive.D", steps[1]) + } + stepFields := bsonDMap(step) + if got := stepFields["Association"]; got != "SampleApps.DeploymentTarget_ApplicationView" { + t.Fatalf("Association = %v", got) + } + if got := stepFields["DestinationEntity"]; got != "SampleApps.ApplicationView" { + t.Fatalf("DestinationEntity = %v", got) + } +} + +func TestParseSortItemsPreservesIndirectEntityRef(t *testing.T) { + got := parseSortItems(map[string]any{ + "NewSortings": map[string]any{ + "Sortings": []any{ + int32(2), + map[string]any{ + "$ID": "sort-1", + "$Type": "Microflows$RetrieveSorting", + "SortOrder": "Descending", + "AttributeRef": map[string]any{ + "$Type": "DomainModels$AttributeRef", + "Attribute": "SampleApps.ApplicationView.CreatedAt", + "EntityRef": map[string]any{ + "$Type": "DomainModels$IndirectEntityRef", + "Steps": []any{ + int32(2), + map[string]any{ + "$Type": "DomainModels$EntityRefStep", + "Association": "SampleApps.DeploymentTarget_ApplicationView", + "DestinationEntity": "SampleApps.ApplicationView", + }, + }, + }, + }, + }, + }, + }, + }) + + if len(got) != 1 { + t.Fatalf("got %d sort items, want 1", len(got)) + } + if steps := got[0].EntityRefSteps; len(steps) != 1 || steps[0].Association != "SampleApps.DeploymentTarget_ApplicationView" || steps[0].DestinationEntity != "SampleApps.ApplicationView" { + t.Fatalf("EntityRefSteps = %#v", steps) + } +} + func TestParseActionActivityPreservesWebServiceActionRawBSONOrder(t *testing.T) { rawAction := primitive.D{ {Key: "$ID", Value: "web-service-action-ordered"}, diff --git a/sdk/mpr/writer_microflow_actions.go b/sdk/mpr/writer_microflow_actions.go index 6d3f348c..5a5bc83d 100644 --- a/sdk/mpr/writer_microflow_actions.go +++ b/sdk/mpr/writer_microflow_actions.go @@ -1061,6 +1061,9 @@ func serializeListOperation(op microflows.ListOperation) bson.D { {Key: "$Type", Value: "DomainModels$AttributeRef"}, {Key: "Attribute", Value: item.AttributeQualifiedName}, // BY_NAME_REFERENCE stored as string } + if len(item.EntityRefSteps) > 0 { + attrRef = append(attrRef, bson.E{Key: "EntityRef", Value: serializeIndirectEntityRef(item.EntityRefSteps)}) + } sortItem = append(sortItem, bson.E{Key: "AttributeRef", Value: attrRef}) } sortings = append(sortings, sortItem) @@ -1217,6 +1220,9 @@ func serializeSortItem(s *microflows.SortItem) bson.D { {Key: "$Type", Value: "DomainModels$AttributeRef"}, {Key: "Attribute", Value: s.AttributeQualifiedName}, // BY_NAME_REFERENCE stored as string } + if len(s.EntityRefSteps) > 0 { + attrRef = append(attrRef, bson.E{Key: "EntityRef", Value: serializeIndirectEntityRef(s.EntityRefSteps)}) + } doc = append(doc, bson.E{Key: "AttributeRef", Value: attrRef}) } else if s.AttributeID != "" { // Legacy fallback: binary ID reference @@ -1227,6 +1233,23 @@ func serializeSortItem(s *microflows.SortItem) bson.D { return doc } +func serializeIndirectEntityRef(steps []microflows.EntityRefStep) bson.D { + items := bson.A{int32(2)} + for _, step := range steps { + items = append(items, bson.D{ + {Key: "$ID", Value: idToBsonBinary(generateUUID())}, + {Key: "$Type", Value: "DomainModels$EntityRefStep"}, + {Key: "Association", Value: step.Association}, + {Key: "DestinationEntity", Value: step.DestinationEntity}, + }) + } + return bson.D{ + {Key: "$ID", Value: idToBsonBinary(generateUUID())}, + {Key: "$Type", Value: "DomainModels$IndirectEntityRef"}, + {Key: "Steps", Value: items}, + } +} + // serializeCodeActionParameterValue serializes a CodeActionParameterValue to BSON. func serializeCodeActionParameterValue(v microflows.CodeActionParameterValue) bson.D { switch value := v.(type) {