From 6db84846929cc4c901350522fa1d3e8a848d5182 Mon Sep 17 00:00:00 2001 From: Dencyuman Date: Sat, 18 Jul 2026 07:02:25 +0900 Subject: [PATCH] feat(engine): materialize table and matrix ViewData --- internal/engine/query_state.go | 20 +- internal/engine/view_materialization.go | 17 +- .../engine/view_materialization_matrix.go | 397 ++++++++++ internal/engine/view_materialization_state.go | 73 ++ internal/engine/view_materialization_table.go | 556 ++++++++++---- .../view_materialization_table_aggregate.go | 313 ++++++++ .../view_materialization_table_matrix_test.go | 702 ++++++++++++++++++ internal/engine/view_materialization_test.go | 6 +- 8 files changed, 1913 insertions(+), 171 deletions(-) create mode 100644 internal/engine/view_materialization_matrix.go create mode 100644 internal/engine/view_materialization_state.go create mode 100644 internal/engine/view_materialization_table_aggregate.go create mode 100644 internal/engine/view_materialization_table_matrix_test.go diff --git a/internal/engine/query_state.go b/internal/engine/query_state.go index 89891cd..fb42054 100644 --- a/internal/engine/query_state.go +++ b/internal/engine/query_state.go @@ -13,10 +13,19 @@ import ( "github.com/dencyuinc/layerdraw/internal/engine/internal/compiler/semantic/definition" ) +// validatedStateQuerySnapshot is the immutable projection needed by direct +// View state reads after the Query evaluator has validated the wire snapshot. +type validatedStateQuerySnapshot struct { + input QueryStateInputRef + subjects map[string]validatedStateSubject + inaccessible map[query.StateFieldPath]bool + currentHashes map[string]string +} + // validateStateQuerySnapshotForDefinition reuses the Query evaluator's closed // snapshot validation for direct View state reads. It performs no Query -// traversal and returns the canonical StateInputRef only after full validation. -func validateStateQuerySnapshotForDefinition(ctx context.Context, identity QueryDefinitionIdentity, graphValue TypedMasterGraph, snapshot StateQuerySnapshot) (QueryStateInputRef, []Diagnostic, error) { +// traversal and exposes only validated, normalized state records. +func validateStateQuerySnapshotForDefinition(ctx context.Context, identity QueryDefinitionIdentity, graphValue TypedMasterGraph, snapshot StateQuerySnapshot) (validatedStateQuerySnapshot, []Diagnostic, error) { limits := DefaultQueryExecutionLimits() validator := &queryExecutor{ ctx: ctx, limits: limits, graph: graphValue, definition: identity, @@ -25,9 +34,12 @@ func validateStateQuerySnapshotForDefinition(ctx context.Context, identity Query currentStateHashes: map[string]string{}, staleStateSubjects: map[string]bool{}, } if !validator.validateStateSnapshot(snapshot) { - return QueryStateInputRef{}, sortedDiagnostics(validator.diagnostics), validator.err + return validatedStateQuerySnapshot{}, sortedDiagnostics(validator.diagnostics), validator.err } - return validator.stateInput, nil, validator.err + return validatedStateQuerySnapshot{ + input: validator.stateInput, subjects: validator.stateSubjects, + inaccessible: validator.stateInaccessible, currentHashes: validator.currentStateHashes, + }, nil, validator.err } const ( diff --git a/internal/engine/view_materialization.go b/internal/engine/view_materialization.go index d9bc008..3eadda5 100644 --- a/internal/engine/view_materialization.go +++ b/internal/engine/view_materialization.go @@ -40,9 +40,11 @@ func (e Engine) MaterializeView(ctx context.Context, input ViewMaterializationIn result.Diagram = m.diagram(base) case view.ShapeTable: result.Table = m.table(base) + case view.ShapeMatrix: + result.Matrix = m.matrix(base) case view.ShapeContext: result.Context = m.contextView(base) - case view.ShapeMatrix, view.ShapeTree, view.ShapeFlow, view.ShapeDiff: + case view.ShapeTree, view.ShapeFlow, view.ShapeDiff: m.addDiag("LDL1701", "unsupported_view_shape_or_export", "View shape materialization is not implemented", input.Recipe.Address, "") return rejectedView(m.diagnostics...) default: @@ -83,6 +85,10 @@ type viewMaterializer struct { incoming map[string][]string diagnostics []Diagnostic directStateReads map[StateReadRef]bool + validatedState *validatedStateQuerySnapshot + staleState map[string]bool + deniedStateReads map[StateReadRef]bool + missingStateWarn bool } func newViewMaterializer(ctx context.Context, input ViewMaterializationInput) *viewMaterializer { @@ -90,7 +96,7 @@ func newViewMaterializer(ctx context.Context, input ViewMaterializationInput) *v ctx: ctx, input: input, entities: map[string]graph.Entity{}, relations: map[string]graph.Relation{}, entityTypes: map[string]definition.EntityType{}, relationTypes: map[string]definition.RelationType{}, layers: map[string]definition.Layer{}, outgoing: map[string][]string{}, incoming: map[string][]string{}, - directStateReads: map[StateReadRef]bool{}, + directStateReads: map[StateReadRef]bool{}, staleState: map[string]bool{}, deniedStateReads: map[StateReadRef]bool{}, } } @@ -178,7 +184,7 @@ func (m *viewMaterializer) validateQueryState(input QueryViewMaterializationInpu m.stateInput = QueryStateInputRef{Kind: "none"} return } - ref, diagnostics, err := validateStateQuerySnapshotForDefinition(m.ctx, input.Snapshot.QueryDefinitionIdentity(), *input.Snapshot.TypedAST.Graph, *input.StateSnapshot) + validated, diagnostics, err := validateStateQuerySnapshotForDefinition(m.ctx, input.Snapshot.QueryDefinitionIdentity(), *input.Snapshot.TypedAST.Graph, *input.StateSnapshot) if err != nil { m.addDiag("LDL1801", "stale_revision_or_semantic_hash", err.Error(), m.input.Recipe.Address, "") return @@ -187,11 +193,12 @@ func (m *viewMaterializer) validateQueryState(input QueryViewMaterializationInpu m.diagnostics = append(m.diagnostics, diagnostics...) return } - if queryPolicy != query.StateNone && input.QueryResult.StateInput != ref { + if queryPolicy != query.StateNone && input.QueryResult.StateInput != validated.input { m.addDiag("LDL1801", "stale_revision_or_semantic_hash", "QueryResult and View do not reference the same StateQuerySnapshot", m.input.Recipe.Address, "") return } - m.stateInput = ref + m.stateInput = validated.input + m.validatedState = &validated } func (m *viewMaterializer) validateDiffInput(input DiffViewMaterializationInput) { diff --git a/internal/engine/view_materialization_matrix.go b/internal/engine/view_materialization_matrix.go new file mode 100644 index 0000000..ed1f921 --- /dev/null +++ b/internal/engine/view_materialization_matrix.go @@ -0,0 +1,397 @@ +// SPDX-License-Identifier: LicenseRef-LayerDraw-1.0 + +package engine + +import ( + "sort" + + "github.com/dencyuinc/layerdraw/internal/engine/internal/compiler/semantic/definition" + "github.com/dencyuinc/layerdraw/internal/engine/internal/compiler/semantic/graph" + "github.com/dencyuinc/layerdraw/internal/engine/internal/compiler/view" +) + +func (m *viewMaterializer) matrix(base ViewDataBase) *MatrixViewData { + shape := m.input.Recipe.Shape.Matrix + if shape == nil { + m.addDiag("LDL1701", "invalid_view_source_category_or_shape", "Matrix View shape is missing", m.input.Recipe.Address, "") + return &MatrixViewData{ViewDataBase: base, RowAxis: []MatrixAxisItem{}, ColumnAxis: []MatrixAxisItem{}, Cells: []MatrixCell{}} + } + allowedTypes := m.matrixRelationTypes(shape.Cell.RelationTypeAddresses) + rowAxis := m.matrixAxis("row", shape.RowAxis) + columnAxis := m.matrixAxis("column", shape.ColumnAxis) + paths := []QueryPath{} + if shape.Cell.Semantic == view.MatrixPathRefs { + paths = m.matrixPaths(allowedTypes) + } + cells := make([]MatrixCell, 0, len(rowAxis)*len(columnAxis)) + for _, row := range rowAxis { + for _, column := range columnAxis { + cell := MatrixCell{ + Key: viewItemKey(m, "matrix-cell", []any{m.input.Recipe.Address, row.EntityAddress, column.EntityAddress}), + RowKey: row.Key, ColumnKey: column.Key, SemanticRefs: []MatrixSemanticRef{}, Source: emptyViewDataSourceRefs(), + } + switch shape.Cell.Semantic { + case view.MatrixRelationRefs: + cell.SemanticRefs, cell.Source = m.matrixRelationRefs(row.EntityAddress, column.EntityAddress, shape.Cell.Direction, allowedTypes) + case view.MatrixPathRefs: + cell.SemanticRefs, cell.Source = m.matrixPathRefs(row.EntityAddress, column.EntityAddress, shape.Cell.Direction, paths) + default: + m.addDiag("LDL1701", "invalid_view_source_category_or_shape", "Matrix semantic mode is invalid", m.input.Recipe.Address, "") + } + cell.DisplayValue, cell.Source = m.matrixDisplayValue(shape, cell.SemanticRefs, cell.Source, allowedTypes) + cells = append(cells, cell) + } + } + return &MatrixViewData{ViewDataBase: base, RowAxis: rowAxis, ColumnAxis: columnAxis, Cells: cells} +} + +func (m *viewMaterializer) matrixAxis(kind string, axis view.MatrixAxis) []MatrixAxisItem { + allowed := map[string]bool{} + if axis.EntityTypeAddresses != nil { + allowed = viewStringSet(*axis.EntityTypeAddresses) + for address := range allowed { + if _, ok := m.entityTypes[address]; !ok { + m.addDiag("LDL1701", "invalid_view_source_category_or_shape", "Matrix axis references an unknown EntityType", address, m.input.Recipe.Address) + } + } + } + items := []MatrixAxisItem{} + seen := map[string]bool{} + for _, address := range m.primaryEntityAddresses() { + entity, ok := m.entities[address] + if !ok { + m.addDiag("LDL1702", "view_materialization_conflict", "Matrix axis Entity is absent from the immutable graph", address, m.input.Recipe.Address) + continue + } + if axis.EntityTypeAddresses != nil && !allowed[entity.TypeAddress] { + continue + } + if seen[address] { + m.addDiag("LDL1702", "view_materialization_conflict", "Matrix axis contains duplicate Entity membership", address, m.input.Recipe.Address) + continue + } + seen[address] = true + label, valid := matrixAxisLabel(entity, axis.LabelField) + if !valid { + m.addDiag("LDL1701", "invalid_view_source_category_or_shape", "Matrix axis label field is invalid", address, m.input.Recipe.Address) + continue + } + items = append(items, MatrixAxisItem{ + Key: viewItemKey(m, "matrix-axis", []any{m.input.Recipe.Address, kind, entity.Address}), + EntityAddress: entity.Address, Label: label, Source: m.entitySource(entity), + }) + } + return items +} + +func matrixAxisLabel(entity graph.Entity, field view.AxisLabelField) (string, bool) { + switch field { + case view.AxisLabelID: + return entity.ID, true + case view.AxisLabelDisplayName: + return entity.DisplayName, true + case view.AxisLabelType: + return entity.TypeAddress, true + case view.AxisLabelLayer: + return entity.LayerAddress, true + default: + return "", false + } +} + +func (m *viewMaterializer) matrixRelationTypes(configured *[]string) map[string]bool { + allowed := map[string]bool{} + if configured != nil { + for _, address := range *configured { + allowed[address] = true + } + } else { + for _, address := range m.relationAddresses() { + if relation, ok := m.relations[address]; ok { + allowed[relation.TypeAddress] = true + } + } + } + for address := range allowed { + m.effectiveMatrixProjection(address) + } + return allowed +} + +func (m *viewMaterializer) matrixRelationRefs(rowAddress, columnAddress string, direction definition.TraversalDirection, allowedTypes map[string]bool) ([]MatrixSemanticRef, ViewDataSourceRefs) { + relations := []graph.Relation{} + for _, address := range m.relationAddresses() { + relation, ok := m.relations[address] + if !ok || !allowedTypes[relation.TypeAddress] { + continue + } + projection, valid := m.effectiveMatrixProjection(relation.TypeAddress) + if !valid { + continue + } + projectedRow, projectedColumn := matrixProjectedEndpoints(relation, projection) + outgoing := projectedRow == rowAddress && projectedColumn == columnAddress + incoming := projectedRow == columnAddress && projectedColumn == rowAddress + if direction == definition.TraversalOutgoing && outgoing || direction == definition.TraversalIncoming && incoming || direction == definition.TraversalBoth && (outgoing || incoming) { + relations = append(relations, relation) + } + } + if direction != definition.TraversalOutgoing && direction != definition.TraversalIncoming && direction != definition.TraversalBoth { + m.addDiag("LDL1701", "invalid_view_source_category_or_shape", "Matrix relation direction is invalid", m.input.Recipe.Address, "") + } + sort.Slice(relations, func(i, j int) bool { return compareRelationTuple(relations[i], relations[j]) < 0 }) + refs := make([]MatrixSemanticRef, 0, len(relations)) + source := emptyViewDataSourceRefs() + for _, relation := range relations { + address := relation.Address + refs = append(refs, MatrixSemanticRef{RelationAddress: &address}) + source = mergeViewDataSourceRefs(source, m.matrixRelationSource(relation)) + } + return refs, source +} + +func (m *viewMaterializer) matrixPaths(allowedTypes map[string]bool) []QueryPath { + selectedRelations := viewStringSet(m.relationAddresses()) + paths := []QueryPath{} + for _, path := range m.queryResult.Paths { + if len(path.EntityAddresses) == 0 || len(path.RelationAddresses)+1 != len(path.EntityAddresses) { + m.addDiag("LDL1702", "view_materialization_conflict", "Matrix path has an invalid Entity/Relation sequence", m.input.Recipe.Address, "") + continue + } + valid := true + for index, entityAddress := range path.EntityAddresses { + if _, ok := m.entities[entityAddress]; !ok { + m.addDiag("LDL1702", "view_materialization_conflict", "Matrix path references an unknown Entity", entityAddress, m.input.Recipe.Address) + valid = false + } + if index >= len(path.RelationAddresses) { + continue + } + relationAddress := path.RelationAddresses[index] + relation, ok := m.relations[relationAddress] + if !ok { + m.addDiag("LDL1702", "view_materialization_conflict", "Matrix path references an unknown Relation", relationAddress, m.input.Recipe.Address) + valid = false + continue + } + if !selectedRelations[relationAddress] { + m.addDiag("LDL1702", "view_materialization_conflict", "Matrix path references a Relation outside the selected source set", relationAddress, m.input.Recipe.Address) + valid = false + continue + } + if !allowedTypes[relation.TypeAddress] { + valid = false + continue + } + next := path.EntityAddresses[index+1] + if !(relation.FromAddress == entityAddress && relation.ToAddress == next || relation.ToAddress == entityAddress && relation.FromAddress == next) { + m.addDiag("LDL1702", "view_materialization_conflict", "Matrix path Relation does not connect adjacent Entities", relationAddress, m.input.Recipe.Address) + valid = false + } + } + if valid { + paths = append(paths, deepClone(path)) + } + } + sort.Slice(paths, func(i, j int) bool { return compareMatrixPaths(paths[i], paths[j]) < 0 }) + for index := 1; index < len(paths); index++ { + if compareMatrixPaths(paths[index-1], paths[index]) == 0 { + m.addDiag("LDL1702", "view_materialization_conflict", "Matrix path collection contains a duplicate path", m.input.Recipe.Address, "") + } + } + return paths +} + +func (m *viewMaterializer) matrixPathRefs(rowAddress, columnAddress string, direction definition.TraversalDirection, paths []QueryPath) ([]MatrixSemanticRef, ViewDataSourceRefs) { + refs := []MatrixSemanticRef{} + source := emptyViewDataSourceRefs() + for _, path := range paths { + first := path.EntityAddresses[0] + last := path.EntityAddresses[len(path.EntityAddresses)-1] + outgoing := first == rowAddress && last == columnAddress + incoming := first == columnAddress && last == rowAddress + if !(direction == definition.TraversalOutgoing && outgoing || direction == definition.TraversalIncoming && incoming || direction == definition.TraversalBoth && (outgoing || incoming)) { + continue + } + pathCopy := deepClone(path) + refs = append(refs, MatrixSemanticRef{Path: &pathCopy}) + source = mergeViewDataSourceRefs(source, m.matrixPathSource(path)) + } + if direction != definition.TraversalOutgoing && direction != definition.TraversalIncoming && direction != definition.TraversalBoth { + m.addDiag("LDL1701", "invalid_view_source_category_or_shape", "Matrix path direction is invalid", m.input.Recipe.Address, "") + } + return refs, source +} + +func (m *viewMaterializer) matrixDisplayValue(shape *view.MatrixShape, refs []MatrixSemanticRef, source ViewDataSourceRefs, allowedTypes map[string]bool) (MatrixDisplayValue, ViewDataSourceRefs) { + switch shape.Cell.Display { + case view.MatrixExists: + return MatrixDisplayValue{Kind: "boolean", Boolean: len(refs) != 0, StringSet: []string{}, Attributes: []MatrixAttributeItem{}}, source + case view.MatrixCount: + return MatrixDisplayValue{Kind: "integer", Integer: int64(len(refs)), StringSet: []string{}, Attributes: []MatrixAttributeItem{}}, source + case view.MatrixRelationTypes: + addresses := map[string]bool{} + for _, ref := range refs { + if ref.RelationAddress != nil { + if relation, ok := m.relations[*ref.RelationAddress]; ok { + addresses[relation.TypeAddress] = true + } + } + if ref.Path != nil { + for _, relationAddress := range ref.Path.RelationAddresses { + if relation, ok := m.relations[relationAddress]; ok { + addresses[relation.TypeAddress] = true + } + } + } + } + ordered := make([]string, 0, len(addresses)) + for address := range addresses { + ordered = append(ordered, address) + } + sort.Slice(ordered, func(i, j int) bool { return compareStableAddressText(ordered[i], ordered[j]) < 0 }) + labels := make([]string, 0, len(ordered)) + for _, address := range ordered { + relationType, ok := m.relationTypes[address] + if !ok { + m.addDiag("LDL1702", "view_materialization_conflict", "Matrix display references an unknown RelationType", address, m.input.Recipe.Address) + continue + } + labels = append(labels, relationType.DisplayName) + } + return MatrixDisplayValue{Kind: "string_set", StringSet: labels, Attributes: []MatrixAttributeItem{}}, source + case view.MatrixAttributeSummary: + if shape.Cell.Semantic != view.MatrixRelationRefs || shape.Cell.AttributeColumnAddresses == nil { + m.addDiag("LDL1701", "invalid_view_source_category_or_shape", "Matrix attribute summary requires relation refs and attribute Columns", m.input.Recipe.Address, "") + return MatrixDisplayValue{Kind: "attributes", StringSet: []string{}, Attributes: []MatrixAttributeItem{}}, source + } + attributes, attributeSource := m.matrixAttributes(refs, *shape.Cell.AttributeColumnAddresses, allowedTypes) + return MatrixDisplayValue{Kind: "attributes", StringSet: []string{}, Attributes: attributes}, mergeViewDataSourceRefs(source, attributeSource) + default: + m.addDiag("LDL1701", "invalid_view_source_category_or_shape", "Matrix display mode is invalid", m.input.Recipe.Address, "") + return MatrixDisplayValue{Kind: "", StringSet: []string{}, Attributes: []MatrixAttributeItem{}}, source + } +} + +func (m *viewMaterializer) matrixAttributes(refs []MatrixSemanticRef, columnAddresses []string, allowedTypes map[string]bool) ([]MatrixAttributeItem, ViewDataSourceRefs) { + allowedColumns := viewStringSet(columnAddresses) + relations := []graph.Relation{} + for _, ref := range refs { + if ref.RelationAddress == nil { + continue + } + relation, ok := m.relations[*ref.RelationAddress] + if !ok || !allowedTypes[relation.TypeAddress] { + continue + } + projection, valid := m.effectiveMatrixProjection(relation.TypeAddress) + if !valid || !projection.IncludeRelationRows { + m.addDiag("LDL1504", "invalid_projection_contract", "Matrix attribute summary requires Relation row inclusion", relation.TypeAddress, m.input.Recipe.Address) + continue + } + relations = append(relations, relation) + } + sort.Slice(relations, func(i, j int) bool { return compareRelationTuple(relations[i], relations[j]) < 0 }) + items := []MatrixAttributeItem{} + source := emptyViewDataSourceRefs() + for _, relation := range relations { + for _, row := range relation.Rows { + for _, cell := range row.Values { + if !allowedColumns[cell.ColumnAddress] { + continue + } + items = append(items, MatrixAttributeItem{RelationAddress: relation.Address, RowAddress: row.Address, ColumnAddress: cell.ColumnAddress, Value: cell.Value}) + refs := m.rowSource(relation.Address, false, row) + refs.CellRefs = []ViewDataCellRef{{RowAddress: row.Address, ColumnAddress: cell.ColumnAddress}} + source = mergeViewDataSourceRefs(source, canonicalViewDataSourceRefs(refs)) + } + } + } + return items, source +} + +func (m *viewMaterializer) effectiveMatrixProjection(relationTypeAddress string) (definition.MatrixProjection, bool) { + relationType, ok := m.relationTypes[relationTypeAddress] + if !ok { + m.addDiag("LDL1702", "view_materialization_conflict", "Matrix RelationType is absent from the immutable definition", relationTypeAddress, m.input.Recipe.Address) + return definition.MatrixProjection{}, false + } + projection := relationType.Projections.Matrix + 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.MatrixProjection{}, false + } + found = true + projection = override.Projections.Matrix + } + if projection == nil || projection.RowEndpoint == projection.ColumnEndpoint || + (projection.RowEndpoint != definition.ProjectionEndpointFrom && projection.RowEndpoint != definition.ProjectionEndpointTo) || + (projection.ColumnEndpoint != definition.ProjectionEndpointFrom && projection.ColumnEndpoint != definition.ProjectionEndpointTo) { + m.addDiag("LDL1504", "invalid_projection_contract", "effective Matrix projection is invalid", relationTypeAddress, m.input.Recipe.Address) + return definition.MatrixProjection{}, false + } + return *projection, true +} + +func matrixProjectedEndpoints(relation graph.Relation, projection definition.MatrixProjection) (string, string) { + row, column := relation.ToAddress, relation.FromAddress + if projection.RowEndpoint == definition.ProjectionEndpointFrom { + row = relation.FromAddress + } + if projection.ColumnEndpoint == definition.ProjectionEndpointTo { + column = relation.ToAddress + } + return row, column +} + +func (m *viewMaterializer) matrixRelationSource(relation graph.Relation) ViewDataSourceRefs { + source := m.relationSource(relation) + if entity, ok := m.entities[relation.FromAddress]; ok { + source = mergeViewDataSourceRefs(source, m.entitySource(entity)) + } + if entity, ok := m.entities[relation.ToAddress]; ok { + source = mergeViewDataSourceRefs(source, m.entitySource(entity)) + } + return source +} + +func (m *viewMaterializer) matrixPathSource(path QueryPath) ViewDataSourceRefs { + source := emptyViewDataSourceRefs() + for _, address := range path.EntityAddresses { + if entity, ok := m.entities[address]; ok { + source = mergeViewDataSourceRefs(source, m.entitySource(entity)) + } + } + for _, address := range path.RelationAddresses { + if relation, ok := m.relations[address]; ok { + source = mergeViewDataSourceRefs(source, m.relationSource(relation)) + } + } + return source +} + +func compareMatrixPaths(left, right QueryPath) int { + maximum := max(len(left.EntityAddresses), len(right.EntityAddresses)) + for index := 0; index < maximum; index++ { + if index >= len(left.EntityAddresses) { + return -1 + } + if index >= len(right.EntityAddresses) { + return 1 + } + if compared := compareStableAddressText(left.EntityAddresses[index], right.EntityAddresses[index]); compared != 0 { + return compared + } + if index < len(left.RelationAddresses) && index < len(right.RelationAddresses) { + if compared := compareStableAddressText(left.RelationAddresses[index], right.RelationAddresses[index]); compared != 0 { + return compared + } + } + } + return compareInt(int64(len(left.RelationAddresses)), int64(len(right.RelationAddresses))) +} diff --git a/internal/engine/view_materialization_state.go b/internal/engine/view_materialization_state.go new file mode 100644 index 0000000..f9493fd --- /dev/null +++ b/internal/engine/view_materialization_state.go @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: LicenseRef-LayerDraw-1.0 + +package engine + +import ( + "github.com/dencyuinc/layerdraw/internal/engine/internal/compiler/query" + "github.com/dencyuinc/layerdraw/internal/engine/internal/compiler/semantic/definition" +) + +func (m *viewMaterializer) readViewState(subjectAddress string, fieldPath query.StateFieldPath) optionalScalar { + read := StateReadRef{SubjectAddress: subjectAddress, FieldPath: string(fieldPath)} + m.directStateReads[read] = true + if m.stateInput.Kind == "none" { + if !m.missingStateWarn { + m.missingStateWarn = true + m.addWarning("LDL1605", "optional_query_state_missing_or_stale", "optional View state is unavailable; state fields evaluate as missing", m.input.Recipe.Address, "") + } + return optionalScalar{} + } + if m.validatedState == nil { + m.addDiag("LDL1801", "stale_revision_or_semantic_hash", "validated View state is absent", m.input.Recipe.Address, "") + return optionalScalar{} + } + if m.validatedState.inaccessible[fieldPath] { + m.denyViewStateRead(read, "state field is inaccessible") + return optionalScalar{} + } + subject, exists := m.validatedState.subjects[subjectAddress] + if !exists { + return optionalScalar{} + } + if subject.ownSubjectHash != m.validatedState.currentHashes[subjectAddress] { + m.markStaleViewStateSubject(subjectAddress) + return optionalScalar{} + } + if subject.redacted[fieldPath] { + m.denyViewStateRead(read, "state field is redacted") + return optionalScalar{} + } + value, present := subject.fields[fieldPath] + if !present { + return optionalScalar{} + } + return optionalScalar{value: value, present: true} +} + +func (m *viewMaterializer) markStaleViewStateSubject(subjectAddress string) { + if m.staleState[subjectAddress] { + return + } + m.staleState[subjectAddress] = true + if m.input.Recipe.StateRequirement == query.StateRequired { + m.addDiag("LDL1604", "required_query_state_unavailable_or_stale", "required View state record is stale", subjectAddress, m.input.Recipe.Address) + return + } + m.addWarning("LDL1605", "optional_query_state_missing_or_stale", "optional View state record is stale; its fields evaluate as missing", subjectAddress, m.input.Recipe.Address) +} + +func (m *viewMaterializer) denyViewStateRead(read StateReadRef, message string) { + if m.deniedStateReads[read] { + return + } + m.deniedStateReads[read] = true + m.addDiag("LDL1904", "query_state_field_forbidden_or_redacted", message, read.SubjectAddress, m.input.Recipe.Address) +} + +func stateTableCell(value optionalScalar, source ViewDataSourceRefs, read StateReadRef) TableCell { + source.State.Reads = canonicalStateReads(append(source.State.Reads, read)) + if !value.present { + return absentTableCell(source) + } + return presentTableCell(scalarViewValue(definition.Scalar(value.value)), source) +} diff --git a/internal/engine/view_materialization_table.go b/internal/engine/view_materialization_table.go index 395f891..0d86bbc 100644 --- a/internal/engine/view_materialization_table.go +++ b/internal/engine/view_materialization_table.go @@ -3,40 +3,190 @@ package engine import ( + "sort" + + "github.com/dencyuinc/layerdraw/internal/engine/internal/compiler/query" "github.com/dencyuinc/layerdraw/internal/engine/internal/compiler/semantic/definition" "github.com/dencyuinc/layerdraw/internal/engine/internal/compiler/semantic/graph" "github.com/dencyuinc/layerdraw/internal/engine/internal/compiler/view" ) +type tableSourceRow struct { + identity []string + entity *graph.Entity + relation *graph.Relation + row *graph.AttributeRow + projection *definition.TableProjection +} + +type materializedTableRow struct { + value TableRow + identities [][]string + groupKey []any +} + func (m *viewMaterializer) table(base ViewDataBase) *TableViewData { shape := m.input.Recipe.Shape.Table if shape == nil { - m.addDiag("LDL1701", "unsupported_view_shape_or_export", "Table View shape is missing", m.input.Recipe.Address, "") + m.addDiag("LDL1701", "invalid_view_source_category_or_shape", "Table View shape is missing", m.input.Recipe.Address, "") return &TableViewData{ViewDataBase: base, Columns: []TableColumn{}, Rows: []TableRow{}} } - columns := m.tableColumns(shape) - rows := m.tableRows(shape, columns) + sources := m.tableSourceRows(shape) + columns := m.tableColumns(shape, sources) + rows := m.materializeTableRows(shape, columns, sources) return &TableViewData{ViewDataBase: base, Columns: columns, Rows: rows} } -func (m *viewMaterializer) tableColumns(shape *view.TableShape) []TableColumn { +func (m *viewMaterializer) tableSourceRows(shape *view.TableShape) []tableSourceRow { + switch shape.RowSource { + case view.RowsEntity, view.RowsEntityRows: + return m.entityTableSourceRows(shape) + case view.RowsRelation, view.RowsRelationRows: + return m.relationTableSourceRows(shape) + case view.RowsAutomaticRelations: + return m.automaticRelationTableSourceRows() + default: + m.addDiag("LDL1701", "invalid_view_source_category_or_shape", "Table row source is invalid", m.input.Recipe.Address, "") + return []tableSourceRow{} + } +} + +func (m *viewMaterializer) entityTableSourceRows(shape *view.TableShape) []tableSourceRow { + addresses := m.primaryEntityAddresses() + if shape.EntityTypeAddresses != nil { + allowed := viewStringSet(*shape.EntityTypeAddresses) + filtered := make([]string, 0, len(addresses)) + for _, address := range addresses { + entity, ok := m.entities[address] + if !ok { + m.addDiag("LDL1702", "view_materialization_conflict", "Table Entity is absent from the immutable graph", address, m.input.Recipe.Address) + continue + } + if allowed[entity.TypeAddress] { + filtered = append(filtered, address) + } + } + addresses = filtered + } + rows := []tableSourceRow{} + for _, address := range addresses { + entity, ok := m.entities[address] + if !ok { + m.addDiag("LDL1702", "view_materialization_conflict", "Table Entity is absent from the immutable graph", address, m.input.Recipe.Address) + continue + } + entityCopy := entity + if shape.RowSource == view.RowsEntity { + rows = append(rows, tableSourceRow{identity: []string{entity.Address}, entity: &entityCopy}) + continue + } + for index := range entity.Rows { + rowCopy := entity.Rows[index] + rows = append(rows, tableSourceRow{identity: []string{entity.Address, rowCopy.Address}, entity: &entityCopy, row: &rowCopy}) + } + } + return rows +} + +func (m *viewMaterializer) relationTableSourceRows(shape *view.TableShape) []tableSourceRow { + rows := []tableSourceRow{} + for _, address := range m.relationAddresses() { + relation, ok := m.relations[address] + if !ok { + m.addDiag("LDL1702", "view_materialization_conflict", "Table Relation is absent from the immutable graph", address, m.input.Recipe.Address) + continue + } + relationCopy := relation + if shape.RowSource == view.RowsRelation { + rows = append(rows, tableSourceRow{identity: []string{relation.Address}, relation: &relationCopy}) + continue + } + for index := range relation.Rows { + rowCopy := relation.Rows[index] + rows = append(rows, tableSourceRow{identity: []string{relation.Address, rowCopy.Address}, relation: &relationCopy, row: &rowCopy}) + } + } + return rows +} + +func (m *viewMaterializer) automaticRelationTableSourceRows() []tableSourceRow { + rows := []tableSourceRow{} + for _, address := range m.relationAddresses() { + relation, ok := m.relations[address] + if !ok { + m.addDiag("LDL1702", "view_materialization_conflict", "automatic Table Relation is absent from the immutable graph", address, m.input.Recipe.Address) + continue + } + projection, valid := m.effectiveTableProjection(relation.TypeAddress) + if !valid { + continue + } + relationCopy := relation + projectionCopy := projection + appendRelation := func() { + rows = append(rows, tableSourceRow{identity: []string{relation.Address}, relation: &relationCopy, projection: &projectionCopy}) + } + appendRows := func() { + for index := range relation.Rows { + rowCopy := relation.Rows[index] + rows = append(rows, tableSourceRow{identity: []string{relation.Address, rowCopy.Address}, relation: &relationCopy, row: &rowCopy, projection: &projectionCopy}) + } + } + switch projection.RowMode { + case definition.TableRowsRelation: + appendRelation() + case definition.TableRowsRelationRows: + appendRows() + case definition.TableRowsAutomatic: + if len(relation.Rows) == 0 { + appendRelation() + } else { + appendRows() + } + default: + m.addDiag("LDL1504", "invalid_projection_contract", "effective Table row mode is invalid", relation.TypeAddress, m.input.Recipe.Address) + } + } + return rows +} + +func (m *viewMaterializer) tableColumns(shape *view.TableShape, sources []tableSourceRow) []TableColumn { columns := []TableColumn{} - appendFixed := func(id, label string) { + seen := map[string]bool{} + appendFixed := func(id, label, valueType string) { + if seen[id] { + m.addDiag("LDL1701", "invalid_view_source_category_or_shape", "Table contains a duplicate fixed Column", id, m.input.Recipe.Address) + return + } + seen[id] = true columns = append(columns, TableColumn{ Key: viewItemKey(m, "table-column", []any{m.input.Recipe.Address, id}), - ID: id, Label: label, ValueType: string(view.TableValueStableAddress), SourceColumnAddresses: []string{}, + ID: id, Label: label, ValueType: valueType, EnumValues: []string{}, SourceColumnAddresses: []string{}, }) } if shape.IncludeEntityID { - appendFixed("entity_id", "Entity ID") + appendFixed("entity_id", "id", string(definition.ScalarString)) } if shape.IncludeType { - appendFixed("type", "Type") + appendFixed("entity_type", "type", string(view.TableValueStableAddress)) } if shape.IncludeLayer { - appendFixed("layer", "Layer") + appendFixed("entity_layer", "layer", string(view.TableValueStableAddress)) + } + if shape.RowSource == view.RowsAutomaticRelations { + automatic := viewStringSet(shape.AutomaticRelationColumns) + for _, fixed := range []struct{ id, label string }{{"from", "from"}, {"to", "to"}, {"relation_type", "relation_type"}} { + if automatic[fixed.id] { + appendFixed(fixed.id, fixed.label, string(view.TableValueStableAddress)) + } + } } for _, source := range shape.Columns { + if source.ID == "" || source.Address == "" || seen[source.ID] { + m.addDiag("LDL1701", "invalid_view_source_category_or_shape", "Table contains an invalid or duplicate named Column", source.Address, m.input.Recipe.Address) + continue + } + seen[source.ID] = true label := source.ID if source.Label != nil { label = *source.Label @@ -45,7 +195,7 @@ func (m *viewMaterializer) tableColumns(shape *view.TableShape) []TableColumn { column := TableColumn{ Key: viewItemKey(m, "table-column", []any{m.input.Recipe.Address, source.Address}), ID: source.ID, Address: &address, Label: label, ValueType: tableValueTypeName(source.ValueType), - SourceColumnAddresses: []string{}, + EnumValues: []string{}, SourceColumnAddresses: []string{}, } if source.ValueType.ScalarType == definition.ScalarEnum { column.EnumValues = append([]string{}, source.ValueType.EnumValues...) @@ -59,6 +209,45 @@ func (m *viewMaterializer) tableColumns(shape *view.TableShape) []TableColumn { } columns = append(columns, column) } + if shape.RowSource != view.RowsAutomaticRelations { + return columns + } + dynamic := map[string]definition.Column{} + for _, source := range sources { + if source.row == nil { + continue + } + for _, cell := range source.row.Values { + column, ok := m.relationColumn(cell.ColumnAddress) + if !ok { + m.addDiag("LDL1702", "view_materialization_conflict", "automatic Table cell references an unknown RelationType Column", cell.ColumnAddress, m.input.Recipe.Address) + continue + } + dynamic[column.Address] = column + } + } + addresses := make([]string, 0, len(dynamic)) + for address := range dynamic { + addresses = append(addresses, address) + } + sort.Slice(addresses, func(i, j int) bool { return compareStableAddressText(addresses[i], addresses[j]) < 0 }) + for _, address := range addresses { + if seen[address] { + m.addDiag("LDL1701", "invalid_view_source_category_or_shape", "dynamic Table Column identity conflicts with another Column", address, m.input.Recipe.Address) + continue + } + seen[address] = true + definitionColumn := dynamic[address] + column := TableColumn{ + Key: viewItemKey(m, "table-column", []any{m.input.Recipe.Address, address}), + ID: address, Label: definitionColumn.DisplayName, ValueType: string(definitionColumn.ValueType), + EnumValues: []string{}, SourceColumnAddresses: []string{address}, + } + if definitionColumn.ValueType == definition.ScalarEnum { + column.EnumValues = append([]string{}, definitionColumn.EnumValues...) + } + columns = append(columns, column) + } return columns } @@ -75,182 +264,152 @@ func tableValueTypeName(value view.TableValueType) string { } } -func (m *viewMaterializer) tableRows(shape *view.TableShape, columns []TableColumn) []TableRow { - switch shape.RowSource { - case view.RowsEntity: - return m.entityTableRows(shape, columns, false) - case view.RowsEntityRows: - return m.entityTableRows(shape, columns, true) - case view.RowsRelation: - return m.relationTableRows(shape, columns, false) - case view.RowsRelationRows, view.RowsAutomaticRelations: - return m.relationTableRows(shape, columns, true) - default: - m.addDiag("LDL1701", "unsupported_view_shape_or_export", "Table row source is invalid", m.input.Recipe.Address, "") - return []TableRow{} +func (m *viewMaterializer) materializeTableRows(shape *view.TableShape, columns []TableColumn, sources []tableSourceRow) []TableRow { + raw := make([]materializedTableRow, 0, len(sources)) + for _, source := range sources { + value := TableRow{Cells: make(map[string]TableCell, len(columns)), Source: m.tableSourceRowSource(source)} + for _, column := range columns { + cell := m.tableSourceCell(shape, column, source) + value.Cells[column.Key] = cell + value.Source = mergeViewDataSourceRefs(value.Source, cell.Source) + } + raw = append(raw, materializedTableRow{value: value, identities: [][]string{append([]string{}, source.identity...)}}) + } + rows := m.finalizeTableRows(shape, columns, raw) + result := make([]TableRow, len(rows)) + for index := range rows { + result[index] = rows[index].value } + return result } -func (m *viewMaterializer) entityTableRows(shape *view.TableShape, columns []TableColumn, rowGrain bool) []TableRow { - addresses := m.materializationEntityAddresses() - if shape.EntityTypeAddresses != nil { - allowed := viewStringSet(*shape.EntityTypeAddresses) - filtered := make([]string, 0, len(addresses)) - for _, address := range addresses { - if allowed[m.entities[address].TypeAddress] { - filtered = append(filtered, address) - } +func (m *viewMaterializer) tableSourceCell(shape *view.TableShape, column TableColumn, source tableSourceRow) TableCell { + baseSource := m.tableSourceIdentitySource(source) + if source.entity != nil { + switch column.ID { + case "entity_id": + return presentTableCell(scalarViewValue(definition.Scalar{Type: definition.ScalarString, String: source.entity.ID}), baseSource) + case "entity_type": + return presentTableCell(addressViewValue(source.entity.TypeAddress), baseSource) + case "entity_layer": + return presentTableCell(addressViewValue(source.entity.LayerAddress), baseSource) } - addresses = filtered } - rows := []TableRow{} - for _, address := range addresses { - entity := m.entities[address] - if !rowGrain { - rows = append(rows, m.entityTableRow(shape, columns, entity, nil)) - continue + if shape.RowSource == view.RowsAutomaticRelations && source.relation != nil && source.projection != nil { + switch column.ID { + case "from": + if source.projection.IncludeFrom { + return presentTableCell(addressViewValue(source.relation.FromAddress), baseSource) + } + return absentTableCell(baseSource) + case "to": + if source.projection.IncludeTo { + return presentTableCell(addressViewValue(source.relation.ToAddress), baseSource) + } + return absentTableCell(baseSource) + case "relation_type": + if source.projection.IncludeRelationType { + return presentTableCell(addressViewValue(source.relation.TypeAddress), baseSource) + } + return absentTableCell(baseSource) } - for index := range entity.Rows { - rows = append(rows, m.entityTableRow(shape, columns, entity, &entity.Rows[index])) + if len(column.SourceColumnAddresses) == 1 && column.ID == column.SourceColumnAddresses[0] { + return m.dynamicRelationAttributeCell(column.SourceColumnAddresses[0], source) } } - return rows -} - -func (m *viewMaterializer) entityTableRow(shape *view.TableShape, columns []TableColumn, entity graph.Entity, row *graph.AttributeRow) TableRow { - identity := [][]string{{entity.Address}} - baseSource := m.entitySource(entity) - if row != nil { - identity = [][]string{{entity.Address, row.Address}} - baseSource = m.rowSource(entity.Address, true, *row) - } - result := TableRow{ - Key: viewItemKey(m, "table-row", []any{m.input.Recipe.Address, string(shape.RowSource), identity, []any{}}), - Cells: make(map[string]TableCell, len(columns)), Source: baseSource, - } - for _, column := range columns { - cell := m.entityTableCell(shape, column, entity, row) - result.Cells[column.Key] = cell - result.Source = mergeViewDataSourceRefs(result.Source, cell.Source) - } - return result -} - -func (m *viewMaterializer) entityTableCell(shape *view.TableShape, column TableColumn, entity graph.Entity, row *graph.AttributeRow) TableCell { - source := m.entitySource(entity) - switch column.ID { - case "entity_id": - return presentTableCell(addressViewValue(entity.Address), source) - case "type": - return presentTableCell(addressViewValue(entity.TypeAddress), source) - case "layer": - return presentTableCell(addressViewValue(entity.LayerAddress), source) - } definitionColumn := findTableColumn(shape.Columns, column.ID) if definitionColumn == nil { - return absentTableCell(source) + return absentTableCell(baseSource) } - switch definitionColumn.Source.Kind { + return m.namedTableCell(*definitionColumn, source, baseSource) +} + +func (m *viewMaterializer) namedTableCell(column view.TableColumn, source tableSourceRow, baseSource ViewDataSourceRefs) TableCell { + switch column.Source.Kind { case view.ColumnField: - return optionalTableCell(entityFieldValue(entity, definitionColumn.Source.Field), source) + if source.entity != nil { + return optionalTableCell(entityFieldValue(*source.entity, column.Source.Field), baseSource) + } + if source.relation != nil { + return optionalTableCell(relationFieldValue(*source.relation, column.Source.Field), baseSource) + } case view.ColumnAttribute: - return m.attributeTableCell(entity.Address, true, entity.Rows, row, definitionColumn.Source.ColumnAddresses) + if source.row == nil { + m.addDiag("LDL1701", "invalid_view_source_category_or_shape", "attribute Table Column requires row-grain input", column.Address, m.input.Recipe.Address) + return absentTableCell(baseSource) + } + return m.attributeTableCell(source, column.Source.ColumnAddresses) + case view.ColumnRelationEndpoint: + if source.relation == nil { + m.addDiag("LDL1701", "invalid_view_source_category_or_shape", "relation endpoint Table Column requires Relation input", column.Address, m.input.Recipe.Address) + return absentTableCell(baseSource) + } + return m.relationEndpointTableCell(*source.relation, column.Source.Endpoint, column.Source.Field, baseSource) case view.ColumnDerivedCount: - count, contributing := m.derivedCount(entity.Address, definitionColumn.Source.Direction, definitionColumn.Source.RelationTypeAddresses) + if source.entity == nil { + m.addDiag("LDL1701", "invalid_view_source_category_or_shape", "derived count Table Column requires Entity input", column.Address, m.input.Recipe.Address) + return absentTableCell(baseSource) + } + count, contributing := m.derivedCount(source.entity.Address, column.Source.Direction, column.Source.RelationTypeAddresses) for _, relation := range contributing { - source = mergeViewDataSourceRefs(source, m.relationSource(relation)) + baseSource = mergeViewDataSourceRefs(baseSource, m.relationSource(relation)) } - return presentTableCell(scalarViewValue(definition.Scalar{Type: definition.ScalarInteger, Int: int64(count)}), source) + return presentTableCell(scalarViewValue(definition.Scalar{Type: definition.ScalarInteger, Int: int64(count)}), baseSource) case view.ColumnState: - read := StateReadRef{SubjectAddress: entity.Address, FieldPath: string(definitionColumn.Source.StateFieldPath)} - m.directStateReads[read] = true - source.State.Reads = canonicalStateReads(append(source.State.Reads, read)) - return absentTableCell(canonicalViewDataSourceRefs(source)) - default: - return absentTableCell(source) - } -} - -func (m *viewMaterializer) relationTableRows(shape *view.TableShape, columns []TableColumn, rowGrain bool) []TableRow { - rows := []TableRow{} - for _, address := range m.relationAddresses() { - relation := m.relations[address] - if !rowGrain { - rows = append(rows, m.relationTableRow(shape, columns, relation, nil)) - continue + if !tableStateFieldKnown(column.Source.StateFieldPath) { + m.addDiag("LDL1601", "invalid_query_or_arguments", "Table state Column uses an unknown field path", column.Address, m.input.Recipe.Address) + return absentTableCell(baseSource) } - for index := range relation.Rows { - rows = append(rows, m.relationTableRow(shape, columns, relation, &relation.Rows[index])) + subject := source.ownerAddress() + if source.row != nil { + subject = source.row.Address } + read := StateReadRef{SubjectAddress: subject, FieldPath: string(column.Source.StateFieldPath)} + return stateTableCell(m.readViewState(subject, column.Source.StateFieldPath), baseSource, read) + default: + m.addDiag("LDL1701", "invalid_view_source_category_or_shape", "Table Column source kind is invalid", column.Address, m.input.Recipe.Address) } - return rows + return absentTableCell(baseSource) } -func (m *viewMaterializer) relationTableRow(shape *view.TableShape, columns []TableColumn, relation graph.Relation, row *graph.AttributeRow) TableRow { - identity := [][]string{{relation.Address}} - baseSource := m.relationSource(relation) - if row != nil { - identity = [][]string{{relation.Address, row.Address}} - baseSource = m.rowSource(relation.Address, false, *row) - } - result := TableRow{ - Key: viewItemKey(m, "table-row", []any{m.input.Recipe.Address, string(shape.RowSource), identity, []any{}}), - Cells: make(map[string]TableCell, len(columns)), Source: baseSource, +func (m *viewMaterializer) relationEndpointTableCell(relation graph.Relation, endpoint definition.ProjectionEndpoint, field string, source ViewDataSourceRefs) TableCell { + address := relation.ToAddress + if endpoint == definition.ProjectionEndpointFrom { + address = relation.FromAddress + } else if endpoint != definition.ProjectionEndpointTo { + m.addDiag("LDL1701", "invalid_view_source_category_or_shape", "Table relation endpoint is invalid", relation.Address, m.input.Recipe.Address) + return absentTableCell(source) } - for _, column := range columns { - cell := m.relationTableCell(shape, column, relation, row) - result.Cells[column.Key] = cell - result.Source = mergeViewDataSourceRefs(result.Source, cell.Source) + entity, ok := m.entities[address] + if !ok { + m.addDiag("LDL1702", "view_materialization_conflict", "Table relation endpoint Entity is absent", address, m.input.Recipe.Address) + return absentTableCell(source) } - return result + return optionalTableCell(entityFieldValue(entity, field), mergeViewDataSourceRefs(source, m.entitySource(entity))) } -func (m *viewMaterializer) relationTableCell(shape *view.TableShape, column TableColumn, relation graph.Relation, row *graph.AttributeRow) TableCell { - source := m.relationSource(relation) - definitionColumn := findTableColumn(shape.Columns, column.ID) - if definitionColumn == nil { - return absentTableCell(source) +func (m *viewMaterializer) attributeTableCell(source tableSourceRow, columns []string) TableCell { + if source.row == nil { + return absentTableCell(m.tableSourceIdentitySource(source)) } - switch definitionColumn.Source.Kind { - case view.ColumnField: - return optionalTableCell(relationFieldValue(relation, definitionColumn.Source.Field), source) - case view.ColumnRelationEndpoint: - if definitionColumn.Source.Endpoint == definition.ProjectionEndpointFrom { - return presentTableCell(addressViewValue(relation.FromAddress), source) - } - return presentTableCell(addressViewValue(relation.ToAddress), source) - case view.ColumnAttribute: - return m.attributeTableCell(relation.Address, false, relation.Rows, row, definitionColumn.Source.ColumnAddresses) - case view.ColumnState: - read := StateReadRef{SubjectAddress: relation.Address, FieldPath: string(definitionColumn.Source.StateFieldPath)} - m.directStateReads[read] = true - source.State.Reads = canonicalStateReads(append(source.State.Reads, read)) - return absentTableCell(canonicalViewDataSourceRefs(source)) - default: - return absentTableCell(source) + if value, cell, ok := rowCellValue(*source.row, columns); ok { + refs := m.tableSourceIdentitySource(source) + refs.CellRefs = []ViewDataCellRef{cell} + return presentTableCell(value, canonicalViewDataSourceRefs(refs)) } + return absentTableCell(m.tableSourceIdentitySource(source)) } -func (m *viewMaterializer) attributeTableCell(owner string, entity bool, rows []graph.AttributeRow, selected *graph.AttributeRow, columns []string) TableCell { - if selected != nil { - if value, cell, ok := rowCellValue(*selected, columns); ok { - source := m.rowSource(owner, entity, *selected) - source.CellRefs = []ViewDataCellRef{cell} - return presentTableCell(value, canonicalViewDataSourceRefs(source)) - } - return absentTableCell(m.rowSource(owner, entity, *selected)) +func (m *viewMaterializer) dynamicRelationAttributeCell(columnAddress string, source tableSourceRow) TableCell { + if source.row == nil { + return absentTableCell(m.tableSourceIdentitySource(source)) } - for _, row := range rows { - if value, cell, ok := rowCellValue(row, columns); ok { - source := m.rowSource(owner, entity, row) - source.CellRefs = []ViewDataCellRef{cell} - return presentTableCell(value, canonicalViewDataSourceRefs(source)) - } - } - if entity { - return absentTableCell(m.entitySource(m.entities[owner])) + if value, cell, ok := rowCellValue(*source.row, []string{columnAddress}); ok { + refs := m.tableSourceIdentitySource(source) + refs.CellRefs = []ViewDataCellRef{cell} + return presentTableCell(value, canonicalViewDataSourceRefs(refs)) } - return absentTableCell(m.relationSource(m.relations[owner])) + return absentTableCell(m.tableSourceIdentitySource(source)) } func rowCellValue(row graph.AttributeRow, columnAddresses []string) (ViewDataValue, ViewDataCellRef, bool) { @@ -263,17 +422,91 @@ func rowCellValue(row graph.AttributeRow, columnAddresses []string) (ViewDataVal return ViewDataValue{}, ViewDataCellRef{}, false } +func (m *viewMaterializer) tableSourceRowSource(source tableSourceRow) ViewDataSourceRefs { + if source.row == nil { + return m.tableSourceIdentitySource(source) + } + if source.entity != nil { + return m.rowSource(source.entity.Address, true, *source.row) + } + return m.rowSource(source.relation.Address, false, *source.row) +} + +func (m *viewMaterializer) tableSourceIdentitySource(source tableSourceRow) ViewDataSourceRefs { + var refs ViewDataSourceRefs + if source.entity != nil { + refs = m.entitySource(*source.entity) + } else if source.relation != nil { + refs = m.relationSource(*source.relation) + } else { + return emptyViewDataSourceRefs() + } + if source.row != nil { + refs.RowAddresses = []string{source.row.Address} + refs.State.Reads = canonicalStateReads(append(refs.State.Reads, m.stateReadsForSubjects(source.row.Address)...)) + } + return canonicalViewDataSourceRefs(refs) +} + +func (source tableSourceRow) ownerAddress() string { + if source.entity != nil { + return source.entity.Address + } + if source.relation != nil { + return source.relation.Address + } + return "" +} + +func (m *viewMaterializer) effectiveTableProjection(relationTypeAddress string) (definition.TableProjection, bool) { + relationType, ok := m.relationTypes[relationTypeAddress] + if !ok { + m.addDiag("LDL1702", "view_materialization_conflict", "Table RelationType is absent from the immutable definition", relationTypeAddress, m.input.Recipe.Address) + return definition.TableProjection{}, false + } + projection := relationType.Projections.Table + 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.TableProjection{}, false + } + found = true + projection = override.Projections.Table + } + if projection.RowMode != definition.TableRowsRelation && projection.RowMode != definition.TableRowsRelationRows && projection.RowMode != definition.TableRowsAutomatic { + m.addDiag("LDL1504", "invalid_projection_contract", "effective Table projection is invalid", relationTypeAddress, m.input.Recipe.Address) + return definition.TableProjection{}, false + } + return projection, true +} + +func (m *viewMaterializer) relationColumn(address string) (definition.Column, bool) { + for _, relationType := range m.relationTypes { + for _, column := range relationType.Columns { + if column.Address == address { + return column, true + } + } + } + return definition.Column{}, false +} + func (m *viewMaterializer) derivedCount(entityAddress string, direction definition.TraversalDirection, relationTypes *[]string) (int, []graph.Relation) { - allowed := map[string]bool{} + allowedTypes := map[string]bool{} if relationTypes != nil { - allowed = viewStringSet(*relationTypes) + allowedTypes = viewStringSet(*relationTypes) } + selected := viewStringSet(m.relationAddresses()) seen := map[string]bool{} values := []graph.Relation{} visit := func(addresses []string) { for _, relationAddress := range addresses { - relation := m.relations[relationAddress] - if seen[relationAddress] || (len(allowed) != 0 && !allowed[relation.TypeAddress]) { + relation, ok := m.relations[relationAddress] + if !ok || !selected[relationAddress] || seen[relationAddress] || len(allowedTypes) != 0 && !allowedTypes[relation.TypeAddress] { continue } seen[relationAddress] = true @@ -286,7 +519,7 @@ func (m *viewMaterializer) derivedCount(entityAddress string, direction definiti if direction == definition.TraversalIncoming || direction == definition.TraversalBoth { visit(m.incoming[entityAddress]) } - sortRelationsByAddress(values) + sort.Slice(values, func(i, j int) bool { return compareRelationTuple(values[i], values[j]) < 0 }) return len(values), values } @@ -330,3 +563,8 @@ func addressViewValue(value string) ViewDataValue { copied := value return ViewDataValue{Kind: "stable_address", Address: &copied, StringSet: []string{}} } + +func tableStateFieldKnown(path query.StateFieldPath) bool { + _, ok := query.LookupStateFieldSchema(path) + return ok +} diff --git a/internal/engine/view_materialization_table_aggregate.go b/internal/engine/view_materialization_table_aggregate.go new file mode 100644 index 0000000..f9526ef --- /dev/null +++ b/internal/engine/view_materialization_table_aggregate.go @@ -0,0 +1,313 @@ +// SPDX-License-Identifier: LicenseRef-LayerDraw-1.0 + +package engine + +import ( + "sort" + "strings" + + "github.com/dencyuinc/layerdraw/internal/engine/internal/compiler/semantic/definition" + "github.com/dencyuinc/layerdraw/internal/engine/internal/compiler/view" +) + +func (m *viewMaterializer) finalizeTableRows(shape *view.TableShape, columns []TableColumn, raw []materializedTableRow) []materializedTableRow { + hasAggregate := false + for _, column := range shape.Columns { + if column.Aggregate != view.AggregateNone { + hasAggregate = true + break + } + } + var rows []materializedTableRow + if hasAggregate { + rows = m.aggregateTableRows(shape, columns, raw) + } else { + rows = raw + for index := range rows { + rows[index].groupKey = tableGroupKey(columns, rows[index].value.Cells) + rows[index].value.Key = m.tableRowKey(shape.RowSource, rows[index].identities, rows[index].groupKey) + } + } + m.sortTableRows(shape, columns, rows) + return rows +} + +func (m *viewMaterializer) aggregateTableRows(shape *view.TableShape, columns []TableColumn, raw []materializedTableRow) []materializedTableRow { + nonAggregate := tableNonAggregateColumns(shape, columns) + type group struct { + key []any + rows []materializedTableRow + } + groups := []group{} + index := map[string]int{} + for _, row := range raw { + key := tableGroupKey(nonAggregate, row.value.Cells) + lookup, err := newViewDataItemKey("table-group", key) + if err != nil { + m.addDiag("LDL1801", "stale_revision_or_semantic_hash", "cannot derive Table group identity", m.input.Recipe.Address, "") + continue + } + groupIndex, ok := index[lookup] + if !ok { + groupIndex = len(groups) + index[lookup] = groupIndex + groups = append(groups, group{key: key}) + } + groups[groupIndex].rows = append(groups[groupIndex].rows, row) + } + if len(raw) == 0 && len(nonAggregate) == 0 { + groups = append(groups, group{key: []any{}}) + } + result := make([]materializedTableRow, 0, len(groups)) + for _, current := range groups { + row := materializedTableRow{ + value: TableRow{Cells: map[string]TableCell{}, Source: emptyViewDataSourceRefs()}, + identities: [][]string{}, + groupKey: current.key, + } + for _, source := range current.rows { + row.identities = append(row.identities, deepClone(source.identities)...) + row.value.Source = mergeViewDataSourceRefs(row.value.Source, source.value.Source) + } + for _, column := range columns { + aggregate := tableColumnAggregate(shape, column.ID) + cell := m.aggregateTableColumn(column, aggregate, current.rows) + row.value.Cells[column.Key] = cell + row.value.Source = mergeViewDataSourceRefs(row.value.Source, cell.Source) + } + row.value.Key = m.tableRowKey(shape.RowSource, row.identities, row.groupKey) + result = append(result, row) + } + return result +} + +func (m *viewMaterializer) aggregateTableColumn(column TableColumn, aggregate view.Aggregate, rows []materializedTableRow) TableCell { + if aggregate == view.AggregateNone { + var result TableCell + for index, row := range rows { + cell := row.value.Cells[column.Key] + if index == 0 { + result = deepClone(cell) + } else { + result.Source = mergeViewDataSourceRefs(result.Source, cell.Source) + } + } + if len(rows) == 0 { + return absentTableCell(emptyViewDataSourceRefs()) + } + return result + } + present := []TableCell{} + for _, row := range rows { + cell := row.value.Cells[column.Key] + if cell.Present && cell.Value != nil { + present = append(present, cell) + } + } + switch aggregate { + case view.AggregateCount: + source := emptyViewDataSourceRefs() + for _, row := range rows { + source = mergeViewDataSourceRefs(source, row.value.Source) + } + return presentTableCell(scalarViewValue(definition.Scalar{Type: definition.ScalarInteger, Int: int64(len(rows))}), source) + case view.AggregateCountDistinct: + distinct := map[string]bool{} + source := emptyViewDataSourceRefs() + for _, cell := range present { + identity, err := newViewDataItemKey("table-value", *cell.Value) + if err != nil { + m.addDiag("LDL1801", "stale_revision_or_semantic_hash", "cannot derive Table aggregate value identity", m.input.Recipe.Address, "") + continue + } + distinct[identity] = true + source = mergeViewDataSourceRefs(source, cell.Source) + } + return presentTableCell(scalarViewValue(definition.Scalar{Type: definition.ScalarInteger, Int: int64(len(distinct))}), source) + case view.AggregateMin, view.AggregateMax: + if len(present) == 0 { + return absentTableCell(emptyViewDataSourceRefs()) + } + selected := present[0] + for _, candidate := range present[1:] { + compared, ok := compareTableValues(*candidate.Value, *selected.Value, column) + if !ok { + m.addDiag("LDL1701", "invalid_view_source_category_or_shape", "Table min/max values are not comparable", column.ID, m.input.Recipe.Address) + return absentTableCell(emptyViewDataSourceRefs()) + } + if aggregate == view.AggregateMin && compared < 0 || aggregate == view.AggregateMax && compared > 0 { + selected = candidate + } + } + source := emptyViewDataSourceRefs() + for _, candidate := range present { + source = mergeViewDataSourceRefs(source, candidate.Source) + } + return presentTableCell(*selected.Value, source) + case view.AggregateJoinUnique: + if len(present) == 0 { + return absentTableCell(emptyViewDataSourceRefs()) + } + values := map[string]bool{} + source := emptyViewDataSourceRefs() + for _, cell := range present { + if cell.Value.Kind != "scalar" || cell.Value.Scalar == nil || cell.Value.Scalar.Type != definition.ScalarString && cell.Value.Scalar.Type != definition.ScalarEnum { + m.addDiag("LDL1701", "invalid_view_source_category_or_shape", "Table join_unique value is not string-compatible", column.ID, m.input.Recipe.Address) + return absentTableCell(emptyViewDataSourceRefs()) + } + values[cell.Value.Scalar.String] = true + source = mergeViewDataSourceRefs(source, cell.Source) + } + ordered := make([]string, 0, len(values)) + for value := range values { + ordered = append(ordered, value) + } + sort.Strings(ordered) + return presentTableCell(scalarViewValue(definition.Scalar{Type: definition.ScalarString, String: strings.Join(ordered, ", ")}), source) + default: + m.addDiag("LDL1701", "invalid_view_source_category_or_shape", "Table aggregate is invalid", column.ID, m.input.Recipe.Address) + return absentTableCell(emptyViewDataSourceRefs()) + } +} + +func tableNonAggregateColumns(shape *view.TableShape, columns []TableColumn) []TableColumn { + result := []TableColumn{} + for _, column := range columns { + if tableColumnAggregate(shape, column.ID) == view.AggregateNone { + result = append(result, column) + } + } + return result +} + +func tableColumnAggregate(shape *view.TableShape, id string) view.Aggregate { + if column := findTableColumn(shape.Columns, id); column != nil { + return column.Aggregate + } + return view.AggregateNone +} + +func tableGroupKey(columns []TableColumn, cells map[string]TableCell) []any { + result := make([]any, 0, len(columns)) + for _, column := range columns { + cell := cells[column.Key] + if !cell.Present || cell.Value == nil { + result = append(result, []any{false}) + continue + } + result = append(result, []any{true, *cell.Value}) + } + return result +} + +func (m *viewMaterializer) tableRowKey(rowSource view.TableRowSource, identities [][]string, groupKey []any) string { + return viewItemKey(m, "table-row", []any{m.input.Recipe.Address, string(rowSource), identities, groupKey}) +} + +func (m *viewMaterializer) sortTableRows(shape *view.TableShape, columns []TableColumn, rows []materializedTableRow) { + byID := map[string]TableColumn{} + for _, column := range columns { + byID[column.ID] = column + } + for _, authored := range shape.Sorts { + if _, ok := byID[authored.ColumnID]; !ok { + m.addDiag("LDL1701", "invalid_view_source_category_or_shape", "Table sort references an unknown Column", authored.ColumnID, m.input.Recipe.Address) + } + } + sort.SliceStable(rows, func(i, j int) bool { + for _, authored := range shape.Sorts { + column, ok := byID[authored.ColumnID] + if !ok { + continue + } + left, right := rows[i].value.Cells[column.Key], rows[j].value.Cells[column.Key] + compared, comparable := compareTableCells(left, right, column, authored.Absent) + if !comparable || compared == 0 { + continue + } + if authored.Direction == view.SortDescending && left.Present == right.Present { + compared = -compared + } + return compared < 0 + } + return false + }) +} + +func compareTableCells(left, right TableCell, column TableColumn, absent view.AbsentOrder) (int, bool) { + if left.Present != right.Present { + if absent == view.AbsentFirst { + if left.Present { + return 1, true + } + return -1, true + } + if left.Present { + return -1, true + } + return 1, true + } + if !left.Present { + return 0, true + } + if left.Value == nil || right.Value == nil { + return 0, false + } + return compareTableValues(*left.Value, *right.Value, column) +} + +func compareTableValues(left, right ViewDataValue, column TableColumn) (int, bool) { + if left.Kind != right.Kind { + return 0, false + } + switch left.Kind { + case "stable_address": + if left.Address == nil || right.Address == nil { + return 0, false + } + return compareStableAddressText(*left.Address, *right.Address), true + case "string_set": + for index := 0; index < len(left.StringSet) && index < len(right.StringSet); index++ { + if compared := strings.Compare(left.StringSet[index], right.StringSet[index]); compared != 0 { + return compared, true + } + } + return compareInt(int64(len(left.StringSet)), int64(len(right.StringSet))), true + case "scalar": + if left.Scalar == nil || right.Scalar == nil || left.Scalar.Type != right.Scalar.Type { + return 0, false + } + switch left.Scalar.Type { + case definition.ScalarString: + return strings.Compare(left.Scalar.String, right.Scalar.String), true + case definition.ScalarEnum: + return compareEnumValues(left.Scalar.String, right.Scalar.String, column.EnumValues) + case definition.ScalarBoolean: + return compareInt(boolRank(left.Scalar.Bool), boolRank(right.Scalar.Bool)), true + default: + return scalarCompare(*left.Scalar, *right.Scalar) + } + default: + return 0, false + } +} + +func compareEnumValues(left, right string, options []string) (int, bool) { + rank := map[string]int{} + for index, option := range options { + rank[option] = index + } + leftRank, leftOK := rank[left] + rightRank, rightOK := rank[right] + if !leftOK || !rightOK { + return 0, false + } + return compareInt(int64(leftRank), int64(rightRank)), true +} + +func boolRank(value bool) int64 { + if value { + return 1 + } + return 0 +} diff --git a/internal/engine/view_materialization_table_matrix_test.go b/internal/engine/view_materialization_table_matrix_test.go new file mode 100644 index 0000000..8048c07 --- /dev/null +++ b/internal/engine/view_materialization_table_matrix_test.go @@ -0,0 +1,702 @@ +// SPDX-License-Identifier: LicenseRef-LayerDraw-1.0 + +package engine + +import ( + "context" + "fmt" + "reflect" + "strings" + "testing" + + "github.com/dencyuinc/layerdraw/internal/engine/internal/compiler/semantic/definition" + "github.com/dencyuinc/layerdraw/internal/engine/internal/compiler/view" +) + +func TestMaterializeTableSupportsEntityRelationAndAutomaticGrains(t *testing.T) { + t.Parallel() + + t.Run("entity owner", func(t *testing.T) { + t.Parallel() + snapshot, queryResult := compileAndExecuteViewFixture(t, entityOwnerTableViewSource()) + table := materializeQueryView(t, snapshot, queryResult, nil).Table + if table == nil || len(table.Rows) != 2 || !reflect.DeepEqual(tableColumnIDs(table.Columns), []string{"entity_id", "tags", "related"}) { + t.Fatalf("table = %+v", table) + } + if got := tableCellByID(t, *table, table.Rows[0], "entity_id"); !got.Present || got.Value == nil || got.Value.Scalar == nil || got.Value.Scalar.String != "alpha" { + t.Fatalf("entity id = %+v", got) + } + if got := tableCellByID(t, *table, table.Rows[0], "related"); !got.Present || got.Value == nil || got.Value.Scalar == nil || got.Value.Scalar.Int != 1 || len(got.Source.RelationAddresses) != 1 { + t.Fatalf("derived count = %+v", got) + } + if got := tableCellByID(t, *table, table.Rows[0], "tags"); !got.Present || got.Value == nil || !reflect.DeepEqual(got.Value.StringSet, []string{"critical"}) { + t.Fatalf("tags = %+v", got) + } + }) + + t.Run("relation owner", func(t *testing.T) { + t.Parallel() + snapshot, queryResult := compileAndExecuteViewFixture(t, relationOwnerTableViewSource()) + table := materializeQueryView(t, snapshot, queryResult, nil).Table + if table == nil || len(table.Rows) != 1 || !reflect.DeepEqual(tableColumnIDs(table.Columns), []string{"relation_id", "relation_name", "from", "to"}) { + t.Fatalf("table = %+v", table) + } + if got := tableCellByID(t, *table, table.Rows[0], "from"); !got.Present || got.Value == nil || got.Value.Address == nil || *got.Value.Address != "ldl:project:p:entity:alpha" || !reflect.DeepEqual(got.Source.EntityAddresses, []string{"ldl:project:p:entity:alpha"}) { + t.Fatalf("from endpoint = %+v", got) + } + if got := tableCellByID(t, *table, table.Rows[0], "relation_name"); got.Present || got.Value != nil { + t.Fatalf("missing relation display name = %+v", got) + } + }) + + t.Run("automatic relation row", func(t *testing.T) { + t.Parallel() + snapshot, queryResult := compileAndExecuteViewFixture(t, automaticRelationTableViewSource()) + table := materializeQueryView(t, snapshot, queryResult, nil).Table + protocol := "ldl:project:p:relation-type:calls:column:protocol" + if table == nil || len(table.Rows) != 1 || !reflect.DeepEqual(tableColumnIDs(table.Columns), []string{"from", "relation_type", protocol}) { + t.Fatalf("table = %+v", table) + } + row := table.Rows[0] + if got := tableCellByID(t, *table, row, "from"); !got.Present || got.Value == nil || got.Value.Address == nil || *got.Value.Address != "ldl:project:p:entity:alpha" { + t.Fatalf("from = %+v", got) + } + if got := tableCellByID(t, *table, row, protocol); !got.Present || got.Value == nil || got.Value.Scalar == nil || got.Value.Scalar.String != "http" || !reflect.DeepEqual(got.Source.CellRefs, []ViewDataCellRef{{RowAddress: "ldl:project:p:relation:alpha_beta:row:primary", ColumnAddress: protocol}}) { + t.Fatalf("dynamic protocol = %+v", got) + } + }) + + t.Run("automatic relation owner", func(t *testing.T) { + t.Parallel() + source := strings.Replace(automaticRelationTableViewSource(), "row_mode automatic", "row_mode relation", 1) + snapshot, queryResult := compileAndExecuteViewFixture(t, source) + table := materializeQueryView(t, snapshot, queryResult, nil).Table + if table == nil || len(table.Rows) != 1 || !reflect.DeepEqual(tableColumnIDs(table.Columns), []string{"from", "relation_type"}) || len(table.Rows[0].Source.RowAddresses) != 0 { + t.Fatalf("relation-grain automatic Table = %+v", table) + } + }) + + t.Run("automatic explicit relation rows", func(t *testing.T) { + t.Parallel() + source := strings.Replace(automaticRelationTableViewSource(), "row_mode automatic", "row_mode relation_rows", 1) + snapshot, queryResult := compileAndExecuteViewFixture(t, source) + table := materializeQueryView(t, snapshot, queryResult, nil).Table + if table == nil || len(table.Rows) != 1 || !reflect.DeepEqual(table.Rows[0].Source.RowAddresses, []string{"ldl:project:p:relation:alpha_beta:row:primary"}) { + t.Fatalf("relation-row automatic Table = %+v", table) + } + }) +} + +func TestMaterializeTableAggregatesSortsAndPreservesContributors(t *testing.T) { + t.Parallel() + snapshot, queryResult := compileAndExecuteViewFixture(t, aggregateTableViewSource()) + first := materializeQueryView(t, snapshot, queryResult, nil) + second := materializeQueryView(t, snapshot, queryResult, nil) + if !reflect.DeepEqual(first, second) { + t.Fatal("aggregate Table materialization is not deterministic") + } + table := first.Table + if table == nil || len(table.Rows) != 2 || !reflect.DeepEqual(tableColumnIDs(table.Columns), []string{"environment", "capacity_max", "entity_count"}) { + t.Fatalf("table = %+v", table) + } + prod := table.Rows[0] + if got := tableCellByID(t, *table, prod, "environment"); !got.Present || got.Value == nil || got.Value.Scalar == nil || got.Value.Scalar.String != "prod" { + t.Fatalf("sorted group = %+v", got) + } + if got := tableCellByID(t, *table, prod, "capacity_max"); !got.Present || got.Value == nil || got.Value.Scalar == nil || got.Value.Scalar.Float != 75 || len(got.Source.CellRefs) != 2 { + t.Fatalf("max = %+v", got) + } + if got := tableCellByID(t, *table, prod, "entity_count"); !got.Present || got.Value == nil || got.Value.Scalar == nil || got.Value.Scalar.Int != 2 || len(got.Source.EntityAddresses) != 2 { + t.Fatalf("count = %+v", got) + } + if got := tableCellByID(t, *table, table.Rows[1], "capacity_max"); got.Value == nil || got.Value.Scalar == nil || got.Value.Scalar.Float != 50 { + t.Fatalf("second group = %+v", got) + } +} + +func TestMaterializeTableCoversEveryAggregateFamily(t *testing.T) { + t.Parallel() + snapshot, queryResult := compileAndExecuteViewFixture(t, aggregateFamilyTableViewSource()) + table := materializeQueryView(t, snapshot, queryResult, nil).Table + if table == nil || len(table.Rows) != 2 { + t.Fatalf("table = %+v", table) + } + prod := table.Rows[0] + assertScalarTableCell(t, tableCellByID(t, *table, prod, "capacity_min"), definition.ScalarNumber, "", 25, 0) + assertScalarTableCell(t, tableCellByID(t, *table, prod, "capacity_max"), definition.ScalarNumber, "", 75, 0) + assertScalarTableCell(t, tableCellByID(t, *table, prod, "entity_count_distinct"), definition.ScalarInteger, "", 0, 2) + assertScalarTableCell(t, tableCellByID(t, *table, prod, "names"), definition.ScalarString, "Alpha, Beta", 0, 0) +} + +func TestMaterializeTableCreatesTheClosedEmptyAggregateGroup(t *testing.T) { + t.Parallel() + snapshot, queryResult := compileAndExecuteViewFixture(t, emptyAggregateTableViewSource()) + queryResult.SeedEntityAddresses = []string{} + queryResult.ReachedEntityAddresses = []string{} + queryResult.TraversedEntityAddresses = []string{} + queryResult.PrimaryEntityAddresses = []string{} + queryResult.SupportEntityAddresses = []string{} + queryResult.PathRelationAddresses = []string{} + queryResult.InducedRelationAddresses = []string{} + queryResult.SelectedRelationAddresses = []string{} + queryResult.Paths = []QueryPath{} + table := materializeQueryView(t, snapshot, queryResult, nil).Table + if table == nil || len(table.Rows) != 1 { + t.Fatalf("empty aggregate Table = %+v", table) + } + assertScalarTableCell(t, tableCellByID(t, *table, table.Rows[0], "entity_count"), definition.ScalarInteger, "", 0, 0) + assertScalarTableCell(t, tableCellByID(t, *table, table.Rows[0], "distinct_count"), definition.ScalarInteger, "", 0, 0) + if names := tableCellByID(t, *table, table.Rows[0], "names"); names.Present || names.Value != nil { + t.Fatalf("empty join_unique = %+v", names) + } + if maximum := tableCellByID(t, *table, table.Rows[0], "capacity_max"); maximum.Present || maximum.Value != nil { + t.Fatalf("empty max = %+v", maximum) + } +} + +func TestTableCellComparisonCoversClosedTypedOrdering(t *testing.T) { + t.Parallel() + addressA, addressB := "ldl:project:p:entity:alpha", "ldl:project:p:entity:beta" + cases := []struct { + name string + left ViewDataValue + right ViewDataValue + column TableColumn + }{ + {name: "stable address", left: addressViewValue(addressA), right: addressViewValue(addressB)}, + {name: "string set", left: ViewDataValue{Kind: "string_set", StringSet: []string{"a"}}, right: ViewDataValue{Kind: "string_set", StringSet: []string{"a", "b"}}}, + {name: "string", left: scalarViewValue(definition.Scalar{Type: definition.ScalarString, String: "a"}), right: scalarViewValue(definition.Scalar{Type: definition.ScalarString, String: "b"})}, + {name: "enum", left: scalarViewValue(definition.Scalar{Type: definition.ScalarEnum, String: "prod"}), right: scalarViewValue(definition.Scalar{Type: definition.ScalarEnum, String: "stg"}), column: TableColumn{EnumValues: []string{"prod", "stg"}}}, + {name: "boolean", left: scalarViewValue(definition.Scalar{Type: definition.ScalarBoolean}), right: scalarViewValue(definition.Scalar{Type: definition.ScalarBoolean, Bool: true})}, + {name: "number", left: scalarViewValue(definition.Scalar{Type: definition.ScalarNumber, Float: 1}), right: scalarViewValue(definition.Scalar{Type: definition.ScalarNumber, Float: 2})}, + } + for _, test := range cases { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + compared, ok := compareTableValues(test.left, test.right, test.column) + if !ok || compared >= 0 { + t.Fatalf("comparison = %d, %v", compared, ok) + } + }) + } + absent, present := absentTableCell(emptyViewDataSourceRefs()), presentTableCell(scalarViewValue(definition.Scalar{Type: definition.ScalarInteger, Int: 1}), emptyViewDataSourceRefs()) + if compared, ok := compareTableCells(absent, present, TableColumn{}, view.AbsentFirst); !ok || compared >= 0 { + t.Fatalf("absent-first comparison = %d, %v", compared, ok) + } + if compared, ok := compareTableCells(absent, present, TableColumn{}, view.AbsentLast); !ok || compared <= 0 { + t.Fatalf("absent-last comparison = %d, %v", compared, ok) + } + if compared, ok := compareTableCells(absent, absent, TableColumn{}, view.AbsentLast); !ok || compared != 0 { + t.Fatalf("absent tie = %d, %v", compared, ok) + } + if _, ok := compareTableValues(addressViewValue(addressA), scalarViewValue(definition.Scalar{Type: definition.ScalarString, String: "a"}), TableColumn{}); ok { + t.Fatal("incompatible Table value kinds were comparable") + } + if _, ok := compareEnumValues("unknown", "prod", []string{"prod"}); ok { + t.Fatal("unknown enum option was comparable") + } +} + +func TestMaterializeTableReadsImmutableStateWithMissingStaleAndRedactedSemantics(t *testing.T) { + t.Parallel() + snapshot, queryResult := compileAndExecuteViewFixture(t, stateTableViewSource()) + alphaRow := "ldl:project:p:entity:alpha:row:primary" + state := validStateQuerySnapshot(t, snapshot, []StateQuerySubject{ + stateSubject(alphaRow, stateFields("system.updated_at", datetimeScalar("2026-01-04T00:00:00Z"))), + }) + + t.Run("present and absent", func(t *testing.T) { + result := materializeQueryView(t, snapshot, queryResult, &state) + table := result.Table + if table == nil || len(table.Rows) != 2 { + t.Fatalf("table = %+v", table) + } + present := tableCellByID(t, *table, table.Rows[0], "updated_at") + wantRead := StateReadRef{SubjectAddress: alphaRow, FieldPath: "system.updated_at"} + if !present.Present || present.Value == nil || present.Value.Scalar == nil || present.Value.Scalar.String != "2026-01-04T00:00:00Z" || !reflect.DeepEqual(present.Source.State.Reads, []StateReadRef{wantRead}) { + t.Fatalf("present state = %+v", present) + } + if absent := tableCellByID(t, *table, table.Rows[1], "updated_at"); absent.Present || absent.Value != nil { + t.Fatalf("absent state = %+v", absent) + } + base, _ := result.Base() + if base.StateInput.Kind != "snapshot" || base.StateInput.SnapshotHash == "" { + t.Fatalf("state input = %+v", base.StateInput) + } + }) + + t.Run("optional stale", func(t *testing.T) { + stale := cloneStateSnapshot(state) + stale.Subjects[0].OwnSubjectHash = semanticHash('f') + result := materializeQueryView(t, snapshot, queryResult, &stale) + base, _ := result.Base() + if !hasDiagnosticCode(base.Diagnostics, "LDL1605") || tableCellByID(t, *result.Table, result.Table.Rows[0], "updated_at").Present { + t.Fatalf("stale state result = %+v", result) + } + }) + + t.Run("redacted", func(t *testing.T) { + redacted := cloneStateSnapshot(state) + redacted.Subjects[0].Fields = map[string]TypedScalar{} + redacted.Subjects[0].RedactedFieldPaths = []string{"system.updated_at"} + response := New(BuildInfo{}).MaterializeView(context.Background(), queryViewInput(snapshot, queryResult, &redacted)) + if response.Status != "rejected" || response.Result != nil || !hasDiagnosticCode(response.Diagnostics, "LDL1904") { + t.Fatalf("redacted state = %+v", response) + } + }) + + t.Run("inaccessible", func(t *testing.T) { + inaccessible := cloneStateSnapshot(state) + inaccessible.InaccessibleFieldPaths = []string{"system.updated_at"} + inaccessible.Subjects = []StateQuerySubject{} + response := New(BuildInfo{}).MaterializeView(context.Background(), queryViewInput(snapshot, queryResult, &inaccessible)) + if response.Status != "rejected" || !hasDiagnosticCode(response.Diagnostics, "LDL1904") { + t.Fatalf("inaccessible state = %+v", response) + } + }) + + t.Run("required stale", func(t *testing.T) { + requiredSnapshot, requiredResult := compileAndExecuteViewFixture(t, strings.Replace(stateTableViewSource(), "state_input optional", "state_input required", 1)) + requiredState := validStateQuerySnapshot(t, requiredSnapshot, []StateQuerySubject{ + stateSubject(alphaRow, stateFields("system.updated_at", datetimeScalar("2026-01-04T00:00:00Z"))), + }) + requiredState.Subjects[0].OwnSubjectHash = semanticHash('f') + response := New(BuildInfo{}).MaterializeView(context.Background(), queryViewInput(requiredSnapshot, requiredResult, &requiredState)) + if response.Status != "rejected" || !hasDiagnosticCode(response.Diagnostics, "LDL1604") { + t.Fatalf("required stale state = %+v", response) + } + }) +} + +func TestMaterializeMatrixResolvesDirectionsProjectionAndCompleteSources(t *testing.T) { + t.Parallel() + + t.Run("outgoing attribute summary", func(t *testing.T) { + t.Parallel() + snapshot, queryResult := compileAndExecuteViewFixture(t, matrixViewSource("outgoing", "relation_refs", "attribute_summary", false)) + result := materializeQueryView(t, snapshot, queryResult, nil) + if repeated := materializeQueryView(t, snapshot, queryResult, nil); !reflect.DeepEqual(result, repeated) { + t.Fatal("Matrix materialization is not deterministic") + } + matrix := result.Matrix + if matrix == nil || len(matrix.RowAxis) != 2 || len(matrix.ColumnAxis) != 2 || len(matrix.Cells) != 4 { + t.Fatalf("matrix = %+v", matrix) + } + if got := matrixAxisAddresses(matrix.RowAxis); !reflect.DeepEqual(got, []string{"ldl:project:p:entity:alpha", "ldl:project:p:entity:beta"}) || matrix.RowAxis[0].Label != "alpha" || matrix.ColumnAxis[0].Label != "Alpha" { + t.Fatalf("axes = %+v / %+v", matrix.RowAxis, matrix.ColumnAxis) + } + cell := matrixCellByEntities(t, *matrix, "ldl:project:p:entity:alpha", "ldl:project:p:entity:beta") + protocol := "ldl:project:p:relation-type:calls:column:protocol" + if len(cell.SemanticRefs) != 1 || cell.SemanticRefs[0].RelationAddress == nil || *cell.SemanticRefs[0].RelationAddress != "ldl:project:p:relation:alpha_beta" || len(cell.DisplayValue.Attributes) != 1 { + t.Fatalf("semantic cell = %+v", cell) + } + attribute := cell.DisplayValue.Attributes[0] + if attribute.RowAddress != "ldl:project:p:relation:alpha_beta:row:primary" || attribute.ColumnAddress != protocol || attribute.Value.Type != definition.ScalarEnum || attribute.Value.String != "http" { + t.Fatalf("attribute = %+v", attribute) + } + if !reflect.DeepEqual(cell.Source.EntityAddresses, []string{"ldl:project:p:entity:alpha", "ldl:project:p:entity:beta"}) || !reflect.DeepEqual(cell.Source.RelationAddresses, []string{"ldl:project:p:relation:alpha_beta"}) || !reflect.DeepEqual(cell.Source.CellRefs, []ViewDataCellRef{{RowAddress: attribute.RowAddress, ColumnAddress: protocol}}) { + t.Fatalf("cell source = %+v", cell.Source) + } + empty := matrixCellByEntities(t, *matrix, "ldl:project:p:entity:beta", "ldl:project:p:entity:alpha") + if len(empty.SemanticRefs) != 0 || empty.DisplayValue.Kind != "attributes" || len(empty.Source.SubjectAddresses) != 0 { + t.Fatalf("empty cell = %+v", empty) + } + }) + + t.Run("support entity exclusion", func(t *testing.T) { + t.Parallel() + snapshot, queryResult := compileAndExecuteViewFixture(t, matrixViewSource("outgoing", "relation_refs", "exists", false)) + queryResult.SupportEntityAddresses = []string{"ldl:project:p:entity:gamma"} + matrix := materializeQueryView(t, snapshot, queryResult, nil).Matrix + if got := matrixAxisAddresses(matrix.RowAxis); !reflect.DeepEqual(got, []string{"ldl:project:p:entity:alpha", "ldl:project:p:entity:beta"}) { + t.Fatalf("support Entity leaked into Matrix axis: %v", got) + } + if present := matrixCellByEntities(t, *matrix, "ldl:project:p:entity:alpha", "ldl:project:p:entity:beta"); present.DisplayValue.Kind != "boolean" || !present.DisplayValue.Boolean { + t.Fatalf("exists cell = %+v", present) + } + }) + + t.Run("relation type labels", func(t *testing.T) { + t.Parallel() + snapshot, queryResult := compileAndExecuteViewFixture(t, matrixViewSource("outgoing", "relation_refs", "relation_types", false)) + matrix := materializeQueryView(t, snapshot, queryResult, nil).Matrix + cell := matrixCellByEntities(t, *matrix, "ldl:project:p:entity:alpha", "ldl:project:p:entity:beta") + if cell.DisplayValue.Kind != "string_set" || !reflect.DeepEqual(cell.DisplayValue.StringSet, []string{"Calls"}) { + t.Fatalf("relation type labels = %+v", cell.DisplayValue) + } + }) + + t.Run("omitted relation selector and alternate axis labels", func(t *testing.T) { + t.Parallel() + source := matrixViewSource("outgoing", "relation_refs", "exists", false) + source = strings.Replace(source, " relation_types [calls]\n", "", 1) + source = strings.Replace(source, " label id", " label type", 1) + source = strings.Replace(source, " label display_name", " label layer", 1) + snapshot, queryResult := compileAndExecuteViewFixture(t, source) + matrix := materializeQueryView(t, snapshot, queryResult, nil).Matrix + if matrix.RowAxis[0].Label != "ldl:project:p:entity-type:service" || matrix.ColumnAxis[0].Label != "ldl:project:p:layer:app" { + t.Fatalf("axis labels = %+v / %+v", matrix.RowAxis, matrix.ColumnAxis) + } + }) + + for _, test := range []struct { + name string + direction string + reverse bool + row string + column string + }{ + {name: "incoming", direction: "incoming", row: "beta", column: "alpha"}, + {name: "reversed projection", direction: "outgoing", reverse: true, row: "beta", column: "alpha"}, + } { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + snapshot, queryResult := compileAndExecuteViewFixture(t, matrixViewSource(test.direction, "relation_refs", "count", test.reverse)) + matrix := materializeQueryView(t, snapshot, queryResult, nil).Matrix + cell := matrixCellByEntities(t, *matrix, "ldl:project:p:entity:"+test.row, "ldl:project:p:entity:"+test.column) + if len(cell.SemanticRefs) != 1 || cell.DisplayValue.Integer != 1 { + t.Fatalf("cell = %+v", cell) + } + }) + } + + t.Run("both", func(t *testing.T) { + t.Parallel() + snapshot, queryResult := compileAndExecuteViewFixture(t, matrixViewSource("both", "relation_refs", "count", false)) + matrix := materializeQueryView(t, snapshot, queryResult, nil).Matrix + for _, pair := range [][2]string{{"alpha", "beta"}, {"beta", "alpha"}} { + cell := matrixCellByEntities(t, *matrix, "ldl:project:p:entity:"+pair[0], "ldl:project:p:entity:"+pair[1]) + if len(cell.SemanticRefs) != 1 || cell.DisplayValue.Integer != 1 { + t.Fatalf("%s -> %s = %+v", pair[0], pair[1], cell) + } + } + }) +} + +func TestMaterializeMatrixUsesOnlyRetainedOrderedPathsAndRejectsMalformedPaths(t *testing.T) { + t.Parallel() + snapshot, queryResult := compileAndExecuteViewFixture(t, matrixViewSource("incoming", "path_refs", "count", false)) + result := materializeQueryView(t, snapshot, queryResult, nil) + cell := matrixCellByEntities(t, *result.Matrix, "ldl:project:p:entity:beta", "ldl:project:p:entity:alpha") + if len(cell.SemanticRefs) != 1 || cell.SemanticRefs[0].Path == nil || cell.DisplayValue.Integer != 1 || !reflect.DeepEqual(cell.Source.RelationAddresses, []string{"ldl:project:p:relation:alpha_beta"}) { + t.Fatalf("path cell = %+v", cell) + } + if got := matrixCellByEntities(t, *result.Matrix, "ldl:project:p:entity:alpha", "ldl:project:p:entity:alpha"); len(got.SemanticRefs) != 1 || got.DisplayValue.Integer != 1 { + t.Fatalf("retained zero-length path = %+v", got) + } + typeSnapshot, typeResult := compileAndExecuteViewFixture(t, matrixViewSource("incoming", "path_refs", "relation_types", false)) + typeCell := matrixCellByEntities(t, *materializeQueryView(t, typeSnapshot, typeResult, nil).Matrix, "ldl:project:p:entity:beta", "ldl:project:p:entity:alpha") + if !reflect.DeepEqual(typeCell.DisplayValue.StringSet, []string{"Calls"}) { + t.Fatalf("path relation types = %+v", typeCell.DisplayValue) + } + + for _, test := range []struct { + name string + mutate func(*QueryResult) + }{ + { + name: "invalid sequence", + mutate: func(result *QueryResult) { + result.Paths = []QueryPath{{EntityAddresses: []string{"ldl:project:p:entity:alpha", "ldl:project:p:entity:beta"}, RelationAddresses: []string{}}} + }, + }, + { + name: "unknown entity", + mutate: func(result *QueryResult) { + result.Paths = []QueryPath{{EntityAddresses: []string{"ldl:project:p:entity:alpha", "ldl:project:p:entity:missing"}, RelationAddresses: []string{"ldl:project:p:relation:alpha_beta"}}} + }, + }, + { + name: "relation outside source set", + mutate: func(result *QueryResult) { + result.Paths = []QueryPath{{EntityAddresses: []string{"ldl:project:p:entity:beta", "ldl:project:p:entity:gamma"}, RelationAddresses: []string{"ldl:project:p:relation:beta_gamma"}}} + }, + }, + { + name: "non-adjacent relation", + mutate: func(result *QueryResult) { + result.Paths = []QueryPath{{EntityAddresses: []string{"ldl:project:p:entity:alpha", "ldl:project:p:entity:gamma"}, RelationAddresses: []string{"ldl:project:p:relation:alpha_beta"}}} + }, + }, + { + name: "unknown relation", + mutate: func(result *QueryResult) { + result.Paths = []QueryPath{{EntityAddresses: []string{"ldl:project:p:entity:alpha", "ldl:project:p:entity:beta"}, RelationAddresses: []string{"ldl:project:p:relation:missing"}}} + }, + }, + { + name: "duplicate path", + mutate: func(result *QueryResult) { + path := QueryPath{EntityAddresses: []string{"ldl:project:p:entity:alpha", "ldl:project:p:entity:beta"}, RelationAddresses: []string{"ldl:project:p:relation:alpha_beta"}} + result.Paths = []QueryPath{path, deepClone(path)} + }, + }, + } { + test := test + t.Run(test.name, func(t *testing.T) { + mutated := deepClone(queryResult) + test.mutate(&mutated) + first := New(BuildInfo{}).MaterializeView(context.Background(), queryViewInput(snapshot, mutated, nil)) + second := New(BuildInfo{}).MaterializeView(context.Background(), queryViewInput(snapshot, mutated, nil)) + if first.Status != "rejected" || first.Result != nil || !hasDiagnosticCode(first.Diagnostics, "LDL1702") || !reflect.DeepEqual(first, second) { + t.Fatalf("malformed path response = %+v", first) + } + }) + } +} + +func entityOwnerTableViewSource() string { + return structuralQuerySource() + ` +view inventory "Inventory" inventory { + source query prod_scope { environment: prod } + table { + rows entity + column entity_id { + source field id + } + column tags { + source field tags + } + column related { + source derived_count both relations [calls] + } + } +} +` +} + +func relationOwnerTableViewSource() string { + return structuralQuerySource() + ` +view dependencies "Dependencies" dependency { + source query prod_scope { environment: prod } + table { + rows relation + column relation_id { + source field id + } + column relation_name { + source field display_name + } + column from { + source relation_endpoint from address + } + column to { + source relation_endpoint to display_name + } + } +} +` +} + +func automaticRelationTableViewSource() string { + return structuralQuerySource() + ` +view dependencies "Dependencies" dependency { + source query prod_scope { environment: prod } + relation_projection calls { + table { + row_mode automatic + include_from true + include_to false + include_relation_type true + } + } + table { + rows automatic_relations + } +} +` +} + +func aggregateTableViewSource() string { + source := strings.Replace(structuralQuerySource(), "beta_gamma primary: grpc", "beta_gamma primary: http", 1) + source = strings.Replace(source, ` where all { + rows any types [service] { + cell environment == $environment + } + } +`, "", 1) + return source + ` +view capacity "Capacity" inventory { + source query prod_scope { environment: prod } + table { + rows entity_rows + column environment { + source attribute environment entity_types [service] + } + column capacity_max { + source attribute capacity entity_types [service] + aggregate max + } + column entity_count { + source field id + aggregate count + } + sort capacity_max descending nulls last + } +} +` +} + +func aggregateFamilyTableViewSource() string { + source := strings.Replace(structuralQuerySource(), "beta_gamma primary: grpc", "beta_gamma primary: http", 1) + source = strings.Replace(source, ` where all { + rows any types [service] { + cell environment == $environment + } + } +`, "", 1) + return source + ` +view capacity "Capacity" inventory { + source query prod_scope { environment: prod } + table { + rows entity_rows + column environment { + source attribute environment entity_types [service] + } + column capacity_min { + source attribute capacity entity_types [service] + aggregate min + } + column capacity_max { + source attribute capacity entity_types [service] + aggregate max + } + column entity_count_distinct { + source field id + aggregate count_distinct + } + column names { + source field display_name + aggregate join_unique + } + sort environment ascending nulls last + } +} +` +} + +func emptyAggregateTableViewSource() string { + return structuralQuerySource() + ` +view counts "Counts" inventory { + source query prod_scope { environment: prod } + table { + rows entity_rows + column entity_count { + source field id + aggregate count + } + column distinct_count { + source field id + aggregate count_distinct + } + column names { + source field display_name + aggregate join_unique + } + column capacity_max { + source attribute capacity entity_types [service] + aggregate max + } + } +} +` +} + +func stateTableViewSource() string { + return structuralQuerySource() + ` +view inventory "Inventory" inventory { + state_input optional + source query prod_scope { environment: prod } + table { + rows entity_rows + column entity_id { + source field id + } + column updated_at { + source state system.updated_at + } + sort updated_at descending nulls last + } +} +` +} + +func matrixViewSource(direction, semantic, display string, reverse bool) string { + rowEndpoint, columnEndpoint := "from", "to" + if reverse { + rowEndpoint, columnEndpoint = "to", "from" + } + attributes := "" + if display == "attribute_summary" { + attributes = "\n attributes [protocol]" + } + return structuralQuerySource() + fmt.Sprintf(` +view dependencies "Dependencies" dependency { + source query prod_scope { environment: prod } + relation_projection calls { + matrix { + row_endpoint %s + column_endpoint %s + include_relation_rows true + } + } + matrix { + row_axis { + entity_types [service] + label id + } + column_axis { + entity_types [service] + label display_name + } + cell { + relation_types [calls] + direction %s + semantic %s + display %s%s + } + } +} +`, rowEndpoint, columnEndpoint, direction, semantic, display, attributes) +} + +func matrixCellByEntities(t *testing.T, matrix MatrixViewData, rowAddress, columnAddress string) MatrixCell { + t.Helper() + rowKey, columnKey := "", "" + for _, item := range matrix.RowAxis { + if item.EntityAddress == rowAddress { + rowKey = item.Key + break + } + } + for _, item := range matrix.ColumnAxis { + if item.EntityAddress == columnAddress { + columnKey = item.Key + break + } + } + for _, cell := range matrix.Cells { + if cell.RowKey == rowKey && cell.ColumnKey == columnKey { + return cell + } + } + t.Fatalf("matrix cell %s -> %s not found", rowAddress, columnAddress) + return MatrixCell{} +} + +func matrixAxisAddresses(values []MatrixAxisItem) []string { + addresses := make([]string, len(values)) + for index, value := range values { + addresses[index] = value.EntityAddress + } + return addresses +} + +func assertScalarTableCell(t *testing.T, cell TableCell, valueType definition.ScalarType, text string, number float64, integer int64) { + t.Helper() + if !cell.Present || cell.Value == nil || cell.Value.Scalar == nil || cell.Value.Scalar.Type != valueType || cell.Value.Scalar.String != text || cell.Value.Scalar.Float != number || cell.Value.Scalar.Int != integer { + t.Fatalf("cell = %+v", cell) + } +} diff --git a/internal/engine/view_materialization_test.go b/internal/engine/view_materialization_test.go index 63b6714..b8ca5cc 100644 --- a/internal/engine/view_materialization_test.go +++ b/internal/engine/view_materialization_test.go @@ -50,7 +50,7 @@ func TestMaterializeViewBuildsTypedTableRowsAndCompleteCellSources(t *testing.T) if result.Table == nil || len(result.Table.Rows) != 2 { t.Fatalf("table = %+v", result.Table) } - if got := tableColumnIDs(result.Table.Columns); !reflect.DeepEqual(got, []string{"entity_id", "type", "layer", "environment", "outgoing"}) { + if got := tableColumnIDs(result.Table.Columns); !reflect.DeepEqual(got, []string{"entity_id", "entity_type", "entity_layer", "environment", "outgoing"}) { t.Fatalf("columns = %v", got) } first := result.Table.Rows[0] @@ -76,7 +76,7 @@ func TestMaterializeViewTracksOptionalDirectStateReadsWithoutInventingValues(t * snapshot, queryResult := compileAndExecuteViewFixture(t, relationTableViewSource()) result := materializeQueryView(t, snapshot, queryResult, nil) base, _ := result.Base() - want := StateReadRef{SubjectAddress: "ldl:project:p:relation:alpha_beta", FieldPath: "system.updated_at"} + want := StateReadRef{SubjectAddress: "ldl:project:p:relation:alpha_beta:row:primary", FieldPath: "system.updated_at"} if base.StatePolicy != "optional" || base.StateInput.Kind != "none" || !reflect.DeepEqual(base.Source.State.Reads, []StateReadRef{want}) { t.Fatalf("state base = %+v", base) } @@ -158,7 +158,7 @@ func TestMaterializeViewFailsClosedForSourceRevisionAndStateMismatches(t *testin requiredSnapshot, requiredResult := compileAndExecuteViewFixture(t, strings.Replace(relationTableViewSource(), "state_input optional", "state_input required", 1)) state := validStateQuerySnapshot(t, requiredSnapshot, []StateQuerySubject{stateSubject( - "ldl:project:p:relation:alpha_beta", stateFields("system.updated_at", datetimeScalar("2026-01-04T00:00:00Z")), + "ldl:project:p:relation:alpha_beta:row:primary", stateFields("system.updated_at", datetimeScalar("2026-01-04T00:00:00Z")), )}) ok := materializeQueryView(t, requiredSnapshot, requiredResult, &state) base, _ := ok.Base()