From f9b6fb95ab9c576382be81ed41c6f577d0978d7a Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Tue, 3 Mar 2020 21:06:23 +0100 Subject: [PATCH 01/18] spec for searches added --- backend/src/schema/resolvers/searches.js | 16 +- backend/src/schema/resolvers/searches.spec.js | 194 ++++++++++++++++++ 2 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 backend/src/schema/resolvers/searches.spec.js diff --git a/backend/src/schema/resolvers/searches.js b/backend/src/schema/resolvers/searches.js index 994d19fa2..5e09b062d 100644 --- a/backend/src/schema/resolvers/searches.js +++ b/backend/src/schema/resolvers/searches.js @@ -48,7 +48,7 @@ export default { const session = context.driver.session() const searchResultPromise = session.readTransaction(async transaction => { const postTransactionResponse = transaction.run(postCypher, { - query: myQuery, + query: createPostQuery(query), limit, thisUserId, }) @@ -71,3 +71,17 @@ export default { }, }, } + +function createPostQuery(str) { + // match the whole text + // console.log('"' + escapeSpecialCharacters(normalizeWhitespace(str)) + '"') + return '"' + escapeSpecialCharacters(normalizeWhitespace(str)) + '"' +} + +function normalizeWhitespace(str) { + return str.replace(/\s+/g, ' ') +} + +function escapeSpecialCharacters(str) { + return str.replace(/(["[\]&|\\{}+!()^~*?:/-])/g, '\\$1') +} diff --git a/backend/src/schema/resolvers/searches.spec.js b/backend/src/schema/resolvers/searches.spec.js new file mode 100644 index 000000000..90cd2e013 --- /dev/null +++ b/backend/src/schema/resolvers/searches.spec.js @@ -0,0 +1,194 @@ +import Factory, { cleanDatabase } from '../../db/factories' +import { gql } from '../../helpers/jest' +import { getNeode, getDriver } from '../../db/neo4j' +import createServer from '../../server' +import { createTestClient } from 'apollo-server-testing' + +let query, authenticatedUser + +const driver = getDriver() +const neode = getNeode() + +jest.setTimeout(30000) + +beforeAll(async () => { + await cleanDatabase() + const { server } = createServer({ + context: () => { + return { + driver, + neode, + user: authenticatedUser, + } + }, + }) + query = createTestClient(server).query +}) + +afterAll(async () => { + await cleanDatabase() +}) + +const searchQuery = gql` + query($query: String!) { + findResources(query: $query, limit: 5) { + __typename + ... on Post { + id + title + content + } + ... on User { + id + slug + name + } + } + } +` + +describe('resolvers', () => { + describe('searches', () => { + beforeAll(async () => { + const user = await Factory.build('user', { + id: 'a-user', + name: 'John Doe', + slug: 'john-doe', + }) + await Factory.build('post', { + id: 'a-post', + title: 'Beitrag', + content: 'Ein erster Beitrag', + }) + authenticatedUser = await user.toJson() + }) + let variables + + describe('basic searches', () => { + it('finds the post', async () => { + variables = { query: 'Beitrag' } + const res = await query({ query: searchQuery, variables }) + // console.log(res) + expect(res.data.findResources).toHaveLength(1) + }) + + it('does not find the post', async () => { + variables = { query: 'Unfug' } + const res = await query({ query: searchQuery, variables }) + expect(res.data.findResources).toHaveLength(0) + }) + + it('finds the user', async () => { + variables = { query: 'John' } + const res = await query({ query: searchQuery, variables }) + // console.log(res) + expect(res.data.findResources).toHaveLength(1) + }) + + it('does not find the user', async () => { + variables = { query: 'Unfug' } + const res = await query({ query: searchQuery, variables }) + expect(res.data.findResources).toHaveLength(0) + }) + }) + + describe('more data added', () => { + beforeAll(async () => { + await Promise.all([ + Factory.build('post', { + id: 'b-post', + title: 'Aufruf', + content: 'Jeder sollte seinen Beitrag leisten.', + }), + Factory.build('post', { + id: 'c-post', + title: 'Die binomischen Formeln', + content: ` +1. binomische Formel: (a + b)² = a² + 2ab + b² +2. binomische Formel: (a - b)² = a² - 2ab + b² +3. binomische Formel: (a + b)(a - b) = a² - b² +`, + }), + Factory.build('post', { + id: 'd-post', + title: 'Der Panther', + content: ` +Sein Blick ist vom Vorübergehn der Stäbe +so müd geworden, daß er nichts mehr hält. +Ihm ist, als ob es tausend Stäbe gäbe +und hinter tausend Stäben keine Welt. + +Der weiche Gang geschmeidig starker Schritte, +der sich im allerkleinsten Kreise dreht, +ist wie ein Tanz von Kraft um eine Mitte, +in der betäubt ein großer Wille steht. + +Nur manchmal schiebt der Vorhang der Pupille +sich lautlos auf –. Dann geht ein Bild hinein, +geht durch der Glieder angespannte Stille – +und hört im Herzen auf zu sein. +`, + }), + Factory.build('post', { + id: 'e-post', + title: 'Typographie', + content: ` +Gelegentlich können sowohl der angeführte Text als auch der Begleitsatz mit Frage- oder Ausrufezeichen enden (§ 91): +Gefällt dir der Roman „Quo vadis?“? Lass doch dieses ewige „Ich will nicht!“! +`, + }), + Factory.build('post', { + id: 'f-post', + title: 'Typographie II', + content: ` +Der Gedankenstrich kann als Auslassungszeichen (Auslassungsstrich) eine längere Pause oder eine Ellipse darstellen: „Du willst doch wohl nicht etwa –“, „Mein Gott, woher nehm ich bloß –?“ +`, + }), + Factory.build('post', { + id: 'g-post', + title: 'AK-47', + content: ` +Vom AK-47 Typ I existiert eine Version mit unter die Waffe klappbarer Schulterstütze, das AKS-47 (russisch Автомат Калашникова складной образца 1947 года, transkr.: Avtomat Kalašnikova skladnoj obrazca 1947 goda, dt. Automat Kalaschnikow klappbar Modell 1947tes Jahr) genannt wird, seltener auch AK-47s. +`, + }), + Factory.build('user', { + id: 'b-user', + name: 'Johnannes der Täufer', + slug: 'johnannes-der-taufer', + }), + Factory.build('user', { + id: 'c-user', + name: 'Rainer Maria Rilke', + slug: 'rainer-maria-rilke', + }), + Factory.build('user', { + id: 'd-user', + name: 'Erich Maria Remarque', + slug: 'erich-maria-remarque', + }), + Factory.build('user', { + id: 'e-user', + name: 'Klaus Dieter', + slug: 'kd', + }), + Factory.build('user', { + id: 'f-user', + name: 'Sluggy', + slug: '_', + }), + Factory.build('user', { + id: 'g-user', + name: 'AKK', + slug: 'akk', + }), + ]) + }) + + it('finds the AK-47', async () => { + variables = { query: 'AK-47' } + const res = await query({ query: searchQuery, variables }) + expect(res.data.findResources).toHaveLength(1) + }) + }) + }) +}) From a6a2ac4fbeec4955fc5f8a1e348b05f750bbcc91 Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Wed, 4 Mar 2020 04:00:29 +0100 Subject: [PATCH 02/18] search spec starts doing what it should --- backend/src/schema/resolvers/searches.js | 36 ++- backend/src/schema/resolvers/searches.spec.js | 281 +++++++++++------- 2 files changed, 202 insertions(+), 115 deletions(-) diff --git a/backend/src/schema/resolvers/searches.js b/backend/src/schema/resolvers/searches.js index 5e09b062d..f2fbfa668 100644 --- a/backend/src/schema/resolvers/searches.js +++ b/backend/src/schema/resolvers/searches.js @@ -1,22 +1,18 @@ import log from './helpers/databaseLogger' +// see http://lucene.apache.org/core/8_3_1/queryparser/org/apache/lucene/queryparser/classic/package-summary.html#package.description + export default { Query: { findResources: async (_parent, args, context, _resolveInfo) => { const { query, limit } = args const { id: thisUserId } = context.user - // see http://lucene.apache.org/core/8_3_1/queryparser/org/apache/lucene/queryparser/classic/package-summary.html#package.description - const myQuery = query - .replace(/\s+/g, ' ') - .replace(/[[@#:*~\\$|^\]?/"'(){}+?!,.-;]/g, '') - .split(' ') - .map(s => (s.toLowerCase().match(/^(not|and|or)$/) ? '"' + s + '"' : s + '*')) - .join(' ') + const postCypher = ` CALL db.index.fulltext.queryNodes('post_fulltext_search', $query) YIELD node as resource, score MATCH (resource)<-[:WROTE]-(author:User) - WHERE score >= 0.5 + WHERE score >= 0.2 AND NOT ( author.deleted = true OR author.disabled = true OR resource.deleted = true OR resource.disabled = true @@ -53,7 +49,7 @@ export default { thisUserId, }) const userTransactionResponse = transaction.run(userCypher, { - query: myQuery, + query: createUserQuery(query), limit, thisUserId, }) @@ -64,6 +60,8 @@ export default { const [postResults, userResults] = await searchResultPromise log(postResults) log(userResults) + // console.log(postResults.summary.query.parameters) + // console.log(userResults) return [...postResults.records, ...userResults.records].map(r => r.get('resource')) } finally { session.close() @@ -72,16 +70,32 @@ export default { }, } +function createUserQuery(str) { + // match the whole text + const normalizedString = normalizeWhitespace(str) + const escapedString = escapeSpecialCharacters(normalizedString) + const result = normalizedString.includes(' ') ? quoteString(escapedString) : escapedString + // console.log('"' + + '"') + return result +} + function createPostQuery(str) { // match the whole text - // console.log('"' + escapeSpecialCharacters(normalizeWhitespace(str)) + '"') - return '"' + escapeSpecialCharacters(normalizeWhitespace(str)) + '"' + const normalizedString = normalizeWhitespace(str) + const escapedString = escapeSpecialCharacters(normalizedString) + const result = normalizedString.includes(' ') ? quoteString(escapedString) : escapedString + // console.log('"' + + '"') + return result } function normalizeWhitespace(str) { return str.replace(/\s+/g, ' ') } +function quoteString(str) { + return '"' + str + '"' +} + function escapeSpecialCharacters(str) { return str.replace(/(["[\]&|\\{}+!()^~*?:/-])/g, '\\$1') } diff --git a/backend/src/schema/resolvers/searches.spec.js b/backend/src/schema/resolvers/searches.spec.js index 90cd2e013..4ae62ca2c 100644 --- a/backend/src/schema/resolvers/searches.spec.js +++ b/backend/src/schema/resolvers/searches.spec.js @@ -26,7 +26,7 @@ beforeAll(async () => { }) afterAll(async () => { - await cleanDatabase() + // await cleanDatabase() }) const searchQuery = gql` @@ -47,73 +47,149 @@ const searchQuery = gql` } ` +const nothingFound = { data: { findResources: [] } } + +const createExpectedObject = array => { + return { data: { findResources: array } } +} + +const addPostToDB = post => { + return Factory.build('post', { + id: post.id, + title: post.title, + content: post.content, + }) +} + +const addUserToDB = user => { + return Factory.build('user', { + id: user.id, + name: user.name, + slug: user.slug, + }) +} + +const createDataObject = (obj, type) => { + return { __typename: type, ...obj } +} + +const createPostObject = post => { + return createDataObject(post, 'Post') +} + +const createUserObject = user => { + return createDataObject(user, 'User') +} + +// see data at the end of the file + +let user + describe('resolvers', () => { describe('searches', () => { beforeAll(async () => { - const user = await Factory.build('user', { - id: 'a-user', - name: 'John Doe', - slug: 'john-doe', - }) - await Factory.build('post', { - id: 'a-post', - title: 'Beitrag', - content: 'Ein erster Beitrag', - }) + user = await addUserToDB(aUser) + await addPostToDB(aPost) authenticatedUser = await user.toJson() }) + let variables describe('basic searches', () => { it('finds the post', async () => { variables = { query: 'Beitrag' } - const res = await query({ query: searchQuery, variables }) - // console.log(res) - expect(res.data.findResources).toHaveLength(1) + const expected = createExpectedObject([aPost]) + await expect(query({ query: searchQuery, variables })).resolves.toMatchObject(expected) }) it('does not find the post', async () => { variables = { query: 'Unfug' } - const res = await query({ query: searchQuery, variables }) - expect(res.data.findResources).toHaveLength(0) + await expect(query({ query: searchQuery, variables })).resolves.toMatchObject(nothingFound) }) it('finds the user', async () => { variables = { query: 'John' } - const res = await query({ query: searchQuery, variables }) - // console.log(res) - expect(res.data.findResources).toHaveLength(1) + const expected = createExpectedObject([aUser]) + await expect(query({ query: searchQuery, variables })).resolves.toMatchObject(expected) }) it('does not find the user', async () => { variables = { query: 'Unfug' } - const res = await query({ query: searchQuery, variables }) - expect(res.data.findResources).toHaveLength(0) + await expect(query({ query: searchQuery, variables })).resolves.toMatchObject(nothingFound) }) }) describe('more data added', () => { beforeAll(async () => { await Promise.all([ - Factory.build('post', { - id: 'b-post', - title: 'Aufruf', - content: 'Jeder sollte seinen Beitrag leisten.', - }), - Factory.build('post', { - id: 'c-post', - title: 'Die binomischen Formeln', - content: ` -1. binomische Formel: (a + b)² = a² + 2ab + b² + addPostToDB(bPost), + addPostToDB(cPost), + addPostToDB(dPost), + addPostToDB(ePost), + addPostToDB(fPost), + addPostToDB(gPost), + addUserToDB(bUser), + addUserToDB(cUser), + addUserToDB(dUser), + addUserToDB(eUser), + addUserToDB(fUser), + addUserToDB(gUser), + ]) + }) + + it('finds the AK-47', async () => { + variables = { query: 'AK-47' } + const expected = createExpectedObject([gPost]) + await expect(query({ query: searchQuery, variables })).resolves.toMatchObject(expected) + }) + + it('finds more than one post', async () => { + variables = { query: 'Beitrag' } + const expected = createExpectedObject([aPost, bPost]) + await expect(query({ query: searchQuery, variables })).resolves.toMatchObject(expected) + }) + + it('finds more than one user by slug', async () => { + variables = { query: '-maria-' } + const expected = createExpectedObject([dUser, cUser]) + await expect(query({ query: searchQuery, variables })).resolves.toMatchObject(expected) + }) + + it('finds Russian text', async () => { + variables = { query: 'Калашникова' } + const expected = createExpectedObject([gPost]) + await expect(query({ query: searchQuery, variables })).resolves.toMatchObject(expected) + }) + }) + }) +}) + +// data section + +const aPost = createPostObject({ + id: 'a-post', + title: 'Beitrag', + content: 'Ein erster Beitrag', +}) + +const bPost = createPostObject({ + id: 'b-post', + title: 'Aufruf', + content: 'Jeder sollte seinen Beitrag leisten.', +}) + +const cPost = createPostObject({ + id: 'c-post', + title: 'Die binomischen Formeln', + content: `1. binomische Formel: (a + b)² = a² + 2ab + b² 2. binomische Formel: (a - b)² = a² - 2ab + b² -3. binomische Formel: (a + b)(a - b) = a² - b² -`, - }), - Factory.build('post', { - id: 'd-post', - title: 'Der Panther', - content: ` -Sein Blick ist vom Vorübergehn der Stäbe +3. binomische Formel: (a + b)(a - b) = a² - b²`, +}) + +const dPost = createPostObject({ + id: 'd-post', + title: 'Der Panther', + content: `Sein Blick ist vom Vorübergehn der Stäbe so müd geworden, daß er nichts mehr hält. Ihm ist, als ob es tausend Stäbe gäbe und hinter tausend Stäben keine Welt. @@ -126,69 +202,66 @@ in der betäubt ein großer Wille steht. Nur manchmal schiebt der Vorhang der Pupille sich lautlos auf –. Dann geht ein Bild hinein, geht durch der Glieder angespannte Stille – -und hört im Herzen auf zu sein. -`, - }), - Factory.build('post', { - id: 'e-post', - title: 'Typographie', - content: ` -Gelegentlich können sowohl der angeführte Text als auch der Begleitsatz mit Frage- oder Ausrufezeichen enden (§ 91): -Gefällt dir der Roman „Quo vadis?“? Lass doch dieses ewige „Ich will nicht!“! -`, - }), - Factory.build('post', { - id: 'f-post', - title: 'Typographie II', - content: ` -Der Gedankenstrich kann als Auslassungszeichen (Auslassungsstrich) eine längere Pause oder eine Ellipse darstellen: „Du willst doch wohl nicht etwa –“, „Mein Gott, woher nehm ich bloß –?“ -`, - }), - Factory.build('post', { - id: 'g-post', - title: 'AK-47', - content: ` -Vom AK-47 Typ I existiert eine Version mit unter die Waffe klappbarer Schulterstütze, das AKS-47 (russisch Автомат Калашникова складной образца 1947 года, transkr.: Avtomat Kalašnikova skladnoj obrazca 1947 goda, dt. Automat Kalaschnikow klappbar Modell 1947tes Jahr) genannt wird, seltener auch AK-47s. -`, - }), - Factory.build('user', { - id: 'b-user', - name: 'Johnannes der Täufer', - slug: 'johnannes-der-taufer', - }), - Factory.build('user', { - id: 'c-user', - name: 'Rainer Maria Rilke', - slug: 'rainer-maria-rilke', - }), - Factory.build('user', { - id: 'd-user', - name: 'Erich Maria Remarque', - slug: 'erich-maria-remarque', - }), - Factory.build('user', { - id: 'e-user', - name: 'Klaus Dieter', - slug: 'kd', - }), - Factory.build('user', { - id: 'f-user', - name: 'Sluggy', - slug: '_', - }), - Factory.build('user', { - id: 'g-user', - name: 'AKK', - slug: 'akk', - }), - ]) - }) - - it('finds the AK-47', async () => { - variables = { query: 'AK-47' } - const res = await query({ query: searchQuery, variables }) - expect(res.data.findResources).toHaveLength(1) - }) - }) - }) +und hört im Herzen auf zu sein.`, +}) + +const ePost = createPostObject({ + id: 'e-post', + title: 'Typographie', + content: `Gelegentlich können sowohl der angeführte Text als auch der Begleitsatz mit Frage- oder Ausrufezeichen enden (§ 91): +Gefällt dir der Roman „Quo vadis?“? Lass doch dieses ewige „Ich will nicht!“!`, +}) + +const fPost = createPostObject({ + id: 'f-post', + title: 'Typographie II', + content: `Der Gedankenstrich kann als Auslassungszeichen (Auslassungsstrich) eine längere Pause oder eine Ellipse darstellen: „Du willst doch wohl nicht etwa –“, „Mein Gott, woher nehm ich bloß –?“`, +}) + +const gPost = createPostObject({ + id: 'g-post', + title: 'AK-47', + content: `Vom AK-47 Typ I existiert eine Version mit unter die Waffe klappbarer Schulterstütze, das AKS-47 (russisch Автомат Калашникова складной образца 1947 года, transkr.: Avtomat Kalašnikova skladnoj obrazca 1947 goda, dt. Automat Kalaschnikow klappbar Modell 1947tes Jahr) genannt wird, seltener auch AK-47s.`, +}) + +const aUser = createUserObject({ + id: 'a-user', + name: 'John Doe', + slug: 'john-doe', +}) + +const bUser = createUserObject({ + id: 'b-user', + name: 'Johnannes der Täufer', + slug: 'johnannes-der-taufer', +}) + +const cUser = createUserObject({ + id: 'c-user', + name: 'Rainer Maria Rilke', + slug: 'rainer-maria-rilke', +}) + +const dUser = createUserObject({ + id: 'd-user', + name: 'Erich Maria Remarque', + slug: 'erich-maria-remarque', +}) + +const eUser = createUserObject({ + id: 'e-user', + name: 'Klaus Dieter', + slug: 'kd', +}) + +const fUser = createUserObject({ + id: 'f-user', + name: 'Sluggy', + slug: '_', +}) + +const gUser = createUserObject({ + id: 'g-user', + name: 'AKK', + slug: 'akk', }) From 9cb489dce13601b6c902e7b8859554af698599a5 Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Tue, 10 Mar 2020 01:07:40 +0100 Subject: [PATCH 03/18] Specs are running and regex in searches.js is cleaned up Matching the whole text entered exactly is boosted by 8. Matching all the words entered exactly is boosted by 4. Matching some words ebtered exactly is boosted by 2. Glob matching is applied for words with more than three characters is not boosted. To Do: Deal with @ and # symbols. To Do: Find a way to match unicode, e.g. kyrillic letters. --- backend/src/schema/resolvers/searches.js | 51 ++++++---- backend/src/schema/resolvers/searches.spec.js | 94 +++++++++++++++---- 2 files changed, 107 insertions(+), 38 deletions(-) diff --git a/backend/src/schema/resolvers/searches.js b/backend/src/schema/resolvers/searches.js index f2fbfa668..ce626f148 100644 --- a/backend/src/schema/resolvers/searches.js +++ b/backend/src/schema/resolvers/searches.js @@ -12,7 +12,7 @@ export default { CALL db.index.fulltext.queryNodes('post_fulltext_search', $query) YIELD node as resource, score MATCH (resource)<-[:WROTE]-(author:User) - WHERE score >= 0.2 + WHERE score >= 0.0 AND NOT ( author.deleted = true OR author.disabled = true OR resource.deleted = true OR resource.disabled = true @@ -35,7 +35,7 @@ export default { CALL db.index.fulltext.queryNodes('user_fulltext_search', $query) YIELD node as resource, score MATCH (resource) - WHERE score >= 0.5 + WHERE score >= 0.0 AND NOT (resource.deleted = true OR resource.disabled = true) RETURN resource {.*, __typename: labels(resource)[0]} LIMIT $limit @@ -70,32 +70,47 @@ export default { }, } -function createUserQuery(str) { - // match the whole text +const createUserQuery = str => { + return createPostQuery(str) +} + +const createPostQuery = str => { + // match the whole text exactly const normalizedString = normalizeWhitespace(str) const escapedString = escapeSpecialCharacters(normalizedString) - const result = normalizedString.includes(' ') ? quoteString(escapedString) : escapedString - // console.log('"' + + '"') + let result = quoteString(escapedString) + '^8' + // match each word exactly + if (escapedString.includes(' ')) { + result += ' OR (' + escapedString.split(' ').forEach((s, i) => { + result += i === 0 ? quoteString(s) : ' AND ' + quoteString(s) + }) + result += ')^4' + } + // match at least one word exactly + if (escapedString.includes(' ')) { + escapedString.split(' ').forEach(s => { + result += ' OR ' + quoteString(s) + '^2' + }) + } + // start globbing ... + escapedString.split(' ').forEach(s => { + if (s.length > 3) + // at least 4 letters. So AND, OR and NOT are never used unquoted + result += ' OR ' + s + '*' + }) + // now we could become fuzzy using ~ return result } -function createPostQuery(str) { - // match the whole text - const normalizedString = normalizeWhitespace(str) - const escapedString = escapeSpecialCharacters(normalizedString) - const result = normalizedString.includes(' ') ? quoteString(escapedString) : escapedString - // console.log('"' + + '"') - return result -} - -function normalizeWhitespace(str) { +const normalizeWhitespace = str => { return str.replace(/\s+/g, ' ') } -function quoteString(str) { +const quoteString = str => { return '"' + str + '"' } -function escapeSpecialCharacters(str) { +const escapeSpecialCharacters = str => { return str.replace(/(["[\]&|\\{}+!()^~*?:/-])/g, '\\$1') } diff --git a/backend/src/schema/resolvers/searches.spec.js b/backend/src/schema/resolvers/searches.spec.js index 4ae62ca2c..ce02c1984 100644 --- a/backend/src/schema/resolvers/searches.spec.js +++ b/backend/src/schema/resolvers/searches.spec.js @@ -3,6 +3,7 @@ import { gql } from '../../helpers/jest' import { getNeode, getDriver } from '../../db/neo4j' import createServer from '../../server' import { createTestClient } from 'apollo-server-testing' +import cloneDeep from 'lodash/cloneDeep' let query, authenticatedUser @@ -49,8 +50,18 @@ const searchQuery = gql` const nothingFound = { data: { findResources: [] } } +const addBrAfterNewlinw = array => { + return array.map(obj => { + const tmp = cloneDeep(obj) + if (tmp.__typename === 'Post') { + tmp.content = tmp.content.replace(/\n/g, '
\n') + } + return tmp + }) +} + const createExpectedObject = array => { - return { data: { findResources: array } } + return { data: { findResources: addBrAfterNewlinw(array) } } } const addPostToDB = post => { @@ -69,6 +80,14 @@ const addUserToDB = user => { }) } +const dumpToDB = array => { + const result = [] + array.forEach(obj => { + obj.__typename === 'Post' ? result.push(addPostToDB(obj)) : result.push(addUserToDB(obj)) + }) + return result +} + const createDataObject = (obj, type) => { return { __typename: type, ...obj } } @@ -97,7 +116,13 @@ describe('resolvers', () => { describe('basic searches', () => { it('finds the post', async () => { - variables = { query: 'Beitrag' } + variables = { query: 'beitrag' } + const expected = createExpectedObject([aPost]) + await expect(query({ query: searchQuery, variables })).resolves.toMatchObject(expected) + }) + + it('finds the post searching only with capital letters', async () => { + variables = { query: 'BEITRAG' } const expected = createExpectedObject([aPost]) await expect(query({ query: searchQuery, variables })).resolves.toMatchObject(expected) }) @@ -121,20 +146,22 @@ describe('resolvers', () => { describe('more data added', () => { beforeAll(async () => { - await Promise.all([ - addPostToDB(bPost), - addPostToDB(cPost), - addPostToDB(dPost), - addPostToDB(ePost), - addPostToDB(fPost), - addPostToDB(gPost), - addUserToDB(bUser), - addUserToDB(cUser), - addUserToDB(dUser), - addUserToDB(eUser), - addUserToDB(fUser), - addUserToDB(gUser), - ]) + await Promise.all( + dumpToDB([ + bPost, + cPost, + dPost, + ePost, + fPost, + gPost, + bUser, + cUser, + dUser, + eUser, + fUser, + gUser, + ]), + ) }) it('finds the AK-47', async () => { @@ -151,15 +178,44 @@ describe('resolvers', () => { it('finds more than one user by slug', async () => { variables = { query: '-maria-' } - const expected = createExpectedObject([dUser, cUser]) + const expected = [cUser, dUser] + await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({ + data: { + findResources: expect.arrayContaining(expected), + }, + }) + }) + + it('finds the binomial formula', async () => { + variables = { query: '(a - b)² = a² - 2ab + b²' } + const expected = createExpectedObject([cPost]) await expect(query({ query: searchQuery, variables })).resolves.toMatchObject(expected) }) + it('finds text over linebreak', async () => { + variables = { query: 'dreht, ist' } + const expected = createExpectedObject([dPost]) + await expect(query({ query: searchQuery, variables })).resolves.toMatchObject(expected) + }) + + it('finds single words with lower score', async () => { + variables = { query: 'der Panther' } + const expected = createExpectedObject([dPost, ePost, fPost, bUser]) + await expect(query({ query: searchQuery, variables })).resolves.toMatchObject(expected) + }) + + it('finds something that starts with the given text', async () => { + variables = { query: 'john' } + const expected = createExpectedObject([aUser, bUser]) + await expect(query({ query: searchQuery, variables })).resolves.toMatchObject(expected) + }) + + /* it('finds Russian text', async () => { variables = { query: 'Калашникова' } const expected = createExpectedObject([gPost]) await expect(query({ query: searchQuery, variables })).resolves.toMatchObject(expected) - }) + }) */ }) }) }) @@ -193,12 +249,10 @@ const dPost = createPostObject({ so müd geworden, daß er nichts mehr hält. Ihm ist, als ob es tausend Stäbe gäbe und hinter tausend Stäben keine Welt. - Der weiche Gang geschmeidig starker Schritte, der sich im allerkleinsten Kreise dreht, ist wie ein Tanz von Kraft um eine Mitte, in der betäubt ein großer Wille steht. - Nur manchmal schiebt der Vorhang der Pupille sich lautlos auf –. Dann geht ein Bild hinein, geht durch der Glieder angespannte Stille – From 1060a2f6d0ac7451c7aa87cf8b956baad05fff9c Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Tue, 10 Mar 2020 02:11:34 +0100 Subject: [PATCH 04/18] cypress tests will pass --- backend/src/schema/resolvers/searches.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/src/schema/resolvers/searches.js b/backend/src/schema/resolvers/searches.js index ce626f148..7f11734a3 100644 --- a/backend/src/schema/resolvers/searches.js +++ b/backend/src/schema/resolvers/searches.js @@ -95,8 +95,9 @@ const createPostQuery = str => { } // start globbing ... escapedString.split(' ').forEach(s => { - if (s.length > 3) + if (!s.matches(/^(AND|OR|NOT)$/)) // at least 4 letters. So AND, OR and NOT are never used unquoted + // but the related cypress test expects a search for just two chars. result += ' OR ' + s + '*' }) // now we could become fuzzy using ~ From 57101b80bf770efb04d91e0f7190360f44a0b321 Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Tue, 10 Mar 2020 02:28:10 +0100 Subject: [PATCH 05/18] cypress tests will really pass --- backend/src/schema/resolvers/searches.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/src/schema/resolvers/searches.js b/backend/src/schema/resolvers/searches.js index 7f11734a3..56913fb3e 100644 --- a/backend/src/schema/resolvers/searches.js +++ b/backend/src/schema/resolvers/searches.js @@ -95,10 +95,11 @@ const createPostQuery = str => { } // start globbing ... escapedString.split(' ').forEach(s => { - if (!s.matches(/^(AND|OR|NOT)$/)) + if (!s.match(/^(AND|OR|NOT)$/i)) { // at least 4 letters. So AND, OR and NOT are never used unquoted // but the related cypress test expects a search for just two chars. result += ' OR ' + s + '*' + } }) // now we could become fuzzy using ~ return result From 8f1b3b9f6b68ac234dc654a80fae1afed6f765d6 Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Tue, 10 Mar 2020 12:12:51 +0100 Subject: [PATCH 06/18] cypress test data adjusted to new search mechanism --- backend/src/schema/resolvers/searches.js | 3 +-- cypress/integration/common/search.js | 2 +- cypress/integration/search/Search.feature | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/backend/src/schema/resolvers/searches.js b/backend/src/schema/resolvers/searches.js index 56913fb3e..918f914cd 100644 --- a/backend/src/schema/resolvers/searches.js +++ b/backend/src/schema/resolvers/searches.js @@ -95,9 +95,8 @@ const createPostQuery = str => { } // start globbing ... escapedString.split(' ').forEach(s => { - if (!s.match(/^(AND|OR|NOT)$/i)) { + if (s.length > 3) { // at least 4 letters. So AND, OR and NOT are never used unquoted - // but the related cypress test expects a search for just two chars. result += ' OR ' + s + '*' } }) diff --git a/cypress/integration/common/search.js b/cypress/integration/common/search.js index 1feece77e..282793547 100644 --- a/cypress/integration/common/search.js +++ b/cypress/integration/common/search.js @@ -43,7 +43,7 @@ Then("I should see the following users in the select dropdown:", table => { }); }); -When("I type {string} and press Enter", value => { +When("I type {PR} and press Enter", value => { cy.get(".searchable-input .ds-select input") .focus() .type(value) diff --git a/cypress/integration/search/Search.feature b/cypress/integration/search/Search.feature index e83f58477..a7bc99d18 100644 --- a/cypress/integration/search/Search.feature +++ b/cypress/integration/search/Search.feature @@ -7,7 +7,7 @@ Feature: Search Given I have a user account And we have the following posts in our database: | id | title | content | - | p1 | 101 Essays that will change the way you think | 101 Essays, of course! | + | p1 | 101 Essays that will change the way you think | 101 Essays, of course (PR)! | | p2 | No searched for content | will be found in this post, I guarantee | And we have the following user accounts: | slug | name | id | From 4a2d2508542208d914948a58bdb59339ca28ea44 Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Tue, 10 Mar 2020 13:31:13 +0100 Subject: [PATCH 07/18] cypress test data adjusted to new search mechanism --- cypress/integration/common/search.js | 2 +- cypress/integration/search/Search.feature | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cypress/integration/common/search.js b/cypress/integration/common/search.js index 282793547..1feece77e 100644 --- a/cypress/integration/common/search.js +++ b/cypress/integration/common/search.js @@ -43,7 +43,7 @@ Then("I should see the following users in the select dropdown:", table => { }); }); -When("I type {PR} and press Enter", value => { +When("I type {string} and press Enter", value => { cy.get(".searchable-input .ds-select input") .focus() .type(value) diff --git a/cypress/integration/search/Search.feature b/cypress/integration/search/Search.feature index a7bc99d18..b77b45d8e 100644 --- a/cypress/integration/search/Search.feature +++ b/cypress/integration/search/Search.feature @@ -24,7 +24,7 @@ Feature: Search | 101 Essays that will change the way you think | Scenario: Press enter starts search - When I type "Es" and press Enter + When I type "PR" and press Enter Then I should have one item in the select dropdown Then I should see the following posts in the select dropdown: | title | From 5b5fc09053f2c102fd6e13abb55acf5e6d645769 Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Tue, 10 Mar 2020 14:34:14 +0100 Subject: [PATCH 08/18] fix typo --- backend/src/schema/resolvers/searches.spec.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/schema/resolvers/searches.spec.js b/backend/src/schema/resolvers/searches.spec.js index ce02c1984..c3a5d1dda 100644 --- a/backend/src/schema/resolvers/searches.spec.js +++ b/backend/src/schema/resolvers/searches.spec.js @@ -50,7 +50,7 @@ const searchQuery = gql` const nothingFound = { data: { findResources: [] } } -const addBrAfterNewlinw = array => { +const addBrAfterNewline = array => { return array.map(obj => { const tmp = cloneDeep(obj) if (tmp.__typename === 'Post') { @@ -61,7 +61,7 @@ const addBrAfterNewlinw = array => { } const createExpectedObject = array => { - return { data: { findResources: addBrAfterNewlinw(array) } } + return { data: { findResources: addBrAfterNewline(array) } } } const addPostToDB = post => { From 4f8d605b88efa4885cc4892943a10af1332f7acd Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Tue, 10 Mar 2020 20:41:10 +0100 Subject: [PATCH 09/18] ensure that every post has an author --- backend/src/schema/resolvers/searches.spec.js | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/backend/src/schema/resolvers/searches.spec.js b/backend/src/schema/resolvers/searches.spec.js index c3a5d1dda..c72c1b256 100644 --- a/backend/src/schema/resolvers/searches.spec.js +++ b/backend/src/schema/resolvers/searches.spec.js @@ -65,11 +65,17 @@ const createExpectedObject = array => { } const addPostToDB = post => { - return Factory.build('post', { - id: post.id, - title: post.title, - content: post.content, - }) + return Factory.build( + 'post', + { + id: post.id, + title: post.title, + content: post.content, + }, + { + authorId: 'a-user', + }, + ) } const addUserToDB = user => { From a9c6356ffae8f6422a4843cde10b2ecafb3cd569 Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Wed, 11 Mar 2020 00:38:21 +0100 Subject: [PATCH 10/18] clean db after test --- backend/src/schema/resolvers/searches.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/schema/resolvers/searches.spec.js b/backend/src/schema/resolvers/searches.spec.js index c72c1b256..b955110e1 100644 --- a/backend/src/schema/resolvers/searches.spec.js +++ b/backend/src/schema/resolvers/searches.spec.js @@ -27,7 +27,7 @@ beforeAll(async () => { }) afterAll(async () => { - // await cleanDatabase() + await cleanDatabase() }) const searchQuery = gql` From 3d25ec5b4ed80dbb275e954f9faf19da88937754 Mon Sep 17 00:00:00 2001 From: roschaefer Date: Wed, 11 Mar 2020 16:02:42 +0100 Subject: [PATCH 11/18] Start to refactor specs and implementation @mogge this is just a rough guideline how to improve the quality of your tests. Of course it needs to be continued. --- backend/src/schema/resolvers/searches.js | 51 +---- backend/src/schema/resolvers/searches.spec.js | 213 ++++++++++-------- .../schema/resolvers/searches/queryString.js | 41 ++++ .../resolvers/searches/queryString.spec.js | 10 + 4 files changed, 177 insertions(+), 138 deletions(-) create mode 100644 backend/src/schema/resolvers/searches/queryString.js create mode 100644 backend/src/schema/resolvers/searches/queryString.spec.js diff --git a/backend/src/schema/resolvers/searches.js b/backend/src/schema/resolvers/searches.js index 918f914cd..ba67bb2d0 100644 --- a/backend/src/schema/resolvers/searches.js +++ b/backend/src/schema/resolvers/searches.js @@ -1,4 +1,5 @@ import log from './helpers/databaseLogger' +import queryString from './searches/queryString' // see http://lucene.apache.org/core/8_3_1/queryparser/org/apache/lucene/queryparser/classic/package-summary.html#package.description @@ -44,12 +45,12 @@ export default { const session = context.driver.session() const searchResultPromise = session.readTransaction(async transaction => { const postTransactionResponse = transaction.run(postCypher, { - query: createPostQuery(query), + query: queryString(query), limit, thisUserId, }) const userTransactionResponse = transaction.run(userCypher, { - query: createUserQuery(query), + query: queryString(query), limit, thisUserId, }) @@ -69,49 +70,3 @@ export default { }, }, } - -const createUserQuery = str => { - return createPostQuery(str) -} - -const createPostQuery = str => { - // match the whole text exactly - const normalizedString = normalizeWhitespace(str) - const escapedString = escapeSpecialCharacters(normalizedString) - let result = quoteString(escapedString) + '^8' - // match each word exactly - if (escapedString.includes(' ')) { - result += ' OR (' - escapedString.split(' ').forEach((s, i) => { - result += i === 0 ? quoteString(s) : ' AND ' + quoteString(s) - }) - result += ')^4' - } - // match at least one word exactly - if (escapedString.includes(' ')) { - escapedString.split(' ').forEach(s => { - result += ' OR ' + quoteString(s) + '^2' - }) - } - // start globbing ... - escapedString.split(' ').forEach(s => { - if (s.length > 3) { - // at least 4 letters. So AND, OR and NOT are never used unquoted - result += ' OR ' + s + '*' - } - }) - // now we could become fuzzy using ~ - return result -} - -const normalizeWhitespace = str => { - return str.replace(/\s+/g, ' ') -} - -const quoteString = str => { - return '"' + str + '"' -} - -const escapeSpecialCharacters = str => { - return str.replace(/(["[\]&|\\{}+!()^~*?:/-])/g, '\\$1') -} diff --git a/backend/src/schema/resolvers/searches.spec.js b/backend/src/schema/resolvers/searches.spec.js index b955110e1..b0b2b1e4a 100644 --- a/backend/src/schema/resolvers/searches.spec.js +++ b/backend/src/schema/resolvers/searches.spec.js @@ -48,8 +48,6 @@ const searchQuery = gql` } ` -const nothingFound = { data: { findResources: [] } } - const addBrAfterNewline = array => { return array.map(obj => { const tmp = cloneDeep(obj) @@ -112,116 +110,151 @@ let user describe('resolvers', () => { describe('searches', () => { - beforeAll(async () => { - user = await addUserToDB(aUser) - await addPostToDB(aPost) - authenticatedUser = await user.toJson() - }) - let variables - describe('basic searches', () => { + describe('given one post and one user', () => { + beforeAll(async () => { + user = await addUserToDB(aUser) + await addPostToDB(aPost) + authenticatedUser = await user.toJson() + }) + it('finds the post', async () => { variables = { query: 'beitrag' } - const expected = createExpectedObject([aPost]) - await expect(query({ query: searchQuery, variables })).resolves.toMatchObject(expected) - }) - - it('finds the post searching only with capital letters', async () => { - variables = { query: 'BEITRAG' } - const expected = createExpectedObject([aPost]) - await expect(query({ query: searchQuery, variables })).resolves.toMatchObject(expected) - }) - - it('does not find the post', async () => { - variables = { query: 'Unfug' } - await expect(query({ query: searchQuery, variables })).resolves.toMatchObject(nothingFound) - }) - - it('finds the user', async () => { - variables = { query: 'John' } - const expected = createExpectedObject([aUser]) - await expect(query({ query: searchQuery, variables })).resolves.toMatchObject(expected) - }) - - it('does not find the user', async () => { - variables = { query: 'Unfug' } - await expect(query({ query: searchQuery, variables })).resolves.toMatchObject(nothingFound) - }) - }) - - describe('more data added', () => { - beforeAll(async () => { - await Promise.all( - dumpToDB([ - bPost, - cPost, - dPost, - ePost, - fPost, - gPost, - bUser, - cUser, - dUser, - eUser, - fUser, - gUser, - ]), - ) - }) - - it('finds the AK-47', async () => { - variables = { query: 'AK-47' } - const expected = createExpectedObject([gPost]) - await expect(query({ query: searchQuery, variables })).resolves.toMatchObject(expected) - }) - - it('finds more than one post', async () => { - variables = { query: 'Beitrag' } - const expected = createExpectedObject([aPost, bPost]) - await expect(query({ query: searchQuery, variables })).resolves.toMatchObject(expected) - }) - - it('finds more than one user by slug', async () => { - variables = { query: '-maria-' } - const expected = [cUser, dUser] await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({ data: { - findResources: expect.arrayContaining(expected), + findResources: [ + { + __typename: 'Post', + id: 'a-post', + title: 'Beitrag', + content: 'Ein erster Beitrag', + }, + ], }, }) }) - it('finds the binomial formula', async () => { - variables = { query: '(a - b)² = a² - 2ab + b²' } - const expected = createExpectedObject([cPost]) - await expect(query({ query: searchQuery, variables })).resolves.toMatchObject(expected) + describe('casing', () => { + it('does not matter', async () => { + variables = { query: 'BEITRAG' } + await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({ + data: { + findResources: [ + { + __typename: 'Post', + id: 'a-post', + title: 'Beitrag', + content: 'Ein erster Beitrag', + }, + ], + }, + }) + }) }) - it('finds text over linebreak', async () => { - variables = { query: 'dreht, ist' } - const expected = createExpectedObject([dPost]) - await expect(query({ query: searchQuery, variables })).resolves.toMatchObject(expected) + describe('query contains first name of user', () => { + it('finds the user', async () => { + variables = { query: 'John' } + const expected = createExpectedObject([aUser]) + await expect(query({ query: searchQuery, variables })).resolves.toMatchObject(expected) + }) }) - it('finds single words with lower score', async () => { - variables = { query: 'der Panther' } - const expected = createExpectedObject([dPost, ePost, fPost, bUser]) - await expect(query({ query: searchQuery, variables })).resolves.toMatchObject(expected) + describe('query consists of words not present in the corpus', () => { + it('returns empty search results', async () => { + await expect( + query({ query: searchQuery, variables: { query: 'Unfug' } }), + ).resolves.toMatchObject({ data: { findResources: [] } }) + }) }) - it('finds something that starts with the given text', async () => { - variables = { query: 'john' } - const expected = createExpectedObject([aUser, bUser]) - await expect(query({ query: searchQuery, variables })).resolves.toMatchObject(expected) - }) + describe('given more posts and users', () => { + beforeAll(async () => { + const factoryOptions = { + authorId: 'a-user', + } + await Promise.all([ + Factory.build( + 'post', + { + id: 'b-post', + title: 'Aufruf', + content: 'Jeder sollte seinen Beitrag leisten.', + }, + factoryOptions, + ), + ...dumpToDB([ + cPost, + dPost, + ePost, + fPost, + gPost, + bUser, + cUser, + dUser, + eUser, + fUser, + gUser, + ]), + ]) + }) - /* + describe('hyphens in query', () => { + it('will be treated as ordinary characters', async () => { + variables = { query: 'AK-47' } + const expected = createExpectedObject([gPost]) + await expect(query({ query: searchQuery, variables })).resolves.toMatchObject(expected) + }) + }) + + it('finds more than one post', async () => { + variables = { query: 'Beitrag' } + const expected = createExpectedObject([aPost, bPost]) + await expect(query({ query: searchQuery, variables })).resolves.toMatchObject(expected) + }) + + it('finds more than one user by slug', async () => { + variables = { query: '-maria-' } + const expected = [cUser, dUser] + await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({ + data: { + findResources: expect.arrayContaining(expected), + }, + }) + }) + + it('finds the binomial formula', async () => { + variables = { query: '(a - b)² = a² - 2ab + b²' } + const expected = createExpectedObject([cPost]) + await expect(query({ query: searchQuery, variables })).resolves.toMatchObject(expected) + }) + + it('finds text over linebreak', async () => { + variables = { query: 'dreht, ist' } + const expected = createExpectedObject([dPost]) + await expect(query({ query: searchQuery, variables })).resolves.toMatchObject(expected) + }) + + it('finds single words with lower score', async () => { + variables = { query: 'der Panther' } + const expected = createExpectedObject([dPost, ePost, fPost, bUser]) + await expect(query({ query: searchQuery, variables })).resolves.toMatchObject(expected) + }) + + it('finds something that starts with the given text', async () => { + variables = { query: 'john' } + const expected = createExpectedObject([aUser, bUser]) + await expect(query({ query: searchQuery, variables })).resolves.toMatchObject(expected) + }) + + /* it('finds Russian text', async () => { variables = { query: 'Калашникова' } const expected = createExpectedObject([gPost]) await expect(query({ query: searchQuery, variables })).resolves.toMatchObject(expected) }) */ + }) }) }) }) diff --git a/backend/src/schema/resolvers/searches/queryString.js b/backend/src/schema/resolvers/searches/queryString.js new file mode 100644 index 000000000..6735b54c0 --- /dev/null +++ b/backend/src/schema/resolvers/searches/queryString.js @@ -0,0 +1,41 @@ +export default function queryString(str) { + // match the whole text exactly + const normalizedString = normalizeWhitespace(str) + const escapedString = escapeSpecialCharacters(normalizedString) + let result = quoteString(escapedString) + '^8' + // match each word exactly + if (escapedString.includes(' ')) { + result += ' OR (' + escapedString.split(' ').forEach((s, i) => { + result += i === 0 ? quoteString(s) : ' AND ' + quoteString(s) + }) + result += ')^4' + } + // match at least one word exactly + if (escapedString.includes(' ')) { + escapedString.split(' ').forEach(s => { + result += ' OR ' + quoteString(s) + '^2' + }) + } + // start globbing ... + escapedString.split(' ').forEach(s => { + if (s.length > 3) { + // at least 4 letters. So AND, OR and NOT are never used unquoted + result += ' OR ' + s + '*' + } + }) + // now we could become fuzzy using ~ + return result +} + +const normalizeWhitespace = str => { + return str.replace(/\s+/g, ' ') +} + +const escapeSpecialCharacters = str => { + return str.replace(/(["[\]&|\\{}+!()^~*?:/-])/g, '\\$1') +} + +const quoteString = str => { + return '"' + str + '"' +} diff --git a/backend/src/schema/resolvers/searches/queryString.spec.js b/backend/src/schema/resolvers/searches/queryString.spec.js new file mode 100644 index 000000000..c5133b631 --- /dev/null +++ b/backend/src/schema/resolvers/searches/queryString.spec.js @@ -0,0 +1,10 @@ +import queryString from './queryString' + +describe('queryString', () => { + describe('exact match', () => { + it.skip('boosts score by factor 8', () => { + expect(queryString('a couple of words')).toContain('"a couple of words"^8') + }) + it.todo('implement more cases here') + }) +}) From 46fca229ec35047eda9ac7809e7bc456785a6c70 Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Fri, 13 Mar 2020 22:34:51 +0100 Subject: [PATCH 12/18] search specs refactored --- backend/src/schema/resolvers/searches.spec.js | 598 ++++++++++-------- 1 file changed, 325 insertions(+), 273 deletions(-) diff --git a/backend/src/schema/resolvers/searches.spec.js b/backend/src/schema/resolvers/searches.spec.js index b0b2b1e4a..6ef6e48b0 100644 --- a/backend/src/schema/resolvers/searches.spec.js +++ b/backend/src/schema/resolvers/searches.spec.js @@ -3,15 +3,12 @@ import { gql } from '../../helpers/jest' import { getNeode, getDriver } from '../../db/neo4j' import createServer from '../../server' import { createTestClient } from 'apollo-server-testing' -import cloneDeep from 'lodash/cloneDeep' let query, authenticatedUser const driver = getDriver() const neode = getNeode() -jest.setTimeout(30000) - beforeAll(async () => { await cleanDatabase() const { server } = createServer({ @@ -48,104 +45,34 @@ const searchQuery = gql` } ` -const addBrAfterNewline = array => { - return array.map(obj => { - const tmp = cloneDeep(obj) - if (tmp.__typename === 'Post') { - tmp.content = tmp.content.replace(/\n/g, '
\n') - } - return tmp - }) -} - -const createExpectedObject = array => { - return { data: { findResources: addBrAfterNewline(array) } } -} - -const addPostToDB = post => { - return Factory.build( - 'post', - { - id: post.id, - title: post.title, - content: post.content, - }, - { - authorId: 'a-user', - }, - ) -} - -const addUserToDB = user => { - return Factory.build('user', { - id: user.id, - name: user.name, - slug: user.slug, - }) -} - -const dumpToDB = array => { - const result = [] - array.forEach(obj => { - obj.__typename === 'Post' ? result.push(addPostToDB(obj)) : result.push(addUserToDB(obj)) - }) - return result -} - -const createDataObject = (obj, type) => { - return { __typename: type, ...obj } -} - -const createPostObject = post => { - return createDataObject(post, 'Post') -} - -const createUserObject = user => { - return createDataObject(user, 'User') -} - -// see data at the end of the file - -let user - describe('resolvers', () => { describe('searches', () => { let variables - describe('given one post and one user', () => { + describe('given one user', () => { beforeAll(async () => { - user = await addUserToDB(aUser) - await addPostToDB(aPost) + const user = await Factory.build('user', { + id: 'a-user', + name: 'John Doe', + slug: 'john-doe', + }) authenticatedUser = await user.toJson() }) - it('finds the post', async () => { - variables = { query: 'beitrag' } - await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({ - data: { - findResources: [ - { - __typename: 'Post', - id: 'a-post', - title: 'Beitrag', - content: 'Ein erster Beitrag', - }, - ], - }, - }) - }) + const factoryOptions = { + authorId: 'a-user', + } - describe('casing', () => { - it('does not matter', async () => { - variables = { query: 'BEITRAG' } + describe('query contains first name of user', () => { + it('finds the user', async () => { + variables = { query: 'John' } await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({ data: { findResources: [ { - __typename: 'Post', - id: 'a-post', - title: 'Beitrag', - content: 'Ein erster Beitrag', + id: 'a-user', + name: 'John Doe', + slug: 'john-doe', }, ], }, @@ -153,208 +80,333 @@ describe('resolvers', () => { }) }) - describe('query contains first name of user', () => { - it('finds the user', async () => { - variables = { query: 'John' } - const expected = createExpectedObject([aUser]) - await expect(query({ query: searchQuery, variables })).resolves.toMatchObject(expected) - }) - }) - - describe('query consists of words not present in the corpus', () => { - it('returns empty search results', async () => { - await expect( - query({ query: searchQuery, variables: { query: 'Unfug' } }), - ).resolves.toMatchObject({ data: { findResources: [] } }) - }) - }) - - describe('given more posts and users', () => { + describe('adding one post', () => { beforeAll(async () => { - const factoryOptions = { - authorId: 'a-user', - } - await Promise.all([ - Factory.build( + await Factory.build( + 'post', + { + id: 'a-post', + title: 'Beitrag', + content: 'Ein erster Beitrag', + }, + factoryOptions, + ) + }) + + describe('query contains title of post', () => { + it('finds the post', async () => { + variables = { query: 'beitrag' } + await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({ + data: { + findResources: [ + { + __typename: 'Post', + id: 'a-post', + title: 'Beitrag', + content: 'Ein erster Beitrag', + }, + ], + }, + }) + }) + }) + + describe('casing', () => { + it('does not matter', async () => { + variables = { query: 'BEITRAG' } + await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({ + data: { + findResources: [ + { + __typename: 'Post', + id: 'a-post', + title: 'Beitrag', + content: 'Ein erster Beitrag', + }, + ], + }, + }) + }) + }) + + describe('query consists of words not present in the corpus', () => { + it('returns empty search results', async () => { + await expect( + query({ query: searchQuery, variables: { query: 'Unfug' } }), + ).resolves.toMatchObject({ data: { findResources: [] } }) + }) + }) + + describe('testing different post content', () => { + const addPost = post => { + return Factory.build( 'post', { - id: 'b-post', - title: 'Aufruf', - content: 'Jeder sollte seinen Beitrag leisten.', + id: post.id, + title: post.title, + content: post.content, }, factoryOptions, - ), - ...dumpToDB([ - cPost, - dPost, - ePost, - fPost, - gPost, - bUser, - cUser, - dUser, - eUser, - fUser, - gUser, - ]), - ]) - }) + ) + } - describe('hyphens in query', () => { - it('will be treated as ordinary characters', async () => { - variables = { query: 'AK-47' } - const expected = createExpectedObject([gPost]) - await expect(query({ query: searchQuery, variables })).resolves.toMatchObject(expected) + describe('adding a post which content contains the title of the first post', () => { + describe('query contains the title of the first post', () => { + it('finds both posts', async () => { + await addPost({ + __typename: 'Post', + id: 'b-post', + title: 'Aufruf', + content: 'Jeder sollte seinen Beitrag leisten.', + }) + variables = { query: 'beitrag' } + await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({ + data: { + findResources: expect.arrayContaining([ + { + __typename: 'Post', + id: 'a-post', + title: 'Beitrag', + content: 'Ein erster Beitrag', + }, + { + __typename: 'Post', + id: 'b-post', + title: 'Aufruf', + content: 'Jeder sollte seinen Beitrag leisten.', + }, + ]), + }, + }) + }) + }) + }) + + describe('adding a post that contains a hyphen between two words and German quotation marks', () => { + describe('hyphens in query', () => { + it('will be treated as ordinary characters', async () => { + await addPost({ + id: 'g-post', + title: 'Zusammengesetzte Wörter', + content: `Ein Bindestrich kann zwischen zwei Substantiven auch dann gesetzt werden, wenn drei gleichlautende Buchstaben aufeinandertreffen. Das ist etwa bei einem „Teeei“ der Fall, das so korrekt geschrieben ist. Möglich ist hier auch die Schreibweise mit Bindestrich: Tee-Ei.`, + }) + variables = { query: 'tee-ei' } + await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({ + data: { + findResources: [ + { + __typename: 'Post', + id: 'g-post', + title: 'Zusammengesetzte Wörter', + content: `Ein Bindestrich kann zwischen zwei Substantiven auch dann gesetzt werden, wenn drei gleichlautende Buchstaben aufeinandertreffen. Das ist etwa bei einem „Teeei“ der Fall, das so korrekt geschrieben ist. Möglich ist hier auch die Schreibweise mit Bindestrich: Tee-Ei.`, + }, + ], + }, + }) + }) + }) + + describe('German quotation marks in query', () => { + it('will be treated as ordinary characters', async () => { + variables = { query: '„teeei“' } + await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({ + data: { + findResources: [ + { + __typename: 'Post', + id: 'g-post', + title: 'Zusammengesetzte Wörter', + content: `Ein Bindestrich kann zwischen zwei Substantiven auch dann gesetzt werden, wenn drei gleichlautende Buchstaben aufeinandertreffen. Das ist etwa bei einem „Teeei“ der Fall, das so korrekt geschrieben ist. Möglich ist hier auch die Schreibweise mit Bindestrich: Tee-Ei.`, + }, + ], + }, + }) + }) + }) + }) + + describe('adding a post that contains a simple mathematical exprssion and linebreaks', () => { + describe('query a part of the mathematical expression', () => { + it('finds that post', async () => { + await addPost({ + id: 'c-post', + title: 'Die binomischen Formeln', + content: `1. binomische Formel: (a + b)² = a² + 2ab + b² +2. binomische Formel: (a - b)² = a² - 2ab + b² +3. binomische Formel: (a + b)(a - b) = a² - b²`, + }) + variables = { query: '(a - b)²' } + await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({ + data: { + findResources: [ + { + __typename: 'Post', + id: 'c-post', + title: 'Die binomischen Formeln', + content: `1. binomische Formel: (a + b)² = a² + 2ab + b²
+2. binomische Formel: (a - b)² = a² - 2ab + b²
+3. binomische Formel: (a + b)(a - b) = a² - b²`, + }, + ], + }, + }) + }) + }) + + describe('query the same part of the mathematical expression without spaces', () => { + it('finds that post', async () => { + variables = { query: '(a-b)²' } + await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({ + data: { + findResources: [ + { + __typename: 'Post', + id: 'c-post', + title: 'Die binomischen Formeln', + content: `1. binomische Formel: (a + b)² = a² + 2ab + b²
+2. binomische Formel: (a - b)² = a² - 2ab + b²
+3. binomische Formel: (a + b)(a - b) = a² - b²`, + }, + ], + }, + }) + }) + }) + + describe('query the mathematical expression over linebreak', () => { + it('finds that post', async () => { + variables = { query: '+ b² 2.' } + await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({ + data: { + findResources: [ + { + __typename: 'Post', + id: 'c-post', + title: 'Die binomischen Formeln', + content: `1. binomische Formel: (a + b)² = a² + 2ab + b²
+2. binomische Formel: (a - b)² = a² - 2ab + b²
+3. binomische Formel: (a + b)(a - b) = a² - b²`, + }, + ], + }, + }) + }) + }) + }) + + describe('adding a post that contains a poem', () => { + describe('query for more than one word, e.g. the title of the poem', () => { + it('finds the poem and another post that contains only one word but with lower score', async () => { + await addPost({ + id: 'd-post', + title: 'Der Panther', + content: `Sein Blick ist vom Vorübergehn der Stäbe +so müd geworden, daß er nichts mehr hält. +Ihm ist, als ob es tausend Stäbe gäbe +und hinter tausend Stäben keine Welt.`, + }) + variables = { query: 'der panther' } + await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({ + data: { + findResources: [ + { + __typename: 'Post', + id: 'd-post', + title: 'Der Panther', + content: `Sein Blick ist vom Vorübergehn der Stäbe
+so müd geworden, daß er nichts mehr hält.
+Ihm ist, als ob es tausend Stäbe gäbe
+und hinter tausend Stäben keine Welt.`, + }, + { + __typename: 'Post', + id: 'g-post', + title: 'Zusammengesetzte Wörter', + content: `Ein Bindestrich kann zwischen zwei Substantiven auch dann gesetzt werden, wenn drei gleichlautende Buchstaben aufeinandertreffen. Das ist etwa bei einem „Teeei“ der Fall, das so korrekt geschrieben ist. Möglich ist hier auch die Schreibweise mit Bindestrich: Tee-Ei.`, + }, + ], + }, + }) + }) + }) + + describe('query for the first four letters of two longer words', () => { + it('finds the posts that contain words starting with these four letters', async () => { + variables = { query: 'Vorü Subs' } + await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({ + data: { + findResources: expect.arrayContaining([ + { + __typename: 'Post', + id: 'd-post', + title: 'Der Panther', + content: `Sein Blick ist vom Vorübergehn der Stäbe
+so müd geworden, daß er nichts mehr hält.
+Ihm ist, als ob es tausend Stäbe gäbe
+und hinter tausend Stäben keine Welt.`, + }, + { + __typename: 'Post', + id: 'g-post', + title: 'Zusammengesetzte Wörter', + content: `Ein Bindestrich kann zwischen zwei Substantiven auch dann gesetzt werden, wenn drei gleichlautende Buchstaben aufeinandertreffen. Das ist etwa bei einem „Teeei“ der Fall, das so korrekt geschrieben ist. Möglich ist hier auch die Schreibweise mit Bindestrich: Tee-Ei.`, + }, + ]), + }, + }) + }) + }) }) }) - it('finds more than one post', async () => { - variables = { query: 'Beitrag' } - const expected = createExpectedObject([aPost, bPost]) - await expect(query({ query: searchQuery, variables })).resolves.toMatchObject(expected) - }) + describe('adding two users that have the same word in their slugs', () => { + beforeAll(async () => { + await Promise.all([ + Factory.build('user', { + id: 'c-user', + name: 'Rainer Maria Rilke', + slug: 'rainer-maria-rilke', + }), + Factory.build('user', { + id: 'd-user', + name: 'Erich Maria Remarque', + slug: 'erich-maria-remarque', + }), + ]) + }) - it('finds more than one user by slug', async () => { - variables = { query: '-maria-' } - const expected = [cUser, dUser] - await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({ - data: { - findResources: expect.arrayContaining(expected), - }, + describe('query the word that both slugs contain', () => { + it('finds both users', async () => { + variables = { query: '-maria-' } + await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({ + data: { + findResources: expect.arrayContaining([ + { + __typename: 'User', + id: 'c-user', + name: 'Rainer Maria Rilke', + slug: 'rainer-maria-rilke', + }, + { + __typename: 'User', + id: 'd-user', + name: 'Erich Maria Remarque', + slug: 'erich-maria-remarque', + }, + ]), + }, + }) + }) }) }) + }) - it('finds the binomial formula', async () => { - variables = { query: '(a - b)² = a² - 2ab + b²' } - const expected = createExpectedObject([cPost]) - await expect(query({ query: searchQuery, variables })).resolves.toMatchObject(expected) - }) - - it('finds text over linebreak', async () => { - variables = { query: 'dreht, ist' } - const expected = createExpectedObject([dPost]) - await expect(query({ query: searchQuery, variables })).resolves.toMatchObject(expected) - }) - - it('finds single words with lower score', async () => { - variables = { query: 'der Panther' } - const expected = createExpectedObject([dPost, ePost, fPost, bUser]) - await expect(query({ query: searchQuery, variables })).resolves.toMatchObject(expected) - }) - - it('finds something that starts with the given text', async () => { - variables = { query: 'john' } - const expected = createExpectedObject([aUser, bUser]) - await expect(query({ query: searchQuery, variables })).resolves.toMatchObject(expected) - }) - - /* + /* it('finds Russian text', async () => { variables = { query: 'Калашникова' } const expected = createExpectedObject([gPost]) await expect(query({ query: searchQuery, variables })).resolves.toMatchObject(expected) - }) */ - }) + }) */ }) }) }) - -// data section - -const aPost = createPostObject({ - id: 'a-post', - title: 'Beitrag', - content: 'Ein erster Beitrag', -}) - -const bPost = createPostObject({ - id: 'b-post', - title: 'Aufruf', - content: 'Jeder sollte seinen Beitrag leisten.', -}) - -const cPost = createPostObject({ - id: 'c-post', - title: 'Die binomischen Formeln', - content: `1. binomische Formel: (a + b)² = a² + 2ab + b² -2. binomische Formel: (a - b)² = a² - 2ab + b² -3. binomische Formel: (a + b)(a - b) = a² - b²`, -}) - -const dPost = createPostObject({ - id: 'd-post', - title: 'Der Panther', - content: `Sein Blick ist vom Vorübergehn der Stäbe -so müd geworden, daß er nichts mehr hält. -Ihm ist, als ob es tausend Stäbe gäbe -und hinter tausend Stäben keine Welt. -Der weiche Gang geschmeidig starker Schritte, -der sich im allerkleinsten Kreise dreht, -ist wie ein Tanz von Kraft um eine Mitte, -in der betäubt ein großer Wille steht. -Nur manchmal schiebt der Vorhang der Pupille -sich lautlos auf –. Dann geht ein Bild hinein, -geht durch der Glieder angespannte Stille – -und hört im Herzen auf zu sein.`, -}) - -const ePost = createPostObject({ - id: 'e-post', - title: 'Typographie', - content: `Gelegentlich können sowohl der angeführte Text als auch der Begleitsatz mit Frage- oder Ausrufezeichen enden (§ 91): -Gefällt dir der Roman „Quo vadis?“? Lass doch dieses ewige „Ich will nicht!“!`, -}) - -const fPost = createPostObject({ - id: 'f-post', - title: 'Typographie II', - content: `Der Gedankenstrich kann als Auslassungszeichen (Auslassungsstrich) eine längere Pause oder eine Ellipse darstellen: „Du willst doch wohl nicht etwa –“, „Mein Gott, woher nehm ich bloß –?“`, -}) - -const gPost = createPostObject({ - id: 'g-post', - title: 'AK-47', - content: `Vom AK-47 Typ I existiert eine Version mit unter die Waffe klappbarer Schulterstütze, das AKS-47 (russisch Автомат Калашникова складной образца 1947 года, transkr.: Avtomat Kalašnikova skladnoj obrazca 1947 goda, dt. Automat Kalaschnikow klappbar Modell 1947tes Jahr) genannt wird, seltener auch AK-47s.`, -}) - -const aUser = createUserObject({ - id: 'a-user', - name: 'John Doe', - slug: 'john-doe', -}) - -const bUser = createUserObject({ - id: 'b-user', - name: 'Johnannes der Täufer', - slug: 'johnannes-der-taufer', -}) - -const cUser = createUserObject({ - id: 'c-user', - name: 'Rainer Maria Rilke', - slug: 'rainer-maria-rilke', -}) - -const dUser = createUserObject({ - id: 'd-user', - name: 'Erich Maria Remarque', - slug: 'erich-maria-remarque', -}) - -const eUser = createUserObject({ - id: 'e-user', - name: 'Klaus Dieter', - slug: 'kd', -}) - -const fUser = createUserObject({ - id: 'f-user', - name: 'Sluggy', - slug: '_', -}) - -const gUser = createUserObject({ - id: 'g-user', - name: 'AKK', - slug: 'akk', -}) From b2ea4df2947cf116673fa4d662cd34ad60ce8038 Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Mon, 16 Mar 2020 01:36:16 +0100 Subject: [PATCH 13/18] refactored queryString, specs for queryString --- backend/src/schema/resolvers/searches.js | 2 +- .../schema/resolvers/searches/queryString.js | 75 +++++++++++-------- .../resolvers/searches/queryString.spec.js | 38 +++++++++- 3 files changed, 78 insertions(+), 37 deletions(-) diff --git a/backend/src/schema/resolvers/searches.js b/backend/src/schema/resolvers/searches.js index ba67bb2d0..5e062f45a 100644 --- a/backend/src/schema/resolvers/searches.js +++ b/backend/src/schema/resolvers/searches.js @@ -1,5 +1,5 @@ import log from './helpers/databaseLogger' -import queryString from './searches/queryString' +import { queryString } from './searches/queryString' // see http://lucene.apache.org/core/8_3_1/queryparser/org/apache/lucene/queryparser/classic/package-summary.html#package.description diff --git a/backend/src/schema/resolvers/searches/queryString.js b/backend/src/schema/resolvers/searches/queryString.js index 6735b54c0..3e53bfec9 100644 --- a/backend/src/schema/resolvers/searches/queryString.js +++ b/backend/src/schema/resolvers/searches/queryString.js @@ -1,41 +1,50 @@ -export default function queryString(str) { - // match the whole text exactly +export function queryString(str) { const normalizedString = normalizeWhitespace(str) - const escapedString = escapeSpecialCharacters(normalizedString) - let result = quoteString(escapedString) + '^8' - // match each word exactly - if (escapedString.includes(' ')) { - result += ' OR (' - escapedString.split(' ').forEach((s, i) => { - result += i === 0 ? quoteString(s) : ' AND ' + quoteString(s) - }) - result += ')^4' - } - // match at least one word exactly - if (escapedString.includes(' ')) { - escapedString.split(' ').forEach(s => { - result += ' OR ' + quoteString(s) + '^2' - }) - } - // start globbing ... - escapedString.split(' ').forEach(s => { - if (s.length > 3) { - // at least 4 letters. So AND, OR and NOT are never used unquoted - result += ' OR ' + s + '*' + const escapedString = escapeSpecialCharacters(normalizedString) + return ` +${matchWholeText(escapedString)} +${matchEachWordExactly(escapedString)} +${matchSomeWordsExactly(escapedString)} +${matchBeginningOfWords(escapedString)} +` +} + +const matchWholeText = (str, boost = 8) => { + return `"${str}"^${boost}` +} + +const matchEachWordExactly = (str, boost = 4) => { + if (str.includes(' ')) { + let tmp = str.split(' ').map((s, i) => i === 0 ? `"${s}"` : `AND "${s}"`).join(' ') + return `(${tmp})^${boost}` + } else { + return '' } - }) - // now we could become fuzzy using ~ - return result } -const normalizeWhitespace = str => { - return str.replace(/\s+/g, ' ') +const matchSomeWordsExactly = (str, boost = 2) => { + if (str.includes(' ')) { + return str.split(' ').map(s => `"${s}"^${boost}`).join(' ') + } else { + return '' + } } -const escapeSpecialCharacters = str => { +const matchBeginningOfWords = str => { + return normalizeWhitespace(str.split(' ').map(s => { + if (s.length > 3) { + // at least 4 letters. So AND, OR and NOT are never used unquoted + return s + '*' + } else { + return '' + } + }).join(' ')) +} + +export function normalizeWhitespace(str) { + return str.replace(/\s+/g, ' ').trim() +} + +export function escapeSpecialCharacters(str) { return str.replace(/(["[\]&|\\{}+!()^~*?:/-])/g, '\\$1') } - -const quoteString = str => { - return '"' + str + '"' -} diff --git a/backend/src/schema/resolvers/searches/queryString.spec.js b/backend/src/schema/resolvers/searches/queryString.spec.js index c5133b631..e431df90f 100644 --- a/backend/src/schema/resolvers/searches/queryString.spec.js +++ b/backend/src/schema/resolvers/searches/queryString.spec.js @@ -1,10 +1,42 @@ -import queryString from './queryString' +import { queryString, escapeSpecialCharacters, normalizeWhitespace } from './queryString' describe('queryString', () => { + describe('special characters', () => { + it('does escaping correctly', () => { + expect(escapeSpecialCharacters('+ - && || ! ( ) { } [ ] ^ " ~ * ? : \\ / ')) + .toEqual('\\+ \\- \\&\\& \\|\\| \\! \\( \\) \\{ \\} \\[ \\] \\^ \\" \\~ \\* \\? \\: \\\\ \\/ ') + }) + }) + + describe('whitespace', () => { + it('is normalized correctly', () => { + expect(normalizeWhitespace(' a \t \n b \n ')) + .toEqual('a b') + }) + }) + describe('exact match', () => { - it.skip('boosts score by factor 8', () => { + it('boosts score by factor 8', () => { expect(queryString('a couple of words')).toContain('"a couple of words"^8') }) - it.todo('implement more cases here') }) + + describe('match all words exactly', () => { + it('boosts score by factor 4', () => { + expect(queryString('a couple of words')).toContain('("a" AND "couple" AND "of" AND "words")^4') + }) + }) + + describe('match at least one word exactly', () => { + it('boosts score by factor 2', () => { + expect(queryString('a couple of words')).toContain('"a"^2 "couple"^2 "of"^2 "words"^2') + }) + }) + + describe('globbing for longer words', () => { + it('globs words with more than three characters', () => { + expect(queryString('a couple of words')).toContain('couple* words*') + }) + }) + }) From d47274fb8eed849791c6ee6010e8699cd1e6351f Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Mon, 16 Mar 2020 01:50:36 +0100 Subject: [PATCH 14/18] linting --- backend/src/schema/resolvers/searches.spec.js | 97 ++++++++++--------- .../schema/resolvers/searches/queryString.js | 57 ++++++----- .../resolvers/searches/queryString.spec.js | 29 +++--- 3 files changed, 98 insertions(+), 85 deletions(-) diff --git a/backend/src/schema/resolvers/searches.spec.js b/backend/src/schema/resolvers/searches.spec.js index 6ef6e48b0..42ffd6f98 100644 --- a/backend/src/schema/resolvers/searches.spec.js +++ b/backend/src/schema/resolvers/searches.spec.js @@ -138,27 +138,55 @@ describe('resolvers', () => { }) describe('testing different post content', () => { - const addPost = post => { - return Factory.build( - 'post', - { - id: post.id, - title: post.title, - content: post.content, - }, - factoryOptions, - ) - } - - describe('adding a post which content contains the title of the first post', () => { - describe('query contains the title of the first post', () => { - it('finds both posts', async () => { - await addPost({ - __typename: 'Post', + beforeAll(async () => { + return Promise.all([ + Factory.build( + 'post', + { id: 'b-post', title: 'Aufruf', content: 'Jeder sollte seinen Beitrag leisten.', - }) + }, + factoryOptions, + ), + Factory.build( + 'post', + { + id: 'g-post', + title: 'Zusammengesetzte Wörter', + content: `Ein Bindestrich kann zwischen zwei Substantiven auch dann gesetzt werden, wenn drei gleichlautende Buchstaben aufeinandertreffen. Das ist etwa bei einem „Teeei“ der Fall, das so korrekt geschrieben ist. Möglich ist hier auch die Schreibweise mit Bindestrich: Tee-Ei.`, + }, + factoryOptions, + ), + Factory.build( + 'post', + { + id: 'c-post', + title: 'Die binomischen Formeln', + content: `1. binomische Formel: (a + b)² = a² + 2ab + b² +2. binomische Formel: (a - b)² = a² - 2ab + b² +3. binomische Formel: (a + b)(a - b) = a² - b²`, + }, + factoryOptions, + ), + Factory.build( + 'post', + { + id: 'd-post', + title: 'Der Panther', + content: `Sein Blick ist vom Vorübergehn der Stäbe +so müd geworden, daß er nichts mehr hält. +Ihm ist, als ob es tausend Stäbe gäbe +und hinter tausend Stäben keine Welt.`, + }, + factoryOptions, + ), + ]) + }) + + describe('a post which content contains the title of the first post', () => { + describe('query contains the title of the first post', () => { + it('finds both posts', async () => { variables = { query: 'beitrag' } await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({ data: { @@ -182,14 +210,9 @@ describe('resolvers', () => { }) }) - describe('adding a post that contains a hyphen between two words and German quotation marks', () => { + describe('a post that contains a hyphen between two words and German quotation marks', () => { describe('hyphens in query', () => { it('will be treated as ordinary characters', async () => { - await addPost({ - id: 'g-post', - title: 'Zusammengesetzte Wörter', - content: `Ein Bindestrich kann zwischen zwei Substantiven auch dann gesetzt werden, wenn drei gleichlautende Buchstaben aufeinandertreffen. Das ist etwa bei einem „Teeei“ der Fall, das so korrekt geschrieben ist. Möglich ist hier auch die Schreibweise mit Bindestrich: Tee-Ei.`, - }) variables = { query: 'tee-ei' } await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({ data: { @@ -225,16 +248,9 @@ describe('resolvers', () => { }) }) - describe('adding a post that contains a simple mathematical exprssion and linebreaks', () => { + describe('a post that contains a simple mathematical exprssion and linebreaks', () => { describe('query a part of the mathematical expression', () => { it('finds that post', async () => { - await addPost({ - id: 'c-post', - title: 'Die binomischen Formeln', - content: `1. binomische Formel: (a + b)² = a² + 2ab + b² -2. binomische Formel: (a - b)² = a² - 2ab + b² -3. binomische Formel: (a + b)(a - b) = a² - b²`, - }) variables = { query: '(a - b)²' } await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({ data: { @@ -294,17 +310,9 @@ describe('resolvers', () => { }) }) - describe('adding a post that contains a poem', () => { + describe('a post that contains a poem', () => { describe('query for more than one word, e.g. the title of the poem', () => { it('finds the poem and another post that contains only one word but with lower score', async () => { - await addPost({ - id: 'd-post', - title: 'Der Panther', - content: `Sein Blick ist vom Vorübergehn der Stäbe -so müd geworden, daß er nichts mehr hält. -Ihm ist, als ob es tausend Stäbe gäbe -und hinter tausend Stäben keine Welt.`, - }) variables = { query: 'der panther' } await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({ data: { @@ -400,13 +408,6 @@ und hinter tausend Stäben keine Welt.`, }) }) }) - - /* - it('finds Russian text', async () => { - variables = { query: 'Калашникова' } - const expected = createExpectedObject([gPost]) - await expect(query({ query: searchQuery, variables })).resolves.toMatchObject(expected) - }) */ }) }) }) diff --git a/backend/src/schema/resolvers/searches/queryString.js b/backend/src/schema/resolvers/searches/queryString.js index 3e53bfec9..e53405c8e 100644 --- a/backend/src/schema/resolvers/searches/queryString.js +++ b/backend/src/schema/resolvers/searches/queryString.js @@ -1,7 +1,7 @@ export function queryString(str) { const normalizedString = normalizeWhitespace(str) - const escapedString = escapeSpecialCharacters(normalizedString) - return ` + const escapedString = escapeSpecialCharacters(normalizedString) + return ` ${matchWholeText(escapedString)} ${matchEachWordExactly(escapedString)} ${matchSomeWordsExactly(escapedString)} @@ -10,39 +10,50 @@ ${matchBeginningOfWords(escapedString)} } const matchWholeText = (str, boost = 8) => { - return `"${str}"^${boost}` + return `"${str}"^${boost}` } const matchEachWordExactly = (str, boost = 4) => { - if (str.includes(' ')) { - let tmp = str.split(' ').map((s, i) => i === 0 ? `"${s}"` : `AND "${s}"`).join(' ') - return `(${tmp})^${boost}` - } else { - return '' - } + if (str.includes(' ')) { + const tmp = str + .split(' ') + .map((s, i) => (i === 0 ? `"${s}"` : `AND "${s}"`)) + .join(' ') + return `(${tmp})^${boost}` + } else { + return '' + } } const matchSomeWordsExactly = (str, boost = 2) => { - if (str.includes(' ')) { - return str.split(' ').map(s => `"${s}"^${boost}`).join(' ') - } else { - return '' - } + if (str.includes(' ')) { + return str + .split(' ') + .map(s => `"${s}"^${boost}`) + .join(' ') + } else { + return '' + } } const matchBeginningOfWords = str => { - return normalizeWhitespace(str.split(' ').map(s => { - if (s.length > 3) { - // at least 4 letters. So AND, OR and NOT are never used unquoted - return s + '*' - } else { - return '' - } - }).join(' ')) + return normalizeWhitespace( + str + .split(' ') + .map(s => { + if (s.length > 3) { + // at least 4 letters. So AND, OR and NOT are never used unquoted + return s + '*' + } else { + return '' + } + }) + .join(' '), + ) } export function normalizeWhitespace(str) { - return str.replace(/\s+/g, ' ').trim() + return str.replace(/\s+/g, ' ').trim() } export function escapeSpecialCharacters(str) { diff --git a/backend/src/schema/resolvers/searches/queryString.spec.js b/backend/src/schema/resolvers/searches/queryString.spec.js index e431df90f..8f646ce25 100644 --- a/backend/src/schema/resolvers/searches/queryString.spec.js +++ b/backend/src/schema/resolvers/searches/queryString.spec.js @@ -1,20 +1,20 @@ import { queryString, escapeSpecialCharacters, normalizeWhitespace } from './queryString' describe('queryString', () => { - describe('special characters', () => { - it('does escaping correctly', () => { - expect(escapeSpecialCharacters('+ - && || ! ( ) { } [ ] ^ " ~ * ? : \\ / ')) - .toEqual('\\+ \\- \\&\\& \\|\\| \\! \\( \\) \\{ \\} \\[ \\] \\^ \\" \\~ \\* \\? \\: \\\\ \\/ ') - }) + describe('special characters', () => { + it('does escaping correctly', () => { + expect(escapeSpecialCharacters('+ - && || ! ( ) { } [ ] ^ " ~ * ? : \\ / ')).toEqual( + '\\+ \\- \\&\\& \\|\\| \\! \\( \\) \\{ \\} \\[ \\] \\^ \\" \\~ \\* \\? \\: \\\\ \\/ ', + ) }) + }) - describe('whitespace', () => { - it('is normalized correctly', () => { - expect(normalizeWhitespace(' a \t \n b \n ')) - .toEqual('a b') - }) + describe('whitespace', () => { + it('is normalized correctly', () => { + expect(normalizeWhitespace(' a \t \n b \n ')).toEqual('a b') }) - + }) + describe('exact match', () => { it('boosts score by factor 8', () => { expect(queryString('a couple of words')).toContain('"a couple of words"^8') @@ -23,7 +23,9 @@ describe('queryString', () => { describe('match all words exactly', () => { it('boosts score by factor 4', () => { - expect(queryString('a couple of words')).toContain('("a" AND "couple" AND "of" AND "words")^4') + expect(queryString('a couple of words')).toContain( + '("a" AND "couple" AND "of" AND "words")^4', + ) }) }) @@ -33,10 +35,9 @@ describe('queryString', () => { }) }) - describe('globbing for longer words', () => { + describe('globbing for longer words', () => { it('globs words with more than three characters', () => { expect(queryString('a couple of words')).toContain('couple* words*') }) }) - }) From 0a15d785a319b1aa6b5d8590e7bcaf4c3b98b348 Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Mon, 16 Mar 2020 02:32:22 +0100 Subject: [PATCH 15/18] test that a post written by muted user is not included in the search results --- backend/src/schema/resolvers/searches.spec.js | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/backend/src/schema/resolvers/searches.spec.js b/backend/src/schema/resolvers/searches.spec.js index 42ffd6f98..a04fbd802 100644 --- a/backend/src/schema/resolvers/searches.spec.js +++ b/backend/src/schema/resolvers/searches.spec.js @@ -44,6 +44,7 @@ const searchQuery = gql` } } ` +let user describe('resolvers', () => { describe('searches', () => { @@ -51,7 +52,7 @@ describe('resolvers', () => { describe('given one user', () => { beforeAll(async () => { - const user = await Factory.build('user', { + user = await Factory.build('user', { id: 'a-user', name: 'John Doe', slug: 'john-doe', @@ -407,6 +408,44 @@ und hinter tausend Stäben keine Welt.`, }) }) }) + + describe('adding a post, written by a user who is muted by the authenticated user', () => { + beforeAll(async () => { + const mutedUser = await Factory.build('user', { + id: 'muted-user', + name: 'Muted', + slug: 'muted', + }) + await user.relateTo(mutedUser, 'muted') + await Factory.build( + 'post', + { + id: 'muted-post', + title: 'Beleidigender Beitrag', + content: 'Dieser Beitrag stammt von einem bleidigendem Nutzer.', + }, + { authorId: 'muted-user' }, + ) + }) + + describe('query for text in a post written by a muted user', () => { + it('does not include the post of the muted user in the results', async () => { + variables = { query: 'beitrag' } + await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({ + data: { + findResources: expect.not.arrayContaining([ + { + __typename: 'Post', + id: 'muted-post', + title: 'Beleidigender Beitrag', + content: 'Dieser Beitrag stammt von einem bleidigendem Nutzer.', + }, + ]), + }, + }) + }) + }) + }) }) }) }) From 48564565a51eaeea44a6fefc8c0bffdcfb90d9df Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Mon, 16 Mar 2020 12:12:01 +0100 Subject: [PATCH 16/18] matchBeginningOfWords more compact --- .../schema/resolvers/searches/queryString.js | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/backend/src/schema/resolvers/searches/queryString.js b/backend/src/schema/resolvers/searches/queryString.js index e53405c8e..38a25c6cf 100644 --- a/backend/src/schema/resolvers/searches/queryString.js +++ b/backend/src/schema/resolvers/searches/queryString.js @@ -37,19 +37,11 @@ const matchSomeWordsExactly = (str, boost = 2) => { } const matchBeginningOfWords = str => { - return normalizeWhitespace( - str - .split(' ') - .map(s => { - if (s.length > 3) { - // at least 4 letters. So AND, OR and NOT are never used unquoted - return s + '*' - } else { - return '' - } - }) - .join(' '), - ) + return str + .split(' ') + .filter(s => s.length > 3) + .map(s => s + '*') + .join(' ') } export function normalizeWhitespace(str) { From 9c08db22dcd0ca1ad6e59be8fb0f287935b45537 Mon Sep 17 00:00:00 2001 From: Moriz Wahl Date: Tue, 17 Mar 2020 12:38:21 +0100 Subject: [PATCH 17/18] Changes requested by @mattwr18 --- backend/src/schema/resolvers/searches.js | 9 +- backend/src/schema/resolvers/searches.spec.js | 692 +++++++++--------- .../schema/resolvers/searches/queryString.js | 28 +- .../resolvers/searches/queryString.spec.js | 2 +- 4 files changed, 358 insertions(+), 373 deletions(-) diff --git a/backend/src/schema/resolvers/searches.js b/backend/src/schema/resolvers/searches.js index 5e062f45a..39fe952bb 100644 --- a/backend/src/schema/resolvers/searches.js +++ b/backend/src/schema/resolvers/searches.js @@ -41,16 +41,17 @@ export default { RETURN resource {.*, __typename: labels(resource)[0]} LIMIT $limit ` - + const myQuery = queryString(query) + const session = context.driver.session() const searchResultPromise = session.readTransaction(async transaction => { const postTransactionResponse = transaction.run(postCypher, { - query: queryString(query), + query: myQuery, limit, thisUserId, }) const userTransactionResponse = transaction.run(userCypher, { - query: queryString(query), + query: myQuery, limit, thisUserId, }) @@ -61,8 +62,6 @@ export default { const [postResults, userResults] = await searchResultPromise log(postResults) log(userResults) - // console.log(postResults.summary.query.parameters) - // console.log(userResults) return [...postResults.records, ...userResults.records].map(r => r.get('resource')) } finally { session.close() diff --git a/backend/src/schema/resolvers/searches.spec.js b/backend/src/schema/resolvers/searches.spec.js index a04fbd802..c454833b8 100644 --- a/backend/src/schema/resolvers/searches.spec.js +++ b/backend/src/schema/resolvers/searches.spec.js @@ -4,7 +4,7 @@ import { getNeode, getDriver } from '../../db/neo4j' import createServer from '../../server' import { createTestClient } from 'apollo-server-testing' -let query, authenticatedUser +let query, authenticatedUser, user const driver = getDriver() const neode = getNeode() @@ -44,36 +44,60 @@ const searchQuery = gql` } } ` -let user +describe('resolvers/searches', () => { + let variables -describe('resolvers', () => { - describe('searches', () => { - let variables + describe('given one user', () => { + beforeAll(async () => { + user = await Factory.build('user', { + id: 'a-user', + name: 'John Doe', + slug: 'john-doe', + }) + authenticatedUser = await user.toJson() + }) - describe('given one user', () => { - beforeAll(async () => { - user = await Factory.build('user', { - id: 'a-user', - name: 'John Doe', - slug: 'john-doe', + describe('query contains first name of user', () => { + it('finds the user', async () => { + variables = { query: 'John' } + await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({ + data: { + findResources: [ + { + id: 'a-user', + name: 'John Doe', + slug: 'john-doe', + }, + ], + }, }) - authenticatedUser = await user.toJson() + }) + }) + + describe('adding one post', () => { + beforeAll(async () => { + await Factory.build( + 'post', + { + id: 'a-post', + title: 'Beitrag', + content: 'Ein erster Beitrag', + }, + { authorId: 'a-user' }, + ) }) - const factoryOptions = { - authorId: 'a-user', - } - - describe('query contains first name of user', () => { - it('finds the user', async () => { - variables = { query: 'John' } + describe('query contains title of post', () => { + it('finds the post', async () => { + variables = { query: 'beitrag' } await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({ data: { findResources: [ { - id: 'a-user', - name: 'John Doe', - slug: 'john-doe', + __typename: 'Post', + id: 'a-post', + title: 'Beitrag', + content: 'Ein erster Beitrag', }, ], }, @@ -81,326 +105,97 @@ describe('resolvers', () => { }) }) - describe('adding one post', () => { - beforeAll(async () => { - await Factory.build( - 'post', - { - id: 'a-post', - title: 'Beitrag', - content: 'Ein erster Beitrag', + describe('casing', () => { + it('does not matter', async () => { + variables = { query: 'BEITRAG' } + await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({ + data: { + findResources: [ + { + __typename: 'Post', + id: 'a-post', + title: 'Beitrag', + content: 'Ein erster Beitrag', + }, + ], }, - factoryOptions, - ) + }) }) + }) - describe('query contains title of post', () => { - it('finds the post', async () => { - variables = { query: 'beitrag' } - await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({ - data: { - findResources: [ - { - __typename: 'Post', - id: 'a-post', - title: 'Beitrag', - content: 'Ein erster Beitrag', - }, - ], + describe('query consists of words not present in the corpus', () => { + it('returns empty search results', async () => { + await expect( + query({ query: searchQuery, variables: { query: 'Unfug' } }), + ).resolves.toMatchObject({ data: { findResources: [] } }) + }) + }) + + describe('testing different post content', () => { + beforeAll(async () => { + return Promise.all([ + Factory.build( + 'post', + { + id: 'b-post', + title: 'Aufruf', + content: 'Jeder sollte seinen Beitrag leisten.', }, - }) - }) - }) - - describe('casing', () => { - it('does not matter', async () => { - variables = { query: 'BEITRAG' } - await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({ - data: { - findResources: [ - { - __typename: 'Post', - id: 'a-post', - title: 'Beitrag', - content: 'Ein erster Beitrag', - }, - ], + { authorId: 'a-user' }, + ), + Factory.build( + 'post', + { + id: 'g-post', + title: 'Zusammengesetzte Wörter', + content: `Ein Bindestrich kann zwischen zwei Substantiven auch dann gesetzt werden, wenn drei gleichlautende Buchstaben aufeinandertreffen. Das ist etwa bei einem „Teeei“ der Fall, das so korrekt geschrieben ist. Möglich ist hier auch die Schreibweise mit Bindestrich: Tee-Ei.`, }, - }) - }) - }) - - describe('query consists of words not present in the corpus', () => { - it('returns empty search results', async () => { - await expect( - query({ query: searchQuery, variables: { query: 'Unfug' } }), - ).resolves.toMatchObject({ data: { findResources: [] } }) - }) - }) - - describe('testing different post content', () => { - beforeAll(async () => { - return Promise.all([ - Factory.build( - 'post', - { - id: 'b-post', - title: 'Aufruf', - content: 'Jeder sollte seinen Beitrag leisten.', - }, - factoryOptions, - ), - Factory.build( - 'post', - { - id: 'g-post', - title: 'Zusammengesetzte Wörter', - content: `Ein Bindestrich kann zwischen zwei Substantiven auch dann gesetzt werden, wenn drei gleichlautende Buchstaben aufeinandertreffen. Das ist etwa bei einem „Teeei“ der Fall, das so korrekt geschrieben ist. Möglich ist hier auch die Schreibweise mit Bindestrich: Tee-Ei.`, - }, - factoryOptions, - ), - Factory.build( - 'post', - { - id: 'c-post', - title: 'Die binomischen Formeln', - content: `1. binomische Formel: (a + b)² = a² + 2ab + b² + { authorId: 'a-user' }, + ), + Factory.build( + 'post', + { + id: 'c-post', + title: 'Die binomischen Formeln', + content: `1. binomische Formel: (a + b)² = a² + 2ab + b² 2. binomische Formel: (a - b)² = a² - 2ab + b² 3. binomische Formel: (a + b)(a - b) = a² - b²`, - }, - factoryOptions, - ), - Factory.build( - 'post', - { - id: 'd-post', - title: 'Der Panther', - content: `Sein Blick ist vom Vorübergehn der Stäbe + }, + { authorId: 'a-user' }, + ), + Factory.build( + 'post', + { + id: 'd-post', + title: 'Der Panther', + content: `Sein Blick ist vom Vorübergehn der Stäbe so müd geworden, daß er nichts mehr hält. Ihm ist, als ob es tausend Stäbe gäbe und hinter tausend Stäben keine Welt.`, - }, - factoryOptions, - ), - ]) - }) - - describe('a post which content contains the title of the first post', () => { - describe('query contains the title of the first post', () => { - it('finds both posts', async () => { - variables = { query: 'beitrag' } - await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({ - data: { - findResources: expect.arrayContaining([ - { - __typename: 'Post', - id: 'a-post', - title: 'Beitrag', - content: 'Ein erster Beitrag', - }, - { - __typename: 'Post', - id: 'b-post', - title: 'Aufruf', - content: 'Jeder sollte seinen Beitrag leisten.', - }, - ]), - }, - }) - }) - }) - }) - - describe('a post that contains a hyphen between two words and German quotation marks', () => { - describe('hyphens in query', () => { - it('will be treated as ordinary characters', async () => { - variables = { query: 'tee-ei' } - await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({ - data: { - findResources: [ - { - __typename: 'Post', - id: 'g-post', - title: 'Zusammengesetzte Wörter', - content: `Ein Bindestrich kann zwischen zwei Substantiven auch dann gesetzt werden, wenn drei gleichlautende Buchstaben aufeinandertreffen. Das ist etwa bei einem „Teeei“ der Fall, das so korrekt geschrieben ist. Möglich ist hier auch die Schreibweise mit Bindestrich: Tee-Ei.`, - }, - ], - }, - }) - }) - }) - - describe('German quotation marks in query', () => { - it('will be treated as ordinary characters', async () => { - variables = { query: '„teeei“' } - await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({ - data: { - findResources: [ - { - __typename: 'Post', - id: 'g-post', - title: 'Zusammengesetzte Wörter', - content: `Ein Bindestrich kann zwischen zwei Substantiven auch dann gesetzt werden, wenn drei gleichlautende Buchstaben aufeinandertreffen. Das ist etwa bei einem „Teeei“ der Fall, das so korrekt geschrieben ist. Möglich ist hier auch die Schreibweise mit Bindestrich: Tee-Ei.`, - }, - ], - }, - }) - }) - }) - }) - - describe('a post that contains a simple mathematical exprssion and linebreaks', () => { - describe('query a part of the mathematical expression', () => { - it('finds that post', async () => { - variables = { query: '(a - b)²' } - await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({ - data: { - findResources: [ - { - __typename: 'Post', - id: 'c-post', - title: 'Die binomischen Formeln', - content: `1. binomische Formel: (a + b)² = a² + 2ab + b²
-2. binomische Formel: (a - b)² = a² - 2ab + b²
-3. binomische Formel: (a + b)(a - b) = a² - b²`, - }, - ], - }, - }) - }) - }) - - describe('query the same part of the mathematical expression without spaces', () => { - it('finds that post', async () => { - variables = { query: '(a-b)²' } - await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({ - data: { - findResources: [ - { - __typename: 'Post', - id: 'c-post', - title: 'Die binomischen Formeln', - content: `1. binomische Formel: (a + b)² = a² + 2ab + b²
-2. binomische Formel: (a - b)² = a² - 2ab + b²
-3. binomische Formel: (a + b)(a - b) = a² - b²`, - }, - ], - }, - }) - }) - }) - - describe('query the mathematical expression over linebreak', () => { - it('finds that post', async () => { - variables = { query: '+ b² 2.' } - await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({ - data: { - findResources: [ - { - __typename: 'Post', - id: 'c-post', - title: 'Die binomischen Formeln', - content: `1. binomische Formel: (a + b)² = a² + 2ab + b²
-2. binomische Formel: (a - b)² = a² - 2ab + b²
-3. binomische Formel: (a + b)(a - b) = a² - b²`, - }, - ], - }, - }) - }) - }) - }) - - describe('a post that contains a poem', () => { - describe('query for more than one word, e.g. the title of the poem', () => { - it('finds the poem and another post that contains only one word but with lower score', async () => { - variables = { query: 'der panther' } - await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({ - data: { - findResources: [ - { - __typename: 'Post', - id: 'd-post', - title: 'Der Panther', - content: `Sein Blick ist vom Vorübergehn der Stäbe
-so müd geworden, daß er nichts mehr hält.
-Ihm ist, als ob es tausend Stäbe gäbe
-und hinter tausend Stäben keine Welt.`, - }, - { - __typename: 'Post', - id: 'g-post', - title: 'Zusammengesetzte Wörter', - content: `Ein Bindestrich kann zwischen zwei Substantiven auch dann gesetzt werden, wenn drei gleichlautende Buchstaben aufeinandertreffen. Das ist etwa bei einem „Teeei“ der Fall, das so korrekt geschrieben ist. Möglich ist hier auch die Schreibweise mit Bindestrich: Tee-Ei.`, - }, - ], - }, - }) - }) - }) - - describe('query for the first four letters of two longer words', () => { - it('finds the posts that contain words starting with these four letters', async () => { - variables = { query: 'Vorü Subs' } - await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({ - data: { - findResources: expect.arrayContaining([ - { - __typename: 'Post', - id: 'd-post', - title: 'Der Panther', - content: `Sein Blick ist vom Vorübergehn der Stäbe
-so müd geworden, daß er nichts mehr hält.
-Ihm ist, als ob es tausend Stäbe gäbe
-und hinter tausend Stäben keine Welt.`, - }, - { - __typename: 'Post', - id: 'g-post', - title: 'Zusammengesetzte Wörter', - content: `Ein Bindestrich kann zwischen zwei Substantiven auch dann gesetzt werden, wenn drei gleichlautende Buchstaben aufeinandertreffen. Das ist etwa bei einem „Teeei“ der Fall, das so korrekt geschrieben ist. Möglich ist hier auch die Schreibweise mit Bindestrich: Tee-Ei.`, - }, - ]), - }, - }) - }) - }) - }) + }, + { authorId: 'a-user' }, + ), + ]) }) - describe('adding two users that have the same word in their slugs', () => { - beforeAll(async () => { - await Promise.all([ - Factory.build('user', { - id: 'c-user', - name: 'Rainer Maria Rilke', - slug: 'rainer-maria-rilke', - }), - Factory.build('user', { - id: 'd-user', - name: 'Erich Maria Remarque', - slug: 'erich-maria-remarque', - }), - ]) - }) - - describe('query the word that both slugs contain', () => { - it('finds both users', async () => { - variables = { query: '-maria-' } + describe('a post which content contains the title of the first post', () => { + describe('query contains the title of the first post', () => { + it('finds both posts', async () => { + variables = { query: 'beitrag' } await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({ data: { findResources: expect.arrayContaining([ { - __typename: 'User', - id: 'c-user', - name: 'Rainer Maria Rilke', - slug: 'rainer-maria-rilke', + __typename: 'Post', + id: 'a-post', + title: 'Beitrag', + content: 'Ein erster Beitrag', }, { - __typename: 'User', - id: 'd-user', - name: 'Erich Maria Remarque', - slug: 'erich-maria-remarque', + __typename: 'Post', + id: 'b-post', + title: 'Aufruf', + content: 'Jeder sollte seinen Beitrag leisten.', }, ]), }, @@ -409,36 +204,154 @@ und hinter tausend Stäben keine Welt.`, }) }) - describe('adding a post, written by a user who is muted by the authenticated user', () => { - beforeAll(async () => { - const mutedUser = await Factory.build('user', { - id: 'muted-user', - name: 'Muted', - slug: 'muted', - }) - await user.relateTo(mutedUser, 'muted') - await Factory.build( - 'post', - { - id: 'muted-post', - title: 'Beleidigender Beitrag', - content: 'Dieser Beitrag stammt von einem bleidigendem Nutzer.', - }, - { authorId: 'muted-user' }, - ) - }) - - describe('query for text in a post written by a muted user', () => { - it('does not include the post of the muted user in the results', async () => { - variables = { query: 'beitrag' } + describe('a post that contains a hyphen between two words and German quotation marks', () => { + describe('hyphens in query', () => { + it('will be treated as ordinary characters', async () => { + variables = { query: 'tee-ei' } await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({ data: { - findResources: expect.not.arrayContaining([ + findResources: [ { __typename: 'Post', - id: 'muted-post', - title: 'Beleidigender Beitrag', - content: 'Dieser Beitrag stammt von einem bleidigendem Nutzer.', + id: 'g-post', + title: 'Zusammengesetzte Wörter', + content: `Ein Bindestrich kann zwischen zwei Substantiven auch dann gesetzt werden, wenn drei gleichlautende Buchstaben aufeinandertreffen. Das ist etwa bei einem „Teeei“ der Fall, das so korrekt geschrieben ist. Möglich ist hier auch die Schreibweise mit Bindestrich: Tee-Ei.`, + }, + ], + }, + }) + }) + }) + + describe('German quotation marks in query to test unicode characters (\u201E ... \u201C)', () => { + it('will be treated as ordinary characters', async () => { + variables = { query: '„teeei“' } + await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({ + data: { + findResources: [ + { + __typename: 'Post', + id: 'g-post', + title: 'Zusammengesetzte Wörter', + content: `Ein Bindestrich kann zwischen zwei Substantiven auch dann gesetzt werden, wenn drei gleichlautende Buchstaben aufeinandertreffen. Das ist etwa bei einem „Teeei“ der Fall, das so korrekt geschrieben ist. Möglich ist hier auch die Schreibweise mit Bindestrich: Tee-Ei.`, + }, + ], + }, + }) + }) + }) + }) + + describe('a post that contains a simple mathematical exprssion and line breaks', () => { + describe('query a part of the mathematical expression', () => { + it('finds that post', async () => { + variables = { query: '(a - b)²' } + await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({ + data: { + findResources: [ + { + __typename: 'Post', + id: 'c-post', + title: 'Die binomischen Formeln', + content: `1. binomische Formel: (a + b)² = a² + 2ab + b²
+2. binomische Formel: (a - b)² = a² - 2ab + b²
+3. binomische Formel: (a + b)(a - b) = a² - b²`, + }, + ], + }, + }) + }) + }) + + describe('query the same part of the mathematical expression without spaces', () => { + it('finds that post', async () => { + variables = { query: '(a-b)²' } + await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({ + data: { + findResources: [ + { + __typename: 'Post', + id: 'c-post', + title: 'Die binomischen Formeln', + content: `1. binomische Formel: (a + b)² = a² + 2ab + b²
+2. binomische Formel: (a - b)² = a² - 2ab + b²
+3. binomische Formel: (a + b)(a - b) = a² - b²`, + }, + ], + }, + }) + }) + }) + + describe('query the mathematical expression over line break', () => { + it('finds that post', async () => { + variables = { query: '+ b² 2.' } + await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({ + data: { + findResources: [ + { + __typename: 'Post', + id: 'c-post', + title: 'Die binomischen Formeln', + content: `1. binomische Formel: (a + b)² = a² + 2ab + b²
+2. binomische Formel: (a - b)² = a² - 2ab + b²
+3. binomische Formel: (a + b)(a - b) = a² - b²`, + }, + ], + }, + }) + }) + }) + }) + + describe('a post that contains a poem', () => { + describe('query for more than one word, e.g. the title of the poem', () => { + it('finds the poem and another post that contains only one word but with lower score', async () => { + variables = { query: 'der panther' } + await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({ + data: { + findResources: [ + { + __typename: 'Post', + id: 'd-post', + title: 'Der Panther', + content: `Sein Blick ist vom Vorübergehn der Stäbe
+so müd geworden, daß er nichts mehr hält.
+Ihm ist, als ob es tausend Stäbe gäbe
+und hinter tausend Stäben keine Welt.`, + }, + { + __typename: 'Post', + id: 'g-post', + title: 'Zusammengesetzte Wörter', + content: `Ein Bindestrich kann zwischen zwei Substantiven auch dann gesetzt werden, wenn drei gleichlautende Buchstaben aufeinandertreffen. Das ist etwa bei einem „Teeei“ der Fall, das so korrekt geschrieben ist. Möglich ist hier auch die Schreibweise mit Bindestrich: Tee-Ei.`, + }, + ], + }, + }) + }) + }) + + describe('query for the first four letters of two longer words', () => { + it('finds the posts that contain words starting with these four letters', async () => { + variables = { query: 'Vorü Subs' } + await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({ + data: { + findResources: expect.arrayContaining([ + { + __typename: 'Post', + id: 'd-post', + title: 'Der Panther', + content: `Sein Blick ist vom Vorübergehn der Stäbe
+so müd geworden, daß er nichts mehr hält.
+Ihm ist, als ob es tausend Stäbe gäbe
+und hinter tausend Stäben keine Welt.`, + }, + { + __typename: 'Post', + id: 'g-post', + title: 'Zusammengesetzte Wörter', + content: `Ein Bindestrich kann zwischen zwei Substantiven auch dann gesetzt werden, wenn drei gleichlautende Buchstaben aufeinandertreffen. Das ist etwa bei einem „Teeei“ der Fall, das so korrekt geschrieben ist. Möglich ist hier auch die Schreibweise mit Bindestrich: Tee-Ei.`, }, ]), }, @@ -447,6 +360,85 @@ und hinter tausend Stäben keine Welt.`, }) }) }) + + describe('adding two users that have the same word in their slugs', () => { + beforeAll(async () => { + await Promise.all([ + Factory.build('user', { + id: 'c-user', + name: 'Rainer Maria Rilke', + slug: 'rainer-maria-rilke', + }), + Factory.build('user', { + id: 'd-user', + name: 'Erich Maria Remarque', + slug: 'erich-maria-remarque', + }), + ]) + }) + + describe('query the word that both slugs contain', () => { + it('finds both users', async () => { + variables = { query: '-maria-' } + await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({ + data: { + findResources: expect.arrayContaining([ + { + __typename: 'User', + id: 'c-user', + name: 'Rainer Maria Rilke', + slug: 'rainer-maria-rilke', + }, + { + __typename: 'User', + id: 'd-user', + name: 'Erich Maria Remarque', + slug: 'erich-maria-remarque', + }, + ]), + }, + }) + }) + }) + }) + + describe('adding a post, written by a user who is muted by the authenticated user', () => { + beforeAll(async () => { + const mutedUser = await Factory.build('user', { + id: 'muted-user', + name: 'Muted', + slug: 'muted', + }) + await user.relateTo(mutedUser, 'muted') + await Factory.build( + 'post', + { + id: 'muted-post', + title: 'Beleidigender Beitrag', + content: 'Dieser Beitrag stammt von einem bleidigendem Nutzer.', + }, + { authorId: 'muted-user' }, + ) + }) + + describe('query for text in a post written by a muted user', () => { + it('does not include the post of the muted user in the results', async () => { + variables = { query: 'beitrag' } + await expect(query({ query: searchQuery, variables })).resolves.toMatchObject({ + data: { + findResources: expect.not.arrayContaining([ + { + __typename: 'Post', + id: 'muted-post', + title: 'Beleidigender Beitrag', + content: 'Dieser Beitrag stammt von einem bleidigendem Nutzer.', + }, + ]), + }, + }) + }) + }) + }) }) }) }) diff --git a/backend/src/schema/resolvers/searches/queryString.js b/backend/src/schema/resolvers/searches/queryString.js index 38a25c6cf..c3500188c 100644 --- a/backend/src/schema/resolvers/searches/queryString.js +++ b/backend/src/schema/resolvers/searches/queryString.js @@ -14,26 +14,20 @@ const matchWholeText = (str, boost = 8) => { } const matchEachWordExactly = (str, boost = 4) => { - if (str.includes(' ')) { - const tmp = str - .split(' ') - .map((s, i) => (i === 0 ? `"${s}"` : `AND "${s}"`)) - .join(' ') - return `(${tmp})^${boost}` - } else { - return '' - } + if (!str.includes(' ')) return '' + const tmp = str + .split(' ') + .map((s, i) => (i === 0 ? `"${s}"` : `AND "${s}"`)) + .join(' ') + return `(${tmp})^${boost}` } const matchSomeWordsExactly = (str, boost = 2) => { - if (str.includes(' ')) { - return str - .split(' ') - .map(s => `"${s}"^${boost}`) - .join(' ') - } else { - return '' - } + if (!str.includes(' ')) return '' + return str + .split(' ') + .map(s => `"${s}"^${boost}`) + .join(' ') } const matchBeginningOfWords = str => { diff --git a/backend/src/schema/resolvers/searches/queryString.spec.js b/backend/src/schema/resolvers/searches/queryString.spec.js index 8f646ce25..23a746be1 100644 --- a/backend/src/schema/resolvers/searches/queryString.spec.js +++ b/backend/src/schema/resolvers/searches/queryString.spec.js @@ -10,7 +10,7 @@ describe('queryString', () => { }) describe('whitespace', () => { - it('is normalized correctly', () => { + it('normalizes correctly', () => { expect(normalizeWhitespace(' a \t \n b \n ')).toEqual('a b') }) }) From 800e33e1be698f0a6cb9f1c9bea680eea474c29b Mon Sep 17 00:00:00 2001 From: mattwr18 Date: Tue, 17 Mar 2020 13:05:37 +0100 Subject: [PATCH 18/18] chore: fix lint --- backend/src/schema/resolvers/searches.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/schema/resolvers/searches.js b/backend/src/schema/resolvers/searches.js index 39fe952bb..5c1e43952 100644 --- a/backend/src/schema/resolvers/searches.js +++ b/backend/src/schema/resolvers/searches.js @@ -42,7 +42,7 @@ export default { LIMIT $limit ` const myQuery = queryString(query) - + const session = context.driver.session() const searchResultPromise = session.readTransaction(async transaction => { const postTransactionResponse = transaction.run(postCypher, {