From 4ea934509ebe0fe830fbaef9d85880b134f6606e Mon Sep 17 00:00:00 2001 From: zadam Date: Sat, 23 May 2020 10:25:22 +0200 Subject: [PATCH] implemented property based access + parent --- spec/lexer.spec.js | 25 ++++--- spec/parser.spec.js | 26 +++---- spec/search.spec.js | 70 +++++++++++++++++++ src/services/search/expressions/and.js | 2 +- src/services/search/expressions/child_of.js | 36 ++++++++++ src/services/search/expressions/expression.js | 1 + ...ield_comparison.js => label_comparison.js} | 4 +- .../search/expressions/property_comparison.js | 29 ++++++++ src/services/search/lexer.js | 4 +- src/services/search/note_set.js | 12 +++- src/services/search/parser.js | 58 ++++++++++++--- 11 files changed, 229 insertions(+), 38 deletions(-) create mode 100644 src/services/search/expressions/child_of.js rename src/services/search/expressions/{field_comparison.js => label_comparison.js} (92%) create mode 100644 src/services/search/expressions/property_comparison.js diff --git a/spec/lexer.spec.js b/spec/lexer.spec.js index 7532faa35..1482f27ee 100644 --- a/spec/lexer.spec.js +++ b/spec/lexer.spec.js @@ -18,8 +18,8 @@ describe("Lexer fulltext", () => { }); it("you can use different quotes and other special characters inside quotes", () => { - expect(lexer("'i can use \" or ` or #@=*' without problem").fulltextTokens) - .toEqual(["i can use \" or ` or #@=*", "without", "problem"]); + expect(lexer("'i can use \" or ` or #~=*' without problem").fulltextTokens) + .toEqual(["i can use \" or ` or #~=*", "without", "problem"]); }); it("if quote is not ended then it's just one long token", () => { @@ -33,15 +33,15 @@ describe("Lexer fulltext", () => { }); it("escaping special characters", () => { - expect(lexer("hello \\#\\@\\'").fulltextTokens) - .toEqual(["hello", "#@'"]); + expect(lexer("hello \\#\\~\\'").fulltextTokens) + .toEqual(["hello", "#~'"]); }); }); describe("Lexer expression", () => { it("simple attribute existence", () => { - expect(lexer("#label @relation").expressionTokens) - .toEqual(["#label", "@relation"]); + expect(lexer("#label ~relation").expressionTokens) + .toEqual(["#label", "~relation"]); }); it("simple label operators", () => { @@ -50,12 +50,17 @@ describe("Lexer expression", () => { }); it("spaces in attribute names and values", () => { - expect(lexer(`#'long label'="hello o' world" @'long relation'`).expressionTokens) - .toEqual(["#long label", "=", "hello o' world", "@long relation"]); + expect(lexer(`#'long label'="hello o' world" ~'long relation'`).expressionTokens) + .toEqual(["#long label", "=", "hello o' world", "~long relation"]); }); it("complex expressions with and, or and parenthesis", () => { - expect(lexer(`# (#label=text OR #second=text) AND @relation`).expressionTokens) - .toEqual(["#", "(", "#label", "=", "text", "or", "#second", "=", "text", ")", "and", "@relation"]); + expect(lexer(`# (#label=text OR #second=text) AND ~relation`).expressionTokens) + .toEqual(["#", "(", "#label", "=", "text", "or", "#second", "=", "text", ")", "and", "~relation"]); + }); + + it("dot separated properties", () => { + expect(lexer(`# ~author.title = 'Hugh Howey' AND note.title = 'Silo'`).expressionTokens) + .toEqual(["#", "~author", ".", "title", "=", "hugh howey", "and", "note", ".", "title", "=", "silo"]); }); }); diff --git a/spec/parser.spec.js b/spec/parser.spec.js index e0993e51d..360e679fa 100644 --- a/spec/parser.spec.js +++ b/spec/parser.spec.js @@ -37,7 +37,7 @@ describe("Parser", () => { parsingContext: new ParsingContext() }); - expect(rootExp.constructor.name).toEqual("FieldComparisonExp"); + expect(rootExp.constructor.name).toEqual("LabelComparisonExp"); expect(rootExp.attributeType).toEqual("label"); expect(rootExp.attributeName).toEqual("mylabel"); expect(rootExp.comparator).toBeTruthy(); @@ -53,10 +53,10 @@ describe("Parser", () => { expect(rootExp.constructor.name).toEqual("AndExp"); const [firstSub, secondSub] = rootExp.subExpressions; - expect(firstSub.constructor.name).toEqual("FieldComparisonExp"); + expect(firstSub.constructor.name).toEqual("LabelComparisonExp"); expect(firstSub.attributeName).toEqual("first"); - expect(secondSub.constructor.name).toEqual("FieldComparisonExp"); + expect(secondSub.constructor.name).toEqual("LabelComparisonExp"); expect(secondSub.attributeName).toEqual("second"); }); @@ -70,27 +70,27 @@ describe("Parser", () => { expect(rootExp.constructor.name).toEqual("AndExp"); const [firstSub, secondSub] = rootExp.subExpressions; - expect(firstSub.constructor.name).toEqual("FieldComparisonExp"); + expect(firstSub.constructor.name).toEqual("LabelComparisonExp"); expect(firstSub.attributeName).toEqual("first"); - expect(secondSub.constructor.name).toEqual("FieldComparisonExp"); + expect(secondSub.constructor.name).toEqual("LabelComparisonExp"); expect(secondSub.attributeName).toEqual("second"); }); it("simple label OR", () => { const rootExp = parser({ fulltextTokens: [], - expressionTokens: ["#first", "=", "text", "OR", "#second", "=", "text"], + expressionTokens: ["#first", "=", "text", "or", "#second", "=", "text"], parsingContext: new ParsingContext() }); expect(rootExp.constructor.name).toEqual("OrExp"); const [firstSub, secondSub] = rootExp.subExpressions; - expect(firstSub.constructor.name).toEqual("FieldComparisonExp"); + expect(firstSub.constructor.name).toEqual("LabelComparisonExp"); expect(firstSub.attributeName).toEqual("first"); - expect(secondSub.constructor.name).toEqual("FieldComparisonExp"); + expect(secondSub.constructor.name).toEqual("LabelComparisonExp"); expect(secondSub.attributeName).toEqual("second"); }); @@ -107,30 +107,30 @@ describe("Parser", () => { expect(firstSub.constructor.name).toEqual("NoteCacheFulltextExp"); expect(firstSub.tokens).toEqual(["hello"]); - expect(secondSub.constructor.name).toEqual("FieldComparisonExp"); + expect(secondSub.constructor.name).toEqual("LabelComparisonExp"); expect(secondSub.attributeName).toEqual("mylabel"); }); it("label sub-expression", () => { const rootExp = parser({ fulltextTokens: [], - expressionTokens: ["#first", "=", "text", "OR", ["#second", "=", "text", "AND", "#third", "=", "text"]], + expressionTokens: ["#first", "=", "text", "or", ["#second", "=", "text", "and", "#third", "=", "text"]], parsingContext: new ParsingContext() }); expect(rootExp.constructor.name).toEqual("OrExp"); const [firstSub, secondSub] = rootExp.subExpressions; - expect(firstSub.constructor.name).toEqual("FieldComparisonExp"); + expect(firstSub.constructor.name).toEqual("LabelComparisonExp"); expect(firstSub.attributeName).toEqual("first"); expect(secondSub.constructor.name).toEqual("AndExp"); const [firstSubSub, secondSubSub] = secondSub.subExpressions; - expect(firstSubSub.constructor.name).toEqual("FieldComparisonExp"); + expect(firstSubSub.constructor.name).toEqual("LabelComparisonExp"); expect(firstSubSub.attributeName).toEqual("second"); - expect(secondSubSub.constructor.name).toEqual("FieldComparisonExp"); + expect(secondSubSub.constructor.name).toEqual("LabelComparisonExp"); expect(secondSubSub.attributeName).toEqual("third"); }); }); diff --git a/spec/search.spec.js b/spec/search.spec.js index 19eb70345..e60ab658c 100644 --- a/spec/search.spec.js +++ b/spec/search.spec.js @@ -83,6 +83,31 @@ describe("Search", () => { expect(findNoteByTitle(searchResults, "Czech Republic")).toBeTruthy(); }); + it("numeric label comparison fallback to string comparison", async () => { + rootNote.child( + note("Europe") + .label('country', '', true) + .child( + note("Austria") + .label('established', '1955-07-27') + ) + .child( + note("Czech Republic") + .label('established', '1993-01-01') + ) + .child( + note("Hungary") + .label('established', '..wrong..') + ) + ); + + const parsingContext = new ParsingContext(); + + const searchResults = await searchService.findNotesWithQuery('#established < 1990', parsingContext); + expect(searchResults.length).toEqual(1); + expect(findNoteByTitle(searchResults, "Austria")).toBeTruthy(); + }); + it("logical or", async () => { rootNote.child( note("Europe") @@ -140,6 +165,51 @@ describe("Search", () => { expect(searchResults.length).toEqual(1); expect(findNoteByTitle(searchResults, "Austria")).toBeTruthy(); }); + + it("filter by note property", async () => { + rootNote.child( + note("Europe") + .child( + note("Austria") + ) + .child( + note("Czech Republic") + ) + ); + + const parsingContext = new ParsingContext(); + + const searchResults = await searchService.findNotesWithQuery('# note.title =* czech', parsingContext); + expect(searchResults.length).toEqual(1); + expect(findNoteByTitle(searchResults, "Czech Republic")).toBeTruthy(); + }); + + it("filter by note's parent", async () => { + rootNote.child( + note("Europe") + .child( + note("Austria") + ) + .child( + note("Czech Republic") + ) + ) + .child( + note("Asia") + .child(note('Taiwan')) + ); + + const parsingContext = new ParsingContext(); + + let searchResults = await searchService.findNotesWithQuery('# note.parent.title = Europe', parsingContext); + expect(searchResults.length).toEqual(2); + expect(findNoteByTitle(searchResults, "Austria")).toBeTruthy(); + expect(findNoteByTitle(searchResults, "Czech Republic")).toBeTruthy(); + + searchResults = await searchService.findNotesWithQuery('# note.parent.title = Asia', parsingContext); + expect(searchResults.length).toEqual(1); + expect(findNoteByTitle(searchResults, "Taiwan")).toBeTruthy(); + }); }); /** @return {Note} */ diff --git a/src/services/search/expressions/and.js b/src/services/search/expressions/and.js index 56973b11c..b5991503f 100644 --- a/src/services/search/expressions/and.js +++ b/src/services/search/expressions/and.js @@ -2,7 +2,7 @@ const Expression = require('./expression'); -class AndExp extends Expression{ +class AndExp extends Expression { static of(subExpressions) { subExpressions = subExpressions.filter(exp => !!exp); diff --git a/src/services/search/expressions/child_of.js b/src/services/search/expressions/child_of.js new file mode 100644 index 000000000..a98b0030c --- /dev/null +++ b/src/services/search/expressions/child_of.js @@ -0,0 +1,36 @@ +"use strict"; + +const Expression = require('./expression'); +const NoteSet = require('../note_set'); + +class ChildOfExp extends Expression { + constructor(subExpression) { + super(); + + this.subExpression = subExpression; + } + + execute(inputNoteSet, searchContext) { + const subInputNoteSet = new NoteSet(); + + for (const note of inputNoteSet.notes) { + subInputNoteSet.addAll(note.parents); + } + + const subResNoteSet = this.subExpression.execute(subInputNoteSet, searchContext); + + const resNoteSet = new NoteSet(); + + for (const parentNote of subResNoteSet.notes) { + for (const childNote of parentNote.children) { + if (inputNoteSet.hasNote(childNote)) { + resNoteSet.add(childNote); + } + } + } + + return resNoteSet; + } +} + +module.exports = ChildOfExp; diff --git a/src/services/search/expressions/expression.js b/src/services/search/expressions/expression.js index 49a1e64e5..48c247152 100644 --- a/src/services/search/expressions/expression.js +++ b/src/services/search/expressions/expression.js @@ -4,6 +4,7 @@ class Expression { /** * @param {NoteSet} noteSet * @param {object} searchContext + * @return {NoteSet} */ execute(noteSet, searchContext) {} } diff --git a/src/services/search/expressions/field_comparison.js b/src/services/search/expressions/label_comparison.js similarity index 92% rename from src/services/search/expressions/field_comparison.js rename to src/services/search/expressions/label_comparison.js index c497552d7..b69d07102 100644 --- a/src/services/search/expressions/field_comparison.js +++ b/src/services/search/expressions/label_comparison.js @@ -4,7 +4,7 @@ const Expression = require('./expression'); const NoteSet = require('../note_set'); const noteCache = require('../../note_cache/note_cache'); -class FieldComparisonExp extends Expression { +class LabelComparisonExp extends Expression { constructor(attributeType, attributeName, comparator) { super(); @@ -37,4 +37,4 @@ class FieldComparisonExp extends Expression { } } -module.exports = FieldComparisonExp; +module.exports = LabelComparisonExp; diff --git a/src/services/search/expressions/property_comparison.js b/src/services/search/expressions/property_comparison.js new file mode 100644 index 000000000..1ca544aa0 --- /dev/null +++ b/src/services/search/expressions/property_comparison.js @@ -0,0 +1,29 @@ +"use strict"; + +const Expression = require('./expression'); +const NoteSet = require('../note_set'); + +class PropertyComparisonExp extends Expression { + constructor(propertyName, comparator) { + super(); + + this.propertyName = propertyName; + this.comparator = comparator; + } + + execute(noteSet, searchContext) { + const resNoteSet = new NoteSet(); + + for (const note of noteSet.notes) { + const value = note[this.propertyName].toLowerCase(); + + if (this.comparator(value)) { + resNoteSet.add(note); + } + } + + return resNoteSet; + } +} + +module.exports = PropertyComparisonExp; diff --git a/src/services/search/lexer.js b/src/services/search/lexer.js index 8a5b90f0a..635d0d031 100644 --- a/src/services/search/lexer.js +++ b/src/services/search/lexer.js @@ -77,7 +77,7 @@ function lexer(str) { continue; } else if (!quotes) { - if (currentWord.length === 0 && (chr === '#' || chr === '@')) { + if (currentWord.length === 0 && (chr === '#' || chr === '~')) { fulltextEnded = true; currentWord = chr; @@ -87,7 +87,7 @@ function lexer(str) { finishWord(); continue; } - else if (fulltextEnded && ['(', ')'].includes(chr)) { + else if (fulltextEnded && ['(', ')', '.'].includes(chr)) { finishWord(); currentWord += chr; finishWord(); diff --git a/src/services/search/note_set.js b/src/services/search/note_set.js index ad22d5487..40b38de35 100644 --- a/src/services/search/note_set.js +++ b/src/services/search/note_set.js @@ -6,11 +6,19 @@ class NoteSet { } add(note) { - this.notes.push(note); + if (!this.hasNote(note)) { + this.notes.push(note); + } } addAll(notes) { - this.notes.push(...notes); + for (const note of notes) { + this.add(note); + } + } + + hasNote(note) { + return this.hasNoteId(note.noteId); } hasNoteId(noteId) { diff --git a/src/services/search/parser.js b/src/services/search/parser.js index d13e1c93d..427b8e456 100644 --- a/src/services/search/parser.js +++ b/src/services/search/parser.js @@ -3,8 +3,10 @@ const AndExp = require('./expressions/and'); const OrExp = require('./expressions/or'); const NotExp = require('./expressions/not'); +const ChildOfExp = require('./expressions/child_of'); +const PropertyComparisonExp = require('./expressions/property_comparison'); const AttributeExistsExp = require('./expressions/attribute_exists'); -const FieldComparisonExp = require('./expressions/field_comparison'); +const LabelComparisonExp = require('./expressions/label_comparison'); const NoteCacheFulltextExp = require('./expressions/note_cache_fulltext'); const NoteContentFulltextExp = require('./expressions/note_content_fulltext'); const comparatorBuilder = require('./comparator_builder'); @@ -38,17 +40,50 @@ function getExpression(tokens, parsingContext) { const expressions = []; let op = null; - for (let i = 0; i < tokens.length; i++) { + let i; + + function parseNoteProperty() { + if (tokens[i] !== '.') { + parsingContext.addError('Expected "." to separate field path'); + return; + } + + i++; + + if (tokens[i] === 'parent') { + i += 1; + + return new ChildOfExp(parseNoteProperty()); + } + + if (tokens[i] === 'title') { + const propertyName = tokens[i]; + const operator = tokens[i + 1]; + const comparedValue = tokens[i + 2]; + const comparator = comparatorBuilder(operator, comparedValue); + + if (!comparator) { + parsingContext.addError(`Can't find operator '${operator}'`); + return; + } + + i += 3; + + return new PropertyComparisonExp(propertyName, comparator); + } + } + + for (i = 0; i < tokens.length; i++) { const token = tokens[i]; - if (token === '#' || token === '@') { + if (token === '#' || token === '~') { continue; } if (Array.isArray(token)) { expressions.push(getExpression(token, parsingContext)); } - else if (token.startsWith('#') || token.startsWith('@')) { + else if (token.startsWith('#') || token.startsWith('~')) { const type = token.startsWith('#') ? 'label' : 'relation'; parsingContext.highlightedTokens.push(token.substr(1)); @@ -70,7 +105,7 @@ function getExpression(tokens, parsingContext) { continue; } - expressions.push(new FieldComparisonExp(type, token.substr(1), comparator)); + expressions.push(new LabelComparisonExp(type, token.substr(1), comparator)); i += 2; } @@ -78,11 +113,18 @@ function getExpression(tokens, parsingContext) { expressions.push(new AttributeExistsExp(type, token.substr(1), parsingContext.fuzzyAttributeSearch)); } } - else if (['and', 'or'].includes(token.toLowerCase())) { + else if (token === 'note') { + i++; + + expressions.push(parseNoteProperty(tokens)); + + continue; + } + else if (['and', 'or'].includes(token)) { if (!op) { - op = token.toLowerCase(); + op = token; } - else if (op !== token.toLowerCase()) { + else if (op !== token) { parsingContext.addError('Mixed usage of AND/OR - always use parenthesis to group AND/OR expressions.'); } }