Exploring New Territory
In the process of developing a Next app, I'm delving into the realm of advanced search forms. To streamline this task, I've stumbled upon a handy library called search-query-parser
. With the magic of search-query-parser
, an input string like
const input = "foo bar -baz answer:42"
can be transformed into a structured object such as const output = { text: ["foo", "bar"], answer: ["42"], exclude: { text: ["baz"] } }
. This library enables the definition of keywords and ranges in array form, facilitating the construction of the output object. Additionally, the usage of zod
is incorporated.
The next step involves converting this output object into a fully typed entity that can be tailored for diverse search forms. The ultimate object will encompass standard properties for pagination control and format rendering. It will preserve both the raw input string and the meticulously parsed and typed conditions extracted from the search query.
A Solution in Progress
import parser, { SearchParserResult } from "search-query-parser";
import { z } from "zod";
const UNPARSED_QUERY_WITH_DEFAULTS = z.object({
q: z.string().describe("serialized search query").optional().default(""),
o: z.coerce.number().describe("offset").optional().default(0),
l: z.coerce
.number()
.describe(
"limit - determines how many results per page (clamp to max value)"
)
.optional()
.default(0),
e: z.coerce
.boolean()
.describe(
"expand - determines if result set should be expanded to one record per version or collapsed to group multiple versions with the same values"
)
.optional()
.default(false),
v: z.enum(["list", "table"]).describe("view").optional().default("list")
});
type UnparsedSearchParams = z.infer<typeof UNPARSED_QUERY_WITH_DEFAULTS>;
type Keywords = readonly [string, ...Array<string>];
type Ranges = readonly [string, ...Array<string>];
type RawQuery<
TKeywords extends Keywords = Keywords,
TRanges extends Ranges = Ranges
> = SearchParserResult & {
text?: string[];
} & Partial<Record<TKeywords[number], Array<string>>> &
Partial<
Record<
TRanges[number],
{
from: string;
to?: string | undefined;
}
>
>;
// SEE https://stackoverflow.com/a/71912306/265558
type BulkRename<
T extends Record<string, string>,
U extends Record<string, unknown>
> = {
[K in keyof U as K extends keyof T
? T[K] extends string
? T[K]
: never
: K]: K extends keyof U ? U[K] : never;
};
type Foo = { one: number; two: string; three: boolean };
type Bar = BulkRename<{ one: "first"; two: "second" }, Foo>;
type FullSearchQuery<
TKeywords extends Keywords = Keywords,
TRanges extends Ranges = Ranges,
RenameMap extends Record<string, string> = {}
> = {
include: Partial<
BulkRename<
RenameMap,
Record<"text" | TKeywords[number] | TRanges[number], unknown>
>
>;
exclude?: Partial<
BulkRename<
RenameMap,
Record<"text" | TKeywords[number] | TRanges[number], unknown>
>
>;
};
const buildSearchQuery = <
RenameMap extends Record<string, string> = {},
TKeywords extends Keywords = Keywords,
TRanges extends Ranges = Ranges,
ExtractConditions extends (
rawQuery: RawQuery<TKeywords, TRanges>
) => FullSearchQuery<TKeywords, TRanges, RenameMap> = (
rawQuery: RawQuery<TKeywords, TRanges>
) => FullSearchQuery<TKeywords, TRanges, RenameMap>
>(
searchParams: UnparsedSearchParams,
keywords: TKeywords,
ranges: TRanges,
extractConditions: ExtractConditions
) => {
const query = parser.parse(searchParams.q, {
offsets: true,
tokenize: true,
keywords: [...keywords],
ranges: [...ranges],
alwaysArray: true
}) as RawQuery<TKeywords, TRanges>;
return {
...searchParams,
conditions: extractConditions(query),
query
};
};
const KEYWORDS = [
"edition",
"version",
"is-earliest-in-cycle",
"is-latest-in-cycle",
"is-latest"
] as const;
const RANGES = ["cycle"] as const;
const BOOL = z.coerce.boolean().optional();
const releaseConditions = (
rawQuery: SearchParserResult & {
text?: string[];
} & Partial<Record<(typeof KEYWORDS)[number], Array<string>>> &
Partial<
Record<
(typeof RANGES)[number],
{
from: string;
to?: string | undefined;
}
>
>
) => ({
include: {
text: rawQuery.text,
edition: rawQuery.edition,
version: rawQuery.version,
cycle: rawQuery.cycle,
isEarliestInCycle: BOOL.parse(
rawQuery["is-earliest-in-cycle"]?.[
rawQuery["is-earliest-in-cycle"].length - 1
]
),
isLatestInCycle: BOOL.parse(
rawQuery["is-latest-in-cycle"]?.[
rawQuery["is-latest-in-cycle"].length - 1
]
),
isLatest: BOOL.parse(
rawQuery["is-latest"]?.[rawQuery["is-latest"].length - 1]
)
},
exclude: rawQuery.exclude
});
type Params = { [key: string]: string | Array<string> };
interface PageProps {
params?: Params;
searchParams?: { [key: string]: string | Array<string> | undefined };
}
const searchParams: PageProps["searchParams"] = {};
const searchParamsWithDefaults =
UNPARSED_QUERY_WITH_DEFAULTS.parse(searchParams);
const releaseQuery = buildSearchQuery<{
"is-earliest-in-cycle": "isEarliestInCycle";
"is-latest-in-cycle": "isLatestInCycle";
"is-latest": "isLatest";
}>(searchParamsWithDefaults, KEYWORDS, RANGES, releaseConditions);
releaseQuery.conditions.include;
// ^?
releaseQuery.conditions.include.version;
//. ^?
releaseQuery.conditions.include.cycle;
//. ^?
releaseQuery.conditions.include.isLatest;
//. ^?
releaseQuery.conditions.exclude;
// ^?
Culminating at the end of the code are various type checks. Despite what console.log
reveals regarding the runtime behavior within my app, the validity of releaseQuery
stands firm. It serves as a prototype of the desired search object structure. The configuration and actual input contribute to the customized shape of releaseQuery.conditions.include
and releaseQuery.conditions.exclude
.
Seeking Assistance
My struggle primarily lies in typing the final output object, particularly with respect to the properties under releaseQuery.conditions.include
.
- The property names originate from the input
keywords
andranges
. Although I came across a property renaming type based on a simple mapping, it doesn't function correctly. Ideally, the ultimate property names should mirror the input to avoid unnecessary rebranding. - Currently, property names do not auto-populate. Previously, they did auto-complete, but suggestions were aligned with
keywords
andranges
, rather than reflecting real options. - All property values are classified as
unknown
. My objective is for these values to be inferred based on the actual output ofreleaseConditions
. releaseConditions
ought to be typed to mandate the presence ofinclude
andexclude
properties. In case an explicit property renaming type parameter is necessary, it should guide the nomenclature of these two properties.
That's all my overworked mind can conjure up at the moment. After refining this project tirelessly for days, and especially several hours today, I'm reaching out for support. If you have any clarifications to seek or questions to pose, feel free to ask. Thank you!