@langchain/community
Version:
Third-party integrations for LangChain.js
885 lines (884 loc) • 34.4 kB
JavaScript
import neo4j from "neo4j-driver";
import * as uuid from "uuid";
import { VectorStore } from "@langchain/core/vectorstores";
import { Document } from "@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.
*/
export class Neo4jVectorStore extends 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(url, neo4j.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 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 Document({
pageContent: result.text,
metadata: Object.fromEntries(Object.entries(result.metadata).filter(([_, v]) => v !== null)),
}),
result.score,
]);
return docs;
}
return [];
}
}
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.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.types.Node ||
// eslint-disable-next-line
obj instanceof neo4j.types.Relationship) {
return obj.properties;
// eslint-disable-next-line
}
else if (obj instanceof neo4j.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.");
}
}