diff --git a/internal/engine/view_materialization.go b/internal/engine/view_materialization.go index 18bb893d..d9bc0089 100644 --- a/internal/engine/view_materialization.go +++ b/internal/engine/view_materialization.go @@ -49,7 +49,7 @@ func (e Engine) MaterializeView(ctx context.Context, input ViewMaterializationIn m.addDiag("LDL1701", "unsupported_view_shape_or_export", "View shape is invalid", input.Recipe.Address, "") return rejectedView(m.diagnostics...) } - if len(m.diagnostics) != 0 { + if hasViewErrorDiagnostics(m.diagnostics) { return rejectedView(m.diagnostics...) } base, ok := result.Base() @@ -280,15 +280,17 @@ func (m *viewMaterializer) validateQueryResultSubjects() { } } for _, values := range [][]string{m.queryResult.PathRelationAddresses, m.queryResult.InducedRelationAddresses, m.queryResult.SelectedRelationAddresses} { - if !canonicalStableAddressSlice(values) { - m.addDiag("LDL1801", "stale_revision_or_semantic_hash", "QueryResult Relation collection is not canonical", m.queryResult.QueryAddress, m.input.Recipe.Address) - return - } + known := true for _, address := range values { if _, ok := m.relations[address]; !ok { m.addDiag("LDL1601", "invalid_query_or_arguments", "QueryResult references an unknown Relation", address, m.input.Recipe.Address) + known = false } } + if known && !m.canonicalQueryRelationSlice(values) { + m.addDiag("LDL1801", "stale_revision_or_semantic_hash", "QueryResult Relation collection is not canonical", m.queryResult.QueryAddress, m.input.Recipe.Address) + return + } } visible := viewStringSet(m.materializationEntityAddresses()) for _, address := range m.relationAddresses() { @@ -426,6 +428,21 @@ func (m *viewMaterializer) addDiag(code, key, message, subject, owner string) { m.diagnostics = append(m.diagnostics, diagnostic(code, key, message, subject, owner)) } +func (m *viewMaterializer) addWarning(code, key, message, subject, owner string) { + value := diagnostic(code, key, message, subject, owner) + value.Severity = "warning" + m.diagnostics = append(m.diagnostics, value) +} + +func hasViewErrorDiagnostics(values []Diagnostic) bool { + for _, value := range values { + if value.Severity != "warning" && value.Severity != "info" { + return true + } + } + return false +} + func rejectedView(diagnostics ...Diagnostic) ViewMaterializationResponse { return ViewMaterializationResponse{Status: "rejected", Diagnostics: sortedDiagnostics(diagnostics)} } @@ -481,6 +498,18 @@ func canonicalStableAddressSlice(values []string) bool { return values != nil } +func (m *viewMaterializer) canonicalQueryRelationSlice(values []string) bool { + if values == nil { + return false + } + for index := 1; index < len(values); index++ { + if compareRelationTuple(m.relations[values[index-1]], m.relations[values[index]]) >= 0 { + return false + } + } + return true +} + func setViewDataBase(value *ViewData, base ViewDataBase) { switch { case value.Diagram != nil: diff --git a/internal/engine/view_materialization_diagram.go b/internal/engine/view_materialization_diagram.go index 4dbca824..3427f094 100644 --- a/internal/engine/view_materialization_diagram.go +++ b/internal/engine/view_materialization_diagram.go @@ -2,42 +2,629 @@ package engine +import ( + "sort" + + "github.com/dencyuinc/layerdraw/internal/engine/internal/compiler/semantic/definition" + "github.com/dencyuinc/layerdraw/internal/engine/internal/compiler/semantic/graph" +) + +type diagramProjectionCandidate struct { + relation graph.Relation + projections definition.ProjectionSet + render definition.RenderSet + order int +} + +type diagramNestCandidate struct { + diagramProjectionCandidate + parentAddress string + childAddress string +} + +type diagramSupportIdentity struct { + kind DiagramSupportKind + entity string + relation string +} + +type diagramSupportAccumulator struct { + materializer *viewMaterializer + items []DiagramSupportItem + orders []int + index map[diagramSupportIdentity]int +} + func (m *viewMaterializer) diagram(base ViewDataBase) *DiagramViewData { + occurrences, occurrenceIndex, occurrenceByEntity := m.diagramOccurrences() + support := newDiagramSupportAccumulator(m) + for index, address := range m.queryResult.SupportEntityAddresses { + entity := m.entities[address] + support.add(index-len(m.queryResult.SupportEntityAddresses), DiagramSupportHiddenEntity, &entity.Address, nil, m.diagramEntitySource(entity)) + } + + candidates := m.diagramCandidates() + edgeQueue := make([]diagramProjectionCandidate, 0, len(candidates)) + nestGroups := map[string][]diagramNestCandidate{} + childOrder := []string{} + overlayQueue := []diagramProjectionCandidate{} + badgeQueue := []diagramProjectionCandidate{} + composed := m.input.Recipe.Shape.Diagram != nil && m.input.Recipe.Shape.Diagram.Composed + + for _, candidate := range candidates { + if !composed { + edgeQueue = append(edgeQueue, candidate) + continue + } + switch candidate.projections.Composed.Mode { + case definition.ComposedEdge: + edgeQueue = append(edgeQueue, candidate) + case definition.ComposedHide: + source := m.diagramRelationSource(candidate.relation) + support.add(candidate.order, DiagramSupportHiddenRelation, nil, &candidate.relation.Address, source) + case definition.ComposedNest: + parent, child, ok := m.diagramProjectionEndpoints(candidate.relation, candidate.projections.Composed.ParentEndpoint, candidate.projections.Composed.ChildEndpoint) + if !ok { + continue + } + if _, exists := nestGroups[child]; !exists { + childOrder = append(childOrder, child) + } + nestGroups[child] = append(nestGroups[child], diagramNestCandidate{ + diagramProjectionCandidate: candidate, + parentAddress: parent, + childAddress: child, + }) + case definition.ComposedOverlay: + overlayQueue = append(overlayQueue, candidate) + case definition.ComposedBadge: + badgeQueue = append(badgeQueue, candidate) + default: + m.addDiag("LDL1504", "invalid_projection_contract", "effective composed projection mode is invalid", candidate.relation.TypeAddress, m.input.Recipe.Address) + } + } + + adopted := map[string]diagramNestCandidate{} + parentByChild := map[string]string{} + for _, child := range childOrder { + m.resolveDiagramNestGroup(nestGroups[child], adopted, parentByChild, &edgeQueue, support) + } + for _, child := range childOrder { + candidate, ok := adopted[child] + if !ok { + continue + } + index, ok := occurrenceIndex[child] + if !ok { + m.addDiag("LDL1702", "view_materialization_conflict", "nested child has no Diagram occurrence", child, m.input.Recipe.Address) + continue + } + parentKey, ok := occurrenceByEntity[candidate.parentAddress] + if !ok { + m.addDiag("LDL1702", "view_materialization_conflict", "nested parent has no Diagram occurrence", candidate.parentAddress, m.input.Recipe.Address) + continue + } + relationAddress := candidate.relation.Address + occurrences[index].ParentKey = &parentKey + occurrences[index].ViaRelationAddress = &relationAddress + occurrences[index].Source = mergeViewDataSourceRefs(occurrences[index].Source, m.diagramRelationSource(candidate.relation)) + } + + edges := m.diagramEdges(edgeQueue, occurrenceByEntity, support) + overlays := m.diagramOverlays(overlayQueue, occurrenceByEntity) + badges := m.diagramBadges(badgeQueue, occurrenceByEntity) + containers := m.diagramContainers(occurrences, adopted) + m.validateDiagramPlacements(occurrences) + + return &DiagramViewData{ + ViewDataBase: base, + Occurrences: occurrences, + Edges: edges, + Containers: containers, + Overlays: overlays, + Badges: badges, + SupportItems: support.sortedItems(), + } +} + +func (m *viewMaterializer) diagramOccurrences() ([]DiagramOccurrence, map[string]int, map[string]string) { entityAddresses := m.materializationEntityAddresses() primary := viewStringSet(m.primaryEntityAddresses()) occurrences := make([]DiagramOccurrence, 0, len(entityAddresses)) - occurrenceByEntity := make(map[string]string, len(entityAddresses)) + index := make(map[string]int, len(entityAddresses)) + keys := make(map[string]string, len(entityAddresses)) for _, address := range entityAddresses { - entity := m.entities[address] - key := viewItemKey(m, "diagram-occurrence", []any{m.input.Recipe.Address, entity.Address}) + entity, exists := m.entities[address] + if !exists { + m.addDiag("LDL1702", "view_materialization_conflict", "Diagram Entity is absent from the immutable graph", address, m.input.Recipe.Address) + continue + } + if _, duplicate := keys[address]; duplicate { + m.addDiag("LDL1702", "view_materialization_conflict", "Diagram Entity produced more than one provisional occurrence", address, m.input.Recipe.Address) + continue + } role := DiagramRoleSupport if primary[address] { role = DiagramRoleNode + if entityType, ok := m.entityTypes[entity.TypeAddress]; ok && entityType.Representation.Kind == definition.RepresentationContainer { + role = DiagramRoleContainer + } } + key := viewItemKey(m, "diagram-occurrence", []any{m.input.Recipe.Address, entity.Address}) + index[address] = len(occurrences) + keys[address] = key occurrences = append(occurrences, DiagramOccurrence{ Key: key, EntityAddress: entity.Address, LayerAddress: entity.LayerAddress, Role: role, - Source: m.entitySource(entity), + Source: m.diagramEntitySource(entity), }) - occurrenceByEntity[address] = key } - edges := make([]DiagramEdge, 0, len(m.relationAddresses())) + return occurrences, index, keys +} + +func (m *viewMaterializer) diagramCandidates() []diagramProjectionCandidate { + projectionByType := map[string]diagramProjectionCandidate{} + values := make([]diagramProjectionCandidate, 0, len(m.relationAddresses())) for _, address := range m.relationAddresses() { - relation := m.relations[address] - fromKey, fromOK := occurrenceByEntity[relation.FromAddress] - toKey, toOK := occurrenceByEntity[relation.ToAddress] + relation, ok := m.relations[address] + if !ok { + m.addDiag("LDL1702", "view_materialization_conflict", "Diagram Relation is absent from the immutable graph", address, m.input.Recipe.Address) + continue + } + effective, cached := projectionByType[relation.TypeAddress] + if !cached { + projections, render, valid := m.effectiveDiagramProjection(relation.TypeAddress) + if !valid { + continue + } + effective = diagramProjectionCandidate{projections: projections, render: render} + projectionByType[relation.TypeAddress] = effective + } + effective.relation = relation + values = append(values, effective) + } + if m.input.Recipe.Shape.Diagram != nil && m.input.Recipe.Shape.Diagram.Composed { + sort.SliceStable(values, func(i, j int) bool { + left, right := values[i], values[j] + if left.projections.Composed.Priority != right.projections.Composed.Priority { + return left.projections.Composed.Priority > right.projections.Composed.Priority + } + for _, pair := range [][2]string{ + {left.relation.TypeAddress, right.relation.TypeAddress}, + {left.relation.Address, right.relation.Address}, + {left.relation.FromAddress, right.relation.FromAddress}, + {left.relation.ToAddress, right.relation.ToAddress}, + } { + if compared := compareStableAddressText(pair[0], pair[1]); compared != 0 { + return compared < 0 + } + } + return false + }) + } + for index := range values { + values[index].order = index + } + return values +} + +func (m *viewMaterializer) effectiveDiagramProjection(relationTypeAddress string) (definition.ProjectionSet, definition.RenderSet, bool) { + relationType, ok := m.relationTypes[relationTypeAddress] + if !ok { + m.addDiag("LDL1702", "view_materialization_conflict", "Diagram RelationType is absent from the immutable definition", relationTypeAddress, m.input.Recipe.Address) + return definition.ProjectionSet{}, definition.RenderSet{}, false + } + projections := relationType.Projections + render := relationType.Render + found := false + for _, override := range m.input.Recipe.RelationProjections { + if override.RelationTypeAddress != relationTypeAddress { + continue + } + if found { + m.addDiag("LDL1504", "invalid_projection_contract", "View contains duplicate effective RelationType projection overrides", relationTypeAddress, m.input.Recipe.Address) + return definition.ProjectionSet{}, definition.RenderSet{}, false + } + found = true + projections = override.Projections + render = override.Render + } + if !validEffectiveComposedProjection(projections.Composed) || !validEffectiveDiagramProjection(projections.Diagram) { + m.addDiag("LDL1504", "invalid_projection_contract", "effective Diagram projection is invalid", relationTypeAddress, m.input.Recipe.Address) + return definition.ProjectionSet{}, definition.RenderSet{}, false + } + return projections, render, true +} + +func validEffectiveComposedProjection(value definition.ComposedProjection) bool { + endpoint := func(value *definition.ProjectionEndpoint) bool { + return value != nil && (*value == definition.ProjectionEndpointFrom || *value == definition.ProjectionEndpointTo) + } + distinct := func(left, right *definition.ProjectionEndpoint) bool { + return endpoint(left) && endpoint(right) && *left != *right + } + if value.Conflict != definition.ProjectionConflictKeepEdge && value.Conflict != definition.ProjectionConflictPreferFirst && value.Conflict != definition.ProjectionConflictDiagnostic { + return false + } + switch value.Mode { + case definition.ComposedNest: + return distinct(value.ParentEndpoint, value.ChildEndpoint) && value.OverlayEndpoint == nil && value.TargetEndpoint == nil && value.BadgeEndpoint == nil + case definition.ComposedOverlay: + return distinct(value.OverlayEndpoint, value.TargetEndpoint) && value.ParentEndpoint == nil && value.ChildEndpoint == nil && value.BadgeEndpoint == nil + case definition.ComposedBadge: + return distinct(value.BadgeEndpoint, value.TargetEndpoint) && value.ParentEndpoint == nil && value.ChildEndpoint == nil && value.OverlayEndpoint == nil + case definition.ComposedEdge, definition.ComposedHide: + return value.ParentEndpoint == nil && value.ChildEndpoint == nil && value.OverlayEndpoint == nil && value.TargetEndpoint == nil && value.BadgeEndpoint == nil + default: + return false + } +} + +func validEffectiveDiagramProjection(value definition.DiagramProjection) bool { + validEndpoint := func(value definition.ProjectionEndpoint) bool { + return value == definition.ProjectionEndpointFrom || value == definition.ProjectionEndpointTo + } + return (value.Mode == definition.DiagramEdge || value.Mode == definition.DiagramHide) && + validEndpoint(value.SourceEndpoint) && validEndpoint(value.TargetEndpoint) && value.SourceEndpoint != value.TargetEndpoint +} + +func (m *viewMaterializer) resolveDiagramNestGroup( + group []diagramNestCandidate, + adopted map[string]diagramNestCandidate, + parentByChild map[string]string, + edgeQueue *[]diagramProjectionCandidate, + support *diagramSupportAccumulator, +) { + if len(group) == 0 { + return + } + sameParent := true + for _, candidate := range group[1:] { + if candidate.parentAddress != group[0].parentAddress { + sameParent = false + break + } + } + if sameParent { + m.adoptDiagramNest(group[0], adopted, parentByChild, edgeQueue) + for _, candidate := range group[1:] { + if candidate.projections.Composed.KeepEdge { + *edgeQueue = append(*edgeQueue, candidate.diagramProjectionCandidate) + } else { + source := m.diagramRelationSource(candidate.relation) + support.add(candidate.order, DiagramSupportSourceOnly, nil, &candidate.relation.Address, source) + } + } + return + } + for _, candidate := range group { + if candidate.projections.Composed.Conflict == definition.ProjectionConflictDiagnostic { + m.addWarning("LDL1704", "composed_parent_ambiguity_retained", "composed Diagram retained ambiguous parent candidates as support data", candidate.childAddress, m.input.Recipe.Address) + for _, retained := range group { + source := m.diagramRelationSource(retained.relation) + support.add(retained.order, DiagramSupportSourceOnly, nil, &retained.relation.Address, source) + } + return + } + } + for _, candidate := range group { + if candidate.projections.Composed.Conflict == definition.ProjectionConflictPreferFirst { + m.adoptDiagramNest(candidate, adopted, parentByChild, edgeQueue) + for _, retained := range group { + if retained.relation.Address != candidate.relation.Address { + m.retainUnselectedNest(retained, edgeQueue, support) + } + } + return + } + } + for _, candidate := range group { + *edgeQueue = append(*edgeQueue, candidate.diagramProjectionCandidate) + } +} + +func (m *viewMaterializer) adoptDiagramNest( + candidate diagramNestCandidate, + adopted map[string]diagramNestCandidate, + parentByChild map[string]string, + edgeQueue *[]diagramProjectionCandidate, +) { + if diagramNestWouldCycle(candidate.parentAddress, candidate.childAddress, parentByChild) { + m.addDiag("LDL1702", "view_materialization_conflict", "composed Diagram nesting creates an occurrence ancestry cycle", candidate.relation.Address, m.input.Recipe.Address) + return + } + adopted[candidate.childAddress] = candidate + parentByChild[candidate.childAddress] = candidate.parentAddress + if candidate.projections.Composed.KeepEdge { + *edgeQueue = append(*edgeQueue, candidate.diagramProjectionCandidate) + } +} + +func diagramNestWouldCycle(parent, child string, parentByChild map[string]string) bool { + if parent == child { + return true + } + seen := map[string]bool{} + for current := parent; current != "" && !seen[current]; current = parentByChild[current] { + if current == child { + return true + } + seen[current] = true + } + return false +} + +func (m *viewMaterializer) retainUnselectedNest(candidate diagramNestCandidate, edgeQueue *[]diagramProjectionCandidate, support *diagramSupportAccumulator) { + if candidate.projections.Composed.Conflict == definition.ProjectionConflictKeepEdge || candidate.projections.Composed.KeepEdge { + *edgeQueue = append(*edgeQueue, candidate.diagramProjectionCandidate) + return + } + source := m.diagramRelationSource(candidate.relation) + support.add(candidate.order, DiagramSupportSourceOnly, nil, &candidate.relation.Address, source) +} + +func (m *viewMaterializer) diagramEdges(candidates []diagramProjectionCandidate, occurrenceByEntity map[string]string, support *diagramSupportAccumulator) []DiagramEdge { + sort.SliceStable(candidates, func(i, j int) bool { return candidates[i].order < candidates[j].order }) + edges := make([]DiagramEdge, 0, len(candidates)) + for _, candidate := range candidates { + projection := candidate.projections.Diagram + source := m.diagramRelationSource(candidate.relation) + if projection.Mode == definition.DiagramHide { + support.add(candidate.order, DiagramSupportHiddenRelation, nil, &candidate.relation.Address, source) + continue + } + fromAddress, toAddress, ok := m.diagramProjectionEndpointPair(candidate.relation, projection.SourceEndpoint, projection.TargetEndpoint) + if !ok { + continue + } + fromKey, fromOK := occurrenceByEntity[fromAddress] + toKey, toOK := occurrenceByEntity[toAddress] if !fromOK || !toOK { - m.addDiag("LDL1701", "unsupported_view_shape_or_export", "Diagram Relation endpoint has no occurrence", relation.Address, m.input.Recipe.Address) + m.addDiag("LDL1702", "view_materialization_conflict", "Diagram Relation endpoint has no occurrence", candidate.relation.Address, m.input.Recipe.Address) continue } - key := viewItemKey(m, "diagram-edge", []any{m.input.Recipe.Address, relation.Address, fromKey, toKey}) + key := viewItemKey(m, "diagram-edge", []any{m.input.Recipe.Address, candidate.relation.Address, fromKey, toKey}) edges = append(edges, DiagramEdge{ Key: key, FromOccurrenceKey: fromKey, ToOccurrenceKey: toKey, - RelationAddress: relation.Address, RelationTypeAddress: relation.TypeAddress, - Source: m.relationSource(relation), + RelationAddress: candidate.relation.Address, RelationTypeAddress: candidate.relation.TypeAddress, + Source: source, }) } - return &DiagramViewData{ - ViewDataBase: base, Occurrences: occurrences, Edges: edges, - Containers: []DiagramContainer{}, Overlays: []DiagramOverlay{}, Badges: []DiagramBadge{}, SupportItems: []DiagramSupportItem{}, + return edges +} + +func (m *viewMaterializer) diagramOverlays(candidates []diagramProjectionCandidate, occurrenceByEntity map[string]string) []DiagramOverlay { + overlays := make([]DiagramOverlay, 0, len(candidates)) + for _, candidate := range candidates { + overlayAddress, targetAddress, ok := m.diagramProjectionEndpoints(candidate.relation, candidate.projections.Composed.OverlayEndpoint, candidate.projections.Composed.TargetEndpoint) + if !ok { + continue + } + targetKey, ok := occurrenceByEntity[targetAddress] + if !ok { + m.addDiag("LDL1702", "view_materialization_conflict", "Diagram overlay target has no occurrence", candidate.relation.Address, m.input.Recipe.Address) + continue + } + key := viewItemKey(m, "diagram-overlay", []any{m.input.Recipe.Address, candidate.relation.Address, targetKey}) + overlays = append(overlays, DiagramOverlay{ + Key: key, TargetOccurrenceKey: targetKey, OverlayEntityAddress: overlayAddress, + RelationAddress: candidate.relation.Address, RelationTypeAddress: candidate.relation.TypeAddress, + Source: m.diagramRelationSource(candidate.relation), + }) + } + return overlays +} + +func (m *viewMaterializer) diagramBadges(candidates []diagramProjectionCandidate, occurrenceByEntity map[string]string) []DiagramBadge { + badges := make([]DiagramBadge, 0, len(candidates)) + for _, candidate := range candidates { + badgeAddress, targetAddress, ok := m.diagramProjectionEndpoints(candidate.relation, candidate.projections.Composed.BadgeEndpoint, candidate.projections.Composed.TargetEndpoint) + if !ok { + continue + } + targetKey, ok := occurrenceByEntity[targetAddress] + if !ok { + m.addDiag("LDL1702", "view_materialization_conflict", "Diagram badge target has no occurrence", candidate.relation.Address, m.input.Recipe.Address) + continue + } + key := viewItemKey(m, "diagram-badge", []any{m.input.Recipe.Address, candidate.relation.Address, targetKey}) + badges = append(badges, DiagramBadge{ + Key: key, TargetOccurrenceKey: targetKey, + RelationAddress: candidate.relation.Address, RelationTypeAddress: candidate.relation.TypeAddress, + Label: m.diagramBadgeLabel(candidate, badgeAddress), Source: m.diagramRelationSource(candidate.relation), + }) + } + return badges +} + +func (m *viewMaterializer) diagramBadgeLabel(candidate diagramProjectionCandidate, badgeAddress string) *string { + entity, ok := m.entities[badgeAddress] + if !ok { + m.addDiag("LDL1702", "view_materialization_conflict", "Diagram badge Entity is absent", badgeAddress, m.input.Recipe.Address) + return nil + } + var value string + switch candidate.render.Badge.Label { + case definition.RenderBadgeLabelType: + entityType, ok := m.entityTypes[entity.TypeAddress] + if !ok { + m.addDiag("LDL1702", "view_materialization_conflict", "Diagram badge EntityType is absent", entity.TypeAddress, m.input.Recipe.Address) + return nil + } + value = entityType.DisplayName + case definition.RenderBadgeLabelDisplayName: + value = entity.DisplayName + case definition.RenderBadgeLabelCount: + value = "1" + case definition.RenderBadgeLabelNone: + return nil + default: + m.addDiag("LDL1504", "invalid_projection_contract", "effective Diagram badge label is invalid", candidate.relation.TypeAddress, m.input.Recipe.Address) + return nil + } + return &value +} + +func (m *viewMaterializer) diagramContainers(occurrences []DiagramOccurrence, adopted map[string]diagramNestCandidate) []DiagramContainer { + children := map[string][]string{} + for _, occurrence := range occurrences { + if occurrence.ParentKey != nil { + children[*occurrence.ParentKey] = append(children[*occurrence.ParentKey], occurrence.Key) + } + } + containers := []DiagramContainer{} + for _, occurrence := range occurrences { + childKeys := children[occurrence.Key] + if len(childKeys) == 0 { + continue + } + source := occurrence.Source + for _, child := range occurrences { + if child.ParentKey == nil || *child.ParentKey != occurrence.Key { + continue + } + source = mergeViewDataSourceRefs(source, child.Source) + if candidate, ok := adopted[child.EntityAddress]; ok { + source = mergeViewDataSourceRefs(source, m.diagramRelationSource(candidate.relation)) + } + } + key := viewItemKey(m, "diagram-container", []any{m.input.Recipe.Address, occurrence.Key}) + containers = append(containers, DiagramContainer{Key: key, OccurrenceKey: occurrence.Key, ChildKeys: append([]string{}, childKeys...), Source: source}) + } + return containers +} + +func (m *viewMaterializer) validateDiagramPlacements(occurrences []DiagramOccurrence) { + shape := m.input.Recipe.Shape.Diagram + if shape == nil { + m.addDiag("LDL1701", "invalid_view_source_category_or_shape", "Diagram View lacks a Diagram shape", m.input.Recipe.Address, "") + return + } + visibleRoots := map[string]bool{} + for _, occurrence := range occurrences { + if occurrence.ParentKey == nil && occurrence.Role != DiagramRoleSupport { + visibleRoots[occurrence.EntityAddress] = true + } + } + placed := map[string]bool{} + for _, placement := range shape.Placements { + if placed[placement.EntityAddress] || !visibleRoots[placement.EntityAddress] { + m.addDiag("LDL1702", "view_materialization_conflict", "Diagram placement must target one visible root occurrence", placement.EntityAddress, m.input.Recipe.Address) + continue + } + placed[placement.EntityAddress] = true + } + if shape.Layout != "manual" { + return + } + for _, occurrence := range occurrences { + if visibleRoots[occurrence.EntityAddress] && !placed[occurrence.EntityAddress] { + m.addDiag("LDL1702", "view_materialization_conflict", "manual Diagram layout requires placement for every visible root occurrence", occurrence.EntityAddress, m.input.Recipe.Address) + } + } +} + +func (m *viewMaterializer) diagramProjectionEndpoints(relation graph.Relation, first, second *definition.ProjectionEndpoint) (string, string, bool) { + if first == nil || second == nil { + m.addDiag("LDL1504", "invalid_projection_contract", "effective composed projection endpoints are absent", relation.TypeAddress, m.input.Recipe.Address) + return "", "", false + } + return m.diagramProjectionEndpointPair(relation, *first, *second) +} + +func (m *viewMaterializer) diagramProjectionEndpointPair(relation graph.Relation, first, second definition.ProjectionEndpoint) (string, string, bool) { + endpoint := func(value definition.ProjectionEndpoint) (string, bool) { + switch value { + case definition.ProjectionEndpointFrom: + return relation.FromAddress, true + case definition.ProjectionEndpointTo: + return relation.ToAddress, true + default: + return "", false + } + } + left, leftOK := endpoint(first) + right, rightOK := endpoint(second) + if !leftOK || !rightOK || first == second { + m.addDiag("LDL1504", "invalid_projection_contract", "effective Diagram projection endpoints are invalid", relation.TypeAddress, m.input.Recipe.Address) + return "", "", false + } + return left, right, true +} + +func (m *viewMaterializer) diagramRelationSource(relation graph.Relation) ViewDataSourceRefs { + values := []ViewDataSourceRefs{m.relationSource(relation)} + for _, row := range relation.Rows { + values = append(values, m.rowSource(relation.Address, false, row)) + } + if entity, ok := m.entities[relation.FromAddress]; ok { + values = append(values, m.diagramEntitySource(entity)) + } + if entity, ok := m.entities[relation.ToAddress]; ok { + values = append(values, m.diagramEntitySource(entity)) + } + return mergeViewDataSourceRefs(values...) +} + +func (m *viewMaterializer) diagramEntitySource(entity graph.Entity) ViewDataSourceRefs { + values := []ViewDataSourceRefs{m.entitySource(entity)} + for _, row := range entity.Rows { + values = append(values, m.rowSource(entity.Address, true, row)) + } + return mergeViewDataSourceRefs(values...) +} + +func newDiagramSupportAccumulator(m *viewMaterializer) *diagramSupportAccumulator { + return &diagramSupportAccumulator{materializer: m, items: []DiagramSupportItem{}, orders: []int{}, index: map[diagramSupportIdentity]int{}} +} + +func (a *diagramSupportAccumulator) add(order int, kind DiagramSupportKind, entityAddress, relationAddress *string, source ViewDataSourceRefs) { + identity := diagramSupportIdentity{kind: kind} + if entityAddress != nil { + identity.entity = *entityAddress + } + if relationAddress != nil { + identity.relation = *relationAddress + } + if index, ok := a.index[identity]; ok { + a.items[index].Source = mergeViewDataSourceRefs(a.items[index].Source, source) + if order < a.orders[index] { + a.orders[index] = order + } + return + } + tuple := []any{a.materializer.input.Recipe.Address, string(kind)} + if entityAddress != nil { + tuple = append(tuple, identity.entity) + } + if relationAddress != nil { + tuple = append(tuple, identity.relation) + } + key := viewItemKey(a.materializer, "diagram-support", tuple) + item := DiagramSupportItem{Key: key, SupportKind: kind, Source: canonicalViewDataSourceRefs(source)} + if entityAddress != nil { + value := *entityAddress + item.EntityAddress = &value + } + if relationAddress != nil { + value := *relationAddress + item.RelationAddress = &value + } + a.index[identity] = len(a.items) + a.items = append(a.items, item) + a.orders = append(a.orders, order) +} + +func (a *diagramSupportAccumulator) sortedItems() []DiagramSupportItem { + indices := make([]int, len(a.items)) + for index := range indices { + indices[index] = index + } + sort.SliceStable(indices, func(i, j int) bool { + return a.orders[indices[i]] < a.orders[indices[j]] + }) + items := make([]DiagramSupportItem, len(indices)) + for index, sourceIndex := range indices { + items[index] = a.items[sourceIndex] } + return items } diff --git a/internal/engine/view_materialization_diagram_test.go b/internal/engine/view_materialization_diagram_test.go new file mode 100644 index 00000000..e1718aa5 --- /dev/null +++ b/internal/engine/view_materialization_diagram_test.go @@ -0,0 +1,606 @@ +// SPDX-License-Identifier: LicenseRef-LayerDraw-1.0 + +package engine + +import ( + "context" + "reflect" + "strings" + "testing" + + "github.com/dencyuinc/layerdraw/internal/engine/internal/compiler/semantic/definition" + "github.com/dencyuinc/layerdraw/internal/engine/internal/compiler/semantic/graph" +) + +func TestMaterializeComposedDiagramProjectsEveryModeWithCompleteSources(t *testing.T) { + t.Parallel() + snapshot, queryResult := compileAndExecuteDiagramFixture(t, composedDiagramSource()) + first := materializeQueryView(t, snapshot, queryResult, nil) + second := materializeQueryView(t, snapshot, queryResult, nil) + if !reflect.DeepEqual(first, second) { + t.Fatal("composed Diagram materialization is not deterministic") + } + diagram := first.Diagram + if diagram == nil || len(diagram.Occurrences) != 5 || len(diagram.Edges) != 2 || len(diagram.Containers) != 1 || len(diagram.Overlays) != 1 || len(diagram.Badges) != 1 || len(diagram.SupportItems) != 1 { + t.Fatalf("diagram = %+v", diagram) + } + + root := diagramOccurrenceByEntity(t, *diagram, "ldl:project:p:entity:root") + child := diagramOccurrenceByEntity(t, *diagram, "ldl:project:p:entity:child") + if root.Role != DiagramRoleContainer || child.ParentKey == nil || *child.ParentKey != root.Key || child.ViaRelationAddress == nil || *child.ViaRelationAddress != "ldl:project:p:relation:root_child" { + t.Fatalf("root/child occurrences = root:%+v child:%+v", root, child) + } + assertCompleteDiagramItemSource(t, root.Source, false) + assertCompleteDiagramItemSource(t, child.Source, true) + + container := diagram.Containers[0] + if container.OccurrenceKey != root.Key || !reflect.DeepEqual(container.ChildKeys, []string{child.Key}) { + t.Fatalf("container = %+v", container) + } + assertCompleteDiagramItemSource(t, container.Source, true) + + nestEdge := diagramEdgeByRelation(t, *diagram, "ldl:project:p:relation:root_child") + if nestEdge.FromOccurrenceKey != root.Key || nestEdge.ToOccurrenceKey != child.Key { + t.Fatalf("nest keep-edge projection = %+v", nestEdge) + } + if diagram.Edges[0].RelationAddress != nestEdge.RelationAddress { + t.Fatalf("candidate priority order = %+v", diagram.Edges) + } + peer := diagramOccurrenceByEntity(t, *diagram, "ldl:project:p:entity:peer") + reversed := diagramEdgeByRelation(t, *diagram, "ldl:project:p:relation:child_peer") + if reversed.FromOccurrenceKey != peer.Key || reversed.ToOccurrenceKey != child.Key { + t.Fatalf("Diagram endpoint override = %+v", reversed) + } + assertCompleteDiagramItemSource(t, reversed.Source, true) + + overlay := diagram.Overlays[0] + if overlay.TargetOccurrenceKey != child.Key || overlay.OverlayEntityAddress != "ldl:project:p:entity:shield" || overlay.RelationAddress != "ldl:project:p:relation:shield_child" { + t.Fatalf("overlay = %+v", overlay) + } + assertCompleteDiagramItemSource(t, overlay.Source, true) + badge := diagram.Badges[0] + if badge.TargetOccurrenceKey != child.Key || badge.RelationAddress != "ldl:project:p:relation:badge_child" || badge.Label == nil || *badge.Label != "Badge" { + t.Fatalf("badge = %+v", badge) + } + assertCompleteDiagramItemSource(t, badge.Source, true) + + support := diagram.SupportItems[0] + if support.SupportKind != DiagramSupportHiddenRelation || support.RelationAddress == nil || *support.RelationAddress != "ldl:project:p:relation:peer_root" { + t.Fatalf("support = %+v", support) + } + assertCompleteDiagramItemSource(t, support.Source, true) + + base, ok := first.Base() + if !ok || base.Shape.Diagram == nil || len(base.Shape.Diagram.Placements) != 1 || base.Shape.Diagram.Placements[0].EntityAddress != root.EntityAddress || base.Shape.Diagram.Placements[0].X != 10 { + t.Fatalf("semantic placement intent = %+v", base.Shape) + } + override := snapshot.TypedAST.Views[0].RelationProjections[0].Projections.Composed + if override.Priority != 20 || !override.KeepEdge || override.ParentEndpoint == nil || *override.ParentEndpoint != definition.ProjectionEndpointFrom || override.ChildEndpoint == nil || *override.ChildEndpoint != definition.ProjectionEndpointTo { + t.Fatalf("effective View override did not retain RelationType endpoints: %+v", override) + } +} + +func TestMaterializeComposedDiagramResolvesParentConflictsByClosedPolicy(t *testing.T) { + t.Parallel() + cases := []struct { + name string + conflict string + wantParent string + wantEdges int + wantSupport int + wantWarning bool + }{ + {name: "diagnostic", conflict: "diagnostic", wantSupport: 2, wantWarning: true}, + {name: "prefer first", conflict: "prefer_first", wantParent: "ldl:project:p:entity:p1", wantSupport: 1}, + {name: "keep edge", conflict: "keep_edge", wantEdges: 2}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + snapshot, queryResult := compileAndExecuteDiagramFixture(t, conflictingDiagramSource(tc.conflict)) + result := materializeQueryView(t, snapshot, queryResult, nil) + diagram := result.Diagram + if diagram == nil || len(diagram.Edges) != tc.wantEdges || len(diagram.SupportItems) != tc.wantSupport { + t.Fatalf("diagram = %+v", diagram) + } + child := diagramOccurrenceByEntity(t, *diagram, "ldl:project:p:entity:child") + if tc.wantParent == "" { + if child.ParentKey != nil { + t.Fatalf("unexpected parent = %+v", child) + } + } else { + parent := diagramOccurrenceByEntity(t, *diagram, tc.wantParent) + if child.ParentKey == nil || *child.ParentKey != parent.Key { + t.Fatalf("selected parent = %+v want %s", child, parent.Key) + } + } + base, _ := result.Base() + warnings := 0 + for _, value := range base.Diagnostics { + if value.Code == "LDL1704" && value.Severity == "warning" && value.MessageKey == "composed_parent_ambiguity_retained" { + warnings++ + } + } + if (warnings == 1) != tc.wantWarning { + t.Fatalf("diagnostics = %+v", base.Diagnostics) + } + }) + } +} + +func TestMaterializeComposedDiagramFailsClosedForInvalidDerivedStructure(t *testing.T) { + t.Parallel() + + t.Run("nesting cycle", func(t *testing.T) { + snapshot, queryResult := compileAndExecuteDiagramFixture(t, cyclicDiagramSource()) + response := New(BuildInfo{}).MaterializeView(context.Background(), queryViewInput(snapshot, queryResult, nil)) + assertRejectedDiagram(t, response, "LDL1702") + }) + + snapshot, queryResult := compileAndExecuteDiagramFixture(t, conflictingDiagramSource("prefer_first")) + t.Run("duplicate occurrence input", func(t *testing.T) { + duplicate := deepClone(queryResult) + duplicate.PrimaryEntityAddresses = append(duplicate.PrimaryEntityAddresses, duplicate.PrimaryEntityAddresses[len(duplicate.PrimaryEntityAddresses)-1]) + response := New(BuildInfo{}).MaterializeView(context.Background(), queryViewInput(snapshot, duplicate, nil)) + assertRejectedDiagram(t, response, "LDL1801") + }) + + t.Run("missing endpoint closure", func(t *testing.T) { + open := deepClone(queryResult) + open.PrimaryEntityAddresses = []string{"ldl:project:p:entity:child", "ldl:project:p:entity:p1"} + response := New(BuildInfo{}).MaterializeView(context.Background(), queryViewInput(snapshot, open, nil)) + assertRejectedDiagram(t, response, "LDL1801") + }) + + t.Run("invalid effective projection", func(t *testing.T) { + invalid := deepClone(snapshot) + value := definition.ProjectionEndpointFrom + invalid.TypedAST.RelationTypes[0].Projections.Composed.ChildEndpoint = &value + response := New(BuildInfo{}).MaterializeView(context.Background(), queryViewInput(invalid, queryResult, nil)) + assertRejectedDiagram(t, response, "LDL1504") + }) + + t.Run("placement targets nested occurrence", func(t *testing.T) { + placedSource := strings.Replace(composedDiagramSource(), "place root 10 20 300 200", "place child 10 20 300 200", 1) + placedSnapshot, placedResult := compileAndExecuteDiagramFixture(t, placedSource) + response := New(BuildInfo{}).MaterializeView(context.Background(), queryViewInput(placedSnapshot, placedResult, nil)) + assertRejectedDiagram(t, response, "LDL1702") + }) +} + +func TestDiagramBadgeLabelUsesClosedSemanticValues(t *testing.T) { + t.Parallel() + m := &viewMaterializer{ + input: ViewMaterializationInput{Recipe: CompiledViewRecipe{Address: "ldl:project:p:view:v"}}, + entities: map[string]graph.Entity{"badge": {Address: "badge", DisplayName: "Badge", TypeAddress: "node"}}, + entityTypes: map[string]definition.EntityType{"node": {Address: "node", DisplayName: "Node"}}, + } + candidate := diagramProjectionCandidate{relation: graph.Relation{TypeAddress: "marks"}} + typeLabel := "Node" + displayNameLabel := "Badge" + countLabel := "1" + cases := []struct { + label definition.RenderBadgeLabel + want *string + }{ + {label: definition.RenderBadgeLabelType, want: &typeLabel}, + {label: definition.RenderBadgeLabelDisplayName, want: &displayNameLabel}, + {label: definition.RenderBadgeLabelCount, want: &countLabel}, + {label: definition.RenderBadgeLabelNone}, + } + for _, tc := range cases { + candidate.render.Badge.Label = tc.label + if got := m.diagramBadgeLabel(candidate, "badge"); !reflect.DeepEqual(got, tc.want) { + t.Fatalf("label %s = %v want %v", tc.label, got, tc.want) + } + } +} + +func TestDiagramProjectionEndpointPairPreservesSelfRelations(t *testing.T) { + t.Parallel() + m := &viewMaterializer{input: ViewMaterializationInput{Recipe: CompiledViewRecipe{Address: "ldl:project:p:view:v"}}} + relation := graph.Relation{Address: "self", TypeAddress: "links", FromAddress: "node", ToAddress: "node"} + from, to, ok := m.diagramProjectionEndpointPair(relation, definition.ProjectionEndpointFrom, definition.ProjectionEndpointTo) + if !ok || from != "node" || to != "node" || len(m.diagnostics) != 0 { + t.Fatalf("self Relation endpoints = (%q, %q, %t), diagnostics = %+v", from, to, ok, m.diagnostics) + } +} + +func TestDiagramSupportAccumulatorMergesDuplicateSourcesDeterministically(t *testing.T) { + t.Parallel() + m := &viewMaterializer{input: ViewMaterializationInput{Recipe: CompiledViewRecipe{Address: "ldl:project:p:view:v"}}} + accumulator := newDiagramSupportAccumulator(m) + entityAddress := "ldl:project:p:entity:hidden" + relationAddress := "ldl:project:p:relation:hidden" + accumulator.add(10, DiagramSupportHiddenEntity, &entityAddress, nil, ViewDataSourceRefs{EntityAddresses: []string{entityAddress}}) + accumulator.add(0, DiagramSupportHiddenRelation, nil, &relationAddress, ViewDataSourceRefs{RelationAddresses: []string{relationAddress}}) + accumulator.add(-1, DiagramSupportHiddenEntity, &entityAddress, nil, ViewDataSourceRefs{RowAddresses: []string{"ldl:project:p:entity:hidden:row:primary"}}) + + items := accumulator.sortedItems() + if len(items) != 2 || items[0].EntityAddress == nil || *items[0].EntityAddress != entityAddress { + t.Fatalf("support items = %+v", items) + } + if len(items[0].Source.EntityAddresses) != 1 || len(items[0].Source.RowAddresses) != 1 { + t.Fatalf("merged support source = %+v", items[0].Source) + } +} + +func compileAndExecuteDiagramFixture(t *testing.T, source string) (Snapshot, QueryResult) { + t.Helper() + snapshot := compileViewFixture(t, source) + response, err := New(BuildInfo{}).ExecuteQuery(context.Background(), QueryExecutionInput{ + Recipe: snapshot.TypedAST.Queries[0], Graph: *snapshot.TypedAST.Graph, + }) + if err != nil { + t.Fatal(err) + } + if response.Status != "ok" || response.Result == nil { + t.Fatalf("ExecuteQuery() = %+v", response) + } + return snapshot, *response.Result +} + +func assertRejectedDiagram(t *testing.T, response ViewMaterializationResponse, code string) { + t.Helper() + if response.Status != "rejected" || response.Result != nil || len(response.Diagnostics) == 0 { + t.Fatalf("response = %+v", response) + } + found := false + for _, value := range response.Diagnostics { + if value.Code == code { + found = true + } + } + if !found { + t.Fatalf("diagnostics = %+v want code %s", response.Diagnostics, code) + } +} + +func assertCompleteDiagramItemSource(t *testing.T, source ViewDataSourceRefs, relation bool) { + t.Helper() + if len(source.EntityAddresses) == 0 || len(source.RowAddresses) == 0 || len(source.CellRefs) == 0 { + t.Fatalf("incomplete Entity/row/cell source = %+v", source) + } + if relation && len(source.RelationAddresses) == 0 { + t.Fatalf("incomplete Relation source = %+v", source) + } +} + +func diagramOccurrenceByEntity(t *testing.T, diagram DiagramViewData, address string) DiagramOccurrence { + t.Helper() + for _, value := range diagram.Occurrences { + if value.EntityAddress == address { + return value + } + } + t.Fatalf("occurrence %s is absent", address) + return DiagramOccurrence{} +} + +func diagramEdgeByRelation(t *testing.T, diagram DiagramViewData, address string) DiagramEdge { + t.Helper() + for _, value := range diagram.Edges { + if value.RelationAddress == address { + return value + } + } + t.Fatalf("edge %s is absent", address) + return DiagramEdge{} +} + +func composedDiagramSource() string { + return ` +project p "Project" {} +layers { + infra "Infrastructure" @10 + app "Application" @20 + security "Security" @30 +} +entity_type group "Group" { + representation container + columns { + note "Note" string + } +} +entity_type node "Node" { + representation shape rect + columns { + note "Note" string + } +} +relation_type contains "Contains" containment { + duplicate_policy allow + from parent types [group] layers [infra] + to child types [node] layers [app] + label "contains" + columns { + note "Note" string + } + projection composed { + mode nest + parent_endpoint from + child_endpoint to + priority 1 + conflict diagnostic + keep_edge false + } +} +relation_type protects "Protects" security { + duplicate_policy allow + from control types [node] layers [security] + to target types [node] layers [app] + label "protects" + columns { + note "Note" string + } + projection composed { + mode overlay + overlay_endpoint from + target_endpoint to + } +} +relation_type marks "Marks" reference { + duplicate_policy allow + from badge types [node] layers [security] + to target types [node] layers [app] + label "marks" + columns { + note "Note" string + } + projection composed { + mode badge + badge_endpoint from + target_endpoint to + } + render badge { + label display_name + } +} +relation_type links "Links" dependency { + duplicate_policy allow + from source types [node] layers [app] + to target types [node] layers [app] + label "links" + columns { + note "Note" string + } + projection composed { + mode edge + } + projection diagram { + mode edge + source_endpoint to + target_endpoint from + } +} +relation_type conceals "Conceals" governance { + duplicate_policy allow + from source types [node] layers [app] + to target types [group] layers [infra] + label "conceals" + columns { + note "Note" string + } + projection composed { + mode hide + } +} +entities group @infra { + root "Root" +} +entities node @app { + child "Child" + peer "Peer" +} +entities node @security { + badge "Badge" + shield "Shield" +} +rows group [note] { + root primary: "root row" +} +rows node [note] { + child primary: "child row" + peer primary: "peer row" + badge primary: "badge row" + shield primary: "shield row" +} +relations contains { + root_child: root -> child +} +relations protects { + shield_child: shield -> child +} +relations marks { + badge_child: badge -> child +} +relations links { + child_peer: child -> peer +} +relations conceals { + peer_root: peer -> root +} +relation_rows contains [note] { + root_child primary: "contains row" +} +relation_rows protects [note] { + shield_child primary: "protects row" +} +relation_rows marks [note] { + badge_child primary: "marks row" +} +relation_rows links [note] { + child_peer primary: "links row" +} +relation_rows conceals [note] { + peer_root primary: "conceals row" +} +query scope "Scope" { + select { + layers [infra, app, security] + entity_types [group, node] + relation_types [contains, protects, marks, links, conceals] + roots [root, child, peer, badge, shield] + } + result [seed_entities, induced_relations] +} +view composed "Composed" topology { + source query scope {} + relation_projection contains { + composed { + priority 20 + keep_edge true + } + } + diagram { + layout layered + direction left_to_right + abstraction normal + composed + place root 10 20 300 200 + } +} +` +} + +func conflictingDiagramSource(conflict string) string { + source := ` +project p "Project" {} +layers { + app "Application" @10 +} +entity_type group "Group" { + representation container + columns { + note "Note" string + } +} +entity_type node "Node" { + representation shape rect + columns { + note "Note" string + } +} +relation_type contains "Contains" containment { + duplicate_policy allow + from parent types [group] layers [app] + to child types [node] layers [app] + label "contains" + columns { + note "Note" string + } + projection composed { + mode nest + parent_endpoint from + child_endpoint to + conflict CONFLICT + keep_edge false + } +} +entities group @app { + p1 "Parent 1" + p2 "Parent 2" +} +entities node @app { + child "Child" +} +rows group [note] { + p1 primary: "p1" + p2 primary: "p2" +} +rows node [note] { + child primary: "child" +} +relations contains { + p1_child: p1 -> child + p2_child: p2 -> child +} +relation_rows contains [note] { + p1_child primary: "one" + p2_child primary: "two" +} +query scope "Scope" { + select { + roots [p1, p2, child] + entity_types [group, node] + relation_types [contains] + } + result [seed_entities, induced_relations] +} +view composed "Composed" topology { + source query scope {} + diagram { + layout layered + direction left_to_right + abstraction normal + composed + } +} +` + return strings.Replace(source, "CONFLICT", conflict, 1) +} + +func cyclicDiagramSource() string { + return ` +project p "Project" {} +layers { + app "Application" @10 +} +entity_type group "Group" { + representation container + columns { + note "Note" string + } +} +relation_type contains "Contains" containment { + duplicate_policy allow + from parent types [group] layers [app] + to child types [group] layers [app] + label "contains" + columns { + note "Note" string + } + projection composed { + mode nest + parent_endpoint from + child_endpoint to + conflict prefer_first + keep_edge false + } +} +entities group @app { + a "A" + b "B" +} +rows group [note] { + a primary: "a" + b primary: "b" +} +relations contains { + a_b: a -> b + b_a: b -> a +} +relation_rows contains [note] { + a_b primary: "one" + b_a primary: "two" +} +query scope "Scope" { + select { + roots [a, b] + entity_types [group] + relation_types [contains] + } + result [seed_entities, induced_relations] +} +view composed "Composed" topology { + source query scope {} + diagram { + layout layered + direction left_to_right + abstraction normal + composed + } +} +` +} diff --git a/internal/engine/view_materialization_test.go b/internal/engine/view_materialization_test.go index 7d127bc8..63b67145 100644 --- a/internal/engine/view_materialization_test.go +++ b/internal/engine/view_materialization_test.go @@ -259,6 +259,7 @@ view topology "Topology" topology { direction left_to_right abstraction normal place alpha 10 20 200 100 + place beta 250 20 200 100 } } `