1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-10 05:30:01 +00:00

Add order by handling to query language

This commit is contained in:
Matt Gibson
2025-03-17 12:10:54 -07:00
parent 078856e304
commit e22eaca945
4 changed files with 151 additions and 25 deletions

View File

@@ -19,6 +19,7 @@ export const AstNodeTypeNames = [
"type",
"website",
"websiteMatch",
"orderBy",
] as const;
export type AstNodeType = (typeof AstNodeTypeNames)[number];
export type AstNode =
@@ -41,7 +42,8 @@ export type AstNode =
| IsFavorite
| TypeFilter
| WebsiteFilter
| WebsiteMatchFilter;
| WebsiteMatchFilter
| OrderBy;
type AstNodeBase = {
d: object[];
@@ -226,3 +228,18 @@ export type WebsiteMatchFilter = AstNodeBase & {
export function isWebsiteMatchFilter(x: AstNode): x is WebsiteMatchFilter {
return x.type === "websiteMatch";
}
export enum OrderDirection {
Asc = "asc",
Desc = "desc",
}
export type OrderBy = AstNodeBase & {
type: "orderBy";
field: string;
direction: OrderDirection;
};
export function isOrderBy(x: AstNode): x is OrderBy {
return x.type === "orderBy";
}

View File

@@ -17,6 +17,9 @@ let lexer = moo.compile({
func_is: 'is:',
func_type: 'type:',
func_website: 'website:',
// Ordering functions and parameters
func_order: 'order:',
param_dir: /:(?:asc|desc)/,
// function parameter separator
access: ':',
// string match, includes quoted strings with escaped quotes and backslashes
@@ -40,36 +43,40 @@ OR -> OR _ %OR _ AND {% function(d) { return { type: 'or', left
TERM ->
# naked string search term, search all fields
%string {% function(d) { const start = d[0].offset; const end = d[0].offset + d[0].value.length; return { type: 'term', value: d[0].value, d: d[0], start, end, length: d[0].value.length } } %}
%string {% function(d) { const start = d[0].offset; const end = d[0].offset + d[0].value.length; return { type: 'term', value: d[0].value, d: d[0], start, end, length: d[0].value.length } } %}
# specified field search term
| %string %access %string {% function(d) { const start = d[0].offset; const end = d[2].offset + d[2].value.length; return { type: 'field term', field: d[0].value, term: d[2].value, d: d, start, end, length: end - start + 1 } } %}
| %string %access %string {% function(d) { const start = d[0].offset; const end = d[2].offset + d[2].value.length; return { type: 'field term', field: d[0].value, term: d[2].value, d: d, start, end, length: end - start + 1 } } %}
# only items with attachments
| %func_has "attachment" {% function(d) { const start = d[0].offset; const length = 14; return { type: 'hasAttachment', d: d, start, end: d[0].offset + length, length } } %}
| %func_has "attachment" {% function(d) { const start = d[0].offset; const length = 14; return { type: 'hasAttachment', d: d, start, end: d[0].offset + length, length } } %}
# only items with URIs
| %func_has "uri" {% function(d) { const start = d[0].offset; const length = 7; return { type: 'hasUri', d: d, start, end: d[0].offset + length, length } } %}
| %func_has "uri" {% function(d) { const start = d[0].offset; const length = 7; return { type: 'hasUri', d: d, start, end: d[0].offset + length, length } } %}
# only items assigned to a folder
| %func_has "folder" {% function(d) { const start = d[0].offset; const length = 10; return { type: 'hasFolder', d: d, start, end: d[0].offset + length, length } } %}
| %func_has "folder" {% function(d) { const start = d[0].offset; const length = 10; return { type: 'hasFolder', d: d, start, end: d[0].offset + length, length } } %}
# only items assigned to a collection
| %func_has "collection" {% function(d) { const start = d[0].offset; const length = 14; return { type: 'hasCollection', d: d, start, end: d[0].offset + length, length } } %}
| %func_has "collection" {% function(d) { const start = d[0].offset; const length = 14; return { type: 'hasCollection', d: d, start, end: d[0].offset + length, length } } %}
# only items assigned to a specified folder
| %func_in "folder" %access %string {% function(d) { const start = d[0].offset; const end = d[3].offset + d[3].value.length; return { type: 'inFolder', folder: d[3].value, d: d, start, end, length: end - start } } %}
| %func_in "folder" %access %string {% function(d) { const start = d[0].offset; const end = d[3].offset + d[3].value.length; return { type: 'inFolder', folder: d[3].value, d: d, start, end, length: end - start } } %}
# only items assigned to a specified collection
| %func_in "collection" %access %string {% function(d) { const start = d[0].offset; const end = d[3].offset + d[3].value.length; return { type: 'inCollection', collection: d[3].value, d: d, start, end, length: end - start + 1 } } %}
| %func_in "collection" %access %string {% function(d) { const start = d[0].offset; const end = d[3].offset + d[3].value.length; return { type: 'inCollection', collection: d[3].value, d: d, start, end, length: end - start + 1 } } %}
# only items assigned to a specified organization
| %func_in "org" %access %string {% function(d) { const start = d[0].offset; const end = d[3].offset + d[3].value.length; return { type: 'inOrg', org: d[3].value, d: d, start, end, length: end - start + 1 } } %}
| %func_in "org" %access %string {% function(d) { const start = d[0].offset; const end = d[3].offset + d[3].value.length; return { type: 'inOrg', org: d[3].value, d: d, start, end, length: end - start + 1 } } %}
# only items in personal vault
| %func_in "my_vault" {% function(d) { const start = d[0].offset; const length = 11; return { type: 'inMyVault', d: d, start, end: start + length, length } } %}
| %func_in "my_vault" {% function(d) { const start = d[0].offset; const length = 11; return { type: 'inMyVault', d: d, start, end: start + length, length } } %}
# only items in trash
| %func_in "trash" {% function(d) { const start = d[0].offset; const length = 8; return { type: 'inTrash', d: d, start, end: start + length, length } } %}
| %func_in "trash" {% function(d) { const start = d[0].offset; const length = 8; return { type: 'inTrash', d: d, start, end: start + length, length } } %}
# only items marked as favorites
| %func_is "favorite" {% function(d) { const start = d[0].offset; const length = 11; return { type: 'isFavorite', d: d, start, end: start + length, length } } %}
| %func_is "favorite" {% function(d) { const start = d[0].offset; const length = 11; return { type: 'isFavorite', d: d, start, end: start + length, length } } %}
# only items of given type type
| %func_type %string {% function(d) { const start = d[0].offset; const end = d[1].offset + d[1].value.length; return { type: 'type', d:d, cipherType: d[1].value, start, end, length: end - start + 1 } } %}
| %func_type %string {% function(d) { const start = d[0].offset; const end = d[1].offset + d[1].value.length; return { type: 'type', d:d, cipherType: d[1].value, start, end, length: end - start + 1 } } %}
# only items with a specified website
| %func_website %string {% function(d) { const start = d[0].offset; const end = d[1].offset + d[1].value.length; return { type: 'website', d: d, website: d[1].value, start, end, length: end - start + 1 } } %}
| %func_website %string {% function(d) { const start = d[0].offset; const end = d[1].offset + d[1].value.length; return { type: 'website', d: d, website: d[1].value, start, end, length: end - start + 1 } } %}
# only items with a specified website and a given match pattern
| %func_website %string %access %string {% function(d) { const start = d[0].offset; const end = d[3].offset + d[3].value.length; return { type: 'websiteMatch', d: d, website: d[1].value, matchType: d[3].value, start, end, length: end - start + 1 } } %}
| %func_website %string %access %string {% function(d) { const start = d[0].offset; const end = d[3].offset + d[3].value.length; return { type: 'websiteMatch', d: d, website: d[1].value, matchType: d[3].value, start, end, length: end - start + 1 } } %}
# order by name
| %func_order %param_dir {% function(d) { const start = d[0].offset; const end = d[1].offset + d[1].value.length; return { type: 'orderBy', d: d, field: 'name', direction: d[1].value.substring(1,d[1].value.length).toLowerCase(), start, end, length: end - start + 1 } } %}
# order by a specified field
| %func_order %string %param_dir {% function(d) { const start = d[0].offset; const end = d[2].offset + d[2].value.length; return { type: 'orderBy', d: d, field: d[1].value, direction: d[2].value.substring(1,d[2].value.length).toLowerCase(), start, end, length: end - start + 1 } } %}
# Boolean NOT operator
| %NOT _ PARENTHESES {% function(d) { const start = d[0].offset; return { type: 'not', value: d[2], d: d, start, end: d[2].end, length: d[2].end - d[0].offset + 1 } } %}
| %NOT _ PARENTHESES {% function(d) { const start = d[0].offset; return { type: 'not', value: d[2], d: d, start, end: d[2].end, length: d[2].end - d[0].offset + 1 } } %}
_ -> %WS:* {% function(d) {return null } %}

View File

@@ -16,6 +16,8 @@ declare var func_in: any;
declare var func_is: any;
declare var func_type: any;
declare var func_website: any;
declare var func_order: any;
declare var param_dir: any;
declare var NOT: any;
declare var WS: any;
@@ -36,6 +38,9 @@ let lexer = moo.compile({
func_is: "is:",
func_type: "type:",
func_website: "website:",
// Ordering functions and parameters
func_order: "order:",
param_dir: /:(?:asc|desc)/,
// function parameter separator
access: ":",
// string match, includes quoted strings with escaped quotes and backslashes
@@ -337,6 +342,47 @@ const grammar: Grammar = {
};
},
},
{
name: "TERM",
symbols: [
lexer.has("func_order") ? { type: "func_order" } : func_order,
lexer.has("param_dir") ? { type: "param_dir" } : param_dir,
],
postprocess: function (d) {
const start = d[0].offset;
const end = d[1].offset + d[1].value.length;
return {
type: "orderBy",
d: d,
field: "name",
direction: d[1].value.substring(1, d[1].value.length).toLowerCase(),
start,
end,
length: end - start + 1,
};
},
},
{
name: "TERM",
symbols: [
lexer.has("func_order") ? { type: "func_order" } : func_order,
lexer.has("string") ? { type: "string" } : string,
lexer.has("param_dir") ? { type: "param_dir" } : param_dir,
],
postprocess: function (d) {
const start = d[0].offset;
const end = d[2].offset + d[2].value.length;
return {
type: "orderBy",
d: d,
field: d[1].value,
direction: d[2].value.substring(1, d[2].value.length).toLowerCase(),
start,
end,
length: end - start + 1,
};
},
},
{
name: "TERM",
symbols: [lexer.has("NOT") ? { type: "NOT" } : NOT, "_", "PARENTHESES"],

View File

@@ -2,11 +2,13 @@ import { Parser, Grammar } from "nearley";
import { UriMatchStrategy, UriMatchStrategySetting } from "../../models/domain/domain-service";
import { Utils } from "../../platform/misc/utils";
import { CipherId } from "../../types/guid";
import { CardLinkedId, CipherType, FieldType, LinkedIdType, LoginLinkedId } from "../enums";
import { CipherView } from "../models/view/cipher.view";
import {
AstNode,
OrderDirection,
isAnd,
isFieldTerm,
isHasAttachment,
@@ -20,6 +22,7 @@ import {
isIsFavorite,
isNot,
isOr,
isOrderBy,
isParentheses,
isSearch,
isTerm,
@@ -367,6 +370,55 @@ function handleNode(node: AstNode): ProcessInstructions {
},
],
};
} else if (isOrderBy(node)) {
// TODO: This logic is shaky at best, this operator needs to be rewritten
const fieldTest = fieldNameToRegexTest(node.field);
return {
filter: (context) => {
const idOrder = context.ciphers
.map((cipher) => fieldValues(cipher, /.*/i))
.sort((a, b) => {
const aValue = a.fields.find((v) =>
fieldTest.test(v.path.split(".").reverse()[0]),
)?.value;
const bValue = b.fields.find((v) =>
fieldTest.test(v.path.split(".").reverse()[0]),
)?.value;
if (aValue === bValue) {
return 0;
}
if (node.direction === OrderDirection.Asc) {
if (aValue === undefined) {
return 1;
}
if (bValue === undefined) {
return -1;
}
return aValue.localeCompare(bValue) ? -1 : 1;
} else {
if (aValue === undefined) {
return -1;
}
if (bValue === undefined) {
return 1;
}
return aValue.localeCompare(bValue) ? 1 : -1;
}
})
.map((fieldValues) => fieldValues.id);
return {
...context,
ciphers: idOrder.map((id) => context.ciphers.find((cipher) => cipher.id === id)!),
};
},
sections: [
{
start: node.start,
end: node.end,
type: node.type,
},
],
};
} else {
throw new Error("Invalid node\n" + JSON.stringify(node, null, 2));
}
@@ -409,7 +461,7 @@ function matchEnum(
function hasTerm(cipher: CipherView, termTest: RegExp, fieldTest: RegExp = /.*/i): boolean {
const foundValues = fieldValues(cipher, fieldTest);
return foundValues.some((foundValue) => termTest.test(foundValue.value));
return foundValues.fields.some((foundValue) => termTest.test(foundValue.value));
}
function termToRegexTest(term: string) {
@@ -443,15 +495,18 @@ const ForbiddenLinkedIds: Readonly<LinkedIdType[]> = Object.freeze([
]);
type FieldValues = { path: string; value: string }[];
function fieldValues(cipher: CipherView, fieldTest: RegExp): FieldValues {
const result = recursiveValues(cipher, fieldTest, "");
function fieldValues(cipher: CipherView, fieldTest: RegExp): { id: CipherId; fields: FieldValues } {
const result = {
id: cipher.id as CipherId,
fields: recursiveValues(cipher, fieldTest, ""),
};
// append custom fields
for (const field of cipher.fields ?? []) {
switch (field.type) {
case FieldType.Text:
if (fieldTest.test(field.name)) {
result.push({
result.fields.push({
path: `customField.${field.name}`,
value: field.value,
});
@@ -467,7 +522,7 @@ function fieldValues(cipher: CipherView, fieldTest: RegExp): FieldValues {
break;
}
if (fieldTest.test(field.name) && value != null) {
result.push({
result.fields.push({
path: `customField.${field.name}`,
value: value,
});
@@ -476,7 +531,7 @@ function fieldValues(cipher: CipherView, fieldTest: RegExp): FieldValues {
}
case FieldType.Boolean: {
if (fieldTest.test(field.name)) {
result.push({
result.fields.push({
path: `customField.${field.name}`,
value: field.value,
});
@@ -491,7 +546,7 @@ function fieldValues(cipher: CipherView, fieldTest: RegExp): FieldValues {
// append attachments
if (fieldTest.test("fileName")) {
cipher.attachments?.forEach((a) => {
result.push({
result.fields.push({
path: `attachment.fileName`,
value: a.fileName,
});
@@ -499,9 +554,10 @@ function fieldValues(cipher: CipherView, fieldTest: RegExp): FieldValues {
}
// Purge forbidden paths from results
return result.filter(({ path }) => {
result.fields = result.fields.filter(({ path }) => {
return !ForbiddenFields.includes(path);
});
return result;
}
function recursiveValues<T extends object>(obj: T, fieldTest: RegExp, crumb: string): FieldValues {