From a08e4fc16660c1aa348511e7fe3c559d3d5de677 Mon Sep 17 00:00:00 2001 From: Felix Ruiz de Arcaute Date: Thu, 18 Jun 2020 15:26:57 +0200 Subject: [PATCH 1/4] Added fuzzyMatch. Works both directly on the attributes of a record and attributes of (nested) relation of the record. Due to filtering on these relations, the change was a bit more complex. The adapter instance needs to be passed down the whole chain of matching, since extra queries need to occur to walk the path of relations. Extra note ---------- It somewhat assumes the last leaf in the path refers to an attribute of a record. If it ends in a relation, this won't provide the results you want as a user. This will work: ``` store.find('user', undefined, { fuzzyMatch: { 'name': 'john D' 'address:building:rooms:roomName': 'Room X' } } ) ``` This not: ``` store.find('user', undefined, { fuzzyMatch: { 'name': 'john D' 'address:building': 'Building Y' } } ) ``` --- lib/adapter/adapters/common.js | 106 ++++++++++++++++++++++++++- lib/adapter/adapters/memory/index.js | 4 +- 2 files changed, 106 insertions(+), 4 deletions(-) diff --git a/lib/adapter/adapters/common.js b/lib/adapter/adapters/common.js index 74caf615a..0d583bf5e 100644 --- a/lib/adapter/adapters/common.js +++ b/lib/adapter/adapters/common.js @@ -38,7 +38,7 @@ var comparisons = [ exports.generateId = generateId -exports.applyOptions = function (fields, records, options, meta) { +exports.applyOptions = function (fields, records, options, meta, adapterInstance, type) { var count, record, field, isInclude, isExclude, language, memoizedRecords var i, j @@ -53,7 +53,7 @@ exports.applyOptions = function (fields, records, options, meta) { records = [] for (i = 0, j = memoizedRecords.length; i < j; i++) { record = memoizedRecords[i] - if (match(fields, options, record)) + if (match(fields, options, record, adapterInstance, type)) records.push(record) } } @@ -117,7 +117,7 @@ function checkValue (fieldDefinition, a) { } } -function match (fields, options, record) { +function match (fields, options, record, adapterInstance, type) { var key for (key in options) @@ -140,6 +140,8 @@ function match (fields, options, record) { case 'exists': if (!matchByExistence(fields, options[key], record)) return false break + case 'fuzzyMatch': + if (!matchByFuzzyMatch (options[key], record, adapterInstance, type) ) return false default: } @@ -232,6 +234,104 @@ function matchByRange (fields, ranges, record) { } +/** + * Fuzzy matching of attribute values. + * Works both on attributes of the record, + * or attributes of (nested) relations of the record. + */ +function matchByFuzzyMatch (filters, record, adapterInstance, type) { + for(const filterProperty of Object.keys(filters)){ + const valueToFilter = filters[filterProperty] + if(isRelationFilter(filterProperty)){ + const isMatching = doesFuzzyMatchRelation(record, + filterProperty, + valueToFilter, + adapterInstance, + type) + if(!isMatching) return false; + } + else if(!doesFuzzyMatchSimple(record, filterProperty, valueToFilter)) + return false + } + return true +} + +function doesFuzzyMatchSimple(record, filterProp, valueToFilter){ + const recordValue = record[filterProp] + return String(recordValue).toLowerCase().includes(String(valueToFilter).toLowerCase()) +} + +function doesFuzzyMatchRelation( record, filterProp, valueToFilter, adapterInstance, type){ + const relationFilterSegments = getRelationFilterSegments(filterProp) + const recordTypes = adapterInstance.recordTypes + const currentFields = recordTypes[type] + const typesPath = constructTypesPathToChild(recordTypes, + currentFields, + relationFilterSegments, + [] ) + const attributeToFilter = relationFilterSegments.splice(-1) + const recordsForFiltering = walkRelationPath(currentFields, + [ record ], + relationFilterSegments, + typesPath, + adapterInstance) + return recordsForFiltering + .filter(record => doesFuzzyMatchSimple(record, attributeToFilter, valueToFilter) ) + .length +} + +function walkRelationPath(currentFields, currRecords, + relationPath, typesPath, adapterInstance){ + + if(!relationPath.length){ + return currRecords + } + const nextRecords = [] + const relation = relationPath[0] + const targetType = typesPath[0] + const isArray = currentFields[relation].isArray + + for(const currRecord of currRecords){ + const ids = isArray ? currRecord[relation] : [ currRecord[relation] ] + + for(const id of ids){ + const record = adapterInstance.db[targetType][id] + if(record) nextRecords.push( record ) + } + } + + return walkRelationPath(adapterInstance.recordTypes[targetType], + nextRecords, relationPath.slice(1), + typesPath.slice(1), adapterInstance) +} + +function isRelationFilter ( field ) { + return field.split(':').length > 1 +} + +function getRelationFilterSegments ( field ) { + return field.split(':') +} + +function constructTypesPathToChild ( recordTypes, parent, + remainingPathSegments, typesPath ) { + if ( !remainingPathSegments.length ) + return typesPath + + + const segment = remainingPathSegments[0] + const nextType = parent[segment].link + + //complex type + if ( nextType ) { + typesPath.push( nextType ) + parent = recordTypes[nextType] + } + return constructTypesPathToChild(recordTypes, parent, + remainingPathSegments.slice(1), typesPath ) +} + + function findByType (type) { return function (pair) { var hasMatch = type === pair[0] || diff --git a/lib/adapter/adapters/memory/index.js b/lib/adapter/adapters/memory/index.js index 224df2380..b44878ca1 100644 --- a/lib/adapter/adapters/memory/index.js +++ b/lib/adapter/adapters/memory/index.js @@ -53,6 +53,7 @@ module.exports = function (Adapter) { var recordTypes = self.recordTypes var fields = recordTypes[type] var collection = self.db[type] + var records = [] var i, j, id, record @@ -74,7 +75,8 @@ module.exports = function (Adapter) { else for (id in collection) records.push(outputRecord.call(self, type, collection[id])) - return Promise.resolve(applyOptions(fields, records, options, meta)) + return Promise + .resolve(applyOptions(fields, records, options, meta, self, type)) } From 3b300834486cd7ca26f49e624ef9e6935a9286ca Mon Sep 17 00:00:00 2001 From: Felix Ruiz de Arcaute Date: Thu, 18 Jun 2020 16:42:45 +0200 Subject: [PATCH 2/4] added unit test --- test/unit/adapter.js | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/test/unit/adapter.js b/test/unit/adapter.js index cb1619ff4..138b3f89d 100644 --- a/test/unit/adapter.js +++ b/test/unit/adapter.js @@ -200,6 +200,46 @@ module.exports = (adapter, options) => { }) }) + run((assert, comment) => { + comment('find: fuzzyMatch') + return test(adapter => { + debugger + return Promise.all([ + adapter.find(type, null, { + fuzzyMatch: { + "friends:name": "jo" + } + }) + ]) + .then(results => { + results.forEach((records) => { + assert(records.length === 1, 'match length is correct') + assert(records[0].name === 'bob', 'matched correct record') + }) + }) + }) + }) + + run((assert, comment) => { + comment('find: fuzzyMatch in this world, the friend of a friend is myself') + return test(adapter => { + debugger + return Promise.all([ + adapter.find(type, null, { + fuzzyMatch: { + "friends:friends:name": "jOHn" + } + }) + ]) + .then(results => { + results.forEach((records) => { + assert(records.length === 1, 'match length is correct') + assert(records[0].name === 'john', 'matched correct record') + }) + }) + }) + }) + run((assert, comment) => { comment('find: match (string)') return test(adapter => { From ed2e0a770e70e7dc0d75956de6df7046c8605149 Mon Sep 17 00:00:00 2001 From: Felix Ruiz de Arcaute Date: Thu, 18 Jun 2020 16:52:26 +0200 Subject: [PATCH 3/4] Added eslint config to support more recent ecma stuff --- .eslintrc | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .eslintrc diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 000000000..20ac58bd0 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,9 @@ +{ + "parserOptions": { + "ecmaVersion": 2017 + }, + + "env": { + "es6": true + } +} \ No newline at end of file From 64a6f63874b369fc2738c2f0a81e8fe8abd691fa Mon Sep 17 00:00:00 2001 From: Felix Ruiz de Arcaute Date: Thu, 18 Jun 2020 16:55:40 +0200 Subject: [PATCH 4/4] make sure nested match(...) calls pass down required arguments --- lib/adapter/adapters/common.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/adapter/adapters/common.js b/lib/adapter/adapters/common.js index 0d583bf5e..f4f41d0a8 100644 --- a/lib/adapter/adapters/common.js +++ b/lib/adapter/adapters/common.js @@ -123,13 +123,13 @@ function match (fields, options, record, adapterInstance, type) { for (key in options) switch (key) { case 'and': - if (!matchByLogicalAnd(fields, options[key], record)) return false + if (!matchByLogicalAnd(fields, options[key], record, adapterInstance, type)) return false break case 'or': - if (!matchByLogicalOr(fields, options[key], record)) return false + if (!matchByLogicalOr(fields, options[key], record, adapterInstance, type)) return false break case 'not': - if (match(fields, options[key], record)) return false + if (match(fields, options[key], record, adapterInstance, type)) return false break case 'range': if (!matchByRange(fields, options[key], record)) return false @@ -148,20 +148,20 @@ function match (fields, options, record, adapterInstance, type) { return true } -function matchByLogicalAnd (fields, clauses, record) { +function matchByLogicalAnd (fields, clauses, record, adapterInstance, type) { var i for (i = 0; i < clauses.length; i++) - if (!match(fields, clauses[i], record)) return false + if (!match(fields, clauses[i], record, adapterInstance, type)) return false return true } -function matchByLogicalOr (fields, clauses, record) { +function matchByLogicalOr (fields, clauses, record, adapterInstance, type) { var i for (i = 0; i < clauses.length; i++) - if (match(fields, clauses[i], record)) return true + if (match(fields, clauses[i], record, adapterInstance, type)) return true return false }