diff --git a/extensions/test-params-subcategory-flatten-nested-false.local.env b/extensions/test-params-subcategory-flatten-nested-false.local.env new file mode 100644 index 0000000..b288c07 --- /dev/null +++ b/extensions/test-params-subcategory-flatten-nested-false.local.env @@ -0,0 +1,12 @@ +LOCATION=us-central1 +FIRESTORE_COLLECTION_PATH=users/{personId}/books +TEST_FIRESTORE_PARENT_COLLECTION_PATH=users +TEST_FIRESTORE_PARENT_ID=123 +TEST_FIRESTORE_CHILD_FIELD_NAME=books +FIRESTORE_COLLECTION_FIELDS=author,title,rating,isAvailable,location,createdAt,nested_field,tags,nullField,ref +FLATTEN_NESTED_DOCUMENTS=false +TYPESENSE_HOSTS=localhost +TYPESENSE_PORT=8108 +TYPESENSE_PROTOCOL=http +TYPESENSE_COLLECTION_NAME=books_firestore/1 +TYPESENSE_API_KEY=xyz diff --git a/functions/src/backfill.js b/functions/src/backfill.js index 61ea417..a2b0f7c 100644 --- a/functions/src/backfill.js +++ b/functions/src/backfill.js @@ -41,7 +41,17 @@ module.exports = functions.firestore.document(config.typesenseBackfillTriggerDoc const typesense = createTypesenseClient(); - const querySnapshot = await admin.firestore().collection(config.firestoreCollectionPath); + const pathSegments = config.firestoreCollectionPath.split("/").filter(Boolean); + const pathPlaceholders = utils.parseFirestorePath(config.firestoreCollectionPath); + const isGroupQuery = pathSegments.length > 1 && Object.values(pathPlaceholders).length; + + let querySnapshot; + if (isGroupQuery) { + const collectionGroup = pathSegments.pop(); + querySnapshot = admin.firestore().collectionGroup(collectionGroup); + } else { + querySnapshot = admin.firestore().collection(config.firestoreCollectionPath); + } let lastDoc = null; @@ -53,9 +63,15 @@ module.exports = functions.firestore.document(config.typesenseBackfillTriggerDoc } const currentDocumentsBatch = await Promise.all( thisBatch.docs.map(async (doc) => { - return await utils.typesenseDocumentFromSnapshot(doc); - }), - ); + const docPath = doc.ref.path; + const pathParams = utils.pathMatchesSelector(docPath, config.firestoreCollectionPath); + + if (!isGroupQuery || (isGroupQuery && pathParams !== null)) { + return await utils.typesenseDocumentFromSnapshot(doc, pathParams); + } else { + return null; + } + }).filter((doc) => doc !== null)); lastDoc = thisBatch.docs.at(-1) ?? null; try { diff --git a/functions/src/indexOnWrite.js b/functions/src/indexOnWrite.js index ea80b4e..2db5935 100644 --- a/functions/src/indexOnWrite.js +++ b/functions/src/indexOnWrite.js @@ -20,7 +20,7 @@ module.exports = functions.firestore.document(config.firestoreCollectionPath) // snapshot.after.ref.get() will refetch the latest version of the document const latestSnapshot = await snapshot.after.ref.get(); - const typesenseDocument = await utils.typesenseDocumentFromSnapshot(latestSnapshot); + const typesenseDocument = await utils.typesenseDocumentFromSnapshot(latestSnapshot, context.params); functions.logger.debug(`Upserting document ${JSON.stringify(typesenseDocument)}`); return await typesense diff --git a/functions/src/utils.js b/functions/src/utils.js index a78c24d..b6b67ca 100644 --- a/functions/src/utils.js +++ b/functions/src/utils.js @@ -131,10 +131,11 @@ function flattenDocument(obj, prefix = "") { /** * @param {DocumentSnapshot} firestoreDocumentSnapshot + * @param {Object} contextParams * @param {Array} fieldsToExtract * @return {Object} typesenseDocument */ -exports.typesenseDocumentFromSnapshot = async (firestoreDocumentSnapshot, fieldsToExtract = config.firestoreCollectionFields) => { +exports.typesenseDocumentFromSnapshot = async (firestoreDocumentSnapshot, contextParams = {}, fieldsToExtract = config.firestoreCollectionFields) => { const data = firestoreDocumentSnapshot.data(); const extractedData = fieldsToExtract.length === 0 ? data : fieldsToExtract.reduce((acc, field) => extractField(data, acc, field), {}); @@ -148,5 +149,77 @@ exports.typesenseDocumentFromSnapshot = async (firestoreDocumentSnapshot, fields typesenseDocument.id = firestoreDocumentSnapshot.id; + if (contextParams && Object.entries(contextParams).length) { + Object.entries(contextParams).forEach(([key, value]) => { + typesenseDocument[key] = value; + }); + } + return typesenseDocument; }; + +/** + * Parses a Firestore path with placeholdersto extract indices and names of placeholders. + * @param {string} firestorePath - The Firestore path to parse. + * @return {Object} An object containing the names of placeholders, and their corresponding indices. + * @throws Will throw an error if the path is invalid. + */ +exports.parseFirestorePath = function(firestorePath) { + if (!firestorePath || typeof firestorePath !== "string") { + throw new Error("Invalid Firestore path: Path must be a non-empty string."); + } + + const segments = firestorePath.split("/").filter(Boolean); + const placeholders = {}; + + segments.forEach((segment, index) => { + const match = segment.match(/^{([^}]+)}$/); // Match placeholders like {userId} + if (match) { + const varName = match[1]; + if (placeholders[varName]) { + throw new Error(`Duplicate placeholder detected: ${varName}`); + } + placeholders[varName] = index; + } + }); + + return placeholders; +}; + +/** + * Verifies if a given Firestore path matches a selector and extracts placeholder values. + * @param {string} path - The static Firestore path (e.g., "users/123/library/456/books/789"). + * @param {string} selector - The path selector with placeholders (e.g., "users/{userId}/library/{libraryId}/books"). + * @return {Object|null} A dictionary of extracted values if the path matches the selector, or `null` if it does not match. + */ +exports.pathMatchesSelector = function(path, selector) { + if (!path || typeof path !== "string") { + throw new Error("Invalid path: Path must be a non-empty string."); + } + if (!selector || typeof selector !== "string") { + throw new Error("Invalid selector: Selector must be a non-empty string."); + } + + const pathSegments = path.split("/").filter(Boolean); + const selectorSegments = selector.split("/").filter(Boolean); + + if (pathSegments.length < selectorSegments.length) { + return null; + } + + const extractedValues = {}; + + for (let i = 0; i < selectorSegments.length; i++) { + const selectorSegment = selectorSegments[i]; + const pathSegment = pathSegments[i]; + + if (selectorSegment.startsWith("{") && selectorSegment.endsWith("}")) { + const placeholderName = selectorSegment.slice(1, -1); // Remove {} + extractedValues[placeholderName] = pathSegment; + } else if (selectorSegment !== pathSegment) { + return null; + } + } + + return extractedValues; +}; diff --git a/package.json b/package.json index 9c1408d..dd5ead9 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,10 @@ "scripts": { "emulator": "cross-env DOTENV_CONFIG=extensions/test-params-flatten-nested-false.local.env firebase emulators:start --import=emulator_data", "export": "firebase emulators:export emulator_data", - "test": "npm run test-part-1 && npm run test-part-2", - "test-part-1": "cp -f extensions/test-params-flatten-nested-true.local.env extensions/firestore-typesense-search.env.local && cross-env NODE_OPTIONS=--experimental-vm-modules DOTENV_CONFIG=extensions/test-params-flatten-nested-true.local.env firebase emulators:exec --only functions,firestore,extensions 'jest --testPathIgnorePatterns=\"WithoutFlattening\"'", + "test": "npm run test-part-1 && npm run test-part-2 && npm run test-part-3", + "test-part-1": "cp -f extensions/test-params-flatten-nested-true.local.env extensions/firestore-typesense-search.env.local && cross-env NODE_OPTIONS=--experimental-vm-modules DOTENV_CONFIG=extensions/test-params-flatten-nested-true.local.env firebase emulators:exec --only functions,firestore,extensions 'jest --testPathIgnorePatterns=\"WithoutFlattening\" --testPathIgnorePatterns=\"Subcollection\"'", "test-part-2": "cp -f extensions/test-params-flatten-nested-false.local.env extensions/firestore-typesense-search.env.local && cross-env NODE_OPTIONS=--experimental-vm-modules DOTENV_CONFIG=extensions/test-params-flatten-nested-false.local.env firebase emulators:exec --only functions,firestore,extensions 'jest --testRegex=\"WithoutFlattening\"'", + "test-part-3": "cp -f extensions/test-params-subcategory-flatten-nested-false.local.env extensions/firestore-typesense-search.env.local && cross-env NODE_OPTIONS=--experimental-vm-modules DOTENV_CONFIG=extensions/test-params-subcategory-flatten-nested-false.local.env firebase emulators:exec --only functions,firestore,extensions 'jest --testRegex=\"Subcollection\"'", "typesenseServer": "docker compose up", "lint:fix": "eslint . --fix", "lint": "eslint ." diff --git a/test/backfillSubcollection.spec.js b/test/backfillSubcollection.spec.js new file mode 100644 index 0000000..b13870e --- /dev/null +++ b/test/backfillSubcollection.spec.js @@ -0,0 +1,311 @@ +const firebase = require("firebase-admin"); +const config = require("../functions/src/config.js"); +const typesense = require("../functions/src/createTypesenseClient.js")(); + +const app = firebase.initializeApp({ + // Use a special URL to talk to the Realtime Database emulator + databaseURL: `${process.env.FIREBASE_DATABASE_EMULATOR_HOST}?ns=${process.env.GCLOUD_PROJECT}`, + projectId: process.env.GCLOUD_PROJECT, +}); +const firestore = app.firestore(); + +describe("backfillSubcollection", () => { + const parentCollectionPath = process.env.TEST_FIRESTORE_PARENT_COLLECTION_PATH; + const unrelatedCollectionPath = "unrelatedCollectionToNotBackfill"; + const childFieldName = process.env.TEST_FIRESTORE_CHILD_FIELD_NAME; + let parentIdField = null; + + beforeAll(() => { + const matches = config.firestoreCollectionPath.match(/{([^}]+)}/g); + expect(matches).toBeDefined(); + expect(matches.length).toBe(1); + + parentIdField = matches[0].replace(/{|}/g, ""); + expect(parentIdField).toBeDefined(); + }); + + beforeEach(async () => { + // Clear the database between tests + await firestore.recursiveDelete(firestore.collection(parentCollectionPath)); + await firestore.recursiveDelete(firestore.collection(unrelatedCollectionPath)); + + // Clear any previously created collections + try { + await typesense.collections(encodeURIComponent(config.typesenseCollectionName)).delete(); + } catch (e) { + console.info(`${config.typesenseCollectionName} collection not found, proceeding...`); + } + + // Create a new Typesense collection + return typesense.collections().create({ + name: config.typesenseCollectionName, + fields: [ + {name: ".*", type: "auto"}, + ], + }); + }); + + afterAll(async () => { + // clean up the firebase app after all tests have run + await app.delete(); + }); + + describe("when firestore_collections is not specified", () => { + it("backfills existing Firestore data in all collections to Typesense" + + " when `trigger: true` is set " + + ` in ${config.typesenseBackfillTriggerDocumentInFirestore}`, async () => { + const parentDocData = { + nested_field: { + field1: "value1", + }, + }; + + const subDocData = { + author: "Author A", + title: "Title X", + country: "USA", + }; + + // create parent document in Firestore + const parentDocRef = await firestore.collection(parentCollectionPath).add(parentDocData); + + // create a subcollection with document under the parent document + const subCollectionRef = parentDocRef.collection(childFieldName); + const subDocRef = await subCollectionRef.add(subDocData); + + // Wait for firestore cloud function to write to Typesense + await new Promise((r) => setTimeout(r, 2000)); + + // The above will automatically add the document to Typesense, + // so delete it so we can test backfill + await typesense.collections(encodeURIComponent(config.typesenseCollectionName)).delete(); + await typesense.collections().create({ + name: config.typesenseCollectionName, + fields: [ + {name: ".*", type: "auto"}, + ], + }); + + await firestore + .collection(config.typesenseBackfillTriggerDocumentInFirestore.split("/")[0]) + .doc("backfill") + .set({trigger: true}); + // Wait for firestore cloud function to write to Typesense + await new Promise((r) => setTimeout(r, 2000)); + + // Check that the data was backfilled + const typesenseDocsStr = await typesense + .collections(encodeURIComponent(config.typesenseCollectionName)) + .documents() + .export(); + const typesenseDocs = typesenseDocsStr.split("\n").map((s) => JSON.parse(s)); + expect(typesenseDocs.length).toBe(1); + + expect(typesenseDocs[0]).toStrictEqual({ + id: subDocRef.id, + author: subDocData.author, + title: subDocData.title, + [parentIdField]: parentDocRef.id, + }); + }); + }); + + describe("when firestore_collections is specified", () => { + describe("when firestore_collections includes this collection", () => { + it("backfills existing Firestore data in this particular collection to Typesense" + + " when `trigger: true` is set " + + ` in ${config.typesenseBackfillTriggerDocumentInFirestore}`, async () => { + const parentDocData = { + nested_field: { + field1: "value1", + }, + }; + + const subDocData = { + author: "Author A", + title: "Title X", + country: "USA", + }; + + // create parent document in Firestore + const parentDocRef = await firestore.collection(parentCollectionPath).add(parentDocData); + + // create a subcollection with document under the parent document + const subCollectionRef = parentDocRef.collection(childFieldName); + const subDocRef = await subCollectionRef.add(subDocData); + + // Wait for firestore cloud function to write to Typesense + await new Promise((r) => setTimeout(r, 2000)); + + // The above will automatically add the document to Typesense, + // so delete it so we can test backfill + await typesense.collections(encodeURIComponent(config.typesenseCollectionName)).delete(); + await typesense.collections().create({ + name: config.typesenseCollectionName, + fields: [ + {name: ".*", type: "auto"}, + ], + }); + + await firestore + .collection(config.typesenseBackfillTriggerDocumentInFirestore.split("/")[0]) + .doc("backfill") + .set({ + trigger: true, + firestore_collections: [config.firestoreCollectionPath], + }); + // Wait for firestore cloud function to write to Typesense + await new Promise((r) => setTimeout(r, 2000)); + + // Check that the data was backfilled + const typesenseDocsStr = await typesense + .collections(encodeURIComponent(config.typesenseCollectionName)) + .documents() + .export(); + const typesenseDocs = typesenseDocsStr.split("\n").map((s) => JSON.parse(s)); + expect(typesenseDocs.length).toBe(1); + expect(typesenseDocs[0]).toStrictEqual({ + id: subDocRef.id, + author: subDocData.author, + title: subDocData.title, + [parentIdField]: parentDocRef.id, + }); + }); + }); + + describe("when firestore_collections does not include this collection", () => { + it("does not backfill existing Firestore data in this particular collection to Typesense" + + " when `trigger: true` is set " + + ` in ${config.typesenseBackfillTriggerDocumentInFirestore}`, async () => { + const parentDocData = { + nested_field: { + field1: "value1", + }, + }; + + const subDocData = { + author: "Author A", + title: "Title X", + country: "USA", + }; + + // create parent document in Firestore + const parentDocRef = await firestore.collection(parentCollectionPath).add(parentDocData); + + // create a subcollection with document under the parent document + const subCollectionRef = parentDocRef.collection(childFieldName); + await subCollectionRef.add(subDocData); + // Wait for firestore cloud function to write to Typesense + await new Promise((r) => setTimeout(r, 2000)); + + // The above will automatically add the document to Typesense, + // so delete it so we can test backfill + await typesense.collections(encodeURIComponent(config.typesenseCollectionName)).delete(); + await typesense.collections().create({ + name: config.typesenseCollectionName, + fields: [ + {name: ".*", type: "auto"}, + ], + }); + + await firestore + .collection(config.typesenseBackfillTriggerDocumentInFirestore.split("/")[0]) + .doc("backfill") + .set({ + trigger: true, + firestore_collections: ["some/other/collection"], + }); + // Wait for firestore cloud function to write to Typesense + await new Promise((r) => setTimeout(r, 2000)); + + // Check that the data was not backfilled + const typesenseDocsStr = await typesense + .collections(encodeURIComponent(config.typesenseCollectionName)) + .documents() + .export(); + expect(typesenseDocsStr).toEqual(""); + }); + }); + }); + + describe("Backfill subcollections", () => { + it("Ensure backfill doesnt backfill unrelated collections", async () => { + const parentDocData = { + nested_field: { + field1: "value1", + }, + }; + + const subDocData = { + author: "Author A", + title: "Title X", + country: "USA", + }; + + // create parent document in Firestore + const parentDocRef = await firestore.collection(parentCollectionPath).add(parentDocData); + + // create a subcollection with document under the parent document + const subCollectionRef = parentDocRef.collection(childFieldName); + const subDocRef = await subCollectionRef.add(subDocData); + + // Create an unrelated set of documents that should not be backfilled + const unrelatedParentDocData = { + nested_field: { + field1: "value3", + }, + }; + + const unrelatedSubDocData = { + author: "Author C", + title: "Title Z", + country: "CAN", + }; + + // create unrelated parent document in Firestore + const unrelatedParentDocRef = await firestore.collection(unrelatedCollectionPath).add(unrelatedParentDocData); + + // create a subcollection with document under the unrelatedparent document + const unrelatedSubCollectionRef = unrelatedParentDocRef.collection(childFieldName); + await unrelatedSubCollectionRef.add(unrelatedSubDocData); + + // Wait for firestore cloud function to write to Typesense + await new Promise((r) => setTimeout(r, 2000)); + + // The above will automatically add the document to Typesense, + // so delete it so we can test backfill + await typesense.collections(encodeURIComponent(config.typesenseCollectionName)).delete(); + await typesense.collections().create({ + name: config.typesenseCollectionName, + fields: [ + {name: ".*", type: "auto"}, + ], + }); + + await firestore + .collection(config.typesenseBackfillTriggerDocumentInFirestore.split("/")[0]) + .doc("backfill") + .set({ + trigger: true, + firestore_collections: [config.firestoreCollectionPath], + }); + // Wait for firestore cloud function to write to Typesense + await new Promise((r) => setTimeout(r, 2000)); + + // Check that the data was backfilled + const typesenseDocsStr = await typesense + .collections(encodeURIComponent(config.typesenseCollectionName)) + .documents() + .export(); + const typesenseDocs = typesenseDocsStr.split("\n").map((s) => JSON.parse(s)); + expect(typesenseDocs.length).toBe(1); + + expect(typesenseDocs[0]).toStrictEqual({ + id: subDocRef.id, + author: subDocData.author, + title: subDocData.title, + [parentIdField]: parentDocRef.id, + }); + }); + }); +}); diff --git a/test/indexOnWriteSubcollection.spec.js b/test/indexOnWriteSubcollection.spec.js new file mode 100644 index 0000000..806f546 --- /dev/null +++ b/test/indexOnWriteSubcollection.spec.js @@ -0,0 +1,124 @@ +const firebase = require("firebase-admin"); +const config = require("../functions/src/config.js"); +const typesense = require("../functions/src/createTypesenseClient.js")(); + +const app = firebase.initializeApp({ + // Use a special URL to talk to the Realtime Database emulator + databaseURL: `${process.env.FIREBASE_DATABASE_EMULATOR_HOST}?ns=${process.env.GCLOUD_PROJECT}`, + projectId: process.env.GCLOUD_PROJECT, +}); +const firestore = app.firestore(); + +describe("indexOnWriteSubcollection", () => { + const parentCollectionPath = process.env.TEST_FIRESTORE_PARENT_COLLECTION_PATH; + const childFieldName = process.env.TEST_FIRESTORE_CHILD_FIELD_NAME; + let parentIdField = null; + + beforeAll(async () => { + const matches = config.firestoreCollectionPath.match(/{([^}]+)}/g); + expect(matches).toBeDefined(); + expect(matches.length).toBe(1); + + parentIdField = matches[0].replace(/{|}/g, ""); + expect(parentIdField).toBeDefined(); + }); + + beforeEach(async () => { + // delete the Firestore collection + await firestore.recursiveDelete(firestore.collection(parentCollectionPath)); + + // Clear any previously created collections + try { + await typesense.collections(encodeURIComponent(config.typesenseCollectionName)).delete(); + } catch (e) { + console.info(`${config.typesenseCollectionName} collection not found, proceeding...`); + } + + // recreate the Typesense collection + await typesense.collections().create({ + name: config.typesenseCollectionName, + fields: [{name: ".*", type: "auto"}], + enable_nested_fields: true, + }); + }); + + afterAll(async () => { + // clean up the whole firebase app + await app.delete(); + }); + + describe("Backfill from dynamic subcollections", () => { + it("backfills documents from dynamic subcollections to Typesense", async () => { + process.env.FLATTEN_NESTED_DOCUMENTS = "false"; + + const parentDocData = { + nested_field: { + field1: "value1", + }, + }; + + const subDocData = { + nested_field: { + field1: "value1", + field2: ["value2", "value3", "value4"], + field3: { + fieldA: "valueA", + fieldB: ["valueB", "valueC", "valueD"], + }, + }, + }; + + // create parent document in Firestore + const parentDocRef = await firestore.collection(parentCollectionPath).add(parentDocData); + + // create a subcollection with document under the parent document + const subCollectionRef = parentDocRef.collection(childFieldName); + const subDocRef = await subCollectionRef.add(subDocData); + + // wait for the Firestore cloud function to write to Typesense + await new Promise((r) => setTimeout(r, 2500)); + + // check that the document was indexed + let typesenseDocsStr = await typesense.collections(encodeURIComponent(config.typesenseCollectionName)).documents().export({exclude_fields: ""}); + let typesenseDocs = typesenseDocsStr.split("\n").map((s) => JSON.parse(s)); + + expect(typesenseDocs.length).toBe(1); + expect(typesenseDocs[0]).toStrictEqual({ + id: subDocRef.id, + ...subDocData, + [parentIdField]: parentDocRef.id, + }); + + // update document in Firestore + subDocData.nested_field.field1 = "new value1"; + + await subDocRef.update(subDocData); + + // wait for the Firestore cloud function to write to Typesense + await new Promise((r) => setTimeout(r, 2500)); + + // check that the document was updated + typesenseDocsStr = await typesense.collections(encodeURIComponent(config.typesenseCollectionName)).documents().export({exclude_fields: ""}); + typesenseDocs = typesenseDocsStr.split("\n").map((s) => JSON.parse(s)); + + expect(typesenseDocs.length).toBe(1); + expect(typesenseDocs[0]).toStrictEqual({ + id: subDocRef.id, + ...subDocData, + [parentIdField]: parentDocRef.id, + }); + + // delete both documents in Firestore + await subDocRef.delete(); + await parentDocRef.delete(); + + // wait for the Firestore cloud function to write to Typesense + await new Promise((r) => setTimeout(r, 2500)); + + // check that the subcollection document was deleted + typesenseDocsStr = await typesense.collections(encodeURIComponent(config.typesenseCollectionName)).documents().export({exclude_fields: ""}); + + expect(typesenseDocsStr).toBe(""); + }); + }); +}); diff --git a/test/utilsSubcollection.js b/test/utilsSubcollection.js new file mode 100644 index 0000000..073e166 --- /dev/null +++ b/test/utilsSubcollection.js @@ -0,0 +1,109 @@ +const utils = require("../functions/src/utils"); + +describe("Utils", () => { + describe("Parsing static firestore path", () => { + it("Static firestore path should do nothing", async () => { + const staticPathVars1 = utils.parseFirestorePath("books"); + expect(staticPathVars1).toEqual({}); + + const staticPathVars2 = utils.parseFirestorePath("books/123"); + expect(staticPathVars2).toEqual({}); + + const staticPathVars5 = utils.parseFirestorePath("books/123/chapter/456/title"); + expect(staticPathVars5).toEqual({}); + }); + + it("Throws an exception if placeholders are empty but fullPath has segments", async () => { + const fullPath = "/users/123/books/456/author"; + const selector = "/users/123/books/456/author/{authorId}/email"; + + const vars = utils.pathMatchesSelector(fullPath, selector); + expect(vars).toBeNull(); + }); + }); + + describe("Parsing dynamic firestore path", () => { + it("Dynamic firestore path should return placeholders", async () => { + const dynamicPath2 = "books/{bookId}/chapter"; + const dynamicPathVars2 = utils.parseFirestorePath(dynamicPath2); + expect(dynamicPathVars2).toEqual({bookId: 1}); + + const actualPathVars2 = utils.pathMatchesSelector("books/123/chapter", dynamicPath2); + expect(actualPathVars2).toEqual({bookId: "123"}); + + const dynamicPath5 = "books/{bookId}/chapter/{chapterId}/title"; + const dynamicPathVars5 = utils.parseFirestorePath(dynamicPath5); + expect(dynamicPathVars5).toEqual({bookId: 1, chapterId: 3}); + + const actualPathVars5 = utils.pathMatchesSelector("books/123/chapter/456/title", dynamicPath5); + expect(actualPathVars5).toEqual({bookId: "123", chapterId: "456"}); + }); + }); + + describe("pathMatchesSelector", () => { + it("should return extracted placeholders for a matching path and selector", () => { + const path = "users/123/library/456/books/789"; + const selector = "users/{userId}/library/{libraryId}/books"; + const result = utils.pathMatchesSelector(path, selector); + expect(result).toEqual({userId: "123", libraryId: "456"}); + }); + + it("should return null for a non-matching path and selector", () => { + const path = "users/123/library/456/magazines/789"; + const selector = "users/{userId}/library/{libraryId}/books/{bookId}"; + const result = utils.pathMatchesSelector(path, selector); + expect(result).toBeNull(); + }); + + it("should return null if the path is shorter than the selector", () => { + const path = "users/123/library/456"; + const selector = "users/{userId}/library/{libraryId}/books/{bookId}"; + const result = utils.pathMatchesSelector(path, selector); + expect(result).toBeNull(); + }); + + it("should handle selectors without placeholders", () => { + const path = "users/123/library"; + const selector = "users/123/library"; + const result = utils.pathMatchesSelector(path, selector); + expect(result).toEqual({}); + }); + + it("should return null if the static segments of path and selector do not match", () => { + const path = "users/123/libraries/456"; + const selector = "users/{userId}/library/{libraryId}"; + const result = utils.pathMatchesSelector(path, selector); + expect(result).toBeNull(); + }); + + it("should throw an error for an invalid path", () => { + const path = null; + const selector = "users/{userId}/library/{libraryId}"; + expect(() => { + utils.pathMatchesSelector(path, selector); + }).toThrow("Invalid path: Path must be a non-empty string."); + }); + + it("should throw an error for an invalid selector", () => { + const path = "users/123/library/456"; + const selector = null; + expect(() => { + utils.pathMatchesSelector(path, selector); + }).toThrow("Invalid selector: Selector must be a non-empty string."); + }); + + it("should extract placeholders even when additional path segments exist", () => { + const path = "users/123/library/456/books/789/reviews/101"; + const selector = "users/{userId}/library/{libraryId}/books"; + const result = utils.pathMatchesSelector(path, selector); + expect(result).toEqual({userId: "123", libraryId: "456"}); + }); + + it("should handle paths and selectors with trailing slashes", () => { + const path = "users/123/library/456/"; + const selector = "users/{userId}/library"; + const result = utils.pathMatchesSelector(path, selector); + expect(result).toEqual({userId: "123"}); + }); + }); +}); diff --git a/test/utilsWithFlattening.spec.js b/test/utilsWithFlattening.spec.js index 217a09a..4031adf 100644 --- a/test/utilsWithFlattening.spec.js +++ b/test/utilsWithFlattening.spec.js @@ -39,7 +39,7 @@ describe("Utils", () => { "id", ); - const result = await typesenseDocumentFromSnapshot(documentSnapshot, []); + const result = await typesenseDocumentFromSnapshot(documentSnapshot, {}, []); expect(result).toEqual({ id: "id", author: "Author X", @@ -82,7 +82,7 @@ describe("Utils", () => { ]; data.forEach(async (item) => { const documentSnapshot = test.firestore.makeDocumentSnapshot(item, "id"); - const result = await typesenseDocumentFromSnapshot(documentSnapshot, []); + const result = await typesenseDocumentFromSnapshot(documentSnapshot, {}, []); expect(result).toEqual({ id: "id", location: [1, 2], @@ -104,7 +104,7 @@ describe("Utils", () => { }, }; const documentSnapshot = test.firestore.makeDocumentSnapshot(data, "id"); - const result = await typesenseDocumentFromSnapshot(documentSnapshot, []); + const result = await typesenseDocumentFromSnapshot(documentSnapshot, {}, []); expect(result).toEqual({ id: "id", title: "Title X", @@ -133,7 +133,7 @@ describe("Utils", () => { }, "id", ); - const result = await typesenseDocumentFromSnapshot(documentSnapshot, ["user.name", "user.address.city", "tags"]); + const result = await typesenseDocumentFromSnapshot(documentSnapshot, {}, ["user.name", "user.address.city", "tags"]); expect(result).toEqual({ id: "id", "user.name": "John Doe", @@ -152,7 +152,7 @@ describe("Utils", () => { }, "id", ); - const result = await typesenseDocumentFromSnapshot(documentSnapshot, ["user.name", "user.address.city"]); + const result = await typesenseDocumentFromSnapshot(documentSnapshot, {}, ["user.name", "user.address.city"]); expect(result).toEqual({ id: "id", "user.name": "John Doe", @@ -171,7 +171,7 @@ describe("Utils", () => { }, "id", ); - const result = await typesenseDocumentFromSnapshot(documentSnapshot, ["title", "user.name"]); + const result = await typesenseDocumentFromSnapshot(documentSnapshot, {}, ["title", "user.name"]); expect(result).toEqual({ id: "id", title: "Main Title", @@ -190,7 +190,7 @@ describe("Utils", () => { }, "id", ); - const result = await typesenseDocumentFromSnapshot(documentSnapshot, ["comments.author", "comments.text", "comments.likes"]); + const result = await typesenseDocumentFromSnapshot(documentSnapshot, {}, ["comments.author", "comments.text", "comments.likes"]); expect(result).toEqual({ id: "id", "comments.author": ["Alice", "Bob"], diff --git a/test/utilsWithoutFlattening.spec.js b/test/utilsWithoutFlattening.spec.js index e1143e9..31ed621 100644 --- a/test/utilsWithoutFlattening.spec.js +++ b/test/utilsWithoutFlattening.spec.js @@ -19,7 +19,7 @@ describe("Utils", () => { }, }; const documentSnapshot = test.firestore.makeDocumentSnapshot(data, "id"); - const result = await typesenseDocumentFromSnapshot(documentSnapshot, []); + const result = await typesenseDocumentFromSnapshot(documentSnapshot, {}, []); expect(result).toEqual({ id: "id", title: "Title X", @@ -50,7 +50,7 @@ describe("Utils", () => { }, "id", ); - const result = await typesenseDocumentFromSnapshot(documentSnapshot, ["user.name", "user.address.city", "tags"]); + const result = await typesenseDocumentFromSnapshot(documentSnapshot, {}, ["user.name", "user.address.city", "tags"]); expect(result).toEqual({ id: "id", user: {name: "John Doe", address: {city: "New York"}}, @@ -68,7 +68,7 @@ describe("Utils", () => { }, "id", ); - const result = await typesenseDocumentFromSnapshot(documentSnapshot, ["user.name", "user.address.city"]); + const result = await typesenseDocumentFromSnapshot(documentSnapshot, {}, ["user.name", "user.address.city"]); expect(result).toEqual({ id: "id", user: { @@ -89,7 +89,7 @@ describe("Utils", () => { }, "id", ); - const result = await typesenseDocumentFromSnapshot(documentSnapshot, ["title", "user.name"]); + const result = await typesenseDocumentFromSnapshot(documentSnapshot, {}, ["title", "user.name"]); expect(result).toEqual({ id: "id", title: "Main Title", @@ -110,7 +110,7 @@ describe("Utils", () => { }, "id", ); - const result = await typesenseDocumentFromSnapshot(documentSnapshot, ["comments.author", "comments.text", "comments.likes"]); + const result = await typesenseDocumentFromSnapshot(documentSnapshot, {}, ["comments.author", "comments.text", "comments.likes"]); expect(result).toEqual({ id: "id", comments: [