package database import ( "context" "fmt" "github.com/dominikbraun/graph" "github.com/fastenhealth/fastenhealth-onprem/backend/pkg" "github.com/fastenhealth/fastenhealth-onprem/backend/pkg/models" "github.com/fastenhealth/fastenhealth-onprem/backend/pkg/utils" "golang.org/x/exp/slices" "log" "strings" ) // Retrieve a list of all fhir resources (vertex), and a list of all associations (edge) // Generate a graph // return list of root nodes, and their flattened related resources. func (sr *SqliteRepository) GetFlattenedResourceGraph(ctx context.Context, graphType pkg.ResourceGraphType) (map[string][]*models.ResourceFhir, error) { currentUser, currentUserErr := sr.GetCurrentUser(ctx) if currentUserErr != nil { return nil, currentUserErr } // Get list of all resources wrappedResourceModels, err := sr.ListResources(ctx, models.ListResourceQueryOptions{}) if err != nil { return nil, err } // Get list of all (non-reciprocal) relationships var relatedResourceRelationships []models.RelatedResource // SELECT * FROM related_resources WHERE user_id = "53c1e930-63af-46c9-b760-8e83cbc1abd9"; result := sr.GormClient.WithContext(ctx). Table("related_resources"). Where(models.RelatedResource{ ResourceFhirUserID: currentUser.ID, }). Scan(&relatedResourceRelationships) if result.Error != nil { return nil, result.Error } //Generate Graph // TODO optimization: eventually cache the graph in a database/storage, and update when new resources are added. g := graph.New(resourceVertexId, graph.Directed(), graph.Acyclic(), graph.Rooted()) //add vertices to the graph (must be done first) for ndx, _ := range wrappedResourceModels { err = g.AddVertex( &wrappedResourceModels[ndx], ) if err != nil { return nil, fmt.Errorf("an error occurred while adding vertex: %v", err) } } //add recriprocial relationships (depending on the graph type) relatedResourceRelationships = sr.PopulateGraphTypeReciprocalRelationships(graphType, relatedResourceRelationships) //add edges to graph for _, relationship := range relatedResourceRelationships { err = g.AddEdge( resourceKeysVertexId(relationship.ResourceFhirSourceID.String(), relationship.ResourceFhirSourceResourceType, relationship.ResourceFhirSourceResourceID), resourceKeysVertexId(relationship.RelatedResourceFhirSourceID.String(), relationship.RelatedResourceFhirSourceResourceType, relationship.RelatedResourceFhirSourceResourceID), ) if err != nil { //this may occur because vertices may not exist sr.Logger.Warnf("ignoring, an error occurred while adding edge: %v", err) } } //// simplify graph if possible. //graph.TransitiveReduction(g) // AdjacencyMap computes and returns an adjacency map containing all vertices in the graph. // // There is an entry for each vertex, and each of those entries is another map whose keys are // the hash values of the adjacent vertices. The value is an Edge instance that stores the // source and target hash values (these are the same as the map keys) as well as edge metadata. // map[string]map[string]Edge[string]{ // "A": map[string]Edge[string]{ // "B": {Source: "A", Target: "B"} // "C": {Source: "A", Target: "C"} // } // } adjacencyMap, err := g.AdjacencyMap() if err != nil { return nil, fmt.Errorf("error while generating AdjacencyMap: %v", err) } // For a directed graph, PredecessorMap is the complement of AdjacencyMap. This is because in a directed graph, only // vertices joined by an outgoing edge are considered adjacent to the current vertex, whereas // predecessors are the vertices joined by an ingoing edge. // ie. "empty" verticies in this map are "root" nodes. predecessorMap, err := g.PredecessorMap() if err != nil { return nil, fmt.Errorf("error while generating PredecessorMap: %v", err) } // Doing this in one massive function, because passing graph by reference is difficult due to generics. // Step 1: use predecessorMap to find all "root" resources (eg. MedicalHistory - encounters and conditions). store those nodes in their respective lists. resourceListDictionary := map[string][]*models.ResourceFhir{} sources, _, sourceFlattenLevel := getSourcesAndSinksForGraphType(graphType) for vertexId, val := range predecessorMap { if len(val) != 0 { //skip any nodes/verticies/resources that are not "root" continue } resource, err := g.Vertex(vertexId) if err != nil { //could not find this vertex in graph, ignoring log.Printf("could not find vertex in graph: %v", err) continue } //check if this "root" node (which has no predecessors) is a valid source type foundSourceType := "" foundSourceLevel := -1 for ndx, sourceResourceTypes := range sources { log.Printf("testing resourceType: %s", resource.SourceResourceType) if slices.Contains(sourceResourceTypes, strings.ToLower(resource.SourceResourceType)) { foundSourceType = resource.SourceResourceType foundSourceLevel = ndx break } } if foundSourceLevel == -1 { continue //skip this resource, it is not a valid source type } if _, ok := resourceListDictionary[foundSourceType]; !ok { resourceListDictionary[foundSourceType] = []*models.ResourceFhir{} } resourceListDictionary[foundSourceType] = append(resourceListDictionary[foundSourceType], resource) } // Step 2: define a function. When given a resource, should find all related resources, flatten the heirarchy and set the RelatedResourceFhir list flattenRelatedResourcesFn := func(resource *models.ResourceFhir) { // this is a "root" encounter, which is not related to any condition, we should add it to the Unknown encounters list vertexId := resourceVertexId(resource) sr.Logger.Debugf("populating resource: %s", vertexId) resource.RelatedResourceFhir = []*models.ResourceFhir{} //get all the resources associated with this node graph.DFS(g, vertexId, func(relatedVertexId string) bool { relatedResourceFhir, _ := g.Vertex(relatedVertexId) //skip the current resource if it's referenced in this list. //also skip the current resource if its a Binary resource (which is a special case) if vertexId != resourceVertexId(relatedResourceFhir) && relatedResourceFhir.SourceResourceType != "Binary" { resource.RelatedResourceFhir = append(resource.RelatedResourceFhir, relatedResourceFhir) } return false }) } for resourceType, _ := range resourceListDictionary { sourceFlatten, sourceFlattenOk := sourceFlattenLevel[strings.ToLower(resourceType)] if sourceFlattenOk && sourceFlatten == true { //if flatten is set to true, we want to flatten the graph. This is usually for non primary source types (eg. Encounter is a source type, but Condition is the primary source type) // Step 3: populate related resources for each encounter, flattened for ndx, _ := range resourceListDictionary[resourceType] { // this is a "root" encounter, which is not related to any condition, we should add it to the Unknown encounters list flattenRelatedResourcesFn(resourceListDictionary[resourceType][ndx]) //sort all related resources (by date, desc) resourceListDictionary[resourceType][ndx].RelatedResourceFhir = utils.SortResourcePtrListByDate(resourceListDictionary[resourceType][ndx].RelatedResourceFhir) } } else { // if flatten is set to false, we want to preserve the top relationships in the graph heirarchy. This is usually for primary source types (eg. Condition is the primary source type) // we want to ensure context is preserved, so we will flatten the graph futher down in the heirarchy // Step 4: find all encounters referenced by the root conditions, populate them, then add them to the condition as RelatedResourceFhir for ndx, _ := range resourceListDictionary[resourceType] { // this is a "root" condition, resourceListDictionary[resourceType][ndx].RelatedResourceFhir = []*models.ResourceFhir{} vertexId := resourceVertexId(resourceListDictionary[resourceType][ndx]) for relatedVertexId, _ := range adjacencyMap[vertexId] { relatedResourceFhir, _ := g.Vertex(relatedVertexId) flattenRelatedResourcesFn(relatedResourceFhir) resourceListDictionary[resourceType][ndx].RelatedResourceFhir = append(resourceListDictionary[resourceType][ndx].RelatedResourceFhir, relatedResourceFhir) } //sort all related resources (by date, desc) resourceListDictionary[resourceType][ndx].RelatedResourceFhir = utils.SortResourcePtrListByDate(resourceListDictionary[resourceType][ndx].RelatedResourceFhir) } } resourceListDictionary[resourceType] = utils.SortResourcePtrListByDate(resourceListDictionary[resourceType]) } // Step 5: return the populated resource list dictionary return resourceListDictionary, nil } //We need to support the following types of graphs: // - Medical History // - AddressBook (contacts) // - Medications // - Billing Report //edges are always "strongly connected", however "source" nodes (roots, like Condition or Encounter -- depending on ) are only one way. //add an edge from every resource to its related resource. Keep in mind that FHIR resources may not contain reciprocal edges, so we ensure the graph is rooted by flipping any //related resources that are "Condition" or "Encounter" func (sr *SqliteRepository) PopulateGraphTypeReciprocalRelationships(graphType pkg.ResourceGraphType, relationships []models.RelatedResource) []models.RelatedResource { reciprocalRelationships := []models.RelatedResource{} //prioritized lists of sources and sinks for the graph. We will use these to determine which resources are "root" nodes. sources, sinks, _ := getSourcesAndSinksForGraphType(graphType) for _, relationship := range relationships { //calculate the resourceAGraphSourceLevel := foundResourceGraphSource(relationship.ResourceFhirSourceResourceType, sources) resourceBGraphSourceLevel := foundResourceGraphSource(relationship.RelatedResourceFhirSourceResourceType, sources) resourceAGraphSinkLevel := foundResourceGraphSink(relationship.ResourceFhirSourceResourceType, sinks) resourceBGraphSinkLevel := foundResourceGraphSink(relationship.RelatedResourceFhirSourceResourceType, sinks) if resourceAGraphSourceLevel > -1 && resourceBGraphSourceLevel > -1 { //handle the case where both resources are "sources" (eg. MedicalHistory - Condition or Encounter) if resourceAGraphSourceLevel <= resourceBGraphSourceLevel { //A is a higher priority than B, so we will add an edge from A to B reciprocalRelationships = append(reciprocalRelationships, relationship) } else { //B is a higher priority than A, so we will add an edge from B to A (flipped relationship) reciprocalRelationships = append(reciprocalRelationships, models.RelatedResource{ ResourceFhirUserID: relationship.RelatedResourceFhirUserID, ResourceFhirSourceID: relationship.RelatedResourceFhirSourceID, ResourceFhirSourceResourceType: relationship.RelatedResourceFhirSourceResourceType, ResourceFhirSourceResourceID: relationship.RelatedResourceFhirSourceResourceID, RelatedResourceFhirUserID: relationship.ResourceFhirUserID, RelatedResourceFhirSourceID: relationship.ResourceFhirSourceID, RelatedResourceFhirSourceResourceType: relationship.ResourceFhirSourceResourceType, RelatedResourceFhirSourceResourceID: relationship.ResourceFhirSourceResourceID, }) } } else if resourceAGraphSourceLevel > -1 || resourceBGraphSinkLevel > -1 { //resource A is a Source, or resource B is a sink, normal A -> B relationship (edge) reciprocalRelationships = append(reciprocalRelationships, relationship) } else if resourceBGraphSourceLevel > -1 || resourceAGraphSinkLevel > -1 { //resource B is a Source, or resource A is a sink, create B -> A relationship (edge) reciprocalRelationships = append(reciprocalRelationships, models.RelatedResource{ ResourceFhirUserID: relationship.RelatedResourceFhirUserID, ResourceFhirSourceID: relationship.RelatedResourceFhirSourceID, ResourceFhirSourceResourceType: relationship.RelatedResourceFhirSourceResourceType, ResourceFhirSourceResourceID: relationship.RelatedResourceFhirSourceResourceID, RelatedResourceFhirUserID: relationship.ResourceFhirUserID, RelatedResourceFhirSourceID: relationship.ResourceFhirSourceID, RelatedResourceFhirSourceResourceType: relationship.ResourceFhirSourceResourceType, RelatedResourceFhirSourceResourceID: relationship.ResourceFhirSourceResourceID, }) } else { //this is a regular pair of resources, create reciprocal edges reciprocalRelationships = append(reciprocalRelationships, relationship) reciprocalRelationships = append(reciprocalRelationships, models.RelatedResource{ ResourceFhirUserID: relationship.RelatedResourceFhirUserID, ResourceFhirSourceID: relationship.RelatedResourceFhirSourceID, ResourceFhirSourceResourceType: relationship.RelatedResourceFhirSourceResourceType, ResourceFhirSourceResourceID: relationship.RelatedResourceFhirSourceResourceID, RelatedResourceFhirUserID: relationship.ResourceFhirUserID, RelatedResourceFhirSourceID: relationship.ResourceFhirSourceID, RelatedResourceFhirSourceResourceType: relationship.ResourceFhirSourceResourceType, RelatedResourceFhirSourceResourceID: relationship.ResourceFhirSourceResourceID, }) } } return reciprocalRelationships } //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // Utilities //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// func getSourcesAndSinksForGraphType(graphType pkg.ResourceGraphType) ([][]string, [][]string, map[string]bool) { var sources [][]string var sinks [][]string var sourceFlattenRelated map[string]bool switch graphType { case pkg.ResourceGraphTypeMedicalHistory: sources = [][]string{ {"condition", "composition"}, {"encounter", "explanationofbenefit"}, } sinks = [][]string{ {"location", "device", "organization", "practitioner", "medication", "patient", "coverage"}, //resources that are shared across multiple conditions {"binary"}, } sourceFlattenRelated = map[string]bool{ "encounter": true, } break case pkg.ResourceGraphTypeAddressBook: sources = [][]string{ {"practitioner", "organization"}, {"practitionerrole", "careteam", "location"}, } sinks = [][]string{ {"condition", "composition", "explanationofbenefits"}, //resources that are shared across multiple practitioners {"encounter", "medication", "patient"}, } sourceFlattenRelated = map[string]bool{} } return sources, sinks, sourceFlattenRelated } //source resource types are resources that are at the root of the graph, nothing may reference them directly // loop though the list of source resource types, and see if the checkResourceType is one of them func foundResourceGraphSource(checkResourceType string, sourceResourceTypes [][]string) int { found := -1 for i, sourceResourceType := range sourceResourceTypes { if slices.Contains(sourceResourceType, strings.ToLower(checkResourceType)) { found = i break } } return found } //sink resource types are the leaves of the graph, they must not reference anything else. (only be referenced) func foundResourceGraphSink(checkResourceType string, sinkResourceTypes [][]string) int { found := -1 for i, sinkResourceType := range sinkResourceTypes { if slices.Contains(sinkResourceType, strings.ToLower(checkResourceType)) { found = i break } } return found } // helper function for GetResourceGraph, creating a "hash" for the resource func resourceVertexId(resource *models.ResourceFhir) string { return resourceKeysVertexId(resource.SourceID.String(), resource.SourceResourceType, resource.SourceResourceID) } func resourceKeysVertexId(sourceId string, resourceType string, resourceId string) string { return strings.ToLower(fmt.Sprintf("%s/%s/%s", sourceId, resourceType, resourceId)) }