diff --git a/libs/common/src/vault/filtering/basic-vault-filter.handler.spec.ts b/libs/common/src/vault/filtering/basic-vault-filter.handler.spec.ts index dacb825e5a7..ef9c47b13f7 100644 --- a/libs/common/src/vault/filtering/basic-vault-filter.handler.spec.ts +++ b/libs/common/src/vault/filtering/basic-vault-filter.handler.spec.ts @@ -5,59 +5,185 @@ import { BasicFilter, BasicVaultFilterHandler } from "./basic-vault-filter.handl describe("BasicVaultFilterHandler", () => { const sut = new BasicVaultFilterHandler(mock()); - describe("tryParse", () => { - it("success", () => { - const result = sut.tryParse( - '(in:collection:"My Collection" OR in:collection:"Other Collection")', - ); - - if (!result.success) { - fail("Result is expected to succeed"); - } - - expect(result.filter).toBe({}); - }); - }); - - describe("toFilter", () => { - const cases: { input: BasicFilter; output: string }[] = [ - { - input: { - vaults: [null, "org_vault"], - collections: ["collection_one", "collection_two"], - fields: ["one", "two"], - types: ["Login", "Card"], - folders: ["folder_one", "folder_two"], - }, - output: - '(in:my_vault OR in:org:"org_vault") AND (in:folder:"folder_one" OR in:folder:"folder_two") AND (in:collection:"collection_one" AND in:collection:"collection_two") AND (type:"Login" OR type:"Card") AND (field:"one" AND field:"two")', + const successfulCases: { basicFilter: BasicFilter; rawFilter: string }[] = [ + { + basicFilter: { + vaults: [null, "org_vault"], + collections: ["collection_one", "collection_two"], + fields: [], + types: ["Login", "Card"], + folders: ["folder_one", "folder_two"], }, + rawFilter: + '(in:my_vault OR in:org:"org_vault") AND (in:folder:"folder_one" OR in:folder:"folder_two") AND (in:collection:"collection_one" AND in:collection:"collection_two") AND (type:"Login" OR type:"Card")', + }, + { + basicFilter: { + vaults: [null, "org_one"], + collections: [], + fields: [], + types: [], + folders: [], + }, + rawFilter: `(in:my_vault OR in:org:"org_one")`, + }, + { + basicFilter: { + vaults: [], + collections: ["collection_one", "Collection two"], + fields: [], + types: [], + folders: [], + }, + rawFilter: '(in:collection:"collection_one" AND in:collection:"Collection two")', + }, + { + basicFilter: { + vaults: [], + collections: [], + fields: [], + types: ["Card", "Login"], + folders: [], + }, + rawFilter: '(type:"Card" OR type:"Login")', + }, + { + basicFilter: { + vaults: [], + collections: [], + fields: [], + types: [], + folders: ["folder_one", "Folder two"], + }, + rawFilter: '(in:folder:"folder_one" OR in:folder:"Folder two")', + }, + { + basicFilter: { + vaults: [], + collections: [], + fields: [], + types: ["Card", "Login"], + folders: ["folder_one", "Folder two"], + }, + rawFilter: + '(in:folder:"folder_one" OR in:folder:"Folder two") AND (type:"Card" OR type:"Login")', + }, + { + // Example of a filter that we could pretty + basicFilter: { + vaults: [null], + collections: [], + fields: [], + types: [], + folders: [], + }, + rawFilter: "(in:my_vault)", + }, + ]; + + describe("tryParse", () => { + it.each(successfulCases)( + "successfully parses $rawFilter query into basic filter", + ({ basicFilter, rawFilter }, done) => { + const result = sut.tryParse(rawFilter); + + if (!result.success) { + done("Result is expected to succeed"); + return; + } + + expect(result.filter).toEqual(basicFilter); + done(); + }, + ); + + // These are cases where they are parsable but they would never be generated this way via the normal basic + const extraAllowedSyntax: { basicFilter: BasicFilter; rawFilter: string }[] = [ { - input: { + basicFilter: { vaults: [null], collections: [], fields: [], types: [], folders: [], }, - output: "(in:my_vault)", + rawFilter: "in:my_vault", }, { - input: { + basicFilter: { vaults: [], - collections: [], - fields: ["Banking"], + collections: ["my_collection"], + fields: [], types: [], folders: [], }, - output: '(field:"Banking")', + rawFilter: 'in:collection:"my_collection"', + }, + { + basicFilter: { + vaults: [], + collections: [], + fields: [], + types: ["Login"], + folders: [], + }, + rawFilter: 'type:"Login"', + }, + { + basicFilter: { + vaults: [], + collections: [], + fields: [], + types: [], + folders: ["my_folder"], + }, + rawFilter: 'in:folder:"my_folder"', }, ]; - it.each(cases)("translates basic filter to $output", ({ input, output }) => { - const actualOutput = sut.toFilter(input); + it.each(extraAllowedSyntax)( + "allows parsing of filter $rawFilter", + ({ basicFilter, rawFilter }, done) => { + const result = sut.tryParse(rawFilter); - expect(actualOutput).toEqual(output); + if (!result.success) { + done("Result is expected to succeed"); + return; + } + + expect(result.filter).toEqual(basicFilter); + done(); + }, + ); + + const unrepresentableInBasic: string[] = [ + // We use OR for folders + '(in:folder:"folder_one" AND in:folder:"Folder two")', + // We use OR for vaults + '(in:my_vault AND in:org:"Org one")', + // We use AND for collections but we could offer the selection and in the case this could be valid too + '(in:collection:"Collection one" OR in:collection:"Collection two")', + // We use OR for type + '(type:"Login" AND type:"Card")', + // We wouldn't put the same expression in multiple groups - This is a place where we could get smarter + '(type:"Login") AND (type:"Card")', + ]; + + it.each(unrepresentableInBasic)("does not succeed when filter is %s", (filter) => { + const result = sut.tryParse(filter); + + expect(result.success).toBe(false); }); }); + + describe("toFilter", () => { + it.each(successfulCases)( + "translates basic filter to $rawFilter", + ({ basicFilter, rawFilter }) => { + const actualOutput = sut.toFilter(basicFilter); + + expect(actualOutput).toEqual(rawFilter); + }, + ); + }); }); diff --git a/libs/common/src/vault/filtering/basic-vault-filter.handler.ts b/libs/common/src/vault/filtering/basic-vault-filter.handler.ts index 571a1279451..ce930af8ccc 100644 --- a/libs/common/src/vault/filtering/basic-vault-filter.handler.ts +++ b/libs/common/src/vault/filtering/basic-vault-filter.handler.ts @@ -1,4 +1,17 @@ import { LogService } from "../../platform/abstractions/log.service"; +import { + AstNode, + isAnd, + isBinary, + isFieldTerm, + isInCollection, + isInFolder, + isInMyVault, + isInOrg, + isOr, + isParentheses, + isTypeFilter, +} from "../search/ast"; import { parseQuery } from "../search/parse"; export type BasicFilter = { @@ -13,14 +26,187 @@ export class BasicVaultFilterHandler { constructor(private readonly logService: LogService) {} tryParse(rawFilter: string): { success: true; filter: BasicFilter } | { success: false } { + // TODO: Handle vaults + const expectedBinaryOperator: Record = { + vaults: "or", + folders: "or", + collections: "and", + types: "or", + fields: "and", + }; + // Parse into AST const ast = parseQuery(rawFilter, this.logService).ast; - if (ast.type !== "search") { + const basicFilter: Partial = { + vaults: null, + folders: null, + collections: null, + types: null, + fields: null, + }; + + type VisitData = { + operator: "or" | "and" | null; + type: keyof BasicFilter | null; + values: string[]; + }; + + const isValidOperator = (data: VisitData) => { + if (data.type == null) { + return false; + } + + if (data.values.length === 1) { + // If there aren't multiple values, a null operator is fine and implied + return data.operator === null; + } + + return expectedBinaryOperator[data.type] === data.operator; + }; + + const visitNode = (node: AstNode, data: VisitData): boolean => { + if (isParentheses(node)) { + return visitNode(node.inner, data); + } + + if (isBinary(node)) { + if (data.operator != null && data.operator !== node.type) { + // All inner operators must be the same + return false; + } + + // Set the operator in case it is null + data.operator = node.type; + // Visit both left and right + return visitNode(node.left, data) && visitNode(node.right, data); + } + + let expressionType: keyof BasicFilter = null; + let value: string | null | undefined = undefined; + + if (isInMyVault(node)) { + expressionType = "vaults"; + // null is used to indicate personal vault in basic filter + value = null; + } else if (isInOrg(node)) { + expressionType = "vaults"; + value = node.org; + } else if (isInFolder(node)) { + expressionType = "folders"; + value = node.folder; + } else if (isInCollection(node)) { + expressionType = "collections"; + value = node.collection; + } else if (isTypeFilter(node)) { + expressionType = "types"; + value = node.cipherType; + } else if (isFieldTerm(node)) { + expressionType = "fields"; + value = node.field; + } else { + // There are various nodes we don't support in the basic filter, this is likely one of those. + return false; + } + + if (data.type == null) { + data.type = expressionType; + } else if (data.type !== expressionType) { + // We've previously visited a node of a different type, unsupported + return false; + } + + if (value === undefined) { + throw new Error("Unreachable"); + } + + // Is the string quoted? + if (value != null && value[0] === '"' && value[value.length - 1] === '"') { + // Unquote the string if it's quoted, ideally the ast offers this. + value = value.substring(1, value.length - 1); + } + + data.values.push(value); + return true; + }; + + const visitTopLevel = (node: AstNode): boolean => { + if (isParentheses(node)) { + // Process top-level parentheses + // Process singular group + // TODO: Visit out "useless" parentheses + const parenthesesData: VisitData = { + operator: null, + type: null, + values: [], + }; + + if (!visitNode(node.inner, parenthesesData)) { + return false; + } + + if (basicFilter[parenthesesData.type] != null) { + // We've already got data for this type + return false; + } + + basicFilter[parenthesesData.type] = parenthesesData.values; + return isValidOperator(parenthesesData); + } else if (isAnd(node)) { + // Process top-level and + return visitTopLevel(node.left) && visitTopLevel(node.right); + } else if (isOr(node)) { + // We do not support top level or + return false; + } else { + // Process singular node + const singularData: VisitData = { + operator: null, + type: null, + values: [], + }; + const visitResult = visitNode(node, singularData); + + if (!visitResult) { + return false; + } + + if (singularData.operator != null) { + // If one of these nodes came back with an operator, it was not the kind of node we were expecting + return false; + } + + if (singularData.values.length !== 1) { + // If one of these nodes came back with multiple values, it was not the kind of node we were expecting + return false; + } + + if (basicFilter[singularData.type] != null) { + // Can't have multiple groups about the same type + return false; + } + + basicFilter[singularData.type] = singularData.values; + + return true; + } + }; + + if (visitTopLevel(ast.contents)) { + // Normalize filter for return + return { + success: true, + filter: { + vaults: basicFilter.vaults ?? [], + collections: basicFilter.collections ?? [], + folders: basicFilter.folders ?? [], + fields: basicFilter.fields ?? [], + types: basicFilter.types ?? [], + }, + }; + } else { return { success: false }; } - - return { success: false }; } toFilter(basicFilter: BasicFilter) { diff --git a/libs/common/src/vault/search/ast.ts b/libs/common/src/vault/search/ast.ts index ea9f667e2c3..4ae2fb2877a 100644 --- a/libs/common/src/vault/search/ast.ts +++ b/libs/common/src/vault/search/ast.ts @@ -99,6 +99,10 @@ export function isOr(x: AstNode): x is Or { return x.type === "or"; } +export function isBinary(x: AstNode): x is And | Or { + return x.type === "and" || x.type === "or"; +} + export type Term = AstNodeBase & { type: "term"; value: string;