diff --git a/README.md b/README.md index 5e0de327..377852f4 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ ![build](https://github.com/bcgsc/pori_graphkb_loader/workflows/build/badge.svg?branch=master) ![Docker Image Version (latest semver)](https://img.shields.io/docker/v/bcgsc/pori-graphkb-loader?label=docker%20image) ![node versions](https://img.shields.io/badge/node-10%20%7C%2012%20%7C%2014-blue) +This repository is part of the [platform for oncogenomic reporting and interpretation](https://github.com/bcgsc/pori). + This package is used to import content from a variety of sources into GraphKB using the API. - [Loaders](#loaders) diff --git a/package-lock.json b/package-lock.json index 1096b450..32e336e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@bcgsc-pori/graphkb-loader", - "version": "5.1.0", + "version": "5.2.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -887,7 +887,8 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true + "dev": true, + "optional": true }, "supports-color": { "version": "7.1.0", @@ -4115,7 +4116,8 @@ "ansi-regex": { "version": "2.1.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "aproba": { "version": "1.2.0", @@ -4158,7 +4160,8 @@ "code-point-at": { "version": "1.1.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "concat-map": { "version": "0.0.1", @@ -4169,7 +4172,8 @@ "console-control-strings": { "version": "1.1.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "core-util-is": { "version": "1.0.2", @@ -4286,7 +4290,8 @@ "inherits": { "version": "2.0.4", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "ini": { "version": "1.3.5", @@ -4298,6 +4303,7 @@ "version": "1.0.0", "bundled": true, "dev": true, + "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -4320,12 +4326,14 @@ "minimist": { "version": "1.2.5", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "minipass": { "version": "2.9.0", "bundled": true, "dev": true, + "optional": true, "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -4344,6 +4352,7 @@ "version": "0.5.3", "bundled": true, "dev": true, + "optional": true, "requires": { "minimist": "^1.2.5" } @@ -4434,7 +4443,8 @@ "number-is-nan": { "version": "1.0.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "object-assign": { "version": "4.1.1", @@ -4446,6 +4456,7 @@ "version": "1.4.0", "bundled": true, "dev": true, + "optional": true, "requires": { "wrappy": "1" } @@ -4523,7 +4534,8 @@ "safe-buffer": { "version": "5.1.2", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "safer-buffer": { "version": "2.1.2", @@ -4559,6 +4571,7 @@ "version": "1.0.2", "bundled": true, "dev": true, + "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -4578,6 +4591,7 @@ "version": "3.0.1", "bundled": true, "dev": true, + "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -4621,12 +4635,14 @@ "wrappy": { "version": "1.0.2", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "yallist": { "version": "3.1.1", "bundled": true, - "dev": true + "dev": true, + "optional": true } } }, @@ -5010,6 +5026,7 @@ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.2.1.tgz", "integrity": "sha512-6e1q1cnWP2RXD9/keSkxHScg508CdXqXWgWBaETNhyuBFz+kUZlKboh+ISK+bU++DmbHimVBrOz/zzPe0sZ3sQ==", "dev": true, + "optional": true, "requires": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -5019,7 +5036,8 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true + "dev": true, + "optional": true } } }, @@ -8546,6 +8564,7 @@ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, + "optional": true, "requires": { "callsites": "^3.0.0" } diff --git a/package.json b/package.json index 4c7fda72..ca6c74f6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@bcgsc-pori/graphkb-loader", "main": "src/index.js", - "version": "5.1.0", + "version": "5.2.0", "repository": { "type": "git", "url": "https://github.com/bcgsc/pori_graphkb_loader.git" diff --git a/src/civic/index.js b/src/civic/index.js index 01fb22af..4882e6e3 100644 --- a/src/civic/index.js +++ b/src/civic/index.js @@ -112,12 +112,20 @@ const validateEvidenceSpec = ajv.compile({ /** - * Convert the CIViC relevance types to GraphKB terms + * Extract the appropriate GraphKB relevance term from a CIViC evidence record */ -const getRelevance = async ({ rawRecord, conn }) => { - const translateRelevance = ({ evidenceType, clinicalSignificance, evidenceDirection }) => { - switch (evidenceType) { // eslint-disable-line default-case - case 'Predictive': { +const translateRelevance = (evidenceType, evidenceDirection, clinicalSignificance) => { + switch (evidenceType) { // eslint-disable-line default-case + case 'Predictive': { + if (evidenceDirection === 'Does Not Support') { + switch (clinicalSignificance) { // eslint-disable-line default-case + case 'Sensitivity': + + case 'Sensitivity/Response': { + return 'no response'; + } + } + } else if (evidenceDirection === 'Supports') { switch (clinicalSignificance) { // eslint-disable-line default-case case 'Sensitivity': case 'Adverse Response': @@ -129,61 +137,66 @@ const getRelevance = async ({ rawRecord, conn }) => { case 'Sensitivity/Response': { return 'sensitivity'; } } - break; } + break; + } - case 'Functional': { - return clinicalSignificance.toLowerCase(); - } + case 'Functional': { + return clinicalSignificance.toLowerCase(); + } - case 'Diagnostic': { - switch (clinicalSignificance) { // eslint-disable-line default-case - case 'Positive': { return 'favours diagnosis'; } + case 'Diagnostic': { + switch (clinicalSignificance) { // eslint-disable-line default-case + case 'Positive': { return 'favours diagnosis'; } - case 'Negative': { return 'opposes diagnosis'; } - } - break; + case 'Negative': { return 'opposes diagnosis'; } } + break; + } - case 'Prognostic': { - switch (clinicalSignificance) { // eslint-disable-line default-case - case 'Negative': + case 'Prognostic': { + switch (clinicalSignificance) { // eslint-disable-line default-case + case 'Negative': - case 'Poor Outcome': { - return 'unfavourable prognosis'; - } - case 'Positive': + case 'Poor Outcome': { + return 'unfavourable prognosis'; + } + case 'Positive': - case 'Better Outcome': { - return 'favourable prognosis'; - } + case 'Better Outcome': { + return 'favourable prognosis'; } - break; } + break; + } - case 'Predisposing': { - if (['Positive', null, 'null'].includes(clinicalSignificance)) { - return 'Predisposing'; - } if (clinicalSignificance.includes('Pathogenic')) { - return clinicalSignificance; - } if (clinicalSignificance === 'Uncertain Significance') { - return 'likely predisposing'; - } - break; + case 'Predisposing': { + if (['Positive', null, 'null'].includes(clinicalSignificance)) { + return 'predisposing'; + } if (clinicalSignificance.includes('Pathogenic')) { + return clinicalSignificance.toLowerCase(); + } if (clinicalSignificance === 'Uncertain Significance') { + return 'likely predisposing'; } + break; } + } - throw new Error( - `unable to process relevance (${JSON.stringify({ clinicalSignificance, evidenceDirection, evidenceType })})`, - ); - }; + throw new Error( + `unable to process relevance (${JSON.stringify({ clinicalSignificance, evidenceDirection, evidenceType })})`, + ); +}; +/** + * Convert the CIViC relevance types to GraphKB terms + */ +const getRelevance = async ({ rawRecord, conn }) => { // translate the type to a GraphKB vocabulary term - let relevance = translateRelevance({ - clinicalSignificance: rawRecord.clinical_significance, - evidenceDirection: rawRecord.evidence_direction, - evidenceType: rawRecord.evidence_type, - }).toLowerCase(); + let relevance = translateRelevance( + rawRecord.evidence_type, + rawRecord.evidence_direction, + rawRecord.clinical_significance, + ).toLowerCase(); if (RELEVANCE_CACHE[relevance] === undefined) { relevance = await conn.getVocabularyTerm(relevance); @@ -572,7 +585,6 @@ const downloadEvidenceRecords = async (baseUrl) => { if ( record.clinical_significance === 'N/A' - || record.evidence_direction === 'Does Not Support' || (record.clinical_significance === null && record.evidence_type === 'Predictive') ) { counts.skip++; @@ -682,6 +694,7 @@ const upload = async (opt) => { const oneToOne = mappedCount === 1 && preupload.size === 1; + // upload all GraphKB statements for this CIViC Evidence Item for (const record of recordList) { for (const variant of record.variants) { for (const drugs of record.drugs) { @@ -738,5 +751,6 @@ const upload = async (opt) => { module.exports = { SOURCE_DEFN, specs: { validateEvidenceSpec }, + translateRelevance, upload, }; diff --git a/src/civic/variant.js b/src/civic/variant.js index f55ec0b7..e92b16a5 100644 --- a/src/civic/variant.js +++ b/src/civic/variant.js @@ -133,6 +133,7 @@ const normalizeVariantRecord = ({ type: name.replace(/-/g, ' '), }]; } if (match = /^t\(([^;()]+);([^;()]+)\)\(([^;()]+);([^;()]+)\)$/i.exec(name)) { + // convert translocation syntax const [, chr1, chr2, pos1, pos2] = match; return [{ positional: true, @@ -141,6 +142,7 @@ const normalizeVariantRecord = ({ variant: `translocation(${pos1}, ${pos2})`, }]; } if (match = /^(p\.)?([a-z*]\d+\S*)\s+\((c\.[^)]+)\)$/i.exec(name)) { + // split combined protein + cds notation let [, , protein, cds] = match; // correct deprecated cds syntac @@ -152,7 +154,7 @@ const normalizeVariantRecord = ({ } } return [{ - inferredBy: [ + inferredBy: [ // keep the cds variant as a link to the protein variant { positional: true, reference1: { ...referenceGene }, @@ -183,7 +185,10 @@ const normalizeVariantRecord = ({ let rest = { type: 'fusion' }; if (tail) { - if (match = /^[a-z](\d+);[a-z](\d+)$/.exec(tail || '')) { + if (match = /^e(\d+)-e(\d+)$/.exec(tail || '')) { + const [, exon1, exon2] = match; + rest = { positional: true, variant: `fusion(e.${exon1},e.${exon2})` }; + } else if (match = /^[a-z](\d+);[a-z](\d+)$/.exec(tail || '')) { const [, exon1, exon2] = match; rest = { positional: true, variant: `fusion(e.${exon1},e.${exon2})` }; } else { diff --git a/src/graphkb.js b/src/graphkb.js index 18b8358d..55d66247 100644 --- a/src/graphkb.js +++ b/src/graphkb.js @@ -45,7 +45,14 @@ const simplifyRecordsLinks = (content, level = 0) => { return content; }; - +/** + * Check if things have changed and we should send an update request + * + * @param {string|ClassModel} modelIn the model name or model object this record belongs to + * @param {Object} originalContentIn the original record + * @param {Object} newContentIn the new record + * @param {string[]} upsertCheckExclude a list of properties to ignore changes in + */ const shouldUpdate = (modelIn, originalContentIn, newContentIn, upsertCheckExclude = []) => { const model = typeof modelIn === 'string' ? schema.get(modelIn) @@ -290,6 +297,15 @@ class ApiConnection { return created; } + /** + * Given some query, fetch all matching records (handles paginating over large queries) + * @param {Object} opt + * @param {Object} opt.filters query filters + * @param {string} opt.target the target class to be queried + * @param {Number} opt.limit maximum number of records to fetch per request + * @param {Number} opt.neighbors maximum record depth to fetch + * @param {string[]} opt.returnProperties properties to return from each record + */ async getRecords(opt) { const { filters, @@ -333,11 +349,16 @@ class ApiConnection { } /** + * Fetch a record with a query. Error if the record cannot be uniquely identified. * - * @param {object} opt - * @param {object} opt.filters the conditions/query parameters for the selection - * @param {string} opt.target the target to query - * @param {function} opt.sort the function to use in sorting if multiple results are found + * @param {Object} opt + * @param {Object} opt.filters query filters + * @param {string} opt.target the target class to be queried + * @param {Number} opt.limit maximum number of records to fetch per request + * @param {Number} opt.neighbors maximum record depth to fetch + * @param {function} opt.sort the comparator function to use in sorting if multiple results are found + * + * @throws on multiple records matching the query that do not have a non-zero sort comparison value */ async getUniqueRecordBy(opt) { const { @@ -367,6 +388,9 @@ class ApiConnection { /** * Fetch therapy by name, ignore plurals for some cases + * + * @param {string} term the name or sourceId of the therapeutic term + * @param {string} source the source record ID the therapy is expected to belong to */ async getTherapy(term, source) { let error, @@ -421,6 +445,10 @@ class ApiConnection { throw error; } + /** + * @param {string} term the name of the vocabulary term to be fetched + * @param {string} sourceName the name of the source the vocabulary term belongs to + */ async getVocabularyTerm(term, sourceName = INTERNAL_SOURCE_NAME) { if (!term) { throw new Error('Cannot fetch vocabulary for empty term name'); @@ -452,6 +480,12 @@ class ApiConnection { return result; } + /** + * This will soft-delete a record via the API + * + * @param {string} target the class this record belongs to + * @param {string} recordId the ID of the record being deleted + */ async deleteRecord(target, recordId) { const model = schema.get(target); const { result } = jc.retrocycle(await this.request({ @@ -540,8 +574,8 @@ class ApiConnection { /** * @param {object} opt - * @param {object} opt.content - * @param {string} opt.target + * @param {object} opt.content the content of the variant record + * @param {string} opt.target the class to add the record to (PositionalVariant or CategoryVariant) */ async addVariant(opt) { const { @@ -574,6 +608,17 @@ class ApiConnection { }); } + /** + * Add a therapy combination. Will split the input name by "+" and query to find individual + * components. These will they be used to create the combination record + * + * TODO: link elements to combination therapy + * + * @param {string|Object} source the source record ID or source record this therapy belongs to + * @param {string} therapyName the name of the therpeutic combination + * @param {Object} opt + * @param {boolean} opt.matchSource flag to indicate sub-components of the therapy must be from the same source + */ async addTherapyCombination(source, therapyName, opt = {}) { const { matchSource = false } = opt; diff --git a/test/civic.test.js b/test/civic.test.js index dab721c1..e276bc62 100644 --- a/test/civic.test.js +++ b/test/civic.test.js @@ -1,5 +1,5 @@ const { normalizeVariantRecord } = require('../src/civic/variant'); - +const { translateRelevance } = require('../src/civic'); describe('normalizeVariantRecord', () => { test('exon mutation', () => { @@ -258,6 +258,24 @@ describe('normalizeVariantRecord', () => { ]); }); + test('fusion with new exon notation', () => { + // EWSR1-FLI1 e7-e6 + // FLI1 Fusion + const variants = normalizeVariantRecord({ + entrezId: 1, + entrezName: 'FLI1', + name: 'EWSR1-FLI1 e7-e6', + }); + expect(variants).toEqual([ + { + positional: true, + reference1: { name: 'ewsr1' }, + reference2: { name: 'fli1', sourceId: '1' }, + variant: 'fusion(e.7,e.6)', + }, + ]); + }); + test('fusion with reference2 input gene', () => { // EML4-ALK E20;A20 // ALK FUSIONS @@ -615,3 +633,31 @@ describe('normalizeVariantRecord', () => { }); }); }); + +describe('translateRelevance', () => { + test.each([ + ['Predictive', 'Supports', 'Sensitivity', 'sensitivity'], + ['Predictive', 'Supports', 'Adverse Response', 'adverse response'], + ['Predictive', 'Supports', 'Reduced Sensitivity', 'reduced sensitivity'], + ['Predictive', 'Supports', 'Resistance', 'resistance'], + ['Predictive', 'Supports', 'Sensitivity/Response', 'sensitivity'], + ['Diagnostic', 'Supports', 'Positive', 'favours diagnosis'], + ['Diagnostic', 'Supports', 'Negative', 'opposes diagnosis'], + ['Prognostic', 'Supports', 'Negative', 'unfavourable prognosis'], + ['Prognostic', 'Supports', 'Poor Outcome', 'unfavourable prognosis'], + ['Prognostic', 'Supports', 'Positive', 'favourable prognosis'], + ['Prognostic', 'Supports', 'Better Outcome', 'favourable prognosis'], + ['Predisposing', 'Supports', 'Positive', 'predisposing'], + ['Predisposing', 'Supports', null, 'predisposing'], + ['Predisposing', 'Supports', 'null', 'predisposing'], + ['Predisposing', 'Supports', 'Pathogenic', 'pathogenic'], + ['Predisposing', 'Supports', 'Likely Pathogenic', 'likely pathogenic'], + ['Functional', 'Supports', 'Gain of Function', 'gain of function'], + ['Predictive', 'Does Not Support', 'Sensitivity', 'no response'], + ['Predictive', 'Does Not Support', 'Sensitivity/Response', 'no response'], + ])( + '%s|%s|%s returns %s', (evidenceType, evidenceDirection, clinicalSignificance, expected) => { + expect(translateRelevance(evidenceType, evidenceDirection, clinicalSignificance)).toEqual(expected); + }, + ); +});