Skip to content

Commit

Permalink
refactor: apply DRY using abstract classes :O
Browse files Browse the repository at this point in the history
  • Loading branch information
PupoSDC committed Feb 6, 2024
1 parent 0a702a6 commit 798256d
Show file tree
Hide file tree
Showing 10 changed files with 427 additions and 560 deletions.
2 changes: 2 additions & 0 deletions libs/core/search/src/entities/search-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ export const searchParams = z.object({
limit: z.number().min(1).max(50),
cursor: z.number().default(0),
});

export type SearchParams = z.infer<typeof searchParams>;
1 change: 1 addition & 0 deletions libs/core/search/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./entities/search-params";
export * from "./entities/annex-search";
export * from "./entities/doc-search";
export * from "./entities/learning-objective-search";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { default as MiniSearch } from "minisearch";
import type { SearchParams as ISearchParams } from "@chair-flight/core/search";
import type { QuestionBank } from "@chair-flight/providers/question-bank";
import type { SearchOptions } from "minisearch";

type Filter = Array<{ id: string; text: string }>;

export abstract class QuestionBankSearchProvider<
SearchDocument extends { id: string; [k: string]: string },
SearchResult extends { id: string },
SearchFilters extends { searchField: string },
SearchParams extends ISearchParams & { filters: SearchFilters },
> {
private searchIndex: MiniSearch<SearchDocument>;
private searchResults = new Map<string, SearchResult>();
private initializationWork = new Map<QuestionBank, Promise<void>>();
private idSearchFields: (keyof SearchDocument)[];

constructor({
idSearchFields,
searchFields,
storeFields,
}: {
idSearchFields: (keyof SearchDocument)[];
searchFields: (keyof SearchDocument)[];
storeFields: (keyof SearchDocument)[];
}) {
this.idSearchFields = idSearchFields;
this.searchIndex = new MiniSearch<SearchDocument>({
fields: searchFields as string[],
storeFields: storeFields as string[],
tokenize: (text, fieldName) => {
if (idSearchFields.includes(fieldName as keyof SearchDocument)) {
return text.split(", ");
}
return MiniSearch.getDefault("tokenize")(text);
},
});
}

private async initializeSearchResults(bank: QuestionBank) {
const resultItems = await this.getResultItems(bank);
const firstId = resultItems[0]?.id;
if (!firstId || this.searchResults.get(firstId)) return;
resultItems.forEach((item) => this.searchResults.set(item.id, item));
}

private async initializeSearchIndex(bank: QuestionBank) {
const thisWork = this.initializationWork.get(bank);
if (thisWork) return await thisWork;

const newWork = (async () => {
const searchDocuments = await this.getSearchDocuments(bank);
const firstId = searchDocuments[0]?.id;
if (!firstId || this.searchIndex.has(firstId)) return;
await this.searchIndex.addAll(searchDocuments);
})();
this.initializationWork.set(bank, newWork);
await newWork;
}

public async search(
bank: QuestionBank,
params: SearchParams,
): Promise<{
items: SearchResult[];
totalResults: number;
nextCursor: number;
}> {
const results = await (async () => {
if (!params.q) {
// Kickstart the indexing processing but dont await it
void this.initializeSearchIndex(bank);
await this.initializeSearchResults(bank);
return Array.from(this.searchResults.values());
} else {
await this.initializeSearchIndex(bank);
await this.initializeSearchResults(bank);

const searchField = params.filters.searchField;
const isFuzzy = this.idSearchFields.includes(
searchField as keyof SearchDocument,
);

const opts: SearchOptions = {
fuzzy: isFuzzy ? false : 0.2,
prefix: !isFuzzy,
fields: searchField === "all" ? undefined : [searchField as string],
tokenize: (text) => {
if (
this.idSearchFields.includes(searchField as keyof SearchDocument)
) {
return text.split(", ");
}
return MiniSearch.getDefault("tokenize")(text);
},
};

return this.searchIndex
.search(params.q, opts)
.map(({ id }) => this.searchResults.get(id));
}
})();

const processedResults = results.filter(this.getSearchResultFilter(params));
const finalItems = processedResults.slice(
params.cursor,
params.cursor + params.limit,
);

return {
items: finalItems,
totalResults: processedResults.length,
nextCursor: params.cursor + finalItems.length,
};
}

public async retrieve(
bank: QuestionBank,
ids: string[],
): Promise<{
items: SearchResult[];
}> {
await this.initializeSearchResults(bank);

const items = ids.map((id) => this.searchResults.get(id)).filter(Boolean);

return { items };
}

public async initialize(bank: QuestionBank) {
await this.initializeSearchIndex(bank);
await this.initializeSearchResults(bank);
}

public abstract getFilters(bank: QuestionBank): Promise<{
filters: Record<keyof SearchFilters, Filter>;
}>;

protected abstract getResultItems(
bank: QuestionBank,
): Promise<SearchResult[]>;

protected abstract getSearchDocuments(
bank: QuestionBank,
): Promise<SearchDocument[]>;

protected abstract getSearchResultFilter(
params: SearchParams,
): (r: SearchResult | undefined) => r is SearchResult;
}
163 changes: 52 additions & 111 deletions libs/providers/search/src/question-bank/annex-search.ts
Original file line number Diff line number Diff line change
@@ -1,96 +1,29 @@
import { default as MiniSearch } from "minisearch";
import type { QuestionBankSearchProvider } from "../types/question-bank-search-provider";
import { QuestionBankSearchProvider } from "../abstract-providers/question-bank-search-provider";
import type {
AnnexSearchResult,
AnnexSearchParams,
AnnexSearchFilters,
} from "@chair-flight/core/search";
import type { QuestionBank } from "@chair-flight/providers/question-bank";
import type { SearchOptions } from "minisearch";

type AnnexSearchField = "id" | "description";
type AnnexSearchDocument = Record<AnnexSearchField, string>;

export class AnnexSearch
implements
QuestionBankSearchProvider<
AnnexSearchResult,
AnnexSearchParams,
AnnexSearchFilters
>
{
private static initializationWork: Promise<void> | undefined;
private static searchResults = new Map<string, AnnexSearchResult>();
private static idSearchFields: AnnexSearchField[] = ["id"];

private static searchIndex = new MiniSearch({
fields: ["id", "description"] satisfies AnnexSearchField[],
storeFields: ["id"] satisfies AnnexSearchField[],
});

async search(params: AnnexSearchParams) {
if (AnnexSearch.initializationWork) await AnnexSearch.initializationWork;

const isFuzzy = AnnexSearch.idSearchFields.includes(
params.filters.searchField,
);

const opts: SearchOptions = {
fuzzy: isFuzzy ? false : 0.2,
prefix: !isFuzzy,
fields:
params.filters.searchField === "all"
? undefined
: [params.filters.searchField],
};

const results = params.q
? AnnexSearch.searchIndex
.search(params.q, opts)
.map(({ id }) => AnnexSearch.searchResults.get(id))
: Array.from(AnnexSearch.searchResults.values());

const processedResults = results.filter(
(result): result is AnnexSearchResult => {
if (!result) {
return false;
}

if (result.questionBank !== params.questionBank) {
return false;
}

if (params.filters.subject !== "all") {
if (!result.subjects.includes(params.filters.subject)) return false;
}

return true;
},
);

const finalItems = processedResults.slice(
params.cursor,
params.cursor + params.limit,
);

return {
items: finalItems,
totalResults: processedResults.length,
nextCursor: params.cursor + finalItems.length,
};
}

async retrieve(ids: string[]) {
if (AnnexSearch.initializationWork) await AnnexSearch.initializationWork;

const items = ids
.map((id) => AnnexSearch.searchResults.get(id))
.filter((r): r is AnnexSearchResult => !!r);

return { items };
export class AnnexSearch extends QuestionBankSearchProvider<
AnnexSearchDocument,
AnnexSearchResult,
AnnexSearchFilters,
AnnexSearchParams
> {
constructor() {
super({
idSearchFields: ["id"],
searchFields: ["description"],
storeFields: ["id"],
});
}

async getFilters(bank: QuestionBank) {
public override async getFilters(bank: QuestionBank) {
const rawSubjects = await bank.getAll("subjects");

const subject = [
Expand All @@ -112,40 +45,48 @@ export class AnnexSearch
};
}

async initialize(bank: QuestionBank) {
if (AnnexSearch.initializationWork) await AnnexSearch.initializationWork;
protected override async getResultItems(bank: QuestionBank) {
const annexes = await bank.getAll("annexes");
return annexes.map((annex) => ({
id: annex.id,
href: annex.href,
description: annex.description,
subjects: annex.subjects,
questionBank: bank.getName(),
questions: annex.questions.map((id) => ({
id,
href: `/modules/${bank.getName()}/questions/${id}`,
})),
learningObjectives: annex.learningObjectives.map((id) => ({
id,
href: `/modules/${bank.getName()}/learning-objectives/${id}`,
})),
}));
}

AnnexSearch.initializationWork = (async () => {
const annexes = await bank.getAll("annexes");
const hasAnnexes = await bank.has("annexes");
const firstId = annexes.at(0)?.id;
protected override async getSearchDocuments(bank: QuestionBank) {
const annexes = await bank.getAll("annexes");
return annexes.map((annex) => ({
id: annex.id,
description: annex.description,
}));
}

if (!hasAnnexes) return;
if (AnnexSearch.searchIndex.has(firstId)) return;
protected override getSearchResultFilter(params: AnnexSearchParams) {
return (r: AnnexSearchResult | undefined): r is AnnexSearchResult => {
if (!r) {
return false;
}

const searchItems: AnnexSearchDocument[] = annexes.map((annex) => ({
id: annex.id,
description: annex.description,
}));
if (r.questionBank !== params.questionBank) {
return false;
}

const resultItems: AnnexSearchResult[] = annexes.map((annex) => ({
id: annex.id,
href: annex.href,
description: annex.description,
subjects: annex.subjects,
questionBank: bank.getName(),
questions: annex.questions.map((id) => ({
id,
href: `/modules/${bank.getName()}/questions/${id}`,
})),
learningObjectives: annex.learningObjectives.map((id) => ({
id,
href: `/modules/${bank.getName()}/learning-objectives/${id}`,
})),
}));
if (params.filters.subject !== "all") {
if (!r.subjects.includes(params.filters.subject)) return false;
}

await AnnexSearch.searchIndex.addAllAsync(searchItems);
resultItems.forEach((r) => AnnexSearch.searchResults.set(r.id, r));
})();
return true;
};
}
}
Loading

0 comments on commit 798256d

Please sign in to comment.