make sure we can dynamically generate directed graph relationships on… (#131)

This commit is contained in:
Jason Kulatunga 2023-04-22 22:08:58 -07:00 committed by GitHub
parent 277aaccb6e
commit 2e53ce79c7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 405 additions and 306 deletions

View File

@ -1,5 +1,7 @@
package pkg package pkg
type ResourceGraphType string
const ( const (
ContextKeyTypeConfig string = "CONFIG" ContextKeyTypeConfig string = "CONFIG"
ContextKeyTypeDatabase string = "REPOSITORY" ContextKeyTypeDatabase string = "REPOSITORY"
@ -9,4 +11,9 @@ const (
ContextKeyTypeAuthToken string = "AUTH_TOKEN" ContextKeyTypeAuthToken string = "AUTH_TOKEN"
FhirResourceTypeComposition string = "Composition" FhirResourceTypeComposition string = "Composition"
ResourceGraphTypeMedicalHistory ResourceGraphType = "MedicalHistory"
ResourceGraphTypeAddressBook ResourceGraphType = "AddressBook"
ResourceGraphTypeMedications ResourceGraphType = "Medications"
ResourceGraphTypeBillingReport ResourceGraphType = "BillingReport"
) )

View File

@ -3,6 +3,7 @@ package database
import ( import (
"context" "context"
sourcePkg "github.com/fastenhealth/fasten-sources/clients/models" sourcePkg "github.com/fastenhealth/fasten-sources/clients/models"
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg"
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/models" "github.com/fastenhealth/fastenhealth-onprem/backend/pkg/models"
) )
@ -24,7 +25,7 @@ type DatabaseRepository interface {
GetPatientForSources(ctx context.Context) ([]models.ResourceFhir, error) GetPatientForSources(ctx context.Context) ([]models.ResourceFhir, error)
AddResourceAssociation(ctx context.Context, source *models.SourceCredential, resourceType string, resourceId string, relatedSource *models.SourceCredential, relatedResourceType string, relatedResourceId string) error AddResourceAssociation(ctx context.Context, source *models.SourceCredential, resourceType string, resourceId string, relatedSource *models.SourceCredential, relatedResourceType string, relatedResourceId string) error
RemoveResourceAssociation(ctx context.Context, source *models.SourceCredential, resourceType string, resourceId string, relatedSource *models.SourceCredential, relatedResourceType string, relatedResourceId string) error RemoveResourceAssociation(ctx context.Context, source *models.SourceCredential, resourceType string, resourceId string, relatedSource *models.SourceCredential, relatedResourceType string, relatedResourceId string) error
GetFlattenedResourceGraph(ctx context.Context) ([]*models.ResourceFhir, []*models.ResourceFhir, error) GetFlattenedResourceGraph(ctx context.Context, graphType pkg.ResourceGraphType) (map[string][]*models.ResourceFhir, error)
AddResourceComposition(ctx context.Context, compositionTitle string, resources []*models.ResourceFhir) error AddResourceComposition(ctx context.Context, compositionTitle string, resources []*models.ResourceFhir) error
//UpsertProfile(context.Context, *models.Profile) error //UpsertProfile(context.Context, *models.Profile) error
//UpsertOrganziation(context.Context, *models.Organization) error //UpsertOrganziation(context.Context, *models.Organization) error

View File

@ -4,7 +4,6 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/dominikbraun/graph"
sourceModel "github.com/fastenhealth/fasten-sources/clients/models" sourceModel "github.com/fastenhealth/fasten-sources/clients/models"
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg" "github.com/fastenhealth/fastenhealth-onprem/backend/pkg"
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/config" "github.com/fastenhealth/fastenhealth-onprem/backend/pkg/config"
@ -14,7 +13,6 @@ import (
"github.com/glebarez/sqlite" "github.com/glebarez/sqlite"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"golang.org/x/exp/slices"
"gorm.io/datatypes" "gorm.io/datatypes"
"gorm.io/gorm" "gorm.io/gorm"
"net/url" "net/url"
@ -234,7 +232,7 @@ func (sr *SqliteRepository) UpsertRawResource(ctx context.Context, sourceCredent
//create associations //create associations
//note: we create the association in the related_resources table **before** the model actually exists. //note: we create the association in the related_resources table **before** the model actually exists.
//note: these associations are not reciprocal, (i.e. if Procedure references Location, Location may not reference Procedure)
if rawResource.ReferencedResources != nil && len(rawResource.ReferencedResources) > 0 { if rawResource.ReferencedResources != nil && len(rawResource.ReferencedResources) > 0 {
for _, referencedResource := range rawResource.ReferencedResources { for _, referencedResource := range rawResource.ReferencedResources {
parts := strings.Split(referencedResource, "/") parts := strings.Split(referencedResource, "/")
@ -250,7 +248,15 @@ func (sr *SqliteRepository) UpsertRawResource(ctx context.Context, sourceCredent
}, },
RelatedResourceFhir: nil, RelatedResourceFhir: nil,
} }
err := sr.AddReciprocalResourceAssociations(ctx, &source, wrappedResourceModel, &source, relatedResource) err := sr.AddResourceAssociation(
ctx,
&source,
wrappedResourceModel.SourceResourceType,
wrappedResourceModel.SourceResourceID,
&source,
relatedResource.SourceResourceType,
relatedResource.SourceResourceID,
)
if err != nil { if err != nil {
sr.Logger.Errorf("Error when creating a reciprocal association for %s: %v", referencedResource, err) sr.Logger.Errorf("Error when creating a reciprocal association for %s: %v", referencedResource, err)
} }
@ -417,283 +423,31 @@ func (sr *SqliteRepository) GetPatientForSources(ctx context.Context) ([]models.
return wrappedResourceModels, results.Error return wrappedResourceModels, results.Error
} }
// 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) ([]*models.ResourceFhir, []*models.ResourceFhir, error) {
currentUser, currentUserErr := sr.GetCurrentUser(ctx)
if currentUserErr != nil {
return nil, nil, currentUserErr
}
// Get list of all resources
wrappedResourceModels, err := sr.ListResources(ctx, models.ListResourceQueryOptions{})
if err != nil {
return nil, nil, err
}
// Get list of all 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, 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, nil, fmt.Errorf("an error occurred while adding vertex: %v", err)
}
}
//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, 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, 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" encounters and conditions. store those nodes in their respective lists.
encounterList := []*models.ResourceFhir{}
conditionList := []*models.ResourceFhir{}
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
continue
}
if strings.ToLower(resource.SourceResourceType) == "condition" || strings.ToLower(resource.SourceResourceType) == strings.ToLower(pkg.FhirResourceTypeComposition) {
conditionList = append(conditionList, resource)
} else if strings.ToLower(resource.SourceResourceType) == "encounter" {
encounterList = append(encounterList, 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
})
}
// Step 3: populate related resources for each encounter, flattened
for ndx, _ := range encounterList {
// this is a "root" encounter, which is not related to any condition, we should add it to the Unknown encounters list
flattenRelatedResourcesFn(encounterList[ndx])
//sort all related resources (by date, desc)
encounterList[ndx].RelatedResourceFhir = utils.SortResourcePtrListByDate(encounterList[ndx].RelatedResourceFhir)
}
// Step 4: find all encounters referenced by the root conditions, populate them, then add them to the condition as RelatedResourceFhir
for ndx, _ := range conditionList {
// this is a "root" condition,
conditionList[ndx].RelatedResourceFhir = []*models.ResourceFhir{}
vertexId := resourceVertexId(conditionList[ndx])
for relatedVertexId, _ := range adjacencyMap[vertexId] {
relatedResourceFhir, _ := g.Vertex(relatedVertexId)
flattenRelatedResourcesFn(relatedResourceFhir)
conditionList[ndx].RelatedResourceFhir = append(conditionList[ndx].RelatedResourceFhir, relatedResourceFhir)
}
//sort all related resources (by date, desc)
conditionList[ndx].RelatedResourceFhir = utils.SortResourcePtrListByDate(conditionList[ndx].RelatedResourceFhir)
}
conditionList = utils.SortResourcePtrListByDate(conditionList)
encounterList = utils.SortResourcePtrListByDate(encounterList)
return conditionList, encounterList, nil
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Resource Associations // Resource Associations
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
func (sr *SqliteRepository) VerifyAssociationPermission(ctx context.Context, sourceUserID uuid.UUID, relatedSourceUserID uuid.UUID) error {
//edges are always "strongly connected", however "source" nodes (roots, like Condition or Encounter) 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) AddReciprocalResourceAssociations(ctx context.Context, source *models.SourceCredential, resource *models.ResourceFhir, relatedSource *models.SourceCredential, relatedResource *models.ResourceFhir) error {
//ensure that the sources are "owned" by the same user //ensure that the sources are "owned" by the same user
currentUser, currentUserErr := sr.GetCurrentUser(ctx) currentUser, currentUserErr := sr.GetCurrentUser(ctx)
if currentUserErr != nil { if currentUserErr != nil {
return currentUserErr return currentUserErr
} }
if source.UserID != relatedSource.UserID { if sourceUserID != relatedSourceUserID {
return fmt.Errorf("user id's must match when adding associations") return fmt.Errorf("user id's must match when adding associations")
} else if source.UserID != currentUser.ID { } else if sourceUserID != currentUser.ID {
return fmt.Errorf("user id's must match current user") return fmt.Errorf("user id's must match current user")
} }
//manually create association(s) (we've tried to create using Association.Append, and it doesnt work for some reason.
if isResourceGraphSource(resource) && isResourceGraphSource(relatedResource) {
//handle the case where both resources are "sources" (Condition or Encounter)
if strings.ToLower(resource.SourceResourceType) == "condition" {
//condition is always the source
if err := sr.AddResourceAssociation(
ctx,
source,
resource.SourceResourceType,
resource.SourceResourceID,
source,
relatedResource.SourceResourceType,
relatedResource.SourceResourceID,
); err != nil {
//ignoring errors, could be due to duplicate edges
sr.Logger.Warnf("an error occurred while creating resource association: %v", err)
}
} else {
if err := sr.AddResourceAssociation(
ctx,
source,
relatedResource.SourceResourceType,
relatedResource.SourceResourceID,
source,
resource.SourceResourceType,
resource.SourceResourceID,
); err != nil {
//ignoring errors, could be due to duplicate edges
sr.Logger.Warnf("an error occurred while creating resource association: %v", err)
}
}
} else if isResourceGraphSource(resource) || isResourceGraphSink(relatedResource) {
//resource is a Source, or the relatedResource is a sink, create a "resource" => "relatedResource" edge
if err := sr.AddResourceAssociation(
ctx,
source,
resource.SourceResourceType,
resource.SourceResourceID,
source,
relatedResource.SourceResourceType,
relatedResource.SourceResourceID,
); err != nil {
//ignoring errors, could be due to duplicate edges
sr.Logger.Warnf("an error occurred while creating resource association: %v", err)
}
} else if isResourceGraphSource(relatedResource) || isResourceGraphSink(resource) {
//relatedResource is a Source, or the resource is a sink, create a "relatedResource" => "resource" edge
if err := sr.AddResourceAssociation(
ctx,
source,
relatedResource.SourceResourceType,
relatedResource.SourceResourceID,
source,
resource.SourceResourceType,
resource.SourceResourceID,
); err != nil {
//ignoring errors, could be due to duplicate edges
sr.Logger.Warnf("an error occurred while creating resource association: %v", err)
}
} else {
//this is a regular pair of resources, create reciprocal edges
if err := sr.AddResourceAssociation(
ctx,
source,
resource.SourceResourceType,
resource.SourceResourceID,
source,
relatedResource.SourceResourceType,
relatedResource.SourceResourceID,
); err != nil {
//ignoring errors, could be due to duplicate edges
sr.Logger.Warnf("an error occurred while creating resource association: %v", err)
}
if err := sr.AddResourceAssociation(
ctx,
source,
relatedResource.SourceResourceType,
relatedResource.SourceResourceID,
source,
resource.SourceResourceType,
resource.SourceResourceID,
); err != nil {
//ignoring errors, could be due to duplicate edges
sr.Logger.Warnf("an error occurred while creating resource association: %v", err)
}
}
return nil return nil
} }
func (sr *SqliteRepository) AddResourceAssociation(ctx context.Context, source *models.SourceCredential, resourceType string, resourceId string, relatedSource *models.SourceCredential, relatedResourceType string, relatedResourceId string) error { func (sr *SqliteRepository) AddResourceAssociation(ctx context.Context, source *models.SourceCredential, resourceType string, resourceId string, relatedSource *models.SourceCredential, relatedResourceType string, relatedResourceId string) error {
//ensure that the sources are "owned" by the same user
err := sr.VerifyAssociationPermission(ctx, source.UserID, relatedSource.UserID)
if err != nil {
return err
}
return sr.GormClient.WithContext(ctx).Table("related_resources").Create(map[string]interface{}{ return sr.GormClient.WithContext(ctx).Table("related_resources").Create(map[string]interface{}{
"resource_fhir_user_id": source.UserID, "resource_fhir_user_id": source.UserID,
"resource_fhir_source_id": source.ID, "resource_fhir_source_id": source.ID,
@ -707,15 +461,10 @@ func (sr *SqliteRepository) AddResourceAssociation(ctx context.Context, source *
} }
func (sr *SqliteRepository) RemoveResourceAssociation(ctx context.Context, source *models.SourceCredential, resourceType string, resourceId string, relatedSource *models.SourceCredential, relatedResourceType string, relatedResourceId string) error { func (sr *SqliteRepository) RemoveResourceAssociation(ctx context.Context, source *models.SourceCredential, resourceType string, resourceId string, relatedSource *models.SourceCredential, relatedResourceType string, relatedResourceId string) error {
currentUser, currentUserErr := sr.GetCurrentUser(ctx) //ensure that the sources are "owned" by the same user
if currentUserErr != nil { err := sr.VerifyAssociationPermission(ctx, source.UserID, relatedSource.UserID)
return currentUserErr if err != nil {
} return err
if source.UserID != relatedSource.UserID {
return fmt.Errorf("user id's must match when adding associations")
} else if source.UserID != currentUser.ID {
return fmt.Errorf("user id's must match current user")
} }
//manually create association (we've tried to create using Association.Append, and it doesnt work for some reason. //manually create association (we've tried to create using Association.Append, and it doesnt work for some reason.
@ -1011,23 +760,3 @@ func sqlitePragmaString(pragmas map[string]string) string {
} }
return "" return ""
} }
//source resource types are resources that are at the root of the graph, nothing may reference them directly
func isResourceGraphSource(resource *models.ResourceFhir) bool {
sourceResourceTypes := []string{"condition", "encounter"}
return slices.Contains(sourceResourceTypes, strings.ToLower(resource.SourceResourceType)) // true
}
//sink resource types are the leaves of the graph, they must not reference anything else. (only be referenced)
func isResourceGraphSink(resource *models.ResourceFhir) bool {
sinkResourceTypes := []string{"location", "binary", "device", "organization", "practitioner", "medication", "patient"}
return slices.Contains(sinkResourceTypes, strings.ToLower(resource.SourceResourceType)) // true
}
// 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))
}

View File

@ -0,0 +1,359 @@
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"},
}
sinks = [][]string{
{"location", "device", "organization", "practitioner", "medication", "patient"}, //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))
}

View File

@ -97,15 +97,14 @@ func GetResourceFhirGraph(c *gin.Context) {
logger := c.MustGet(pkg.ContextKeyTypeLogger).(*logrus.Entry) logger := c.MustGet(pkg.ContextKeyTypeLogger).(*logrus.Entry)
databaseRepo := c.MustGet(pkg.ContextKeyTypeDatabase).(database.DatabaseRepository) databaseRepo := c.MustGet(pkg.ContextKeyTypeDatabase).(database.DatabaseRepository)
conditionResourceList, encounterResourceList, err := databaseRepo.GetFlattenedResourceGraph(c) graphType := strings.Trim(c.Param("graphType"), "/")
resourceListDictionary, err := databaseRepo.GetFlattenedResourceGraph(c, pkg.ResourceGraphType(graphType))
if err != nil { if err != nil {
logger.Errorln("An error occurred while retrieving list of resources", err) logger.Errorln("An error occurred while retrieving list of resources", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false}) c.JSON(http.StatusInternalServerError, gin.H{"success": false})
return return
} }
c.JSON(http.StatusOK, gin.H{"success": true, "data": map[string][]*models.ResourceFhir{ c.JSON(http.StatusOK, gin.H{"success": true, "data": resourceListDictionary})
"Condition": conditionResourceList,
"Encounter": encounterResourceList,
}})
} }

View File

@ -59,7 +59,7 @@ func (ae *AppEngine) Setup(logger *logrus.Entry) *gin.Engine {
secure.POST("/source/:sourceId/sync", handler.SourceSync) secure.POST("/source/:sourceId/sync", handler.SourceSync)
secure.GET("/source/:sourceId/summary", handler.GetSourceSummary) secure.GET("/source/:sourceId/summary", handler.GetSourceSummary)
secure.GET("/resource/fhir", handler.ListResourceFhir) secure.GET("/resource/fhir", handler.ListResourceFhir)
secure.GET("/resource/graph", handler.GetResourceFhirGraph) secure.GET("/resource/graph/:graphType", handler.GetResourceFhirGraph)
secure.GET("/resource/fhir/:sourceId/:resourceId", handler.GetResourceFhir) secure.GET("/resource/fhir/:sourceId/:resourceId", handler.GetResourceFhir)
secure.POST("/resource/composition", handler.CreateResourceComposition) secure.POST("/resource/composition", handler.CreateResourceComposition)
@ -82,7 +82,7 @@ func (ae *AppEngine) Setup(logger *logrus.Entry) *gin.Engine {
{ {
//http://localhost:9090/api/raw/test@test.com/436d7277-ad56-41ce-9823-44e353d1b3f6/Patient/smart-1288992 //http://localhost:9090/api/raw/test@test.com/436d7277-ad56-41ce-9823-44e353d1b3f6/Patient/smart-1288992
unsafe.GET("/:username/:sourceId/*path", handler.UnsafeRequestSource) unsafe.GET("/:username/:sourceId/*path", handler.UnsafeRequestSource)
unsafe.GET("/:username/graph", handler.UnsafeResourceGraph) unsafe.GET("/:username/graph/:graphType", handler.UnsafeResourceGraph)
} }
} }

View File

@ -30,8 +30,8 @@ export class MedicalHistoryComponent implements OnInit {
this.loading = true this.loading = true
this.fastenApi.getResourceGraph().subscribe(results => { this.fastenApi.getResourceGraph().subscribe(results => {
this.loading = false this.loading = false
this.conditions = results["Condition"] this.conditions = [].concat(results["Condition"] || [], results["Composition"] || [])
this.unassigned_encounters = results["Encounter"] this.unassigned_encounters = results["Encounter"] || []
//populate a lookup table with all resources //populate a lookup table with all resources
for(let condition of this.conditions){ for(let condition of this.conditions){

View File

@ -148,8 +148,12 @@ export class FastenApiService {
); );
} }
getResourceGraph(): Observable<{[resourceType: string]: ResourceFhir[]}> { getResourceGraph(graphType?: string): Observable<{[resourceType: string]: ResourceFhir[]}> {
return this._httpClient.get<any>(`${GetEndpointAbsolutePath(globalThis.location, environment.fasten_api_endpoint_base)}/secure/resource/graph`) if(!graphType){
graphType = "MedicalHistory"
}
return this._httpClient.get<any>(`${GetEndpointAbsolutePath(globalThis.location, environment.fasten_api_endpoint_base)}/secure/resource/graph/${graphType}`)
.pipe( .pipe(
map((response: ResponseWrapper) => { map((response: ResponseWrapper) => {
console.log("RESPONSE", response) console.log("RESPONSE", response)