UNPKG

@langchain/community

Version:
915 lines (914 loc) 35.9 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.Neo4jVectorStore = void 0; const neo4j_driver_1 = __importDefault(require("neo4j-driver")); const uuid = __importStar(require("uuid")); const vectorstores_1 = require("@langchain/core/vectorstores"); const documents_1 = require("@langchain/core/documents"); const DEFAULT_SEARCH_TYPE = "vector"; const DEFAULT_INDEX_TYPE = "NODE"; const DEFAULT_DISTANCE_STRATEGY = "cosine"; const DEFAULT_NODE_EMBEDDING_PROPERTY = "embedding"; /** * @security *Security note*: Make sure that the database connection uses credentials * that are narrowly-scoped to only include necessary permissions. * Failure to do so may result in data corruption or loss, since the calling * code may attempt commands that would result in deletion, mutation * of data if appropriately prompted or reading sensitive data if such * data is present in the database. * The best way to guard against such negative outcomes is to (as appropriate) * limit the permissions granted to the credentials used with this tool. * For example, creating read only users for the database is a good way to * ensure that the calling code cannot mutate or delete data. * * @link See https://js.langchain.com/docs/security for more information. */ class Neo4jVectorStore extends vectorstores_1.VectorStore { _vectorstoreType() { return "neo4jvector"; } constructor(embeddings, config) { super(embeddings, config); Object.defineProperty(this, "driver", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "database", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "preDeleteCollection", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "nodeLabel", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "embeddingNodeProperty", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "embeddingDimension", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "textNodeProperty", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "keywordIndexName", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "indexName", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "retrievalQuery", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "searchType", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "indexType", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "distanceStrategy", { enumerable: true, configurable: true, writable: true, value: DEFAULT_DISTANCE_STRATEGY }); Object.defineProperty(this, "supportMetadataFilter", { enumerable: true, configurable: true, writable: true, value: true }); Object.defineProperty(this, "isEnterprise", { enumerable: true, configurable: true, writable: true, value: false }); } static async initialize(embeddings, config) { const store = new Neo4jVectorStore(embeddings, config); await store._initializeDriver(config); await store._verifyConnectivity(); const { preDeleteCollection = false, nodeLabel = "Chunk", textNodeProperty = "text", embeddingNodeProperty = DEFAULT_NODE_EMBEDDING_PROPERTY, keywordIndexName = "keyword", indexName = "vector", retrievalQuery = "", searchType = DEFAULT_SEARCH_TYPE, indexType = DEFAULT_INDEX_TYPE, } = config; store.embeddingDimension = (await embeddings.embedQuery("foo")).length; store.preDeleteCollection = preDeleteCollection; store.nodeLabel = nodeLabel; store.textNodeProperty = textNodeProperty; store.embeddingNodeProperty = embeddingNodeProperty; store.keywordIndexName = keywordIndexName; store.indexName = indexName; store.retrievalQuery = retrievalQuery; store.searchType = searchType; store.indexType = indexType; if (store.preDeleteCollection) { await store._dropIndex(); } return store; } async _initializeDriver({ url, username, password, database = "neo4j", }) { try { this.driver = neo4j_driver_1.default.driver(url, neo4j_driver_1.default.auth.basic(username, password)); this.database = database; } catch (error) { throw new Error("Could not create a Neo4j driver instance. Please check the connection details."); } } async _verifyConnectivity() { await this.driver.verifyAuthentication(); } async _verifyVersion() { try { const data = await this.query("CALL dbms.components()"); const versionString = data[0].versions[0]; const targetVersion = [5, 11, 0]; let version; if (versionString.includes("aura")) { // Get the 'x.y.z' part before '-aura' const baseVersion = versionString.split("-")[0]; version = baseVersion.split(".").map(Number); version.push(0); } else { version = versionString.split(".").map(Number); } if (isVersionLessThan(version, targetVersion)) { throw new Error("Version index is only supported in Neo4j version 5.11 or greater"); } const metadataTargetVersion = [5, 18, 0]; if (isVersionLessThan(version, metadataTargetVersion)) { this.supportMetadataFilter = false; } this.isEnterprise = data[0].edition === "enterprise"; } catch (error) { console.error("Database version check failed:", error); } } async close() { await this.driver.close(); } async _dropIndex() { try { await this.query(` MATCH (n:\`${this.nodeLabel}\`) CALL { WITH n DETACH DELETE n } IN TRANSACTIONS OF 10000 ROWS; `); await this.query(`DROP INDEX ${this.indexName}`); } catch (error) { console.error("An error occurred while dropping the index:", error); } } async query(query, params = {}) { const session = this.driver.session({ database: this.database }); const result = await session.run(query, params); return toObjects(result.records); } static async fromTexts(texts, metadatas, embeddings, config) { const docs = []; for (let i = 0; i < texts.length; i += 1) { const metadata = Array.isArray(metadatas) ? metadatas[i] : metadatas; const newDoc = new documents_1.Document({ pageContent: texts[i], metadata, }); docs.push(newDoc); } return Neo4jVectorStore.fromDocuments(docs, embeddings, config); } static async fromDocuments(docs, embeddings, config) { const { searchType = DEFAULT_SEARCH_TYPE, createIdIndex = true, textNodeProperties = [], } = config; const store = await this.initialize(embeddings, config); const embeddingDimension = await store.retrieveExistingIndex(); if (!embeddingDimension) { await store.createNewIndex(); } else if (store.embeddingDimension !== embeddingDimension) { throw new Error(`Index with name "${store.indexName}" already exists. The provided embedding function and vector index dimensions do not match. Embedding function dimension: ${store.embeddingDimension} Vector index dimension: ${embeddingDimension}`); } if (searchType === "hybrid") { const ftsNodeLabel = await store.retrieveExistingFtsIndex(); if (!ftsNodeLabel) { await store.createNewKeywordIndex(textNodeProperties); } else { if (ftsNodeLabel !== store.nodeLabel) { throw Error("Vector and keyword index don't index the same node label"); } } } if (createIdIndex) { await store.query(`CREATE CONSTRAINT IF NOT EXISTS FOR (n:${store.nodeLabel}) REQUIRE n.id IS UNIQUE;`); } await store.addDocuments(docs); return store; } static async fromExistingIndex(embeddings, config) { const { searchType = DEFAULT_SEARCH_TYPE, keywordIndexName = "keyword" } = config; if (searchType === "hybrid" && !keywordIndexName) { throw Error("keyword_index name has to be specified when using hybrid search option"); } const store = await this.initialize(embeddings, config); const embeddingDimension = await store.retrieveExistingIndex(); if (!embeddingDimension) { throw Error("The specified vector index name does not exist. Make sure to check if you spelled it correctly"); } if (store.embeddingDimension !== embeddingDimension) { throw new Error(`The provided embedding function and vector index dimensions do not match. Embedding function dimension: ${store.embeddingDimension} Vector index dimension: ${embeddingDimension}`); } if (searchType === "hybrid") { const ftsNodeLabel = await store.retrieveExistingFtsIndex(); if (!ftsNodeLabel) { throw Error("The specified keyword index name does not exist. Make sure to check if you spelled it correctly"); } else { if (ftsNodeLabel !== store.nodeLabel) { throw Error("Vector and keyword index don't index the same node label"); } } } return store; } static async fromExistingGraph(embeddings, config) { const { textNodeProperties = [], embeddingNodeProperty = DEFAULT_NODE_EMBEDDING_PROPERTY, searchType = DEFAULT_SEARCH_TYPE, retrievalQuery = "", nodeLabel, } = config; let _retrievalQuery = retrievalQuery; if (textNodeProperties.length === 0) { throw Error("Parameter `text_node_properties` must not be an empty array"); } if (!retrievalQuery) { _retrievalQuery = ` RETURN reduce(str='', k IN ${JSON.stringify(textNodeProperties)} | str + '\\n' + k + ': ' + coalesce(node[k], '')) AS text, node {.*, \`${embeddingNodeProperty}\`: Null, id: Null, ${textNodeProperties .map((prop) => `\`${prop}\`: Null`) .join(", ")} } AS metadata, score `; } const store = await this.initialize(embeddings, { ...config, retrievalQuery: _retrievalQuery, }); const embeddingDimension = await store.retrieveExistingIndex(); if (!embeddingDimension) { await store.createNewIndex(); } else if (store.embeddingDimension !== embeddingDimension) { throw new Error(`Index with name ${store.indexName} already exists. The provided embedding function and vector index dimensions do not match.\nEmbedding function dimension: ${store.embeddingDimension}\nVector index dimension: ${embeddingDimension}`); } if (searchType === "hybrid") { const ftsNodeLabel = await store.retrieveExistingFtsIndex(textNodeProperties); if (!ftsNodeLabel) { await store.createNewKeywordIndex(textNodeProperties); } else { if (ftsNodeLabel !== store.nodeLabel) { throw Error("Vector and keyword index don't index the same node label"); } } } // eslint-disable-next-line no-constant-condition while (true) { const fetchQuery = ` MATCH (n:\`${nodeLabel}\`) WHERE n.${embeddingNodeProperty} IS null AND any(k in $props WHERE n[k] IS NOT null) RETURN elementId(n) AS id, reduce(str='', k IN $props | str + '\\n' + k + ':' + coalesce(n[k], '')) AS text LIMIT 1000 `; const data = await store.query(fetchQuery, { props: textNodeProperties }); if (!data) { continue; } const textEmbeddings = await embeddings.embedDocuments(data.map((el) => el.text)); const params = { data: data.map((el, index) => ({ id: el.id, embedding: textEmbeddings[index], })), }; await store.query(` UNWIND $data AS row MATCH (n:\`${nodeLabel}\`) WHERE elementId(n) = row.id CALL db.create.setVectorProperty(n, '${embeddingNodeProperty}', row.embedding) YIELD node RETURN count(*) `, params); if (data.length < 1000) { break; } } return store; } async createNewIndex() { const indexQuery = ` CALL db.index.vector.createNodeIndex( $index_name, $node_label, $embedding_node_property, toInteger($embedding_dimension), $similarity_metric ) `; const parameters = { index_name: this.indexName, node_label: this.nodeLabel, embedding_node_property: this.embeddingNodeProperty, embedding_dimension: this.embeddingDimension, similarity_metric: this.distanceStrategy, }; await this.query(indexQuery, parameters); } async retrieveExistingIndex() { let indexInformation = await this.query(` SHOW INDEXES YIELD name, type, labelsOrTypes, properties, options WHERE type = 'VECTOR' AND (name = $index_name OR (labelsOrTypes[0] = $node_label AND properties[0] = $embedding_node_property)) RETURN name, labelsOrTypes, properties, options `, { index_name: this.indexName, node_label: this.nodeLabel, embedding_node_property: this.embeddingNodeProperty, }); if (indexInformation) { indexInformation = this.sortByIndexName(indexInformation, this.indexName); try { const [index] = indexInformation; const [labelOrType] = index.labelsOrTypes; const [property] = index.properties; this.indexName = index.name; this.nodeLabel = labelOrType; this.embeddingNodeProperty = property; const embeddingDimension = index.options.indexConfig["vector.dimensions"]; return Number(embeddingDimension); } catch (error) { return null; } } return null; } async retrieveExistingFtsIndex(textNodeProperties = []) { const indexInformation = await this.query(` SHOW INDEXES YIELD name, type, labelsOrTypes, properties, options WHERE type = 'FULLTEXT' AND (name = $keyword_index_name OR (labelsOrTypes = [$node_label] AND properties = $text_node_property)) RETURN name, labelsOrTypes, properties, options `, { keyword_index_name: this.keywordIndexName, node_label: this.nodeLabel, text_node_property: textNodeProperties.length > 0 ? textNodeProperties : [this.textNodeProperty], }); if (indexInformation) { // Sort the index information by index name const sortedIndexInformation = this.sortByIndexName(indexInformation, this.indexName); try { const [index] = sortedIndexInformation; const [labelOrType] = index.labelsOrTypes; const [property] = index.properties; this.keywordIndexName = index.name; this.textNodeProperty = property; this.nodeLabel = labelOrType; return labelOrType; } catch (error) { return null; } } return null; } async createNewKeywordIndex(textNodeProperties = []) { const nodeProps = textNodeProperties.length > 0 ? textNodeProperties : [this.textNodeProperty]; // Construct the Cypher query to create a new full text index const ftsIndexQuery = ` CREATE FULLTEXT INDEX ${this.keywordIndexName} FOR (n:\`${this.nodeLabel}\`) ON EACH [${nodeProps.map((prop) => `n.\`${prop}\``).join(", ")}] `; await this.query(ftsIndexQuery); } sortByIndexName(values, indexName) { return values.sort((a, b) => (a.name === indexName ? -1 : 0) - (b.name === indexName ? -1 : 0)); } async addVectors(vectors, documents, metadatas, ids) { let _ids = ids; const _metadatas = metadatas; if (!_ids) { _ids = documents.map(() => uuid.v1()); } const importQuery = ` UNWIND $data AS row CALL { WITH row MERGE (c:\`${this.nodeLabel}\` {id: row.id}) WITH c, row CALL db.create.setVectorProperty(c, '${this.embeddingNodeProperty}', row.embedding) YIELD node SET c.\`${this.textNodeProperty}\` = row.text SET c += row.metadata } IN TRANSACTIONS OF 1000 ROWS `; const parameters = { data: documents.map(({ pageContent, metadata }, index) => ({ text: pageContent, metadata: _metadatas ? _metadatas[index] : metadata, embedding: vectors[index], id: _ids ? _ids[index] : null, })), }; await this.query(importQuery, parameters); return _ids; } async addDocuments(documents) { const texts = documents.map(({ pageContent }) => pageContent); return this.addVectors(await this.embeddings.embedDocuments(texts), documents); } async similaritySearch(query, k = 4, params = {}) { const embedding = await this.embeddings.embedQuery(query); const results = await this.similaritySearchVectorWithScore(embedding, k, query, params); return results.map((result) => result[0]); } async similaritySearchWithScore(query, k = 4, params = {}) { const embedding = await this.embeddings.embedQuery(query); return this.similaritySearchVectorWithScore(embedding, k, query, params); } async similaritySearchVectorWithScore(vector, k, query, params = {}) { let indexQuery; let filterParams; const { filter } = params; if (filter) { if (!this.supportMetadataFilter) { throw new Error("Metadata filtering is only supported in Neo4j version 5.18 or greater."); } if (this.searchType === "hybrid") { throw new Error("Metadata filtering can't be use in combination with a hybrid search approach."); } const parallelQuery = this.isEnterprise ? "CYPHER runtime = parallel parallelRuntimeSupport=all " : ""; const baseIndexQuery = ` ${parallelQuery} MATCH (n:\`${this.nodeLabel}\`) WHERE n.\`${this.embeddingNodeProperty}\` IS NOT NULL AND size(n.\`${this.embeddingNodeProperty}\`) = toInteger(${this.embeddingDimension}) AND `; const baseCosineQuery = ` WITH n as node, vector.similarity.cosine( n.\`${this.embeddingNodeProperty}\`, $embedding ) AS score ORDER BY score DESC LIMIT toInteger($k) `; const [fSnippets, fParams] = constructMetadataFilter(filter); indexQuery = baseIndexQuery + fSnippets + baseCosineQuery; filterParams = fParams; } else { indexQuery = getSearchIndexQuery(this.searchType, this.indexType); filterParams = {}; } let defaultRetrieval; if (this.indexType === "RELATIONSHIP") { defaultRetrieval = ` RETURN relationship.${this.textNodeProperty} AS text, score, relationship {.*, ${this.textNodeProperty}: Null, ${this.embeddingNodeProperty}: Null, id: Null } AS metadata `; } else { defaultRetrieval = ` RETURN node.${this.textNodeProperty} AS text, score, node {.*, ${this.textNodeProperty}: Null, ${this.embeddingNodeProperty}: Null, id: Null } AS metadata `; } const retrievalQuery = this.retrievalQuery ? this.retrievalQuery : defaultRetrieval; const readQuery = `${indexQuery} ${retrievalQuery}`; const parameters = { index: this.indexName, k: Number(k), embedding: vector, keyword_index: this.keywordIndexName, query: removeLuceneChars(query), ...params, ...filterParams, }; const results = await this.query(readQuery, parameters); if (results) { if (results.some((result) => result.text == null)) { if (!this.retrievalQuery) { throw new Error("Make sure that none of the '" + this.textNodeProperty + "' properties on nodes with label '" + this.nodeLabel + "' are missing or empty"); } else { throw new Error("Inspect the 'retrievalQuery' and ensure it doesn't return null for the 'text' column"); } } const docs = results.map((result) => [ new documents_1.Document({ pageContent: result.text, metadata: Object.fromEntries(Object.entries(result.metadata).filter(([_, v]) => v !== null)), }), result.score, ]); return docs; } return []; } } exports.Neo4jVectorStore = Neo4jVectorStore; function toObjects(records) { const recordValues = records.map((record) => { const rObj = record.toObject(); const out = {}; Object.keys(rObj).forEach((key) => { out[key] = itemIntToString(rObj[key]); }); return out; }); return recordValues; } function itemIntToString(item) { if (neo4j_driver_1.default.isInt(item)) return item.toString(); if (Array.isArray(item)) return item.map((ii) => itemIntToString(ii)); if (["number", "string", "boolean"].indexOf(typeof item) !== -1) return item; if (item === null) return item; if (typeof item === "object") return objIntToString(item); } function objIntToString(obj) { const entry = extractFromNeoObjects(obj); let newObj = null; if (Array.isArray(entry)) { newObj = entry.map((item) => itemIntToString(item)); } else if (entry !== null && typeof entry === "object") { newObj = {}; Object.keys(entry).forEach((key) => { newObj[key] = itemIntToString(entry[key]); }); } return newObj; } function extractFromNeoObjects(obj) { if ( // eslint-disable-next-line obj instanceof neo4j_driver_1.default.types.Node || // eslint-disable-next-line obj instanceof neo4j_driver_1.default.types.Relationship) { return obj.properties; // eslint-disable-next-line } else if (obj instanceof neo4j_driver_1.default.types.Path) { // eslint-disable-next-line return [].concat.apply([], extractPathForRows(obj)); } return obj; } function extractPathForRows(path) { let { segments } = path; // Zero length path. No relationship, end === start if (!Array.isArray(path.segments) || path.segments.length < 1) { segments = [{ ...path, end: null }]; } return segments.map((segment) => [ objIntToString(segment.start), objIntToString(segment.relationship), objIntToString(segment.end), ].filter((part) => part !== null)); } function getSearchIndexQuery(searchType, indexType = DEFAULT_INDEX_TYPE) { if (indexType === "NODE") { const typeToQueryMap = { vector: "CALL db.index.vector.queryNodes($index, $k, $embedding) YIELD node, score", hybrid: ` CALL { CALL db.index.vector.queryNodes($index, $k, $embedding) YIELD node, score WITH collect({node:node, score:score}) AS nodes, max(score) AS max UNWIND nodes AS n // We use 0 as min RETURN n.node AS node, (n.score / max) AS score UNION CALL db.index.fulltext.queryNodes($keyword_index, $query, {limit: $k}) YIELD node, score WITH collect({node: node, score: score}) AS nodes, max(score) AS max UNWIND nodes AS n RETURN n.node AS node, (n.score / max) AS score } WITH node, max(score) AS score ORDER BY score DESC LIMIT toInteger($k) `, }; return typeToQueryMap[searchType]; } else { return ` CALL db.index.vector.queryRelationships($index, $k, $embedding) YIELD relationship, score `; } } function removeLuceneChars(text) { if (text === undefined || text === null) { return null; } // Remove Lucene special characters const specialChars = [ "+", "-", "&", "|", "!", "(", ")", "{", "}", "[", "]", "^", '"', "~", "*", "?", ":", "\\", ]; let modifiedText = text; for (const char of specialChars) { modifiedText = modifiedText.split(char).join(" "); } return modifiedText.trim(); } function isVersionLessThan(v1, v2) { for (let i = 0; i < Math.min(v1.length, v2.length); i += 1) { if (v1[i] < v2[i]) { return true; } else if (v1[i] > v2[i]) { return false; } } // If all the corresponding parts are equal, the shorter version is less return v1.length < v2.length; } // Filter utils const COMPARISONS_TO_NATIVE = { $eq: "=", $ne: "<>", $lt: "<", $lte: "<=", $gt: ">", $gte: ">=", }; const COMPARISONS_TO_NATIVE_OPERATORS = new Set(Object.keys(COMPARISONS_TO_NATIVE)); const TEXT_OPERATORS = new Set(["$like", "$ilike"]); const LOGICAL_OPERATORS = new Set(["$and", "$or"]); const SPECIAL_CASED_OPERATORS = new Set(["$in", "$nin", "$between"]); const SUPPORTED_OPERATORS = new Set([ ...COMPARISONS_TO_NATIVE_OPERATORS, ...TEXT_OPERATORS, ...LOGICAL_OPERATORS, ...SPECIAL_CASED_OPERATORS, ]); const IS_IDENTIFIER_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/; function combineQueries(inputQueries, operator) { let combinedQuery = ""; const combinedParams = {}; const paramCounter = {}; for (const [query, params] of inputQueries) { let newQuery = query; for (const [param, value] of Object.entries(params)) { if (param in paramCounter) { paramCounter[param] += 1; } else { paramCounter[param] = 1; } const newParamName = `${param}_${paramCounter[param]}`; newQuery = newQuery.replace(`$${param}`, `$${newParamName}`); combinedParams[newParamName] = value; } if (combinedQuery) { combinedQuery += ` ${operator} `; } combinedQuery += `(${newQuery})`; } return [combinedQuery, combinedParams]; } function collectParams(inputData) { const queryParts = []; const params = {}; for (const [queryPart, param] of inputData) { queryParts.push(queryPart); Object.assign(params, param); } return [queryParts, params]; } function handleFieldFilter(field, value, paramNumber = 1) { if (typeof field !== "string") { throw new Error(`field should be a string but got: ${typeof field} with value: ${field}`); } if (field.startsWith("$")) { throw new Error(`Invalid filter condition. Expected a field but got an operator: ${field}`); } // Allow [a - zA - Z0 -9_], disallow $ for now until we support escape characters if (!IS_IDENTIFIER_REGEX.test(field)) { throw new Error(`Invalid field name: ${field}. Expected a valid identifier.`); } let operator; let filterValue; if (typeof value === "object" && value !== null && !Array.isArray(value)) { const keys = Object.keys(value); if (keys.length !== 1) { throw new Error(`Invalid filter condition. Expected a value which is a dictionary with a single key that corresponds to an operator but got a dictionary with ${keys.length} keys. The first few keys are: ${keys .slice(0, 3) .join(", ")} `); } // eslint-disable-next-line prefer-destructuring operator = keys[0]; filterValue = value[operator]; if (!SUPPORTED_OPERATORS.has(operator)) { throw new Error(`Invalid operator: ${operator}. Expected one of ${SUPPORTED_OPERATORS}`); } } else { operator = "$eq"; filterValue = value; } if (COMPARISONS_TO_NATIVE_OPERATORS.has(operator)) { const native = COMPARISONS_TO_NATIVE[operator]; const querySnippet = `n.${field} ${native} $param_${paramNumber}`; const queryParam = { [`param_${paramNumber}`]: filterValue }; return [querySnippet, queryParam]; } else if (operator === "$between") { const [low, high] = filterValue; const querySnippet = `$param_${paramNumber}_low <= n.${field} <= $param_${paramNumber}_high`; const queryParam = { [`param_${paramNumber}_low`]: low, [`param_${paramNumber}_high`]: high, }; return [querySnippet, queryParam]; } else if (["$in", "$nin", "$like", "$ilike"].includes(operator)) { if (["$in", "$nin"].includes(operator)) { filterValue.forEach((val) => { if (typeof val !== "string" && typeof val !== "number" && typeof val !== "boolean") { throw new Error(`Unsupported type: ${typeof val} for value: ${val}`); } }); } if (operator === "$in") { const querySnippet = `n.${field} IN $param_${paramNumber}`; const queryParam = { [`param_${paramNumber}`]: filterValue }; return [querySnippet, queryParam]; } else if (operator === "$nin") { const querySnippet = `n.${field} NOT IN $param_${paramNumber}`; const queryParam = { [`param_${paramNumber}`]: filterValue }; return [querySnippet, queryParam]; } else if (operator === "$like") { const querySnippet = `n.${field} CONTAINS $param_${paramNumber}`; const queryParam = { [`param_${paramNumber}`]: filterValue.slice(0, -1) }; return [querySnippet, queryParam]; } else if (operator === "$ilike") { const querySnippet = `toLower(n.${field}) CONTAINS $param_${paramNumber}`; const queryParam = { [`param_${paramNumber}`]: filterValue.slice(0, -1) }; return [querySnippet, queryParam]; } else { throw new Error("Not Implemented"); } } else { throw new Error("Not Implemented"); } } function constructMetadataFilter(filter) { if (typeof filter !== "object" || filter === null) { throw new Error("Expected a dictionary representing the filter condition."); } const entries = Object.entries(filter); if (entries.length === 1) { const [key, value] = entries[0]; if (key.startsWith("$")) { if (!["$and", "$or"].includes(key.toLowerCase())) { throw new Error(`Invalid filter condition. Expected $and or $or but got: ${key}`); } if (!Array.isArray(value)) { throw new Error(`Expected an array for logical conditions, but got ${typeof value} for value: ${value}`); } const operation = key.toLowerCase() === "$and" ? "AND" : "OR"; const combinedQueries = combineQueries(value.map((v) => constructMetadataFilter(v)), operation); return combinedQueries; } else { return handleFieldFilter(key, value); } } else if (entries.length > 1) { for (const [key] of entries) { if (key.startsWith("$")) { throw new Error(`Invalid filter condition. Expected a field but got an operator: ${key}`); } } const and_multiple = collectParams(entries.map(([field, val], index) => handleFieldFilter(field, val, index + 1))); if (and_multiple.length >= 1) { return [and_multiple[0].join(" AND "), and_multiple[1]]; } else { throw Error("Invalid filter condition. Expected a dictionary but got an empty dictionary"); } } else { throw new Error("Filter condition contains no entries."); } }