Repository: parse-community/Parse-SDK-JS
Pull request #573 (2018-05-22 02:30:49)

Add options to full text search
This PR adds the support for options on the full text search, namely: $caseSensitive, $language and $diacriticSensitive.

codecov[bot]: # [Codecov](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/573?src=pr&el=h1) Report
> Merging [#573](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/573?src=pr&el=desc) into [master](https://codecov.io/gh/parse-community/Parse-SDK-JS/commit/d7242d36f9e561528fa58aabcfb589e6caa3658f?src=pr&el=desc) will **decrease** coverage by `0.01%`.
> The diff coverage is `75%`.

[![Impacted file tree graph](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/573/graphs/tree.svg?height=150&width=650&token=8nCENM0QHj&src=pr)](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/573?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #573      +/-   ##
==========================================
- Coverage   84.76%   84.74%   -0.02%     
==========================================
  Files          48       48              
  Lines        4003     4005       +2     
  Branches      903      904       +1     
==========================================
+ Hits         3393     3394       +1     
- Misses        610      611       +1
```


| [Impacted Files](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/573?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [src/ParseQuery.js](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/573/diff?src=pr&el=tree#diff-c3JjL1BhcnNlUXVlcnkuanM=) | `94.1% <75%> (-0.22%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/573?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/573?src=pr&el=footer). Last update [d7242d3...a2b1b5b](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/573?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

dplewis: @flovilmart Can you take a look at travis?
flovilmart: @dplewis seems that integration tests are failing. Not sure why or if we broke something else.
dplewis: @flovilmart Thanks for fixing the tests
flovilmart: NP! 
dplewis: @flovilmart is there an easy way to view generated docs for a PR without pulling it down?
flovilmart: Not really ;/ you’ll have to pull it 
dplewis: @flovilmart I think the CONTRIBUTION.md looks outdated. Doesn’t say how to run those unit jest test, integration test, generate docs, etc.
flovilmart: @dplewis you're right ;) we need to do somethings about that
dplewis: @danielbm I added a few more comments on your test cases
cleever: Any news on this?
danielbm: @cleever Not really. I'm just short of time to fix this PR.
dplewis: @danielbm I can finish off this PR for you, if thats cool.
danielbm: @dplewis Sure! Feel free to do it!
flovilmart: @dplewis where are we at on this one?
dplewis: @danielbm how does this look?

Patch:
diff --git a/src/ParseQuery.js b/src/ParseQuery.js
index 130dfe1..6470235 100644
--- a/src/ParseQuery.js
+++ b/src/ParseQuery.js
@@ -874,7 +874,6 @@ class ParseQuery {
   /**
    * Adds a constraint to the query that requires a particular key's value to
    * contain each one of the provided list of values starting with given strings.
-   * @method containsAllStartingWith
    * @param {String} key The key to check.  This key's value must be an array.
    * @param {Array<String>} values The string values that will match as starting string.
    * @return {Parse.Query} Returns the query, so you can chain this call.
@@ -1015,13 +1014,13 @@ class ParseQuery {
     return this._addCondition(key, '$regex', quote(value));
   }
 
-   /**
+  /**
   * Adds a constraint for finding string values that contain a provided
   * string. This may be slow for large datasets. Requires Parse-Server > 2.5.0
   *
   * In order to sort you must use select and ascending ($score is required)
   *  <pre>
-  *   query.fullText('term');
+  *   query.fullText('field', 'term');
   *   query.ascending('$score');
   *   query.select('$score');
   *  </pre>
@@ -1031,23 +1030,63 @@ class ParseQuery {
   *   object->get('score');
   *  </pre>
   *
+  * You can define optionals by providing an object as a third parameter
+  *  <pre>
+  *   query.fullText('field', 'term', { language: 'es', diacriticSensitive: true });
+  *  </pre>
+  *
   * @param {String} key The key that the string to match is stored in.
   * @param {String} value The string to search
+  * @param {Object} options (Optional)
+  * @param {String} options.language The language that determines the list of stop words for the search and the rules for the stemmer and tokenizer.
+  * @param {Boolean} options.caseSensitive A boolean flag to enable or disable case sensitive search.
+  * @param {Boolean} options.diacriticSensitive A boolean flag to enable or disable diacritic sensitive search.
   * @return {Parse.Query} Returns the query, so you can chain this call.
   */
- fullText(key: string, value: string): ParseQuery {
-   if (!key) {
-     throw new Error('A key is required.');
-   }
-   if (!value) {
-     throw new Error('A search term is required');
-   }
-   if (typeof value !== 'string') {
-     throw new Error('The value being searched for must be a string.');
-   }
-
-   return this._addCondition(key, '$text', { $search: { $term: value } });
- }
+  fullText(key: string, value: string, options: ?Object): ParseQuery {
+    options = options || {};
+
+    if (!key) {
+      throw new Error('A key is required.');
+    }
+    if (!value) {
+      throw new Error('A search term is required');
+    }
+    if (typeof value !== 'string') {
+      throw new Error('The value being searched for must be a string.');
+    }
+
+    const fullOptions = { $term: value };
+    for (const option in options) {
+      switch (option) {
+        case 'language':
+          fullOptions.$language = options[option];
+          break;
+        case 'caseSensitive':
+          fullOptions.$caseSensitive = options[option];
+          break;
+        case 'diacriticSensitive':
+          fullOptions.$diacriticSensitive = options[option];
+          break;
+        default:
+          throw new Error(`Unknown option: ${option}`);
+          break;
+      }
+    }
+
+    return this._addCondition(key, '$text', { $search: fullOptions });
+  }
+
+  /**
+   * Method to sort the full text search by text score
+   *
+   * @return {Parse.Query} Returns the query, so you can chain this call.
+   */
+  sortByTextScore() {
+    this.ascending('$score');
+    this.select(['$score']);
+    return this;
+  }
 
   /**
    * Adds a constraint for finding string values that start with a provided
@@ -1105,7 +1144,7 @@ class ParseQuery {
    *   defaults to true.
    * @return {Parse.Query} Returns the query, so you can chain this call.
    */
-  withinRadians(key: string, point: ParseGeoPoint, distance: number, sorted: boolean): ParseQuery {  
+  withinRadians(key: string, point: ParseGeoPoint, distance: number, sorted: boolean): ParseQuery {
     if (sorted || sorted === undefined) {
       this.near(key, point);
       return this._addCondition(key, '$maxDistance', distance);
@@ -1375,7 +1414,6 @@ class ParseQuery {
    *
    * will create a compoundQuery that is an and of the query1, query2, and
    * query3.
-   * @method and
    * @param {...Parse.Query} var_args The list of queries to AND.
    * @static
    * @return {Parse.Query} The query that is the AND of the passed in queries.


Test patch:
diff --git a/integration/test/ParseQueryTest.js b/integration/test/ParseQueryTest.js
index a25a6c1..0418ea6 100644
--- a/integration/test/ParseQueryTest.js
+++ b/integration/test/ParseQueryTest.js
@@ -1481,7 +1481,7 @@ describe('Parse Query', () => {
     });
   });
 
-  it('full text search', (done) => {
+  it('can perform a full text search', () => {
     const subjects = [
       'coffee',
       'Coffee Shopping',
@@ -1497,17 +1497,16 @@ describe('Parse Query', () => {
       const obj = new TestObject({ subject: subjects[i] });
       objects.push(obj);
     }
-    Parse.Object.saveAll(objects).then(() => {
+    return Parse.Object.saveAll(objects).then(() => {
       const q = new Parse.Query(TestObject);
       q.fullText('subject', 'coffee');
       return q.find();
     }).then((results) => {
       assert.equal(results.length, 3);
-      done();
     });
   });
 
-  it('full text search sort', (done) => {
+  it('can perform a full text search sort', () => {
     const subjects = [
       'coffee',
       'Coffee Shopping',
@@ -1523,7 +1522,7 @@ describe('Parse Query', () => {
       const obj = new TestObject({ comment: subjects[i] });
       objects.push(obj);
     }
-    Parse.Object.saveAll(objects).then(() => {
+    return Parse.Object.saveAll(objects).then(() => {
       const q = new Parse.Query(TestObject);
       q.fullText('comment', 'coffee');
       q.ascending('$score');
@@ -1534,6 +1533,132 @@ describe('Parse Query', () => {
       assert.equal(results[0].get('score'), 1);
       assert.equal(results[1].get('score'), 0.75);
       assert.equal(results[2].get('score'), 0.75);
+    });
+  });
+
+
+  it('can perform a full text search with language options', () => {
+    const subjects = [
+      'café',
+      'loja de café',
+      'preparando um café',
+      'preparar',
+      'café com leite',
+      'Сырники',
+      'prepare café e creme',
+      'preparação de cafe com leite',
+    ];
+    const TestLanguageOption = Parse.Object.extend('TestLanguageOption');
+    const objects = [];
+    for (const i in subjects) {
+      const obj = new TestLanguageOption({ language_comment: subjects[i] });
+      objects.push(obj);
+    }
+    return Parse.Object.saveAll(objects).then(() => {
+      const q = new Parse.Query(TestLanguageOption);
+      q.fullText('language_comment', 'preparar', { language: 'portuguese' });
+      return q.find();
+    }).then((results) => {
+      assert.equal(results.length, 1);
+    });
+  });
+
+  it('can perform a full text search with case sensitive options', () => {
+    const subjects = [
+      'café',
+      'loja de café',
+      'Preparando um café',
+      'preparar',
+      'café com leite',
+      'Сырники',
+      'Preparar café e creme',
+      'preparação de cafe com leite',
+    ];
+    const TestCaseOption = Parse.Object.extend('TestCaseOption');
+    const objects = [];
+    for (const i in subjects) {
+      const obj = new TestCaseOption({ casesensitive_comment: subjects[i] });
+      objects.push(obj);
+    }
+    return Parse.Object.saveAll(objects).then(() => {
+      const q = new Parse.Query(TestCaseOption);
+      q.fullText('casesensitive_comment', 'Preparar', { caseSensitive: true });
+      return q.find();
+    }).then((results) => {
+      assert.equal(results.length, 1);
+    });
+  });
+
+  it('can perform a full text search with diacritic sensitive options', () => {
+    const subjects = [
+      'café',
+      'loja de café',
+      'preparando um café',
+      'Preparar',
+      'café com leite',
+      'Сырники',
+      'preparar café e creme',
+      'preparação de cafe com leite',
+    ];
+    const TestDiacriticOption = Parse.Object.extend('TestDiacriticOption');
+    const objects = [];
+    for (const i in subjects) {
+      const obj = new TestDiacriticOption({ diacritic_comment: subjects[i] });
+      objects.push(obj);
+    }
+    return Parse.Object.saveAll(objects).then(() => {
+      const q = new Parse.Query(TestDiacriticOption);
+      q.fullText('diacritic_comment', 'cafe', { diacriticSensitive: true });
+      return q.find();
+    }).then((results) => {
+      assert.equal(results.length, 1);
+    });
+  });
+
+  it('can perform a full text search with case and diacritic sensitive options', () => {
+    const subjects = [
+      'Café',
+      'café',
+      'preparar Cafe e creme',
+      'preparação de cafe com leite',
+    ];
+    const TestCaseDiacriticOption = Parse.Object.extend('TestCaseDiacriticOption');
+    const objects = [];
+    for (const i in subjects) {
+      const obj = new TestCaseDiacriticOption({ diacritic_comment: subjects[i] });
+      objects.push(obj);
+    }
+    return Parse.Object.saveAll(objects).then(() => {
+      const q = new Parse.Query(TestCaseDiacriticOption);
+      q.fullText('diacritic_comment', 'cafe', { caseSensitive: true, diacriticSensitive: true });
+      return q.find();
+    }).then((results) => {
+      assert.equal(results.length, 1);
+      assert.equal(results[0].get('diacritic_comment'), 'preparação de cafe com leite');
+    });
+  });
+
+  it('fails to perform a full text search with unknown options', (done) => {
+    const subjects = [
+      'café',
+      'loja de café',
+      'preparando um café',
+      'preparar',
+      'café com leite',
+      'Сырники',
+      'prepare café e creme',
+      'preparação de cafe com leite',
+    ];
+    const objects = [];
+    for (const i in subjects) {
+      const obj = new TestObject({ comment: subjects[i] });
+      objects.push(obj);
+    }
+    Parse.Object.saveAll(objects).then(() => {
+      const q = new Parse.Query(TestObject);
+      q.fullText('comment', 'preparar', { language: "portuguese", notAnOption: true });
+      return q.find();
+    }).catch((e) => {
       done();
     });
   });
diff --git a/src/__tests__/ParseQuery-test.js b/src/__tests__/ParseQuery-test.js
index 7dbc484..b3914d4 100644
--- a/src/__tests__/ParseQuery-test.js
+++ b/src/__tests__/ParseQuery-test.js
@@ -2015,9 +2015,9 @@ describe('ParseQuery', () => {
   });
 
   it('full text search value required', (done) => {
-      const query = new ParseQuery('Item');
-      expect(() => query.fullText('key')).toThrow('A search term is required');
-      done();
+    const query = new ParseQuery('Item');
+    expect(() => query.fullText('key')).toThrow('A search term is required');
+    done();
   });
 
   it('full text search value must be string', (done) => {
@@ -2025,4 +2025,47 @@ describe('ParseQuery', () => {
     expect(() => query.fullText('key', [])).toThrow('The value being searched for must be a string.');
     done();
   });
+
+  it('full text search with all parameters', () => {
+    let query = new ParseQuery('Item');
+
+    query.fullText('size', 'medium', { language: 'en', caseSensitive: false, diacriticSensitive: true });
+
+    expect(query.toJSON()).toEqual({
+      where: {
+        size: {
+          $text: {
+            $search: {
+              $term: 'medium',
+              $language: 'en',
+              $caseSensitive: false,
+              $diacriticSensitive: true,
+            },
+          },
+        },
+      },
+    });
+  });
+
+  it('add the score for the full text search', () => {
+    const query = new ParseQuery('Item');
+
+    query.fullText('size', 'medium', { language: 'fr' });
+    query.sortByTextScore();
+
+    expect(query.toJSON()).toEqual({
+      where: {
+        size: {
+          $text: {
+            $search: {
+              $term: 'medium',
+              $language: 'fr',
+            },
+          },
+        },
+      },
+      keys: '$score',
+      order: '$score',
+    });
+  });
 });