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:
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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 } %}
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user