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

WIP searchable decorator

This commit is contained in:
Matt Gibson
2025-03-19 07:17:34 -07:00
parent fd1ed3607e
commit e94300ba37
6 changed files with 363 additions and 1 deletions

View File

@@ -6,6 +6,7 @@ import { InitializerKey } from "../../../platform/services/cryptography/initiali
import { DeepJsonify } from "../../../types/deep-jsonify";
import { CipherType, LinkedIdType } from "../../enums";
import { CipherRepromptType } from "../../enums/cipher-reprompt-type";
import { searchable } from "../../search/searchable.decorator";
import { CipherPermissionsApi } from "../api/cipher-permissions.api";
import { LocalData } from "../data/local.data";
import { Cipher } from "../domain/cipher";
@@ -22,12 +23,17 @@ import { SshKeyView } from "./ssh-key.view";
export class CipherView implements View, InitializerMetadata {
readonly initializerKey = InitializerKey.CipherView;
@searchable()
id: string = null;
organizationId: string = null;
folderId: string = null;
@searchable()
name: string = null;
@searchable()
notes: string = null;
@searchable({ key: "type", strategy: { enum: CipherType } })
type: CipherType = null;
@searchable({ key: "favorite", strategy: { boolean: true } })
favorite = false;
organizationUseTotp = false;
permissions: CipherPermissionsApi = new CipherPermissionsApi();
@@ -46,6 +52,7 @@ export class CipherView implements View, InitializerMetadata {
revisionDate: Date = null;
creationDate: Date = null;
deletedDate: Date = null;
@searchable({ key: "reprompt", strategy: { enum: CipherRepromptType } })
reprompt: CipherRepromptType = CipherRepromptType.None;
/**

View File

@@ -6,9 +6,11 @@ import { UriMatchStrategy, UriMatchStrategySetting } from "../../../models/domai
import { View } from "../../../models/view/view";
import { SafeUrls } from "../../../platform/misc/safe-urls";
import { Utils } from "../../../platform/misc/utils";
import { searchable } from "../../search/searchable.decorator";
import { LoginUri } from "../domain/login-uri";
export class LoginUriView implements View {
@searchable({ key: "match detection", strategy: { enum: UriMatchStrategy } })
match: UriMatchStrategySetting = null;
private _uri: string = null;
@@ -25,6 +27,7 @@ export class LoginUriView implements View {
this.match = u.match;
}
@searchable({ key: "website", strategy: "string" })
get uri(): string {
return this._uri;
}

View File

@@ -5,6 +5,7 @@ import { Utils } from "../../../platform/misc/utils";
import { DeepJsonify } from "../../../types/deep-jsonify";
import { LoginLinkedId as LinkedId } from "../../enums";
import { linkedFieldOption } from "../../linked-field-option.decorator";
import { searchable } from "../../search/searchable.decorator";
import { Login } from "../domain/login";
import { Fido2CredentialView } from "./fido2-credential.view";
@@ -12,6 +13,7 @@ import { ItemView } from "./item.view";
import { LoginUriView } from "./login-uri.view";
export class LoginView extends ItemView {
@searchable()
@linkedFieldOption(LinkedId.Username, { sortPosition: 0 })
username: string = null;
@linkedFieldOption(LinkedId.Password, { sortPosition: 1 })

View File

@@ -0,0 +1,158 @@
import { SearchableValueType, getSearchableFields, searchable } from "./searchable.decorator";
describe("Searchable Decorator", () => {
it("adds a searchable string", () => {
class TestClass {
@searchable() property = "test";
}
const instance = new TestClass();
const searchableFields = getSearchableFields(instance);
expect(searchableFields).toEqual([
{ fieldName: "property", type: SearchableValueType.String, getter: expect.any(Function) },
]);
expect(searchableFields[0].getter(instance)).toBe("test");
});
it("adds a searchable enum", () => {
enum TestEnum {
Option1,
Option2,
}
class TestClass {
@searchable({ strategy: { enum: TestEnum } }) property = TestEnum.Option1;
}
const instance = new TestClass();
const searchableFields = getSearchableFields(instance);
expect(searchableFields).toEqual([
{ fieldName: "property", type: SearchableValueType.Enum, getter: expect.any(Function) },
]);
expect(searchableFields[0].getter(instance)).toEqual({
type: SearchableValueType.Enum,
enum: TestEnum,
value: TestEnum.Option1,
});
});
it("add a searchable getter", () => {
class TestClass {
@searchable() get property() {
return "test";
}
}
const instance = new TestClass();
const searchableFields = getSearchableFields(instance);
expect(searchableFields).toEqual([
{ fieldName: "property", type: SearchableValueType.String, getter: expect.any(Function) },
]);
expect(searchableFields[0].getter(instance)).toBe("test");
});
it("overrides the name of the searchable field", () => {
class TestClass {
@searchable({ key: "customName", strategy: "string" }) property = "test";
}
const instance = new TestClass();
const searchableFields = getSearchableFields(instance);
expect(searchableFields).toEqual([
{ fieldName: "customName", type: SearchableValueType.String, getter: expect.any(Function) },
]);
expect(searchableFields[0].getter(instance)).toBe("test");
});
it("adds all fields to the searchable fields", () => {
class TestClass {
@searchable() property1 = "test";
@searchable({ key: "customName", strategy: "string" }) property2 = "test";
@searchable({ strategy: { enum: { Option1: 1, Option2: 2 } } }) property3 = 1;
@searchable() get property4() {
return "test";
}
}
const instance = new TestClass();
const searchableFields = getSearchableFields(instance);
expect(searchableFields).toEqual([
{ fieldName: "property1", type: SearchableValueType.String, getter: expect.any(Function) },
{ fieldName: "customName", type: SearchableValueType.String, getter: expect.any(Function) },
{ fieldName: "property3", type: SearchableValueType.Enum, getter: expect.any(Function) },
{ fieldName: "property4", type: SearchableValueType.String, getter: expect.any(Function) },
]);
expect(searchableFields[0].getter(instance)).toBe("test");
expect(searchableFields[1].getter(instance)).toBe("test");
expect(searchableFields[2].getter(instance)).toEqual({
type: SearchableValueType.Enum,
enum: { Option1: 1, Option2: 2 },
value: 1,
});
expect(searchableFields[3].getter(instance)).toBe("test");
});
});
describe("getSearchableFields", () => {
it("returns the searchable fields", () => {
class TestClass {
@searchable() property1 = "test";
@searchable() get property2() {
return "test";
}
}
const instance = new TestClass();
const searchableFields = getSearchableFields(instance);
expect(searchableFields).toEqual([
{ fieldName: "property1", type: SearchableValueType.String, getter: expect.any(Function) },
{ fieldName: "property2", type: SearchableValueType.String, getter: expect.any(Function) },
]);
expect(searchableFields[0].getter(instance)).toBe("test");
expect(searchableFields[1].getter(instance)).toBe("test");
});
it("throws when a target class is not searchable", () => {
class TestClass {
property1 = "test";
}
const instance = new TestClass();
expect(() => getSearchableFields(instance)).toThrow("Target is not searchable");
});
it("recurses to get searchable fields from properties", () => {
class Inner {
@searchable() innerString = "innerVal";
}
class Outer {
@searchable() outerString = "outerVal";
inner = new Inner();
}
const instance = new Outer();
const searchableFields = getSearchableFields(instance);
expect(searchableFields).toEqual([
{ fieldName: "outerString", type: SearchableValueType.String, getter: expect.any(Function) },
{ fieldName: "innerString", type: SearchableValueType.String, getter: expect.any(Function) },
]);
expect(searchableFields[0].getter(instance)).toBe("outerVal");
expect(searchableFields[1].getter(instance)).toBe("innerVal");
});
it("recurses to get searchable fields from arrays", () => {
class Inner {
@searchable() innerString;
constructor(innerVal?: string) {
this.innerString = innerVal ?? "innerVal";
}
}
class Outer {
@searchable() outerString = "outerVal";
innerArray: Inner[] = [new Inner(), new Inner("secondInnerVal")];
}
const instance = new Outer();
const searchableFields = getSearchableFields(instance);
expect(searchableFields).toEqual([
{ fieldName: "outerString", type: SearchableValueType.String, getter: expect.any(Function) },
{ fieldName: "innerString", type: SearchableValueType.String, getter: expect.any(Function) },
{ fieldName: "innerString", type: SearchableValueType.String, getter: expect.any(Function) },
]);
expect(searchableFields[0].getter(instance)).toBe("outerVal");
expect(searchableFields[1].getter(instance)).toBe("innerVal");
expect(searchableFields[2].getter(instance)).toBe("secondInnerVal");
});
});

View File

@@ -0,0 +1,193 @@
const searchableFields = Symbol("searchable");
/** The types of values that are searchable */
export enum SearchableValueType {
String,
Enum,
Boolean,
}
/**
* The union type representing expected return data for each kind of searchable value.
* This is more complicated than a simple value because matchers for different types of values need
* different data to perform the search.
*/
export type SearchableData = string | EnumSearchableData | BooleanSearchableData;
/** The packet of data used by the search logic to perform matching */
type SearchableGetter = {
/** The search field */
fieldName: string;
/** The type of search to perform */
type: SearchableValueType;
/** A getter for the current value of this search field from a given instance */
getter: (instance: unknown) => SearchableData;
};
type SearchableTypes = "string" | EnumMetadata | BooleanMetadata;
export interface Searchable {
[searchableFields]: SearchableGetter[];
}
/** Guard function determining if an object contains Searchable fields */
export function isSearchable(x: unknown): x is Searchable {
return typeof x === "object" && x !== null && searchableFields in x;
}
/**
* Extract {@link SearchableGetter} array from a searchable object. This method recurses all properties to find any
* searchable field in the tree.
* @param target
* @returns
*/
export function getSearchableFields(target: unknown): SearchableGetter[] {
if (!isSearchable(target)) {
throw new Error("Target is not searchable");
}
return recurseSearchableFields(target, (ancestor) => ancestor);
}
/**
* Traverses an object and returns all searchable fields. Getters are updated to traverse the object in the same way.
*
* Arrays containing searchable objects are also traversed.
*
* @param target The target searchable object
* @param traversal The breadcrumb traversal function to use to traverse an ancestor object to the current level.
* @returns
*/
function recurseSearchableFields(
target: Searchable,
traversal: (ancestor: unknown) => unknown,
): SearchableGetter[] {
const result = target[searchableFields].map((getterData) => ({
...getterData,
getter: (ancestor: unknown) => getterData.getter(traversal(ancestor)),
}));
for (const [key, value] of Object.entries(target)) {
const newTraversal = (ancestor: unknown) => (traversal(ancestor) as any)[key];
if (Array.isArray(value)) {
value.forEach((item, i) => {
const arrayTraversal = (ancestor: unknown) => (newTraversal(ancestor) as any)[i];
if (isSearchable(item)) {
result.push(...recurseSearchableFields(item, arrayTraversal));
}
});
} else if (isSearchable(value)) {
result.push(...recurseSearchableFields(value, newTraversal));
}
}
return result;
}
/**
* The data needed to set up enum searching
*/
type EnumMetadata = {
enum: { [name: string]: any };
};
function isEnumMetadata(type: unknown): type is EnumMetadata {
return typeof type === "object" && type !== null && "enum" in type;
}
/**
* The data needed to perform enum searching for a given field
*/
type EnumSearchableData = {
type: SearchableValueType.Enum;
enum: { [name: string]: any };
value: any;
};
type BooleanMetadata = {
boolean: boolean;
};
function isBooleanMetadata(type: unknown): type is BooleanMetadata {
return typeof type === "object" && type !== null && "boolean" in type;
}
type BooleanSearchableData = {
type: SearchableValueType.Boolean;
boolean: boolean;
value: boolean;
};
function getSearchableTypeValue(type: SearchableTypes): SearchableValueType {
if (type === "string") {
return SearchableValueType.String;
}
if (isEnumMetadata(type)) {
return SearchableValueType.Enum;
} else if (isBooleanMetadata(type)) {
return SearchableValueType.Boolean;
}
throw new Error("Invalid searchable type");
}
export type SearchableOptions = {
key?: string;
strategy: SearchableTypes;
};
/**
* Decorator indicating that a the decorated field or getter should be included in search results.
*
* Note: It is important that the strategy is set to the correct type. There is _no_ type validation to ensure this.
* It is undefined behavior to set the strategy to a type that does not match the field or to use this decorator on a
* type that it cannot process.
*
* @see {@link SearchableValueType} for the types of values that can be searched
*
* @param options.key Optional name of the searchable field. Defaults to the property name
* @param options.strategy The type of searchable value. Defaults to string if no options are given
*/
export function searchable(options: SearchableOptions = { strategy: "string" }) {
return (prototype: unknown, propertyKey: string) => {
if (options.key == null) {
options.key = propertyKey;
}
const target = prototype as Searchable & Record<string, any>;
target[searchableFields] ??= [];
const type = getSearchableTypeValue(options.strategy);
const getter = (instance: unknown) => {
const i = instance as Record<string, any>;
switch (type) {
case SearchableValueType.Enum: {
return {
type,
enum: (options.strategy as EnumMetadata).enum,
value: i[propertyKey],
} as EnumSearchableData;
}
case SearchableValueType.String:
return i[propertyKey];
case SearchableValueType.Boolean: {
return {
type,
boolean: (options.strategy as BooleanMetadata).boolean,
value: i[propertyKey],
} as BooleanSearchableData;
}
default:
throw new Error("Invalid searchable type");
}
};
target[searchableFields].push({
fieldName: options.key,
type,
getter,
});
};
}

View File

@@ -139,7 +139,6 @@ describe("Cipher Service", () => {
domainSettingsService,
apiService,
i18nService,
searchService,
stateService,
autofillSettingsService,
encryptService,