diff --git a/CHANGELOG.md b/CHANGELOG.md index 41505c94cd..da59db3ef9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ * Added support for a sync configuration option to provide an `SSLConfiguration` with a custom function for validating the server's SSL certificate. ([#5485](https://github.com/realm/realm-js/issues/5485)) * Improve performance of equality queries on a non-indexed mixed property by about 30%. ([realm/realm-core#6506](https://github.com/realm/realm-core/pull/6506)) * Improve performance of rolling back write transactions after making changes. ([realm/realm-core#6513](https://github.com/realm/realm-core/pull/6513)) +* Extended `PropertySchema.indexed` with the `full-text` option, that allows to create an index for full-text search queries. ([#5755](https://github.com/realm/realm-js/issues/5755)) ### Fixed * Fix a stack overflow crash when using the query parser with long chains of AND/OR conditions. ([realm/realm-core#6428](https://github.com/realm/realm-core/pull/6428), since v10.11.0) diff --git a/integration-tests/tests/src/tests/queries.ts b/integration-tests/tests/src/tests/queries.ts index 8a36558a8e..3d7841c4d4 100644 --- a/integration-tests/tests/src/tests/queries.ts +++ b/integration-tests/tests/src/tests/queries.ts @@ -15,7 +15,7 @@ // limitations under the License. // //////////////////////////////////////////////////////////////////////////// -import Realm, { BSON } from "realm"; +import Realm, { BSON, ObjectSchema } from "realm"; import { expect } from "chai"; import { openRealmBeforeEach } from "../hooks"; import { IPerson, PersonSchema } from "../schemas/person-and-dogs"; @@ -63,6 +63,25 @@ class NullableTypesObject extends Realm.Object implements INullableTypesObject { }; } +interface IStory { + title?: string; + content?: string; +} + +class Story extends Realm.Object implements IStory { + title?: string; + content?: string; + + static schema: ObjectSchema = { + name: "Story", + properties: { + title: { type: "string" }, + content: { type: "string", indexed: "full-text" }, + }, + primaryKey: "title", + }; +} + type QueryLengthPair = [ExpectedLength: number, Query: string, ...QueryArgs: Array]; type QueryExceptionPair = [ExpectedException: string, Query: string, ...QueryArgs: Array]; type QueryResultsPair = [ExpectedResults: any[], Query: string, ...QueryArgs: Array]; @@ -131,6 +150,75 @@ const expectQueryResultValues = ( }; describe("Queries", () => { + describe("Full text search", () => { + openRealmBeforeEach({ schema: [Story] }); + + const story1: IStory = { + title: "Dogs and cats", + content: "A short story about a dog running after two cats", + }; + + const story2: IStory = { + title: "Adventure", + content: "A novel about two friends looking for a treasure", + }; + + const story3: IStory = { + title: "A friend", + content: "A short poem about friendship", + }; + + const story4: IStory = { + title: "Lord of the rings", + content: "A long story about the quest for a ring", + }; + + beforeEach(function (this: RealmContext) { + this.realm.write(() => { + this.realm.create("Story", story1); + this.realm.create("Story", story2); + this.realm.create("Story", story3); + this.realm.create("Story", story4); + }); + }); + + it("single term", function (this: RealmContext) { + expectQueryResultValues(this.realm, Story, "title", [[[story1.title], "content TEXT 'cats'"]]); + expectQueryResultValues(this.realm, Story, "title", [[[story1.title, story4.title], "content TEXT 'story'"]]); + }); + + it("multiple terms", function (this: RealmContext) { + expectQueryResultValues(this.realm, Story, "title", [[[story1.title], "content TEXT 'two dog'"]]); + expectQueryResultValues(this.realm, Story, "title", [[[story3.title], "content TEXT 'poem short friendship'"]]); + expectQueryResultValues(this.realm, Story, "title", [ + [[story1.title, story2.title, story3.title, story4.title], "content TEXT 'about a'"], + ]); + }); + + it("exclude term", function (this: RealmContext) { + expectQueryResultValues(this.realm, Story, "title", [[[story4.title], "content TEXT 'story -cats'"]]); + }); + + it("empty results", function (this: RealmContext) { + expectQueryResultValues(this.realm, Story, "title", [[[], "content TEXT 'two dog friends'"]]); + expectQueryResultValues(this.realm, Story, "title", [[[], "content TEXT 'amazing'"]]); + }); + + it("query parameters", function (this: RealmContext) { + expectQueryResultValues(this.realm, Story, "title", [[[story1.title], "content TEXT $0", "cats"]]); + expectQueryResultValues(this.realm, Story, "title", [[[story1.title], "content TEXT $0", "two dog"]]); + expectQueryResultValues(this.realm, Story, "title", [[[story4.title], "content TEXT $0", "story -cats"]]); + + expectQueryResultValues(this.realm, Story, "title", [[[story1.title], "content TEXT $0", "'cats'"]]); + expectQueryResultValues(this.realm, Story, "title", [[[story1.title], "content TEXT $0", "'two dog'"]]); + expectQueryResultValues(this.realm, Story, "title", [[[story4.title], "content TEXT $0", "'story -cats'"]]); + }); + + it("throws on column with no index", function (this: RealmContext) { + expectQueryException(this.realm, Story, [["Column has no fulltext index", "title TEXT 'cats'"]]); + }); + }); + describe("Basic types", () => { openRealmBeforeEach({ schema: [NullableTypesObject] }); diff --git a/integration-tests/tests/src/tests/schema.ts b/integration-tests/tests/src/tests/schema.ts index 0a621d01d7..1c5b538a50 100644 --- a/integration-tests/tests/src/tests/schema.ts +++ b/integration-tests/tests/src/tests/schema.ts @@ -83,4 +83,54 @@ describe("Realm schema", () => { expect(obj.simpleValue).to.equal(123); }); }); + + describe("Schema validation", () => { + it("throws on invalid indexed type", () => { + expect(() => { + new Realm({ + schema: [ + { + name: "testProp", + properties: { + content: { type: "string", indexed: 22 }, + }, + }, + ], + }); + }).throws( + "Invalid type declaration for property 'content' on 'testProp': Expected 'content.indexed' on 'testProp' to be a boolean or 'full-text'", + ); + }); + }); + + it("throws when declaring full-text index on non string property", () => { + expect(() => { + new Realm({ + schema: [ + { + name: "testProp", + properties: { + num: { type: "int", indexed: "full-text" }, + }, + }, + ], + }); + }).throws("Index not supported for this property: num"); + }); + + it("throws when declaring full-text index on primary key", () => { + expect(() => { + new Realm({ + schema: [ + { + name: "testProp", + properties: { + myString: { type: "string", indexed: "full-text" }, + }, + primaryKey: "myString", + }, + ], + }); + }).throws("Primary keys cannot be full-text indexed."); + }); }); diff --git a/packages/realm/bindgen/vendor/realm-core b/packages/realm/bindgen/vendor/realm-core index 4d50ce4e99..4fc4df8834 160000 --- a/packages/realm/bindgen/vendor/realm-core +++ b/packages/realm/bindgen/vendor/realm-core @@ -1 +1 @@ -Subproject commit 4d50ce4e99f63079194edae3e499fb595d78b28e +Subproject commit 4fc4df8834baf7c6678fd26489bb53ed75139191 diff --git a/packages/realm/src/index.ts b/packages/realm/src/index.ts index a14c82287e..a45f93bda1 100644 --- a/packages/realm/src/index.ts +++ b/packages/realm/src/index.ts @@ -122,6 +122,7 @@ export { ProgressRealmPromise, PropertiesTypes, PropertySchema, + PropertySchemaCommon, PropertySchemaParseError, PropertySchemaShorthand, PropertySchemaStrict, diff --git a/packages/realm/src/schema/from-binding.ts b/packages/realm/src/schema/from-binding.ts index ea32cae42b..a8b16a59e8 100644 --- a/packages/realm/src/schema/from-binding.ts +++ b/packages/realm/src/schema/from-binding.ts @@ -114,10 +114,10 @@ export function fromBindingObjectSchema({ * @internal */ export function fromBindingPropertySchema(propertySchema: BindingProperty): CanonicalPropertySchema { - const { name, isIndexed, publicName } = propertySchema; + const { name, isIndexed, isFulltextIndexed, publicName } = propertySchema; const result: CanonicalPropertySchema = { name, - indexed: isIndexed, + indexed: isFulltextIndexed ? "full-text" : isIndexed, mapTo: name, ...fromBindingPropertyTypeName(propertySchema), }; diff --git a/packages/realm/src/schema/normalize.ts b/packages/realm/src/schema/normalize.ts index 690d370e5c..01d099c488 100644 --- a/packages/realm/src/schema/normalize.ts +++ b/packages/realm/src/schema/normalize.ts @@ -318,6 +318,7 @@ function normalizePropertySchemaObject(info: PropertyInfoUsingObject): Canonical if (info.isPrimaryKey) { assert(indexed !== false, propError(info, "Primary keys must always be indexed.")); + assert(indexed !== "full-text", propError(info, "Primary keys cannot be full-text indexed.")); indexed = true; } @@ -325,9 +326,10 @@ function normalizePropertySchemaObject(info: PropertyInfoUsingObject): Canonical name: info.propertyName, type: type as PropertyTypeName, optional: !!optional, - indexed: !!indexed, + indexed: indexed !== undefined ? indexed : false, mapTo: propertySchema.mapTo || info.propertyName, }; + // Add optional properties only if defined (tests expect no 'undefined' properties) if (objectType !== undefined) normalizedSchema.objectType = objectType; if (property !== undefined) normalizedSchema.property = property; diff --git a/packages/realm/src/schema/to-binding.ts b/packages/realm/src/schema/to-binding.ts index 6fce050f62..3755d040df 100644 --- a/packages/realm/src/schema/to-binding.ts +++ b/packages/realm/src/schema/to-binding.ts @@ -99,10 +99,16 @@ export function toBindingPropertySchema(name: string, schema: CanonicalPropertyS const result: BindingProperty = { name, type: toBindingPropertyType(schema), - isIndexed: schema.indexed, objectType: schema.objectType && schema.objectType in TYPE_MAPPINGS ? undefined : schema.objectType, linkOriginPropertyName: schema.property, }; + + if (schema.indexed === "full-text") { + result.isFulltextIndexed = true; + } else { + result.isIndexed = schema.indexed; + } + if (schema.mapTo && schema.mapTo !== schema.name) { result.publicName = result.name; result.name = schema.mapTo; diff --git a/packages/realm/src/schema/types.ts b/packages/realm/src/schema/types.ts index 801dce9c35..9505b328c0 100644 --- a/packages/realm/src/schema/types.ts +++ b/packages/realm/src/schema/types.ts @@ -84,6 +84,13 @@ export type CanonicalRealmSchema = CanonicalObjectSchema[]; */ export type CanonicalObjectSchemaProperty = CanonicalPropertySchema; +/** + * The type of index on a property: + * - `true` enables a regular index + * - `"full-text"` enables a full-text search index and can only be applied to string properties. + */ +export type IndexedType = boolean | "full-text"; + /** * The canonical representation of the schema of a specific property. */ @@ -91,7 +98,7 @@ export type CanonicalPropertySchema = { name: string; type: PropertyTypeName; optional: boolean; - indexed: boolean; + indexed: IndexedType; mapTo: string; // TODO: Make this optional and leave it out when it equals the name objectType?: string; property?: string; @@ -239,11 +246,11 @@ export type PropertySchema = { */ optional?: boolean; /** - * Whether the property should be indexed. + * The type of index applied to the property. * * Default value: `false` if the property is not a primary key, otherwise `true`. */ - indexed?: boolean; + indexed?: IndexedType; /** * The name to be persisted in the Realm file if it differs from the already-defined * JavaScript/TypeScript (JS/TS) property name. This is useful for allowing different @@ -261,8 +268,8 @@ export type PropertySchema = { /** * Keys used in the property schema that are common among all variations of {@link PropertySchemaStrict}. */ -type PropertySchemaCommon = { - indexed?: boolean; +export type PropertySchemaCommon = { + indexed?: IndexedType; mapTo?: string; default?: unknown; }; diff --git a/packages/realm/src/schema/validate.ts b/packages/realm/src/schema/validate.ts index 93b98442f3..2d8ab257ab 100644 --- a/packages/realm/src/schema/validate.ts +++ b/packages/realm/src/schema/validate.ts @@ -22,13 +22,12 @@ import { Configuration, DefaultObject, ObjectSchema, + ObjectSchemaParseError, PropertySchema, + PropertySchemaParseError, RealmObject, RealmObjectConstructor, - SchemaParseError, assert, - PropertySchemaParseError, - ObjectSchemaParseError, } from "../internal"; // Need to use `CanonicalObjectSchema` rather than `ObjectSchema` due to some @@ -164,7 +163,10 @@ export function validatePropertySchema( assert.string(property, `'${propertyName}.property' on '${objectName}'`); } if (indexed !== undefined) { - assert.boolean(indexed, `'${propertyName}.indexed' on '${objectName}'`); + assert( + typeof indexed === "boolean" || indexed === "full-text", + `Expected '${propertyName}.indexed' on '${objectName}' to be a boolean or 'full-text'.`, + ); } if (mapTo !== undefined) { assert.string(mapTo, `'${propertyName}.mapTo' on '${objectName}'`); diff --git a/packages/realm/src/tests/schema-normalization.test.ts b/packages/realm/src/tests/schema-normalization.test.ts index 9aa9e98bcc..197e42c464 100644 --- a/packages/realm/src/tests/schema-normalization.test.ts +++ b/packages/realm/src/tests/schema-normalization.test.ts @@ -701,6 +701,19 @@ describe("normalizePropertySchema", () => { { isPrimaryKey: false }, ); + itNormalizes( + { + type: "string", + indexed: "full-text", + }, + { + type: "string", + indexed: "full-text", + optional: false, + }, + { isPrimaryKey: false }, + ); + itNormalizes( { type: "string", @@ -966,6 +979,15 @@ describe("normalizePropertySchema", () => { "Primary keys must always be indexed.", { isPrimaryKey: true }, ); + + itThrowsWhenNormalizing( + { + type: "string", + indexed: "full-text", + }, + "Primary keys cannot be full-text indexed.", + { isPrimaryKey: true }, + ); }); }); diff --git a/packages/realm/src/tests/schema-validation.test.ts b/packages/realm/src/tests/schema-validation.test.ts index 883947f199..464dcedefa 100644 --- a/packages/realm/src/tests/schema-validation.test.ts +++ b/packages/realm/src/tests/schema-validation.test.ts @@ -236,7 +236,7 @@ describe("validatePropertySchema", () => { type: "", indexed: NOT_A_BOOLEAN, }, - `Expected '${PROPERTY_NAME}.indexed' on '${OBJECT_NAME}' to be a boolean, got a number`, + `Expected '${PROPERTY_NAME}.indexed' on '${OBJECT_NAME}' to be a boolean or 'full-text'`, ); itThrowsWhenValidating(