From 89da9422c973837b44122fda085ae076afe951c3 Mon Sep 17 00:00:00 2001 From: David Glymph Date: Tue, 23 Jul 2024 10:26:42 -0400 Subject: [PATCH] qualified predicates + TRAPI handling --- .../textEditorRow/QualifiersSelector.jsx | 113 +++++++ .../textEditorRow/TextEditorRow.jsx | 277 ++++++++++++++---- .../textEditorRow/textEditorRow.css | 16 + src/pages/queryBuilder/useQueryBuilder.js | 10 + src/stores/useBiolinkModel.js | 6 +- 5 files changed, 360 insertions(+), 62 deletions(-) create mode 100644 src/pages/queryBuilder/textEditor/textEditorRow/QualifiersSelector.jsx diff --git a/src/pages/queryBuilder/textEditor/textEditorRow/QualifiersSelector.jsx b/src/pages/queryBuilder/textEditor/textEditorRow/QualifiersSelector.jsx new file mode 100644 index 00000000..4a3af6fd --- /dev/null +++ b/src/pages/queryBuilder/textEditor/textEditorRow/QualifiersSelector.jsx @@ -0,0 +1,113 @@ +/* eslint-disable no-restricted-syntax */ +import React, { useContext } from 'react'; +import { TextField } from '@material-ui/core'; +import { Autocomplete } from '@material-ui/lab'; +import QueryBuilderContext from '~/context/queryBuilder'; + +const flattenTree = (root, includeMixins) => { + const items = [root]; + if (root.children) { + for (const child of root.children) { + items.push(...flattenTree(child, includeMixins)); + } + } + if (root.mixinChildren && includeMixins === true) { + for (const mixinChild of root.mixinChildren) { + items.push(...flattenTree(mixinChild, includeMixins)); + } + } + return items; +}; + +const getQualifierOptions = ({ range, subpropertyOf }) => { + const options = []; + + if (range) { + if (range.permissible_values) { + options.push(...Object.keys(range.permissible_values)); + } else { + options.push(...flattenTree(range).map(({ name }) => name)); + } + } + + if (subpropertyOf) { + options.push(...flattenTree(subpropertyOf).map(({ name }) => name)); + } + + return options; +}; + +// const getBestAssociationOption = (associationOptions) => { +// let best = null; +// for (const opt of associationOptions) { +// if (opt.qualifiers.length > (best.length || 0)) best = opt; +// } +// return best; +// }; + +export default function QualifiersSelector({ id, associations }) { + const queryBuilder = useContext(QueryBuilderContext); + + const associationOptions = associations.map(({ association, qualifiers }) => ({ + name: association.name, + uuid: association.uuid, + qualifiers: qualifiers.map((q) => ({ + name: q.qualifier.name, + options: getQualifierOptions(q), + })), + })); + + const [value, setValue] = React.useState(associationOptions[0] || null); + const [qualifiers, setQualifiers] = React.useState({}); + React.useEffect(() => { + queryBuilder.dispatch({ type: 'editQualifiers', payload: { id, qualifiers } }); + }, [qualifiers]); + + if (associationOptions.length === 0) return null; + if (associationOptions.length === 1 && associationOptions[0].name === 'association') return null; + + return ( +
+ Qualifiers +
+ { + setValue(newValue); + }} + size="small" + options={associationOptions} + getOptionLabel={(option) => option.name} + getOptionSelected={(opt, val) => opt.uuid === val.uuid} + style={{ width: 300 }} + renderInput={(params) => } + /> + +
+ + { + value.qualifiers.map(({ name, options }) => ( + { + if (newValue === null) { + setQualifiers((prev) => { + const next = { ...prev }; + delete next[name]; + return next; + }); + } else { setQualifiers((prev) => ({ ...prev, [name]: newValue || null })); } + }} + options={options} + style={{ width: 300 }} + renderInput={(params) => } + size="small" + /> + )) + } +
+ +
+ ); +} diff --git a/src/pages/queryBuilder/textEditor/textEditorRow/TextEditorRow.jsx b/src/pages/queryBuilder/textEditor/textEditorRow/TextEditorRow.jsx index 580524e3..5243c04d 100644 --- a/src/pages/queryBuilder/textEditor/textEditorRow/TextEditorRow.jsx +++ b/src/pages/queryBuilder/textEditor/textEditorRow/TextEditorRow.jsx @@ -3,18 +3,172 @@ import IconButton from '@material-ui/core/IconButton'; import AddBoxOutlinedIcon from '@material-ui/icons/AddBoxOutlined'; import IndeterminateCheckBoxOutlinedIcon from '@material-ui/icons/IndeterminateCheckBoxOutlined'; +import BiolinkContext from '~/context/biolink'; import QueryBuilderContext from '~/context/queryBuilder'; import NodeSelector from './NodeSelector'; import PredicateSelector from './PredicateSelector'; +import QualifiersSelector from './QualifiersSelector'; import './textEditorRow.css'; +function getValidAssociations(s, p, o, model) { + const validAssociations = []; + + const subject = model.classes.lookup.get(s); + const predicate = model.slots.lookup.get(p); + const object = model.classes.lookup.get(o); + + const isInRange = ( + n, + range + ) => { + const traverse = (nodes, search) => { + for (const n of nodes) { + if (n === search) return true; + if (n.parent) { + if (traverse([n.parent], search)) return true; + } + if (n.mixinParents) { + if (traverse(n.mixinParents, search)) return true; + } + } + return false; + }; + return traverse([n], range); + }; + + /** + * Get the inherited subject/predicate/object ranges for an association + */ + const getInheritedSPORanges = ( + association + ) => { + const namedThing = model.classes.lookup.get("named thing"); + const relatedTo = model.slots.lookup.get("related to"); + + const traverse = ( + nodes, + part + ) => { + for (const node of nodes) { + if (node.slotUsage?.[part]) return node.slotUsage[part]; + if (node.parent) { + const discoveredType = traverse([node.parent], part); + if (discoveredType !== null) return discoveredType; + } + if (node.mixinParents) { + const discoveredType = traverse(node.mixinParents, part); + if (discoveredType !== null) return discoveredType; + } + } + + return null; + }; + + const subject = traverse([association], "subject") ?? namedThing; + const predicate = traverse([association], "predicate") ?? relatedTo; + const object = traverse([association], "object") ?? namedThing; + + return { subject, predicate, object }; + }; + + // DFS over associations + const traverse = (nodes, level = 0) => { + for (const association of nodes) { + if (association.slotUsage && !association.abstract) { + const inherited = getInheritedSPORanges(association); + + const validSubject = isInRange(subject, inherited.subject); + const validObject = isInRange(object, inherited.object); + const validPredicate = isInRange(predicate, inherited.predicate); + + const qualifiers = Object.entries(association.slotUsage) + .map(([qualifierName, properties]) => { + if (properties === null) return null; + const qualifier = model.slots.lookup.get(qualifierName); + if (!qualifier || !isInRange(qualifier, model.qualifiers)) + return null; + + let range = undefined; + if (properties.range) { + const potentialEnum = + model.enums[properties.range]; + const potentialClassNode = + model.classes.lookup.get(properties.range); + + if (potentialEnum) range = potentialEnum; + if (potentialClassNode) range = potentialClassNode; + } + + let subpropertyOf = undefined; + if ( + properties.subproperty_of && + model.slots.lookup.has(properties.subproperty_of) + ) { + subpropertyOf = model.slots.lookup.get( + properties.subproperty_of + ); + } + + return { + qualifier, + range, + subpropertyOf, + }; + }) + .filter((q) => q !== null); + + if (validSubject && validObject && validPredicate) { + validAssociations.push({ + association, + inheritedRanges: inherited, + level, + qualifiers, + }); + } + } + traverse(association.children, level + 1); + } + }; + traverse([model.associations]); + + validAssociations.sort((a, b) => b.level - a.level); + + return validAssociations; +} + export default function TextEditorRow({ row, index }) { const queryBuilder = useContext(QueryBuilderContext); + const { model } = useContext(BiolinkContext); + if (!model) return "Loading..."; const { query_graph } = queryBuilder; const edge = query_graph.edges[row.edgeId]; const { edgeId, subjectIsReference, objectIsReference } = row; + const subject = (query_graph.nodes[edge.subject].categories?.[0] ?? 'biolink:NamedThing') + .replace('biolink:', '') + .match(/[A-Z][a-z]+/g) + .join(' ') + .toLowerCase(); + const predicate = (edge.predicates?.[0] ?? 'biolink:related_to') + .replace('biolink:', '') + .replace(/_/g, ' '); + const object = (query_graph.nodes[edge.object].categories?.[0] ?? 'biolink:NamedThing') + .replace('biolink:', '') + .match(/[A-Z][a-z]+/g) + .join(' ') + .toLowerCase(); + + const validAssociations = getValidAssociations(subject, predicate, object, model); + + // console.log( + // `\ + // S: ${subjectCategory}\n\ + // P: ${predicate}\n\ + // O: ${objectCategory}\ + // ` + // ) + function deleteEdge() { queryBuilder.dispatch({ type: 'deleteEdge', payload: { id: edgeId } }); } @@ -33,67 +187,74 @@ export default function TextEditorRow({ row, index }) { return (
- - - -

- {index === 0 && 'Find'} - {index === 1 && 'where'} - {index > 1 && 'and where'} -

- setReference('subject', nodeId)} - update={subjectIsReference ? ( - () => setReference('subject', null) - ) : ( - editNode - )} - isReference={subjectIsReference} - options={{ - includeCuries: !subjectIsReference, - includeCategories: !subjectIsReference, - includeExistingNodes: index !== 0, - existingNodes: Object.keys(query_graph.nodes).filter( - (key) => key !== edge.object, - ).map((key) => ({ ...query_graph.nodes[key], key })), - }} - /> - + + + +

+ {index === 0 && 'Find'} + {index === 1 && 'where'} + {index > 1 && 'and where'} +

+ setReference('subject', nodeId)} + update={subjectIsReference ? ( + () => setReference('subject', null) + ) : ( + editNode + )} + isReference={subjectIsReference} + options={{ + includeCuries: !subjectIsReference, + includeCategories: !subjectIsReference, + includeExistingNodes: index !== 0, + existingNodes: Object.keys(query_graph.nodes).filter( + (key) => key !== edge.object, + ).map((key) => ({ ...query_graph.nodes[key], key })), + }} + /> + + setReference('object', nodeId)} + update={objectIsReference ? ( + () => setReference('object', null) + ) : ( + editNode + )} + isReference={objectIsReference} + options={{ + includeCuries: !objectIsReference, + includeCategories: !objectIsReference, + includeExistingNodes: index !== 0, + existingNodes: Object.keys(query_graph.nodes).filter( + (key) => key !== edge.subject, + ).map((key) => ({ ...query_graph.nodes[key], key })), + }} + /> + + + +
+ + - setReference('object', nodeId)} - update={objectIsReference ? ( - () => setReference('object', null) - ) : ( - editNode - )} - isReference={objectIsReference} - options={{ - includeCuries: !objectIsReference, - includeCategories: !objectIsReference, - includeExistingNodes: index !== 0, - existingNodes: Object.keys(query_graph.nodes).filter( - (key) => key !== edge.subject, - ).map((key) => ({ ...query_graph.nodes[key], key })), - }} - /> - - - ); } diff --git a/src/pages/queryBuilder/textEditor/textEditorRow/textEditorRow.css b/src/pages/queryBuilder/textEditor/textEditorRow/textEditorRow.css index b8928316..e2291c49 100644 --- a/src/pages/queryBuilder/textEditor/textEditorRow/textEditorRow.css +++ b/src/pages/queryBuilder/textEditor/textEditorRow/textEditorRow.css @@ -9,4 +9,20 @@ display: flex; flex-direction: column; align-items: flex-start; +} +.editor-row-wrapper { + display: flex; + flex-direction: column; +} + +summary { + display: list-item; + cursor: pointer; +} + +.qualifiers-dropdown { + padding: 1rem 0rem; + display: flex; + flex-direction: column; + gap: 1rem; } \ No newline at end of file diff --git a/src/pages/queryBuilder/useQueryBuilder.js b/src/pages/queryBuilder/useQueryBuilder.js index 2e9e224e..193be0b0 100644 --- a/src/pages/queryBuilder/useQueryBuilder.js +++ b/src/pages/queryBuilder/useQueryBuilder.js @@ -2,6 +2,7 @@ import { useEffect, useContext, useReducer, useMemo, } from 'react'; +import _ from 'lodash'; import AlertContext from '~/context/alert'; import queryBuilderUtils from '~/utils/queryBuilder'; import queryGraphUtils from '~/utils/queryGraph'; @@ -67,6 +68,15 @@ function reducer(state, action) { state.message.message.query_graph.edges[id].predicates = predicates; break; } + case 'editQualifiers': { + const { id, qualifiers } = action.payload; + const qualifier_set = Object.entries(qualifiers).map(([name, value]) => ({ + qualifier_type_id: `biolink:${_.snakeCase(name)}`, + qualifier_value: name === 'qualified predicate' ? `biolink:${_.snakeCase(value)}` : _.snakeCase(value), + })); + state.message.message.query_graph.edges[id].qualifier_constraints = [{ qualifier_set }]; + break; + } case 'deleteEdge': { const { id } = action.payload; delete state.message.message.query_graph.edges[id]; diff --git a/src/stores/useBiolinkModel.js b/src/stores/useBiolinkModel.js index e51b3fb4..704f0002 100644 --- a/src/stores/useBiolinkModel.js +++ b/src/stores/useBiolinkModel.js @@ -296,7 +296,7 @@ export default function useBiolinkModel() { } } - const m = { + setModel({ classes: { treeRootNodes: rootItems, lookup, @@ -308,9 +308,7 @@ export default function useBiolinkModel() { associations: lookup.get("association"), qualifiers: slotLookup.get("qualifier"), enums: biolinkModel.enums, - }; - console.log(m); - setModel(m); + }); } }, [biolinkModel]);