diff --git a/internal/explorer/authoringv2/command_columns.go b/internal/explorer/authoringv2/command_columns.go new file mode 100644 index 000000000..a17f469cb --- /dev/null +++ b/internal/explorer/authoringv2/command_columns.go @@ -0,0 +1,294 @@ +package authoringv2 + +import ( + "fmt" + "strings" +) + +func applyAddColumnCommand(workspace *Workspace, catalog CatalogSnapshot, commandID string, index int, command Command) (CommandResult, error) { + result := CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID} + document := documentIndex(workspace, command.OutputID) + candidate, ok := catalogCandidate(catalog, command.CandidateID) + if document < 0 || !ok { + return result, fmt.Errorf("output or candidate was not found") + } + occurrence := findRoute(&workspace.Documents[document].Route, command.OccurrenceID) + node, nodeOK := catalogNode(catalog, candidate.NodeID) + if occurrence == nil || !nodeOK || occurrence.ResourceType != node.ResourceType { + return result, fmt.Errorf("candidate %q does not belong to occurrence %q", candidate.ID, command.OccurrenceID) + } + mode := strings.ToUpper(strings.TrimSpace(command.ProjectionMode)) + if mode == "" { + mode = candidate.DefaultProjectionMode + } + if !contains(candidate.ProjectionModes, mode) { + return result, fmt.Errorf("projection mode %q is not advertised", mode) + } + presentation := strings.ToUpper(strings.TrimSpace(command.InitialPresentation)) + if presentation == "" { + presentation = InitialPresentationTable + } + if presentation == InitialPresentationFilter && !candidate.Filterable { + return result, fmt.Errorf("candidate %q does not support filters", candidate.ID) + } + if presentation == InitialPresentationChart && !candidate.Chartable { + return result, fmt.Errorf("candidate %q does not support charts", candidate.ID) + } + for i := range workspace.Documents[document].Columns { + column := &workspace.Documents[document].Columns[i] + if column.OccurrenceID == command.OccurrenceID && column.Source.Kind == SourceField && column.Source.Field != nil && strings.TrimPrefix(column.Source.Field.Path, "root.") == strings.TrimPrefix(candidate.FieldPath, "root.") && strings.EqualFold(column.Source.Field.ProjectionMode, mode) { + applyInitialPresentation(column, presentation, nextTableOrder(workspace.Documents[document])) + return CommandResult{Type: CommandResultColumnAdded, OutputID: command.OutputID, Column: column.Column}, nil + } + } + columnID := commandGeneratedID("col_", command.OutputID, command.OccurrenceID, candidate.ID, mode) + if command.OccurrenceID != RootOccurrenceID { + columnID = command.OccurrenceID + "__" + columnID + } + label := strings.TrimSpace(command.Title) + if label == "" { + label = candidate.Label + } + source := editableSource(command.OccurrenceID, ColumnSource{Kind: SourceField, Field: &FieldSource{Path: strings.TrimPrefix(candidate.FieldPath, "root."), ProjectionMode: mode}}) + column := Column{Column: columnID, Label: label, LogicalType: candidate.LogicalType, OccurrenceID: command.OccurrenceID, Source: source} + column.ColumnID = stagedSourceColumnID(workspace.Documents[document].Output.ID, commandID, index, command.Type, column.Column) + applyInitialPresentation(&column, presentation, nextTableOrder(workspace.Documents[document])) + workspace.Documents[document].Columns = append(workspace.Documents[document].Columns, column) + return CommandResult{Type: CommandResultColumnAdded, OutputID: command.OutputID, Column: columnID}, nil +} + +func applyUpdateColumnCommand(workspace *Workspace, command Command) (CommandResult, error) { + result := CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID} + document := documentIndex(workspace, command.OutputID) + if document < 0 { + return result, fmt.Errorf("output %q was not found", command.OutputID) + } + for i := range workspace.Documents[document].Columns { + current := &workspace.Documents[document].Columns[i] + if current.Column != command.Column { + continue + } + value := command.ColumnValue + if strings.TrimSpace(value.Label) == "" { + return result, fmt.Errorf("column label is required") + } + current.Label, current.Table, current.Filter, current.Chart = value.Label, value.Table, value.Filter, value.Chart + if value.Contributor != nil { + contributor := value.Contributor.Normalized() + current.Contributor = &contributor + } + return CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID, Column: current.Column}, nil + } + return result, fmt.Errorf("column %q was not found", command.Column) +} + +func applyUpdateConstructionOutputCommand(workspace *Workspace, command Command) (CommandResult, error) { + result := CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID} + documentIndex := documentIndex(workspace, command.OutputID) + if documentIndex < 0 { + return result, fmt.Errorf("output %q was not found", command.OutputID) + } + construction := workspace.Documents[documentIndex].Construction + if construction == nil { + return result, fmt.Errorf("output %q has no construction", command.OutputID) + } + presentation := command.ConstructionOutput + outputName := "" + for _, step := range construction.Steps { + if step.ID != presentation.StepID { + continue + } + for _, output := range step.Outputs { + if output.ID == presentation.ColumnID { + outputName = output.Name + break + } + } + break + } + if outputName == "" { + return result, fmt.Errorf("construction output %q was not found in step %q", presentation.ColumnID, presentation.StepID) + } + for stepIndex := range construction.Steps { + for outputIndex := range construction.Steps[stepIndex].Outputs { + current := &construction.Steps[stepIndex].Outputs[outputIndex] + if current.ID != presentation.ColumnID { + continue + } + current.Label = presentation.Label + if presentation.Table == nil { + current.Table = nil + } else { + table := *presentation.Table + current.Table = &table + } + } + } + return CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID, Column: outputName}, nil +} + +func applyUpdateColumnTransformationCommand(workspace *Workspace, catalog CatalogSnapshot, command Command) (CommandResult, error) { + result := CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID} + document := documentIndex(workspace, command.OutputID) + if document < 0 { + return result, fmt.Errorf("output %q was not found", command.OutputID) + } + for i := range workspace.Documents[document].Columns { + current := &workspace.Documents[document].Columns[i] + if current.Column != command.Column { + continue + } + if command.TransformationChange.Kind == "REMOVE" { + current.ValueTransformation = nil + } else { + if err := validateColumnValueTransformationForCatalog( + workspace.Documents[document], catalog, *current, current.Source, *command.TransformationChange.Transformation, + ); err != nil { + return result, err + } + transformation := command.TransformationChange.Transformation.Clone() + current.ValueTransformation = &transformation + } + return CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID, Column: current.Column}, nil + } + return result, fmt.Errorf("column %q was not found", command.Column) +} + +func applySetColumnContributorCommand(workspace *Workspace, catalog CatalogSnapshot, command Command) (CommandResult, error) { + result := CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID} + document := documentIndex(workspace, command.OutputID) + if document < 0 { + return result, fmt.Errorf("output %q was not found", command.OutputID) + } + for i := range workspace.Documents[document].Columns { + current := &workspace.Documents[document].Columns[i] + if current.Column != command.Column { + continue + } + contributor := command.Contributor.Normalized() + if err := ValidateContributorForCatalog(workspace.Documents[document], catalog, current.OccurrenceID, current.Source, contributor); err != nil { + return result, err + } + current.Contributor = &contributor + return CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID, Column: current.Column}, nil + } + return result, fmt.Errorf("column %q was not found", command.Column) +} + +func applyClearColumnContributorCommand(workspace *Workspace, command Command) (CommandResult, error) { + result := CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID} + document := documentIndex(workspace, command.OutputID) + if document < 0 { + return result, fmt.Errorf("output %q was not found", command.OutputID) + } + for i := range workspace.Documents[document].Columns { + current := &workspace.Documents[document].Columns[i] + if current.Column != command.Column { + continue + } + current.Contributor = nil + return CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID, Column: current.Column}, nil + } + return result, fmt.Errorf("column %q was not found", command.Column) +} + +func applyUpdateColumnSourceCommand(workspace *Workspace, catalog CatalogSnapshot, command Command) (CommandResult, error) { + result := CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID} + document := documentIndex(workspace, command.OutputID) + if document < 0 { + return result, fmt.Errorf("output %q was not found", command.OutputID) + } + for i := range workspace.Documents[document].Columns { + current := &workspace.Documents[document].Columns[i] + if current.Column != command.Column { + continue + } + source := editableSource(current.OccurrenceID, *command.Source) + if err := validateEditableSource(workspace.Documents[document], catalog, current.OccurrenceID, source); err != nil { + return result, err + } + if current.ValueTransformation != nil { + if err := validateColumnValueTransformationForCatalog(workspace.Documents[document], catalog, *current, source, *current.ValueTransformation); err != nil { + return result, err + } + } + current.Source = source + current.LogicalType = inferredSourceLogicalType(workspace.Documents[document], catalog, current.OccurrenceID, source, current.LogicalType) + return CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID, Column: current.Column}, nil + } + return result, fmt.Errorf("column %q was not found", command.Column) +} + +func applyUpdateColumnRowValuePolicyCommand(workspace *Workspace, command Command) (CommandResult, error) { + result := CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID} + if err := command.validate(); err != nil { + return result, err + } + documentPos := documentIndex(workspace, command.OutputID) + if documentPos < 0 { + return result, fmt.Errorf("output %q was not found", command.OutputID) + } + document := &workspace.Documents[documentPos] + if document.Rows.Kind != RowDefinitionGroups || document.Rows.Groups == nil || + document.Rows.Groups.Source.Kind != GroupSourceExplicit || document.Rows.Groups.Source.Explicit == nil { + return result, fmt.Errorf("UPDATE_COLUMN_ROW_VALUE_POLICY requires explicit grouped rows") + } + columnIndex := -1 + for index := range document.Columns { + if document.Columns[index].Column != command.Column { + continue + } + if columnIndex >= 0 { + return result, fmt.Errorf("column %q is ambiguous", command.Column) + } + columnIndex = index + } + if columnIndex < 0 { + return result, fmt.Errorf("column %q was not found", command.Column) + } + column := document.Columns[columnIndex] + if column.ColumnID == "" || !supportsExplicitGroupRowValueColumn(column) { + return result, fmt.Errorf("column %q must be a supported root FHIR field with a stable columnId", command.Column) + } + matchingBinding := -1 + for index := range document.Rows.Groups.RowValues { + if document.Rows.Groups.RowValues[index].ColumnID != column.ColumnID { + continue + } + if matchingBinding >= 0 { + return result, fmt.Errorf("row value binding for columnId %q is ambiguous", column.ColumnID) + } + matchingBinding = index + } + if matchingBinding < 0 { + return result, fmt.Errorf("row value binding for columnId %q was not found", column.ColumnID) + } + document.Rows.Groups.RowValues[matchingBinding].Policy = command.RowValuePolicy + return CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID, Column: column.Column}, nil +} + +func applyRemoveColumnCommand(workspace *Workspace, command Command) (CommandResult, error) { + result := CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID} + document := documentIndex(workspace, command.OutputID) + if document < 0 { + return result, fmt.Errorf("output %q was not found", command.OutputID) + } + columns := workspace.Documents[document].Columns[:0] + found := false + for _, column := range workspace.Documents[document].Columns { + if column.Column == command.Column { + found = true + removeConstructionColumnValues(&workspace.Documents[document], column.ColumnID) + removeExplicitGroupRowValue(&workspace.Documents[document], column.ColumnID) + continue + } + columns = append(columns, column) + } + if !found { + return result, fmt.Errorf("column %q was not found", command.Column) + } + workspace.Documents[document].Columns = columns + cleanupDocumentReferences(&workspace.Documents[document]) + cleanupWorkspaceBindings(workspace) + return result, nil +} diff --git a/internal/explorer/authoringv2/command_proposals.go b/internal/explorer/authoringv2/command_proposals.go new file mode 100644 index 000000000..7add0a8be --- /dev/null +++ b/internal/explorer/authoringv2/command_proposals.go @@ -0,0 +1,232 @@ +package authoringv2 + +import ( + "fmt" + "strings" +) + +func applyTableRootRebaseCommand(workspace *Workspace, catalog CatalogSnapshot, command Command) (CommandResult, error) { + result := CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID} + proposal := *command.RowChange + if proposal.Mode == RowChangeModeReplace { + replaced, err := ApplyRowReplacement(*workspace, catalog, proposal) + if err != nil { + return result, err + } + *workspace = replaced + result.OutputID = proposal.OutputID + result.OccurrenceID = RootOccurrenceID + return result, nil + } + document := documentIndex(workspace, proposal.OutputID) + if document < 0 { + return result, fmt.Errorf("output %q was not found", proposal.OutputID) + } + rebased, err := ApplyRowChange(workspace.Documents[document], catalog, proposal) + if err != nil { + return result, err + } + workspace.Documents[document] = rebased + result.OutputID = proposal.OutputID + result.OccurrenceID = RootOccurrenceID + return result, nil +} + +func applyConstructionChoiceCommand(workspace *Workspace, catalog CatalogSnapshot, commandID string, index int, command Command) (CommandResult, error) { + result := CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID} + resolved := command.ResolvedChoice + if resolved == nil || strings.TrimSpace(resolved.CandidateID) == "" || strings.TrimSpace(resolved.LogicalType) == "" { + return result, fmt.Errorf("construction choice has no server-resolved source") + } + presentation := strings.ToUpper(strings.TrimSpace(command.InitialPresentation)) + if presentation == "" { + presentation = InitialPresentationTable + } + documentPos := documentIndex(workspace, command.OutputID) + if documentPos < 0 { + return result, fmt.Errorf("output %q was not found", command.OutputID) + } + document := workspace.Documents[documentPos] + if document.Route.OccurrenceID != RootOccurrenceID || document.Route.ResourceType != document.RootResourceType { + return result, fmt.Errorf("table root route is invalid") + } + targetResourceType := document.RootResourceType + if len(resolved.Route) > 0 { + targetResourceType = resolved.Route[len(resolved.Route)-1].ToResourceType + } + if presentation != InitialPresentationTable { + candidate, ok := catalogCandidate(catalog, resolved.CandidateID) + if !ok { + return result, fmt.Errorf("construction choice candidate %q was not found", resolved.CandidateID) + } + node, ok := catalogNode(catalog, candidate.NodeID) + if !ok { + return result, fmt.Errorf("construction choice candidate %q has no catalog node", resolved.CandidateID) + } + if node.ResourceType != targetResourceType || len(resolved.Route) > 0 && node.ID != resolved.Route[len(resolved.Route)-1].ToNodeID { + return result, fmt.Errorf("construction choice candidate %q does not match its exact terminal route", resolved.CandidateID) + } + if presentation == InitialPresentationFilter && !candidate.Filterable { + return result, fmt.Errorf("construction choice candidate %q does not support filters", resolved.CandidateID) + } + if presentation == InitialPresentationChart && !candidate.Chartable { + return result, fmt.Errorf("construction choice candidate %q does not support charts", resolved.CandidateID) + } + } + occurrenceID, routeErr := ensureConstructionRoute(&workspace.Documents[documentPos], catalog, commandID, index, resolved.Route, resolved.CandidateID) + if routeErr != nil { + return result, routeErr + } + if err := initializeEmptyConstructionBeforeSourceAdd(workspace, command.OutputID); err != nil { + return result, err + } + command.OccurrenceID = occurrenceID + applied, err := applyColumnSource(workspace, catalog, commandID, index, command, resolved.Source, resolved.LogicalType, presentation) + if err != nil { + return result, err + } + if resolved.FrameID != "" { + if presentation != InitialPresentationTable { + return result, fmt.Errorf("frame categories can only be added as table columns") + } + columns := workspace.Documents[documentPos].Columns + found := false + for columnIndex := range columns { + if columns[columnIndex].Column == applied.Column { + columns[columnIndex].FrameID = resolved.FrameID + found = true + break + } + } + if !found { + return result, fmt.Errorf("applied frame category column %q was not found", applied.Column) + } + } + return applied, nil +} + +func applyInterpretationCandidateCommand(workspace *Workspace, command Command) (CommandResult, error) { + result := CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID} + if strings.TrimSpace(command.OutputID) == "" || strings.TrimSpace(command.Column) == "" || + command.InterpretationCandidate == nil || + strings.TrimSpace(command.InterpretationCandidate.CandidateReceiptID) == "" || + strings.TrimSpace(command.InterpretationCandidate.RevisionID) == "" || + command.InterpretationCandidate.CandidateReceiptID != strings.TrimSpace(command.InterpretationCandidate.CandidateReceiptID) || + command.InterpretationCandidate.RevisionID != strings.TrimSpace(command.InterpretationCandidate.RevisionID) { + return result, fmt.Errorf("APPLY_INTERPRETATION_CANDIDATE requires outputId, column, and exact interpretationCandidate payload") + } + document := documentIndex(workspace, command.OutputID) + if document < 0 { + return result, fmt.Errorf("output %q was not found", command.OutputID) + } + for index := range workspace.Documents[document].Columns { + current := &workspace.Documents[document].Columns[index] + if current.Column != command.Column { + continue + } + revisionID := command.InterpretationCandidate.RevisionID + current.Interpretation = &FeatureInterpretation{ + Kind: FeatureInterpretationPinned, + Pinned: &PinnedInterpretation{RevisionID: revisionID}, + } + current.Contributor = nil + return CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID, Column: current.Column}, nil + } + return result, fmt.Errorf("column %q was not found", command.Column) +} + +func applyRowDefinitionProposalCommand(workspace *Workspace, command Command) (CommandResult, error) { + result := CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID} + if command.resolvedRowDefinition == nil { + return result, fmt.Errorf("APPLY_ROW_DEFINITION_PROPOSAL has no lifecycle-resolved row definition") + } + rows, err := cloneRowDefinition(*command.resolvedRowDefinition) + if err != nil { + return result, err + } + document := documentIndex(workspace, command.OutputID) + if document < 0 { + return result, fmt.Errorf("output %q was not found", command.OutputID) + } + workspace.Documents[document].Rows = rows + return result, nil +} + +func applyTableShapeProposalCommand(workspace *Workspace, command Command) (CommandResult, error) { + result := CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID} + if !command.resolvedTableShapeSet { + return result, fmt.Errorf("APPLY_TABLE_SHAPE_PROPOSAL has no lifecycle-resolved table shape") + } + document := documentIndex(workspace, command.OutputID) + if document < 0 { + return result, fmt.Errorf("output %q was not found", command.OutputID) + } + shape, err := cloneTableShape(command.resolvedTableShape) + if err != nil { + return result, err + } + workspace.Documents[document].TableShape = shape + return CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID}, nil +} + +func applyPopulationMemberProposalCommand(workspace *Workspace, command Command) (CommandResult, error) { + result := CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID} + if !command.resolvedPopulationSet || command.resolvedPopulation == nil { + return result, fmt.Errorf("APPLY_POPULATION_MEMBER_PROPOSAL has no lifecycle-resolved population") + } + documentPos := documentIndex(workspace, command.OutputID) + if documentPos < 0 { + return result, fmt.Errorf("output %q was not found", command.OutputID) + } + population := *command.resolvedPopulation + population.Route = clonePopulationRouteSteps(command.resolvedPopulation.Route) + if err := population.Validate(); err != nil { + return result, fmt.Errorf("resolved population is invalid: %w", err) + } + workspace.Documents[documentPos].Population = &population + return CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID}, nil +} + +func applyConstructionProposalCommand(workspace *Workspace, command Command) (CommandResult, error) { + result := CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID} + if !command.resolvedConstructionSet || command.resolvedConstruction == nil || command.resolvedConstructionRows == nil { + return result, fmt.Errorf("APPLY_CONSTRUCTION_PROPOSAL has no lifecycle-resolved construction") + } + documentIndexValue := documentIndex(workspace, command.OutputID) + if documentIndexValue < 0 { + return result, fmt.Errorf("output %q was not found", command.OutputID) + } + document, err := UpgradeDocumentToConstruction(workspace.Documents[documentIndexValue]) + if err != nil { + return result, fmt.Errorf("prepare staged construction: %w", err) + } + construction, err := cloneConstruction(command.resolvedConstruction) + if err != nil { + return result, err + } + document.Construction = construction + rows, err := cloneRowDefinition(*command.resolvedConstructionRows) + if err != nil { + return result, err + } + document.Rows = rows + document.TableShape = nil + if err := document.Validate(); err != nil { + return result, fmt.Errorf("resolved construction is invalid: %w", err) + } + workspace.Documents[documentIndexValue] = document + return CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID}, nil +} + +func applyRestoreDraftRevisionCommand(workspace *Workspace, command Command) (CommandResult, error) { + result := CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID} + if command.resolvedDraftRevision == nil { + return result, fmt.Errorf("RESTORE_DRAFT_REVISION has no lifecycle-resolved draft revision") + } + restored, err := cloneWorkspace(*command.resolvedDraftRevision) + if err != nil { + return result, err + } + *workspace = restored + return CommandResult{Type: CommandResultDraftRestored}, nil +} diff --git a/internal/explorer/authoringv2/command_routes.go b/internal/explorer/authoringv2/command_routes.go new file mode 100644 index 000000000..11415a093 --- /dev/null +++ b/internal/explorer/authoringv2/command_routes.go @@ -0,0 +1,123 @@ +package authoringv2 + +import ( + "fmt" +) + +func applyAddRouteCommand(workspace *Workspace, catalog CatalogSnapshot, commandID string, index int, command Command) (CommandResult, error) { + result := CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID} + document := documentIndex(workspace, command.OutputID) + if document < 0 { + return result, fmt.Errorf("output %q was not found", command.OutputID) + } + parent := findRoute(&workspace.Documents[document].Route, command.ParentOccurrenceID) + edge, ok := catalogEdge(catalog, command.EdgeID) + if parent == nil || !ok { + return result, fmt.Errorf("parent occurrence or edge was not found") + } + parentDepth, depthFound := routeDepth(&workspace.Documents[document].Route, command.ParentOccurrenceID) + if !depthFound { + return result, fmt.Errorf("parent occurrence or edge was not found") + } + if catalog.RoutePolicy.MaxHops != nil && parentDepth+1 > *catalog.RoutePolicy.MaxHops { + return result, fmt.Errorf("ROUTE_TOO_LONG: route exceeds capability route policy (maxHops=%d, hops=%d)", *catalog.RoutePolicy.MaxHops, parentDepth+1) + } + from, fromOK := catalogNode(catalog, edge.FromNodeID) + to, toOK := catalogNode(catalog, edge.ToNodeID) + parentNodeID, parentNodeFound := catalogNodeIDForOccurrence(workspace.Documents[document].Route, parent.OccurrenceID, catalog) + if !fromOK || !toOK || !parentNodeFound || edge.FromNodeID != parentNodeID || from.ResourceType != parent.ResourceType { + return result, fmt.Errorf("edge %q does not extend occurrence %q", edge.ID, command.ParentOccurrenceID) + } + if !catalog.RoutePolicy.AllowRepeatedEdges { + used, err := routePathUsesCatalogEdge(workspace.Documents[document].Route, command.ParentOccurrenceID, edge.ID, catalog) + if err != nil { + return result, fmt.Errorf("route parent does not resolve to an exact path: %w", err) + } + if used { + return result, fmt.Errorf("edge %q is already used in this route", edge.ID) + } + } + occurrenceID := commandGeneratedID("occ_", commandID, index, command.Type) + if findRoute(&workspace.Documents[document].Route, occurrenceID) != nil { + return CommandResult{Type: CommandResultRouteAdded, OutputID: command.OutputID, OccurrenceID: occurrenceID}, nil + } + parent.Children = append(parent.Children, RouteNode{OccurrenceID: occurrenceID, ResourceType: to.ResourceType, CatalogEdgeID: edge.ID, Relationship: edge.Label}) + return CommandResult{Type: CommandResultRouteAdded, OutputID: command.OutputID, OccurrenceID: occurrenceID}, nil +} + +func applyUpdateRouteEdgeCommand(workspace *Workspace, catalog CatalogSnapshot, command Command) (CommandResult, error) { + result := CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID} + document := documentIndex(workspace, command.OutputID) + if document < 0 { + return result, fmt.Errorf("output %q was not found", command.OutputID) + } + parent, occurrence := findRouteWithParent(&workspace.Documents[document].Route, command.OccurrenceID) + edge, ok := catalogEdge(catalog, command.EdgeID) + if parent == nil || occurrence == nil || !ok { + return result, fmt.Errorf("route occurrence, parent, or edge was not found") + } + parentNodeID, parentNodeFound := catalogNodeIDForOccurrence(workspace.Documents[document].Route, parent.OccurrenceID, catalog) + occurrenceNodeID, occurrenceNodeFound := catalogNodeIDForOccurrence(workspace.Documents[document].Route, occurrence.OccurrenceID, catalog) + if !parentNodeFound || !occurrenceNodeFound { + return result, fmt.Errorf("route occurrence does not resolve to one exact catalog path") + } + from, fromOK := catalogNode(catalog, edge.FromNodeID) + to, toOK := catalogNode(catalog, edge.ToNodeID) + if !fromOK || !toOK || edge.FromNodeID != parentNodeID || edge.ToNodeID != occurrenceNodeID || from.ResourceType != parent.ResourceType || to.ResourceType != occurrence.ResourceType { + return result, fmt.Errorf("edge %q cannot replace the relationship for occurrence %q", edge.ID, command.OccurrenceID) + } + if !catalog.RoutePolicy.AllowRepeatedEdges { + usedOnPath, err := routePathUsesCatalogEdge(workspace.Documents[document].Route, parent.OccurrenceID, edge.ID, catalog) + if err != nil { + return result, fmt.Errorf("route parent does not resolve to an exact path: %w", err) + } + usedInSubtree, err := routeSubtreeUsesCatalogEdge(*occurrence, occurrenceNodeID, edge.ID, catalog) + if err != nil { + return result, fmt.Errorf("route subtree does not resolve to exact paths: %w", err) + } + if usedOnPath || usedInSubtree { + return result, fmt.Errorf("edge %q is already used in this route", edge.ID) + } + } + occurrence.CatalogEdgeID = edge.ID + occurrence.Relationship = edge.Label + result.OccurrenceID = occurrence.OccurrenceID + return result, nil +} + +func applySetRouteMatchModeCommand(workspace *Workspace, command Command) (CommandResult, error) { + result := CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID} + document := documentIndex(workspace, command.OutputID) + if document < 0 { + return result, fmt.Errorf("output %q was not found", command.OutputID) + } + occurrence := findRoute(&workspace.Documents[document].Route, command.OccurrenceID) + if occurrence == nil || occurrence.OccurrenceID == RootOccurrenceID { + return result, fmt.Errorf("non-root route occurrence %q was not found", command.OccurrenceID) + } + occurrence.MatchMode = command.MatchMode.Normalized() + result.OccurrenceID = occurrence.OccurrenceID + return result, nil +} + +func applyRemoveRouteCommand(workspace *Workspace, command Command) (CommandResult, error) { + result := CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID} + document := documentIndex(workspace, command.OutputID) + if document < 0 { + return result, fmt.Errorf("output %q was not found", command.OutputID) + } + removed := map[string]bool{} + if !removeRoute(&workspace.Documents[document].Route, command.OccurrenceID, removed) { + return result, fmt.Errorf("occurrence %q was not found", command.OccurrenceID) + } + columns := workspace.Documents[document].Columns[:0] + for _, column := range workspace.Documents[document].Columns { + if !removed[column.OccurrenceID] { + columns = append(columns, column) + } + } + workspace.Documents[document].Columns = columns + cleanupDocumentReferences(&workspace.Documents[document]) + cleanupWorkspaceBindings(workspace) + return result, nil +} diff --git a/internal/explorer/authoringv2/command_tables.go b/internal/explorer/authoringv2/command_tables.go new file mode 100644 index 000000000..7519e6a64 --- /dev/null +++ b/internal/explorer/authoringv2/command_tables.go @@ -0,0 +1,138 @@ +package authoringv2 + +import ( + "fmt" + "sort" + "strings" +) + +func applyCreateTableCommand(workspace *Workspace, catalog CatalogSnapshot, commandID string, index int, command Command) (CommandResult, error) { + result := CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID} + node, ok := catalogNode(catalog, command.RootNodeID) + if !ok || !node.RowRootEligible { + return result, fmt.Errorf("rootNodeId %q is not an eligible catalog root", command.RootNodeID) + } + outputID := commandGeneratedID("out_", commandID, index, command.Type) + tabID := commandGeneratedID("tab_", commandID, index, command.Type) + for _, document := range workspace.Documents { + if document.Output.ID == outputID { + return CommandResult{Type: CommandResultTableCreated, OutputID: outputID, TabID: tabID}, nil + } + } + title := strings.TrimSpace(command.Title) + workspace.Documents = append(workspace.Documents, Document{Kind: Kind, Output: Output{ID: outputID, Title: title}, RootResourceType: node.ResourceType, Route: RouteNode{OccurrenceID: RootOccurrenceID, ResourceType: node.ResourceType}, Rows: RecordsRowDefinition(), Columns: []Column{}}) + workspace.Tabs = append(workspace.Tabs, Tab{ID: tabID, Title: title, OutputID: outputID, Order: len(workspace.Tabs), Visible: true}) + return CommandResult{Type: CommandResultTableCreated, OutputID: outputID, TabID: tabID, OccurrenceID: RootOccurrenceID}, nil +} + +func applyDuplicateTableCommand(workspace *Workspace, commandID string, index int, command Command) (CommandResult, error) { + result := CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID} + sourceIndex := documentIndex(workspace, command.SourceOutputID) + if sourceIndex < 0 { + return result, fmt.Errorf("source output %q was not found", command.SourceOutputID) + } + outputID := commandGeneratedID("out_", commandID, index, command.Type) + tabID := commandGeneratedID("tab_", commandID, index, command.Type) + copy := workspace.Documents[sourceIndex] + copy.Output.ID, copy.Output.Title = outputID, strings.TrimSpace(command.Title) + workspace.Documents = append(workspace.Documents, copy) + workspace.Tabs = append(workspace.Tabs, Tab{ID: tabID, Title: copy.Output.Title, OutputID: outputID, Order: len(workspace.Tabs), Visible: true}) + return CommandResult{Type: CommandResultTableCreated, OutputID: outputID, TabID: tabID, OccurrenceID: RootOccurrenceID}, nil +} + +func applyDeleteTableCommand(workspace *Workspace, command Command) (CommandResult, error) { + result := CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID} + document := documentIndex(workspace, command.OutputID) + if document < 0 { + return result, fmt.Errorf("output %q was not found", command.OutputID) + } + workspace.Documents = append(workspace.Documents[:document], workspace.Documents[document+1:]...) + workspace.Tabs = removeOutputTab(workspace.Tabs, command.OutputID) + cleanupWorkspaceBindings(workspace) + return result, nil +} + +func applyRenameTableCommand(workspace *Workspace, command Command) (CommandResult, error) { + result := CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID} + document := documentIndex(workspace, command.OutputID) + if document < 0 { + return result, fmt.Errorf("output %q was not found", command.OutputID) + } + title := strings.TrimSpace(command.Title) + workspace.Documents[document].Output.Title = title + for i := range workspace.Tabs { + if workspace.Tabs[i].OutputID == command.OutputID { + workspace.Tabs[i].Title = title + } + } + return result, nil +} + +func applyReorderTablesCommand(workspace *Workspace, command Command) (CommandResult, error) { + result := CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID} + if len(command.OutputIDs) != len(workspace.Documents) { + return result, fmt.Errorf("outputIds must list every table exactly once") + } + positions := map[string]int{} + seen := map[string]bool{} + for order, outputID := range command.OutputIDs { + if documentIndex(workspace, outputID) < 0 || seen[outputID] { + return result, fmt.Errorf("outputIds contains an unknown or duplicate output") + } + seen[outputID] = true + positions[outputID] = order + 1 + } + sort.SliceStable(workspace.Tabs, func(i, j int) bool { + return positions[workspace.Tabs[i].OutputID] < positions[workspace.Tabs[j].OutputID] + }) + for order := range workspace.Tabs { + workspace.Tabs[order].Order = order + } + return result, nil +} + +func applySetTableRootCommand(workspace *Workspace, catalog CatalogSnapshot, command Command) (CommandResult, error) { + result := CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID} + document := documentIndex(workspace, command.OutputID) + node, ok := catalogNode(catalog, command.RootNodeID) + if document < 0 || !ok || !node.RowRootEligible { + return result, fmt.Errorf("output or eligible root node was not found") + } + current := &workspace.Documents[document] + if current.RootResourceType == node.ResourceType { + return result, nil + } + if current.RootResourceType != node.ResourceType && documentHasConfiguredMeaning(*current) { + return result, fmt.Errorf("ROOT_REBASE_REQUIRED: changing table root would discard configured routes, columns, filters, actions, or population") + } + current.RootResourceType = node.ResourceType + current.Route = RouteNode{OccurrenceID: RootOccurrenceID, ResourceType: node.ResourceType} + return result, nil +} + +func applySetTablePopulationCommand(workspace *Workspace, catalog CatalogSnapshot, command Command) (CommandResult, error) { + result := CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID} + document := documentIndex(workspace, command.OutputID) + if document < 0 { + return result, fmt.Errorf("output %q was not found", command.OutputID) + } + if command.RouteChoiceID != "" { + if command.ResolvedPopulationRoute == nil { + return result, fmt.Errorf("population route choice has no server resolution") + } + setPopulationRoute(&workspace.Documents[document], command.SelectionRevisionID, command.ResolvedPopulationRoute) + } else if err := setPopulation(&workspace.Documents[document], catalog, command.SelectionRevisionID, command.EdgeIDs); err != nil { + return result, err + } + return result, nil +} + +func applyClearTablePopulationCommand(workspace *Workspace, command Command) (CommandResult, error) { + result := CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID} + document := documentIndex(workspace, command.OutputID) + if document < 0 { + return result, fmt.Errorf("output %q was not found", command.OutputID) + } + workspace.Documents[document].Population = nil + return result, nil +} diff --git a/internal/explorer/authoringv2/commands.go b/internal/explorer/authoringv2/commands.go index 2a7c9ffca..488066b2f 100644 --- a/internal/explorer/authoringv2/commands.go +++ b/internal/explorer/authoringv2/commands.go @@ -5,7 +5,6 @@ import ( "encoding/hex" "encoding/json" "fmt" - "sort" "strings" "github.com/calypr/loom/internal/catalog" @@ -839,688 +838,70 @@ func applyCommand(workspace *Workspace, catalog CatalogSnapshot, commandID strin case CommandSetFrameSource, CommandReplaceFrameSource, CommandRemoveFrameSource: return applyFrameSourceCommand(workspace, command) case CommandCreateTable: - node, ok := catalogNode(catalog, command.RootNodeID) - if !ok || !node.RowRootEligible { - return result, fmt.Errorf("rootNodeId %q is not an eligible catalog root", command.RootNodeID) - } - outputID := commandGeneratedID("out_", commandID, index, command.Type) - tabID := commandGeneratedID("tab_", commandID, index, command.Type) - for _, document := range workspace.Documents { - if document.Output.ID == outputID { - return CommandResult{Type: CommandResultTableCreated, OutputID: outputID, TabID: tabID}, nil - } - } - title := strings.TrimSpace(command.Title) - workspace.Documents = append(workspace.Documents, Document{Kind: Kind, Output: Output{ID: outputID, Title: title}, RootResourceType: node.ResourceType, Route: RouteNode{OccurrenceID: RootOccurrenceID, ResourceType: node.ResourceType}, Rows: RecordsRowDefinition(), Columns: []Column{}}) - workspace.Tabs = append(workspace.Tabs, Tab{ID: tabID, Title: title, OutputID: outputID, Order: len(workspace.Tabs), Visible: true}) - return CommandResult{Type: CommandResultTableCreated, OutputID: outputID, TabID: tabID, OccurrenceID: RootOccurrenceID}, nil + return applyCreateTableCommand(workspace, catalog, commandID, index, command) case CommandDuplicateTable: - sourceIndex := documentIndex(workspace, command.SourceOutputID) - if sourceIndex < 0 { - return result, fmt.Errorf("source output %q was not found", command.SourceOutputID) - } - outputID := commandGeneratedID("out_", commandID, index, command.Type) - tabID := commandGeneratedID("tab_", commandID, index, command.Type) - copy := workspace.Documents[sourceIndex] - copy.Output.ID, copy.Output.Title = outputID, strings.TrimSpace(command.Title) - workspace.Documents = append(workspace.Documents, copy) - workspace.Tabs = append(workspace.Tabs, Tab{ID: tabID, Title: copy.Output.Title, OutputID: outputID, Order: len(workspace.Tabs), Visible: true}) - return CommandResult{Type: CommandResultTableCreated, OutputID: outputID, TabID: tabID, OccurrenceID: RootOccurrenceID}, nil + return applyDuplicateTableCommand(workspace, commandID, index, command) case CommandDeleteTable: - document := documentIndex(workspace, command.OutputID) - if document < 0 { - return result, fmt.Errorf("output %q was not found", command.OutputID) - } - workspace.Documents = append(workspace.Documents[:document], workspace.Documents[document+1:]...) - workspace.Tabs = removeOutputTab(workspace.Tabs, command.OutputID) - cleanupWorkspaceBindings(workspace) - return result, nil + return applyDeleteTableCommand(workspace, command) case CommandRenameTable: - document := documentIndex(workspace, command.OutputID) - if document < 0 { - return result, fmt.Errorf("output %q was not found", command.OutputID) - } - title := strings.TrimSpace(command.Title) - workspace.Documents[document].Output.Title = title - for i := range workspace.Tabs { - if workspace.Tabs[i].OutputID == command.OutputID { - workspace.Tabs[i].Title = title - } - } - return result, nil + return applyRenameTableCommand(workspace, command) case CommandReorderTables: - if len(command.OutputIDs) != len(workspace.Documents) { - return result, fmt.Errorf("outputIds must list every table exactly once") - } - positions := map[string]int{} - seen := map[string]bool{} - for order, outputID := range command.OutputIDs { - if documentIndex(workspace, outputID) < 0 || seen[outputID] { - return result, fmt.Errorf("outputIds contains an unknown or duplicate output") - } - seen[outputID] = true - positions[outputID] = order + 1 - } - sort.SliceStable(workspace.Tabs, func(i, j int) bool { - return positions[workspace.Tabs[i].OutputID] < positions[workspace.Tabs[j].OutputID] - }) - for order := range workspace.Tabs { - workspace.Tabs[order].Order = order - } - return result, nil + return applyReorderTablesCommand(workspace, command) case CommandSetTableRoot: - document := documentIndex(workspace, command.OutputID) - node, ok := catalogNode(catalog, command.RootNodeID) - if document < 0 || !ok || !node.RowRootEligible { - return result, fmt.Errorf("output or eligible root node was not found") - } - current := &workspace.Documents[document] - if current.RootResourceType == node.ResourceType { - return result, nil - } - if current.RootResourceType != node.ResourceType && documentHasConfiguredMeaning(*current) { - return result, fmt.Errorf("ROOT_REBASE_REQUIRED: changing table root would discard configured routes, columns, filters, actions, or population") - } - current.RootResourceType = node.ResourceType - current.Route = RouteNode{OccurrenceID: RootOccurrenceID, ResourceType: node.ResourceType} - return result, nil + return applySetTableRootCommand(workspace, catalog, command) case CommandApplyTableRootRebase: - proposal := *command.RowChange - if proposal.Mode == RowChangeModeReplace { - replaced, err := ApplyRowReplacement(*workspace, catalog, proposal) - if err != nil { - return result, err - } - *workspace = replaced - result.OutputID = proposal.OutputID - result.OccurrenceID = RootOccurrenceID - return result, nil - } - document := documentIndex(workspace, proposal.OutputID) - if document < 0 { - return result, fmt.Errorf("output %q was not found", proposal.OutputID) - } - rebased, err := ApplyRowChange(workspace.Documents[document], catalog, proposal) - if err != nil { - return result, err - } - workspace.Documents[document] = rebased - result.OutputID = proposal.OutputID - result.OccurrenceID = RootOccurrenceID - return result, nil + return applyTableRootRebaseCommand(workspace, catalog, command) case CommandSetTablePopulation: - document := documentIndex(workspace, command.OutputID) - if document < 0 { - return result, fmt.Errorf("output %q was not found", command.OutputID) - } - if command.RouteChoiceID != "" { - if command.ResolvedPopulationRoute == nil { - return result, fmt.Errorf("population route choice has no server resolution") - } - setPopulationRoute(&workspace.Documents[document], command.SelectionRevisionID, command.ResolvedPopulationRoute) - } else if err := setPopulation(&workspace.Documents[document], catalog, command.SelectionRevisionID, command.EdgeIDs); err != nil { - return result, err - } - return result, nil + return applySetTablePopulationCommand(workspace, catalog, command) case CommandClearTablePopulation: - document := documentIndex(workspace, command.OutputID) - if document < 0 { - return result, fmt.Errorf("output %q was not found", command.OutputID) - } - workspace.Documents[document].Population = nil - return result, nil + return applyClearTablePopulationCommand(workspace, command) case CommandAddRoute: - document := documentIndex(workspace, command.OutputID) - if document < 0 { - return result, fmt.Errorf("output %q was not found", command.OutputID) - } - parent := findRoute(&workspace.Documents[document].Route, command.ParentOccurrenceID) - edge, ok := catalogEdge(catalog, command.EdgeID) - if parent == nil || !ok { - return result, fmt.Errorf("parent occurrence or edge was not found") - } - parentDepth, depthFound := routeDepth(&workspace.Documents[document].Route, command.ParentOccurrenceID) - if !depthFound { - return result, fmt.Errorf("parent occurrence or edge was not found") - } - if catalog.RoutePolicy.MaxHops != nil && parentDepth+1 > *catalog.RoutePolicy.MaxHops { - return result, fmt.Errorf("ROUTE_TOO_LONG: route exceeds capability route policy (maxHops=%d, hops=%d)", *catalog.RoutePolicy.MaxHops, parentDepth+1) - } - from, fromOK := catalogNode(catalog, edge.FromNodeID) - to, toOK := catalogNode(catalog, edge.ToNodeID) - parentNodeID, parentNodeFound := catalogNodeIDForOccurrence(workspace.Documents[document].Route, parent.OccurrenceID, catalog) - if !fromOK || !toOK || !parentNodeFound || edge.FromNodeID != parentNodeID || from.ResourceType != parent.ResourceType { - return result, fmt.Errorf("edge %q does not extend occurrence %q", edge.ID, command.ParentOccurrenceID) - } - if !catalog.RoutePolicy.AllowRepeatedEdges { - used, err := routePathUsesCatalogEdge(workspace.Documents[document].Route, command.ParentOccurrenceID, edge.ID, catalog) - if err != nil { - return result, fmt.Errorf("route parent does not resolve to an exact path: %w", err) - } - if used { - return result, fmt.Errorf("edge %q is already used in this route", edge.ID) - } - } - occurrenceID := commandGeneratedID("occ_", commandID, index, command.Type) - if findRoute(&workspace.Documents[document].Route, occurrenceID) != nil { - return CommandResult{Type: CommandResultRouteAdded, OutputID: command.OutputID, OccurrenceID: occurrenceID}, nil - } - parent.Children = append(parent.Children, RouteNode{OccurrenceID: occurrenceID, ResourceType: to.ResourceType, CatalogEdgeID: edge.ID, Relationship: edge.Label}) - return CommandResult{Type: CommandResultRouteAdded, OutputID: command.OutputID, OccurrenceID: occurrenceID}, nil + return applyAddRouteCommand(workspace, catalog, commandID, index, command) case CommandUpdateRouteEdge: - document := documentIndex(workspace, command.OutputID) - if document < 0 { - return result, fmt.Errorf("output %q was not found", command.OutputID) - } - parent, occurrence := findRouteWithParent(&workspace.Documents[document].Route, command.OccurrenceID) - edge, ok := catalogEdge(catalog, command.EdgeID) - if parent == nil || occurrence == nil || !ok { - return result, fmt.Errorf("route occurrence, parent, or edge was not found") - } - parentNodeID, parentNodeFound := catalogNodeIDForOccurrence(workspace.Documents[document].Route, parent.OccurrenceID, catalog) - occurrenceNodeID, occurrenceNodeFound := catalogNodeIDForOccurrence(workspace.Documents[document].Route, occurrence.OccurrenceID, catalog) - if !parentNodeFound || !occurrenceNodeFound { - return result, fmt.Errorf("route occurrence does not resolve to one exact catalog path") - } - from, fromOK := catalogNode(catalog, edge.FromNodeID) - to, toOK := catalogNode(catalog, edge.ToNodeID) - if !fromOK || !toOK || edge.FromNodeID != parentNodeID || edge.ToNodeID != occurrenceNodeID || from.ResourceType != parent.ResourceType || to.ResourceType != occurrence.ResourceType { - return result, fmt.Errorf("edge %q cannot replace the relationship for occurrence %q", edge.ID, command.OccurrenceID) - } - if !catalog.RoutePolicy.AllowRepeatedEdges { - usedOnPath, err := routePathUsesCatalogEdge(workspace.Documents[document].Route, parent.OccurrenceID, edge.ID, catalog) - if err != nil { - return result, fmt.Errorf("route parent does not resolve to an exact path: %w", err) - } - usedInSubtree, err := routeSubtreeUsesCatalogEdge(*occurrence, occurrenceNodeID, edge.ID, catalog) - if err != nil { - return result, fmt.Errorf("route subtree does not resolve to exact paths: %w", err) - } - if usedOnPath || usedInSubtree { - return result, fmt.Errorf("edge %q is already used in this route", edge.ID) - } - } - occurrence.CatalogEdgeID = edge.ID - occurrence.Relationship = edge.Label - result.OccurrenceID = occurrence.OccurrenceID - return result, nil + return applyUpdateRouteEdgeCommand(workspace, catalog, command) case CommandSetRouteMatchMode: - document := documentIndex(workspace, command.OutputID) - if document < 0 { - return result, fmt.Errorf("output %q was not found", command.OutputID) - } - occurrence := findRoute(&workspace.Documents[document].Route, command.OccurrenceID) - if occurrence == nil || occurrence.OccurrenceID == RootOccurrenceID { - return result, fmt.Errorf("non-root route occurrence %q was not found", command.OccurrenceID) - } - occurrence.MatchMode = command.MatchMode.Normalized() - result.OccurrenceID = occurrence.OccurrenceID - return result, nil + return applySetRouteMatchModeCommand(workspace, command) case CommandRemoveRoute: - document := documentIndex(workspace, command.OutputID) - if document < 0 { - return result, fmt.Errorf("output %q was not found", command.OutputID) - } - removed := map[string]bool{} - if !removeRoute(&workspace.Documents[document].Route, command.OccurrenceID, removed) { - return result, fmt.Errorf("occurrence %q was not found", command.OccurrenceID) - } - columns := workspace.Documents[document].Columns[:0] - for _, column := range workspace.Documents[document].Columns { - if !removed[column.OccurrenceID] { - columns = append(columns, column) - } - } - workspace.Documents[document].Columns = columns - cleanupDocumentReferences(&workspace.Documents[document]) - cleanupWorkspaceBindings(workspace) - return result, nil + return applyRemoveRouteCommand(workspace, command) case CommandAddColumn: - document := documentIndex(workspace, command.OutputID) - candidate, ok := catalogCandidate(catalog, command.CandidateID) - if document < 0 || !ok { - return result, fmt.Errorf("output or candidate was not found") - } - occurrence := findRoute(&workspace.Documents[document].Route, command.OccurrenceID) - node, nodeOK := catalogNode(catalog, candidate.NodeID) - if occurrence == nil || !nodeOK || occurrence.ResourceType != node.ResourceType { - return result, fmt.Errorf("candidate %q does not belong to occurrence %q", candidate.ID, command.OccurrenceID) - } - mode := strings.ToUpper(strings.TrimSpace(command.ProjectionMode)) - if mode == "" { - mode = candidate.DefaultProjectionMode - } - if !contains(candidate.ProjectionModes, mode) { - return result, fmt.Errorf("projection mode %q is not advertised", mode) - } - presentation := strings.ToUpper(strings.TrimSpace(command.InitialPresentation)) - if presentation == "" { - presentation = InitialPresentationTable - } - if presentation == InitialPresentationFilter && !candidate.Filterable { - return result, fmt.Errorf("candidate %q does not support filters", candidate.ID) - } - if presentation == InitialPresentationChart && !candidate.Chartable { - return result, fmt.Errorf("candidate %q does not support charts", candidate.ID) - } - for i := range workspace.Documents[document].Columns { - column := &workspace.Documents[document].Columns[i] - if column.OccurrenceID == command.OccurrenceID && column.Source.Kind == SourceField && column.Source.Field != nil && strings.TrimPrefix(column.Source.Field.Path, "root.") == strings.TrimPrefix(candidate.FieldPath, "root.") && strings.EqualFold(column.Source.Field.ProjectionMode, mode) { - applyInitialPresentation(column, presentation, nextTableOrder(workspace.Documents[document])) - return CommandResult{Type: CommandResultColumnAdded, OutputID: command.OutputID, Column: column.Column}, nil - } - } - columnID := commandGeneratedID("col_", command.OutputID, command.OccurrenceID, candidate.ID, mode) - if command.OccurrenceID != RootOccurrenceID { - columnID = command.OccurrenceID + "__" + columnID - } - label := strings.TrimSpace(command.Title) - if label == "" { - label = candidate.Label - } - source := editableSource(command.OccurrenceID, ColumnSource{Kind: SourceField, Field: &FieldSource{Path: strings.TrimPrefix(candidate.FieldPath, "root."), ProjectionMode: mode}}) - column := Column{Column: columnID, Label: label, LogicalType: candidate.LogicalType, OccurrenceID: command.OccurrenceID, Source: source} - column.ColumnID = stagedSourceColumnID(workspace.Documents[document].Output.ID, commandID, index, command.Type, column.Column) - applyInitialPresentation(&column, presentation, nextTableOrder(workspace.Documents[document])) - workspace.Documents[document].Columns = append(workspace.Documents[document].Columns, column) - return CommandResult{Type: CommandResultColumnAdded, OutputID: command.OutputID, Column: columnID}, nil + return applyAddColumnCommand(workspace, catalog, commandID, index, command) case CommandAddColumnSource: return applyColumnSource(workspace, catalog, commandID, index, command, *command.Source, "", InitialPresentationTable) case CommandApplyConstructionChoice: - resolved := command.ResolvedChoice - if resolved == nil || strings.TrimSpace(resolved.CandidateID) == "" || strings.TrimSpace(resolved.LogicalType) == "" { - return result, fmt.Errorf("construction choice has no server-resolved source") - } - presentation := strings.ToUpper(strings.TrimSpace(command.InitialPresentation)) - if presentation == "" { - presentation = InitialPresentationTable - } - documentPos := documentIndex(workspace, command.OutputID) - if documentPos < 0 { - return result, fmt.Errorf("output %q was not found", command.OutputID) - } - document := workspace.Documents[documentPos] - if document.Route.OccurrenceID != RootOccurrenceID || document.Route.ResourceType != document.RootResourceType { - return result, fmt.Errorf("table root route is invalid") - } - targetResourceType := document.RootResourceType - if len(resolved.Route) > 0 { - targetResourceType = resolved.Route[len(resolved.Route)-1].ToResourceType - } - if presentation != InitialPresentationTable { - candidate, ok := catalogCandidate(catalog, resolved.CandidateID) - if !ok { - return result, fmt.Errorf("construction choice candidate %q was not found", resolved.CandidateID) - } - node, ok := catalogNode(catalog, candidate.NodeID) - if !ok { - return result, fmt.Errorf("construction choice candidate %q has no catalog node", resolved.CandidateID) - } - if node.ResourceType != targetResourceType || len(resolved.Route) > 0 && node.ID != resolved.Route[len(resolved.Route)-1].ToNodeID { - return result, fmt.Errorf("construction choice candidate %q does not match its exact terminal route", resolved.CandidateID) - } - if presentation == InitialPresentationFilter && !candidate.Filterable { - return result, fmt.Errorf("construction choice candidate %q does not support filters", resolved.CandidateID) - } - if presentation == InitialPresentationChart && !candidate.Chartable { - return result, fmt.Errorf("construction choice candidate %q does not support charts", resolved.CandidateID) - } - } - occurrenceID, routeErr := ensureConstructionRoute(&workspace.Documents[documentPos], catalog, commandID, index, resolved.Route, resolved.CandidateID) - if routeErr != nil { - return result, routeErr - } - if err := initializeEmptyConstructionBeforeSourceAdd(workspace, command.OutputID); err != nil { - return result, err - } - command.OccurrenceID = occurrenceID - applied, err := applyColumnSource(workspace, catalog, commandID, index, command, resolved.Source, resolved.LogicalType, presentation) - if err != nil { - return result, err - } - if resolved.FrameID != "" { - if presentation != InitialPresentationTable { - return result, fmt.Errorf("frame categories can only be added as table columns") - } - columns := workspace.Documents[documentPos].Columns - found := false - for columnIndex := range columns { - if columns[columnIndex].Column == applied.Column { - columns[columnIndex].FrameID = resolved.FrameID - found = true - break - } - } - if !found { - return result, fmt.Errorf("applied frame category column %q was not found", applied.Column) - } - } - return applied, nil + return applyConstructionChoiceCommand(workspace, catalog, commandID, index, command) case CommandAddSemanticSelections: if err := initializeEmptyConstructionBeforeSourceAdd(workspace, command.OutputID); err != nil { return result, err } return applySemanticSelections(workspace, catalog, commandID, index, command) case CommandUpdateColumn: - document := documentIndex(workspace, command.OutputID) - if document < 0 { - return result, fmt.Errorf("output %q was not found", command.OutputID) - } - for i := range workspace.Documents[document].Columns { - current := &workspace.Documents[document].Columns[i] - if current.Column != command.Column { - continue - } - value := command.ColumnValue - if strings.TrimSpace(value.Label) == "" { - return result, fmt.Errorf("column label is required") - } - current.Label, current.Table, current.Filter, current.Chart = value.Label, value.Table, value.Filter, value.Chart - if value.Contributor != nil { - contributor := value.Contributor.Normalized() - current.Contributor = &contributor - } - return CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID, Column: current.Column}, nil - } - return result, fmt.Errorf("column %q was not found", command.Column) + return applyUpdateColumnCommand(workspace, command) case CommandUpdateConstructionOutput: - documentIndex := documentIndex(workspace, command.OutputID) - if documentIndex < 0 { - return result, fmt.Errorf("output %q was not found", command.OutputID) - } - construction := workspace.Documents[documentIndex].Construction - if construction == nil { - return result, fmt.Errorf("output %q has no construction", command.OutputID) - } - presentation := command.ConstructionOutput - outputName := "" - for _, step := range construction.Steps { - if step.ID != presentation.StepID { - continue - } - for _, output := range step.Outputs { - if output.ID == presentation.ColumnID { - outputName = output.Name - break - } - } - break - } - if outputName == "" { - return result, fmt.Errorf("construction output %q was not found in step %q", presentation.ColumnID, presentation.StepID) - } - for stepIndex := range construction.Steps { - for outputIndex := range construction.Steps[stepIndex].Outputs { - current := &construction.Steps[stepIndex].Outputs[outputIndex] - if current.ID != presentation.ColumnID { - continue - } - current.Label = presentation.Label - if presentation.Table == nil { - current.Table = nil - } else { - table := *presentation.Table - current.Table = &table - } - } - } - return CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID, Column: outputName}, nil + return applyUpdateConstructionOutputCommand(workspace, command) case CommandUpdateColumnTransformation: - document := documentIndex(workspace, command.OutputID) - if document < 0 { - return result, fmt.Errorf("output %q was not found", command.OutputID) - } - for i := range workspace.Documents[document].Columns { - current := &workspace.Documents[document].Columns[i] - if current.Column != command.Column { - continue - } - if command.TransformationChange.Kind == "REMOVE" { - current.ValueTransformation = nil - } else { - if err := validateColumnValueTransformationForCatalog( - workspace.Documents[document], catalog, *current, current.Source, *command.TransformationChange.Transformation, - ); err != nil { - return result, err - } - transformation := command.TransformationChange.Transformation.Clone() - current.ValueTransformation = &transformation - } - return CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID, Column: current.Column}, nil - } - return result, fmt.Errorf("column %q was not found", command.Column) + return applyUpdateColumnTransformationCommand(workspace, catalog, command) case CommandSetColumnContributor: - document := documentIndex(workspace, command.OutputID) - if document < 0 { - return result, fmt.Errorf("output %q was not found", command.OutputID) - } - for i := range workspace.Documents[document].Columns { - current := &workspace.Documents[document].Columns[i] - if current.Column != command.Column { - continue - } - contributor := command.Contributor.Normalized() - if err := ValidateContributorForCatalog(workspace.Documents[document], catalog, current.OccurrenceID, current.Source, contributor); err != nil { - return result, err - } - current.Contributor = &contributor - return CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID, Column: current.Column}, nil - } - return result, fmt.Errorf("column %q was not found", command.Column) + return applySetColumnContributorCommand(workspace, catalog, command) case CommandClearColumnContributor: - document := documentIndex(workspace, command.OutputID) - if document < 0 { - return result, fmt.Errorf("output %q was not found", command.OutputID) - } - for i := range workspace.Documents[document].Columns { - current := &workspace.Documents[document].Columns[i] - if current.Column != command.Column { - continue - } - current.Contributor = nil - return CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID, Column: current.Column}, nil - } - return result, fmt.Errorf("column %q was not found", command.Column) + return applyClearColumnContributorCommand(workspace, command) case CommandApplyInterpretationCandidate: - if strings.TrimSpace(command.OutputID) == "" || strings.TrimSpace(command.Column) == "" || - command.InterpretationCandidate == nil || - strings.TrimSpace(command.InterpretationCandidate.CandidateReceiptID) == "" || - strings.TrimSpace(command.InterpretationCandidate.RevisionID) == "" || - command.InterpretationCandidate.CandidateReceiptID != strings.TrimSpace(command.InterpretationCandidate.CandidateReceiptID) || - command.InterpretationCandidate.RevisionID != strings.TrimSpace(command.InterpretationCandidate.RevisionID) { - return result, fmt.Errorf("APPLY_INTERPRETATION_CANDIDATE requires outputId, column, and exact interpretationCandidate payload") - } - document := documentIndex(workspace, command.OutputID) - if document < 0 { - return result, fmt.Errorf("output %q was not found", command.OutputID) - } - for index := range workspace.Documents[document].Columns { - current := &workspace.Documents[document].Columns[index] - if current.Column != command.Column { - continue - } - revisionID := command.InterpretationCandidate.RevisionID - current.Interpretation = &FeatureInterpretation{ - Kind: FeatureInterpretationPinned, - Pinned: &PinnedInterpretation{RevisionID: revisionID}, - } - current.Contributor = nil - return CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID, Column: current.Column}, nil - } - return result, fmt.Errorf("column %q was not found", command.Column) + return applyInterpretationCandidateCommand(workspace, command) case CommandApplyRowDefinitionProposal: - if command.resolvedRowDefinition == nil { - return result, fmt.Errorf("APPLY_ROW_DEFINITION_PROPOSAL has no lifecycle-resolved row definition") - } - rows, err := cloneRowDefinition(*command.resolvedRowDefinition) - if err != nil { - return result, err - } - document := documentIndex(workspace, command.OutputID) - if document < 0 { - return result, fmt.Errorf("output %q was not found", command.OutputID) - } - workspace.Documents[document].Rows = rows - return result, nil + return applyRowDefinitionProposalCommand(workspace, command) case CommandApplyTableShapeProposal: - if !command.resolvedTableShapeSet { - return result, fmt.Errorf("APPLY_TABLE_SHAPE_PROPOSAL has no lifecycle-resolved table shape") - } - document := documentIndex(workspace, command.OutputID) - if document < 0 { - return result, fmt.Errorf("output %q was not found", command.OutputID) - } - shape, err := cloneTableShape(command.resolvedTableShape) - if err != nil { - return result, err - } - workspace.Documents[document].TableShape = shape - return CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID}, nil + return applyTableShapeProposalCommand(workspace, command) case CommandApplyPopulationMemberProposal: - if !command.resolvedPopulationSet || command.resolvedPopulation == nil { - return result, fmt.Errorf("APPLY_POPULATION_MEMBER_PROPOSAL has no lifecycle-resolved population") - } - documentPos := documentIndex(workspace, command.OutputID) - if documentPos < 0 { - return result, fmt.Errorf("output %q was not found", command.OutputID) - } - population := *command.resolvedPopulation - population.Route = clonePopulationRouteSteps(command.resolvedPopulation.Route) - if err := population.Validate(); err != nil { - return result, fmt.Errorf("resolved population is invalid: %w", err) - } - workspace.Documents[documentPos].Population = &population - return CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID}, nil + return applyPopulationMemberProposalCommand(workspace, command) case CommandApplyConstructionProposal: - if !command.resolvedConstructionSet || command.resolvedConstruction == nil || command.resolvedConstructionRows == nil { - return result, fmt.Errorf("APPLY_CONSTRUCTION_PROPOSAL has no lifecycle-resolved construction") - } - documentIndexValue := documentIndex(workspace, command.OutputID) - if documentIndexValue < 0 { - return result, fmt.Errorf("output %q was not found", command.OutputID) - } - document, err := UpgradeDocumentToConstruction(workspace.Documents[documentIndexValue]) - if err != nil { - return result, fmt.Errorf("prepare staged construction: %w", err) - } - construction, err := cloneConstruction(command.resolvedConstruction) - if err != nil { - return result, err - } - document.Construction = construction - rows, err := cloneRowDefinition(*command.resolvedConstructionRows) - if err != nil { - return result, err - } - document.Rows = rows - document.TableShape = nil - if err := document.Validate(); err != nil { - return result, fmt.Errorf("resolved construction is invalid: %w", err) - } - workspace.Documents[documentIndexValue] = document - return CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID}, nil + return applyConstructionProposalCommand(workspace, command) case CommandRestoreDraftRevision: - if command.resolvedDraftRevision == nil { - return result, fmt.Errorf("RESTORE_DRAFT_REVISION has no lifecycle-resolved draft revision") - } - restored, err := cloneWorkspace(*command.resolvedDraftRevision) - if err != nil { - return result, err - } - *workspace = restored - return CommandResult{Type: CommandResultDraftRestored}, nil + return applyRestoreDraftRevisionCommand(workspace, command) case CommandUpdateColumnSource: - document := documentIndex(workspace, command.OutputID) - if document < 0 { - return result, fmt.Errorf("output %q was not found", command.OutputID) - } - for i := range workspace.Documents[document].Columns { - current := &workspace.Documents[document].Columns[i] - if current.Column != command.Column { - continue - } - source := editableSource(current.OccurrenceID, *command.Source) - if err := validateEditableSource(workspace.Documents[document], catalog, current.OccurrenceID, source); err != nil { - return result, err - } - if current.ValueTransformation != nil { - if err := validateColumnValueTransformationForCatalog(workspace.Documents[document], catalog, *current, source, *current.ValueTransformation); err != nil { - return result, err - } - } - current.Source = source - current.LogicalType = inferredSourceLogicalType(workspace.Documents[document], catalog, current.OccurrenceID, source, current.LogicalType) - return CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID, Column: current.Column}, nil - } - return result, fmt.Errorf("column %q was not found", command.Column) + return applyUpdateColumnSourceCommand(workspace, catalog, command) case CommandUpdateColumnRowValuePolicy: - if err := command.validate(); err != nil { - return result, err - } - documentPos := documentIndex(workspace, command.OutputID) - if documentPos < 0 { - return result, fmt.Errorf("output %q was not found", command.OutputID) - } - document := &workspace.Documents[documentPos] - if document.Rows.Kind != RowDefinitionGroups || document.Rows.Groups == nil || - document.Rows.Groups.Source.Kind != GroupSourceExplicit || document.Rows.Groups.Source.Explicit == nil { - return result, fmt.Errorf("UPDATE_COLUMN_ROW_VALUE_POLICY requires explicit grouped rows") - } - columnIndex := -1 - for index := range document.Columns { - if document.Columns[index].Column != command.Column { - continue - } - if columnIndex >= 0 { - return result, fmt.Errorf("column %q is ambiguous", command.Column) - } - columnIndex = index - } - if columnIndex < 0 { - return result, fmt.Errorf("column %q was not found", command.Column) - } - column := document.Columns[columnIndex] - if column.ColumnID == "" || !supportsExplicitGroupRowValueColumn(column) { - return result, fmt.Errorf("column %q must be a supported root FHIR field with a stable columnId", command.Column) - } - matchingBinding := -1 - for index := range document.Rows.Groups.RowValues { - if document.Rows.Groups.RowValues[index].ColumnID != column.ColumnID { - continue - } - if matchingBinding >= 0 { - return result, fmt.Errorf("row value binding for columnId %q is ambiguous", column.ColumnID) - } - matchingBinding = index - } - if matchingBinding < 0 { - return result, fmt.Errorf("row value binding for columnId %q was not found", column.ColumnID) - } - document.Rows.Groups.RowValues[matchingBinding].Policy = command.RowValuePolicy - return CommandResult{Type: CommandResultTableChanged, OutputID: command.OutputID, Column: column.Column}, nil + return applyUpdateColumnRowValuePolicyCommand(workspace, command) case CommandRemoveColumn: - document := documentIndex(workspace, command.OutputID) - if document < 0 { - return result, fmt.Errorf("output %q was not found", command.OutputID) - } - columns := workspace.Documents[document].Columns[:0] - found := false - for _, column := range workspace.Documents[document].Columns { - if column.Column == command.Column { - found = true - removeConstructionColumnValues(&workspace.Documents[document], column.ColumnID) - removeExplicitGroupRowValue(&workspace.Documents[document], column.ColumnID) - continue - } - columns = append(columns, column) - } - if !found { - return result, fmt.Errorf("column %q was not found", command.Column) - } - workspace.Documents[document].Columns = columns - cleanupDocumentReferences(&workspace.Documents[document]) - cleanupWorkspaceBindings(workspace) - return result, nil + return applyRemoveColumnCommand(workspace, command) } return result, fmt.Errorf("unsupported command type %q", command.Type) }