diff --git a/.changeset/find-many-where.md b/.changeset/find-many-where.md new file mode 100644 index 00000000..258b32f4 --- /dev/null +++ b/.changeset/find-many-where.md @@ -0,0 +1,27 @@ +--- +"@example/erp": minor +"@genseki/react": minor +--- + +## @genseki/react: `findMany` method's `where` + +Previously the server doesn't have filter functionality. +The only way to do this is via client-side, which will be sophisticated and troublesome; i.e., filtering data that are limited to the first n records would give the user an incomplete result. + +Nevertheless, providing a trivial filter, such as `like` would not be sufficient for real-world use cases. +So it's better to give them all [operators that drizzle provided](https://github.com/softnetics/genseki/pull/61/files#diff-64e2e95728cdee0c91653b1fe43241682ab23fb39642970eb9f054d709202690). + +Along with type-safety capability, that would help developers from foot-gunning themselves by mistyping or unknowingly using the where filter in a misconceived way. + +See [simple](https://github.com/softnetics/genseki/pull/61/files?diff=unified&w=0#diff-57a06782ec15e2e9a736382771d2f201304e359479752f893038c5bab01d76dcR33-R46) and [complex filter samples](https://github.com/softnetics/genseki/pull/61/files?diff=unified&w=0#diff-0a8eec2eb4f9356b504b16d81f11d60b5b6e6566d24fed79a4c2bd92b2ec588eR305-R383) for reference. + +## @example/erp: new `/admin/collections/custom-food-page` page + +intended to demonstrate how to use a filter in source code. + +https://github.com/user-attachments/assets/53cb2a21-ac7a-4c8f-a238-b107cda00c59 + +> [!TIP] +> Also added a [custom list component](https://github.com/softnetics/genseki/pull/61/files?diff=unified&w=0#diff-bb8bf64086ef44df2aa775aa4431016d1b340674be456571131bf9ea38bc16c7) to demonstrate how to display data with custom cell rendering. +> +> see [renderFoodsCells](https://github.com/softnetics/genseki/pull/61/files?diff=unified&w=0#diff-e0a7d24561c070f8468733072ef63f60f8960a2e752d7334cc0979b98d313076) for reference. diff --git a/examples/erp/package.json b/examples/erp/package.json index 6b57777a..90da0446 100644 --- a/examples/erp/package.json +++ b/examples/erp/package.json @@ -18,6 +18,7 @@ "@genseki/react": "workspace:^", "@genseki/react-query": "workspace:^", "@genseki/rest": "workspace:^", + "@hookform/resolvers": "^5.0.1", "@tailwindcss/postcss": "^4.1.7", "@tanstack/react-query": "^5.71.5", "@tiptap/extension-color": "^2.14.0", @@ -26,6 +27,7 @@ "@tiptap/extension-text-style": "^2.14.0", "@tiptap/extension-underline": "^2.14.0", "@tiptap/starter-kit": "^2.14.0", + "date-fns": "^4.1.0", "drizzle-orm": "^0.41.0", "next": "15.2.2", "next-themes": "^0.4.6", @@ -33,6 +35,7 @@ "postcss": "^8.5.3", "react": "19.1.0", "react-dom": "19.1.0", + "react-hook-form": "^7.56.3", "tailwindcss": "^4.1.7", "zod": "3.25.53" }, diff --git a/examples/erp/src/app/(admin)/admin/collections/custom-food-page/cells.tsx b/examples/erp/src/app/(admin)/admin/collections/custom-food-page/cells.tsx new file mode 100644 index 00000000..1fb39a60 --- /dev/null +++ b/examples/erp/src/app/(admin)/admin/collections/custom-food-page/cells.tsx @@ -0,0 +1,30 @@ +'use client' + +import { format, parse } from 'date-fns' +import Image from 'next/image' + +import type { serverConfig } from '~/drizzlify/config' +import type { FieldsRenderFn } from '~/src/components/list.client' + +const formatDate: (data: string) => React.ReactNode = (date) => format(date, 'dd MMM yyyy') +const formatTime = (time: string): string => + format(parse(time, 'HH:mm:ss', new Date()), "H 'hr(s)', m 'minutes'") + +const typeDisplay = { + fruit: '🍌', + vegetable: '🥗', + meat: '🍖', + dairy: '🥛', + grain: '🌾', + other: '...', +} + +export const renderFoodsCells: FieldsRenderFn = { + name: String, + cookingDate: formatDate, + cookingTime: formatTime, + isCooked: (cooked) => (cooked ? '✅' : '❌'), + cookingTypes: (type) => typeDisplay[type as unknown as keyof typeof typeDisplay], + foodAvatar: (src) => (src === 'Unknown' ? 'N/A' : ), + description: () => 'N/A', +} diff --git a/examples/erp/src/app/(admin)/admin/collections/custom-food-page/display.tsx b/examples/erp/src/app/(admin)/admin/collections/custom-food-page/display.tsx new file mode 100644 index 00000000..05b7d790 --- /dev/null +++ b/examples/erp/src/app/(admin)/admin/collections/custom-food-page/display.tsx @@ -0,0 +1,137 @@ +'use client' +import { useForm } from 'react-hook-form' + +import { zodResolver } from '@hookform/resolvers/zod' +// eslint-disable-next-line no-restricted-imports +import { z } from 'zod' + +import { + Form, + FormControl, + FormField, + FormItem, + FormMessage, + type InferFields, + Select, + SelectList, + SelectOption, + SelectTrigger, + SubmitButton, + Switch, + TextField, + type ToClientCollection, + useNavigation, +} from '@genseki/react' + +import type { serverConfig } from '~/drizzlify/config' +import { ListTable } from '~/src/components/list.client' + +import { renderFoodsCells } from './cells' + +import { typesEnum } from '../../../../../../db/schema' + +type FoodCollection = typeof serverConfig.collections.foods + +interface IDisplayProps { + data: InferFields[] + collection: ToClientCollection +} + +const filterFormSchema = z.object({ + name: z.string().optional(), + isCooked: z.boolean().optional(), + cookingType: z.enum(typesEnum.enumValues).optional(), +}) + +export const Display = ({ data, collection }: IDisplayProps) => { + const { navigate } = useNavigation() + + const filterForm = useForm({ + resolver: zodResolver(filterFormSchema), + mode: 'onSubmit', + }) + async function search(data: z.infer) { + const query = Object.entries(data) + .map(([field, value]) => value !== undefined && `${field}=${value}`) + .filter((v) => v) + .join('&') + + navigate(`?${query}`) + } + return ( +
+
+ + ( + + + + + + + )} + /> +
+ ( + + + + + + + )} + /> + + ( + + + + + + + )} + /> +
+ Search + + + +
+ ) +} diff --git a/examples/erp/src/app/(admin)/admin/collections/custom-food-page/page.tsx b/examples/erp/src/app/(admin)/admin/collections/custom-food-page/page.tsx new file mode 100644 index 00000000..be77564b --- /dev/null +++ b/examples/erp/src/app/(admin)/admin/collections/custom-food-page/page.tsx @@ -0,0 +1,57 @@ +import { headers } from 'next/headers' + +import { RootLayout } from '@genseki/next' +import { CollectionAppLayout, Context, createAuth, getClientCollection } from '@genseki/react' + +import { Display } from './display' + +import { serverConfig } from '../../../../../../drizzlify/config' +import { serverFunction } from '../../../_helper/server' + +const collection = serverConfig.collections.foods + +const getHeadersObject = (headers: Headers): Record => { + const headersRecord: Record = {} + headers.forEach((value, key) => { + headersRecord[key] = value + }) + return headersRecord +} + +interface CustomFoodProps { + searchParams: Promise> +} + +const CustomFoodPage = async (props: CustomFoodProps) => { + const { context: authContext } = createAuth(serverConfig.auth, serverConfig.context) + + const searchParams = await props.searchParams + const nameQuery = searchParams['name'] + const isCookedQuery = JSON.parse(searchParams['isCooked'] ?? 'null') as boolean | null + const cookingTypeQuery = searchParams['cookingType'] + + const result = await collection.admin.endpoints.findMany.handler({ + context: Context.toRequestContext(authContext, getHeadersObject(await headers())), + query: { + limit: 10, + offset: 0, + orderBy: undefined, + orderType: undefined, + where: { + name: nameQuery ? { $like: `%${nameQuery}%` } : '$isNotNull', + isCooked: isCookedQuery !== null ? { $eq: isCookedQuery } : '$isNotNull', + cookingTypes: cookingTypeQuery ? { $eq: cookingTypeQuery } : '$isNotNull', + }, + }, + }) + + return ( + + + + + + ) +} + +export default CustomFoodPage diff --git a/examples/erp/src/components/list.client.tsx b/examples/erp/src/components/list.client.tsx new file mode 100644 index 00000000..b2d706a7 --- /dev/null +++ b/examples/erp/src/components/list.client.tsx @@ -0,0 +1,82 @@ +'use client' + +import type { AnyCollection, InferFields, ToClientCollection } from '@genseki/react' +import { Table, TableBody, TableCell, TableColumn, TableHeader, TableRow } from '@genseki/react' + +const tableDataExtract = ( + collection: ToClientCollection, + data: InferFields[] +) => { + const headers = Object.values(collection.fields).map((column) => { + return { ...column, label: column.fieldName /* Fallback to key if no label */ } + }) + + headers.sort((a, b) => (b.label === collection.identifierColumn ? 1 : -1)) + + const rows = data.map((record) => ({ + key: record.__id, + rows: headers.map( + (header) => + record[header.fieldName as keyof typeof record] ?? + 'Unknown' /* Unknown meant that it's missing a correct heading label */ + ), + })) + + return { headers, rows } +} + +export type FieldsRenderFn< + TCollection extends AnyCollection, + TFields extends InferFields = InferFields, +> = { [key in keyof TFields]?: (data: TFields[key]) => React.ReactNode } + +interface IListTableProps { + collection: ToClientCollection + data: InferFields[] + renderCellFns?: FieldsRenderFn +} + +export function ListTable(props: IListTableProps) { + const { headers, rows } = tableDataExtract(props.collection, props.data) + + return ( + <> + + + {headers.map(({ label }) => ( + + {label} + + ))} + + + {({ key, rows }) => ( + + { + /* @ts-expect-error union type too complex */ + rows.map((cell, i) => { + const fieldName = headers[i].fieldName + let renderFn: (data: any) => React.ReactNode = JSON.stringify + + if (!!props.renderCellFns && fieldName in props.renderCellFns) + renderFn = + props.renderCellFns[fieldName as keyof InferFields]! + + return ( + {renderFn(cell)} + ) + }) + } + + )} + +
+ + ) +} diff --git a/packages/react/src/core/__mocks__/all-type-schema.ts b/packages/react/src/core/__mocks__/all-type-schema.ts index 16047a30..553b251c 100644 --- a/packages/react/src/core/__mocks__/all-type-schema.ts +++ b/packages/react/src/core/__mocks__/all-type-schema.ts @@ -29,7 +29,7 @@ export const allFieldTypes = pgTable('table', { integer: integer(), smallint: smallint(), bigint: bigint({ mode: 'bigint' }), - serial: serial(), + serial: serial().primaryKey(), smallserial: smallserial(), bigserialNumber: bigserial({ mode: 'number' }), bigserialBigInt: bigserial({ mode: 'bigint' }), diff --git a/packages/react/src/core/__mocks__/vary-collection-server-config.ts b/packages/react/src/core/__mocks__/vary-collection-server-config.ts new file mode 100644 index 00000000..6fff319c --- /dev/null +++ b/packages/react/src/core/__mocks__/vary-collection-server-config.ts @@ -0,0 +1,188 @@ +import Color from '@tiptap/extension-color' +import TextAlign from '@tiptap/extension-text-align' +import TextStyle from '@tiptap/extension-text-style' +import Underline from '@tiptap/extension-underline' +import StarterKit from '@tiptap/starter-kit' +import { drizzle } from 'drizzle-orm/node-postgres' + +import { allFieldTypes } from './all-type-schema' +import * as baseSchema from './complex-schema' + +import { + BackColorExtension, + CustomImageExtension, + ImageUploadNodeExtension, + SelectionExtension, +} from '../../react' +import { Builder } from '../builder' +import { defineBaseConfig, defineServerConfig } from '../config' + +export const schema = { + ...baseSchema, + vary: allFieldTypes, +} +const db = drizzle({ + connection: '', + schema: schema, +}) + +const postEditorProviderProps = { + immediatelyRender: false, + shouldRerenderOnTransaction: true, + content: '

This came from Post content field

', + extensions: [ + Color, + BackColorExtension, + Underline.configure({ HTMLAttributes: { class: 'earth-underline' } }), + SelectionExtension, + TextStyle, + TextAlign.configure({ + types: ['heading', 'paragraph'], + alignments: ['left', 'center', 'right', 'justify'], + defaultAlignment: 'left', + }), + StarterKit.configure({ + bold: { HTMLAttributes: { class: 'bold large-black' } }, + paragraph: { HTMLAttributes: { class: 'paragraph-custom' } }, + heading: { HTMLAttributes: { class: 'heading-custom' } }, + bulletList: { HTMLAttributes: { class: 'list-custom' } }, + orderedList: { HTMLAttributes: { class: 'ordered-list' } }, + code: { HTMLAttributes: { class: 'code' } }, + codeBlock: { HTMLAttributes: { class: 'code-block' } }, + horizontalRule: { HTMLAttributes: { class: 'hr-custom' } }, + italic: { HTMLAttributes: { class: 'italic-text' } }, + strike: { HTMLAttributes: { class: 'strikethrough' } }, + blockquote: { HTMLAttributes: { class: 'blockquote-custom' } }, + }), + CustomImageExtension.configure({ HTMLAttributes: { className: 'image-displayer' } }), + ImageUploadNodeExtension.configure({ + showProgress: false, + accept: 'image/*', + maxSize: 1024 * 1024 * 10, // 10MB + limit: 3, + }), + ], +} + +const baseConfig = defineBaseConfig({ + db: db, + schema: schema, + context: { example: 'example' }, + auth: { + user: { + model: schema.user, + }, + session: { + model: schema.session, + }, + account: { + model: schema.account, + }, + verification: { + model: schema.verification, + }, + emailAndPassword: { + enabled: true, + }, + oauth2: { + google: { + enabled: true, + clientId: '', + clientSecret: '', + }, + }, + secret: '', + }, +}) +const builder = new Builder({ schema }).$context() +const vary = builder.collection('vary', { + slug: 'allFields', + identifierColumn: 'serial', + fields: builder.fields('vary', (fb) => ({ + integer: fb.columns('integer', { + type: 'number', + }), + smallint: fb.columns('smallint', { type: 'number' }), + // bigint: fb.columns('bigint', { type: 'number' }), // Not supported yet + serial: fb.columns('serial', { type: 'number' }), + smallserial: fb.columns('smallserial', { type: 'number' }), + bigserial_number: fb.columns('bigserialNumber', { type: 'number' }), + // bigserialBigInt: fb.columns('bigserialBigInt', { type: 'number' }), // Not supported yet + boolean: fb.columns('boolean', { type: 'switch' }), + text: fb.columns('text', { type: 'text' }), + text_enum: fb.columns('textEnum', { type: 'text' }), + varchar: fb.columns('varchar', { type: 'text' }), + char: fb.columns('char', { type: 'text' }), + // numeric: fb.columns('numeric', { type: 'text' }), // Not supported yet + // decimal: fb.columns('decimal', { type: 'text' }), // Not supported yet + real: fb.columns('real', { type: 'number' }), + doublePrecision: fb.columns('doublePrecision', { type: 'number' }), + json: fb.columns('json', { type: 'richText', editor: postEditorProviderProps }), // Not supported yet + jsonType: fb.columns('jsonType', { type: 'richText', editor: postEditorProviderProps }), // Not supported yet + jsonb: fb.columns('jsonb', { type: 'richText', editor: postEditorProviderProps }), // Not supported yet + jsonbType: fb.columns('jsonbType', { type: 'richText', editor: postEditorProviderProps }), // Not supported yet + time: fb.columns('time', { type: 'time' }), + timestamp: fb.columns('timestamp', { type: 'date' }), + date: fb.columns('date', { type: 'date' }), + // interval: fb.columns('interval', { type: 'time' }), // Not supported yet + // point: fb.columns('point', { type: 'comboboxNumber' }), // Not supported yet + // line: fb.columns('line', { type: 'comboboxNumber' }), // Not supported yet + // enum: fb.columns('enum', { type: 'comboboxNumber' }), // Not supported yet + + arrayOfText: fb.columns('arrayOfText', { + type: 'comboboxText', + options: () => [{ label: '', value: '' }], + }), + arrayOfNumbers: fb.columns('arrayOfNumbers', { + type: 'comboboxNumber', + options: () => [{ label: '', value: 0 }], + }), + // arrayOfObjects: fb.columns('arrayOfObjects', { + // type: 'comboboxText', + // options: () => [{ label: '', value: '' }], + // }), Not supported yet + arrayOfBooleans: fb.columns('arrayOfBooleans', { + type: 'comboboxBoolean', + options: () => [{ label: '', value: false }], + }), + arrayOfDates: fb.columns('arrayOfDates', { + type: 'comboboxText', + options: () => [{ label: '', value: '' }], + }), + arrayOfTimes: fb.columns('arrayOfTimes', { + type: 'comboboxText', + options: () => [{ label: '', value: '' }], + }), + // arrayOfTimestamps: fb.columns('arrayOfTimestamps', { + // type: '', + // options: () => [{ label: '', value: '' }], + // }),// Not supported yet + // arrayOfPoints: fb.columns('arrayOfPoints', { + // type: '', + // options: () => [{ label: '', value: '' }], + // }), // Not supported yet + // arrayOfLines: fb.columns('arrayOfLines', { + // type: '', + // options: () => [{ label: '', value: '' }], + // }), // Not supported yet + arrayOfEnums: fb.columns('arrayOfEnums', { + type: 'comboboxText', + options: () => [{ label: '', value: '' }], + }), + // arrayOfJson: fb.columns('arrayOfJson', { + // type: '', + // options: () => [{ label: '', value: '' }], + // }), // Not supported yet + // arrayOfJsonb: fb.columns('arrayOfJsonb', { + // type: '', + // options: () => [{ label: '', value: '' }], + // }), // Not supported yet + })), +}) +export const serverConfig = defineServerConfig(baseConfig, { + collections: { + vary, + }, +}) + +export type VaryCollectionFields = typeof serverConfig.collections.vary.fields diff --git a/packages/react/src/core/builder.handler.ts b/packages/react/src/core/builder.handler.ts index 4891c801..ee2199b4 100644 --- a/packages/react/src/core/builder.handler.ts +++ b/packages/react/src/core/builder.handler.ts @@ -16,6 +16,7 @@ import type { RelationalQueryBuilder } from 'drizzle-orm/pg-core/query-builders/ import type { ApiDefaultMethod, ApiHandlerFn, CollectionAdminApi, InferFields } from './collection' import type { AnyContext, RequestContext } from './context' import type { AnyFields, Field, Fields } from './field' +import { transformQueryObjectToSQL } from './filter' import { createDrizzleQuery, getColumnTsName, @@ -44,6 +45,7 @@ export function createDefaultApiHandlers< const tableName = tableRelationalConfig.tsName const tableSchema = getTableFromSchema(schema, tableTsKey) const queryPayload = createDrizzleQuery(fields, tables, tableRelationalConfig, identifierColumn) + const toSQL = transformQueryObjectToSQL(tableRelationalConfig) const findOne: ApiHandlerFn = async ( args @@ -75,9 +77,11 @@ export function createDefaultApiHandlers< const orderType = args.orderType ?? 'asc' const orderBy = args.orderBy + const where = args.where ? toSQL(args.where) : undefined const result = await query.findMany({ ...queryPayload, + where, limit: args.limit, offset: args.offset, orderBy: orderBy diff --git a/packages/react/src/core/collection.spec.ts b/packages/react/src/core/collection.spec.ts new file mode 100644 index 00000000..6300f054 --- /dev/null +++ b/packages/react/src/core/collection.spec.ts @@ -0,0 +1,32 @@ +import { assertType, test } from 'vitest' + +import type { VaryCollectionFields } from './__mocks__/vary-collection-server-config' +import type { InferColumnsType } from './collection' + +test('assertType: InferColumnWithTypes', () => { + assertType>({ integer: 'PgInteger' }) + assertType>({ smallint: 'PgSmallInt' }) + assertType>({ serial: 'PgSerial' }) + assertType>({ smallserial: 'PgSmallSerial' }) + assertType>({ bigserialNumber: 'PgBigSerial53' }) + assertType>({ boolean: 'PgBoolean' }) + assertType>({ text: 'PgText' }) + assertType>({ textEnum: 'PgText' }) + assertType>({ varchar: 'PgVarchar' }) + assertType>({ char: 'PgChar' }) + assertType>({ real: 'PgReal' }) + assertType>({ doublePrecision: 'PgDoublePrecision' }) + assertType>({ json: 'PgJson' }) + assertType>({ jsonType: 'PgJson' }) + assertType>({ jsonb: 'PgJsonb' }) + assertType>({ jsonbType: 'PgJsonb' }) + assertType>({ time: 'PgTime' }) + assertType>({ timestamp: 'PgTimestamp' }) + assertType>({ date: 'PgDateString' }) + assertType>({ arrayOfText: 'PgArray' }) + assertType>({ arrayOfNumbers: 'PgArray' }) + assertType>({ arrayOfBooleans: 'PgArray' }) + assertType>({ arrayOfDates: 'PgArray' }) + assertType>({ arrayOfTimes: 'PgArray' }) + assertType>({ arrayOfEnums: 'PgArray' }) +}) diff --git a/packages/react/src/core/collection.ts b/packages/react/src/core/collection.ts index e70d27d4..c703e422 100644 --- a/packages/react/src/core/collection.ts +++ b/packages/react/src/core/collection.ts @@ -26,6 +26,7 @@ import { fieldsToZodObject, type FieldsWithFieldName, } from './field' +import type { WhereExpression } from './filter' import { type GetTableByTableTsName, type ToZodObject } from './utils' type SimplifyConditionalExcept = Simplify> @@ -303,6 +304,17 @@ export type InferFields = SimplifyConditionalExcep never > & { __pk: string | number; __id: string | number } +type _InferFieldsColumnType = TColumn extends { + columnTsName: infer C extends string + column: { columnType: infer D } +} + ? Record + : _InferFieldsColumnType> + +export type InferColumnsType = _InferFieldsColumnType< + TFields[keyof TFields]['_'] +> + export interface ServerApiHandlerArgs< TContext extends AnyContext = AnyContext, TFields extends Fields = Fields, @@ -321,7 +333,7 @@ export type ApiArgs< : TMethod extends typeof ApiDefaultMethod.FIND_ONE ? ServerApiHandlerArgs & ApiFindOneArgs : TMethod extends typeof ApiDefaultMethod.FIND_MANY - ? ServerApiHandlerArgs & ApiFindManyArgs + ? ServerApiHandlerArgs & ApiFindManyArgs : TMethod extends typeof ApiDefaultMethod.UPDATE ? ServerApiHandlerArgs & ApiUpdateArgs : TMethod extends typeof ApiDefaultMethod.DELETE @@ -348,11 +360,12 @@ export type ApiFindOneArgs = { id: string | number } -export type ApiFindManyArgs = { +export type ApiFindManyArgs = { limit?: number offset?: number orderBy?: string orderType?: 'asc' | 'desc' + where?: WhereExpression } export type ApiCreateArgs = { @@ -383,7 +396,7 @@ export type ClientApiArgs< : TMethod extends typeof ApiDefaultMethod.FIND_ONE ? ApiFindOneArgs : TMethod extends typeof ApiDefaultMethod.FIND_MANY - ? ApiFindManyArgs + ? ApiFindManyArgs : TMethod extends typeof ApiDefaultMethod.UPDATE ? ApiUpdateArgs : TMethod extends typeof ApiDefaultMethod.DELETE @@ -599,6 +612,7 @@ export type ConvertCollectionDefaultApiToApiRouteSchema< offset?: number orderBy?: string orderType?: 'asc' | 'desc' + where?: WhereExpression }> responses: { 200: ConditionalAnyOutput>> @@ -752,6 +766,7 @@ export function getDefaultCollectionAdminApiRouter< offset: z.number().optional(), orderBy: z.string().optional(), orderType: z.enum(['asc', 'desc']).optional(), + where: z.object().optional(), }), responses: { 200: response, @@ -767,6 +782,7 @@ export function getDefaultCollectionAdminApiRouter< offset: args.query.offset, orderBy: args.query.orderBy, orderType: args.query.orderType, + where: args.query.where as any, }) return { status: 200, body: response } } diff --git a/packages/react/src/core/filter.spec.ts b/packages/react/src/core/filter.spec.ts new file mode 100644 index 00000000..73bb8e03 --- /dev/null +++ b/packages/react/src/core/filter.spec.ts @@ -0,0 +1,384 @@ +import { + and, + arrayContained, + arrayContains, + arrayOverlaps, + between, + createTableRelationsHelpers, + eq, + extractTablesRelationalConfig, + gt, + gte, + ilike, + inArray, + like, + lt, + lte, + ne, + notBetween, + notIlike, + notInArray, + or, +} from 'drizzle-orm' +import { assertType, describe, expect, it } from 'vitest' + +import { schema, type VaryCollectionFields } from './__mocks__/vary-collection-server-config' +import { transformQueryObjectToSQL, type WhereExpression } from './filter' + +type VaryCollectionWhereExpr = WhereExpression + +describe('assertType: WhereExpression', () => { + it('For numeric fields, it should support CompareOperators, NullOperators, and ListOperator', () => { + // CompareOperators + assertType({ + smallint: { $gt: 1 }, + integer: { $gte: 1 }, + serial: { $lt: 2 }, + smallserial: { $lte: 2 }, + bigserialNumber: { $between: [1, 2] }, + real: { $notBetween: [1, 2] }, + doublePrecision: { $gt: 1 }, + }) + + // NullOperators + assertType({ + smallint: '$isNotNull', + integer: '$isNull', + serial: '$isNotNull', + smallserial: '$isNull', + bigserialNumber: '$isNotNull', + real: '$isNull', + doublePrecision: '$isNotNull', + }) + + // ListOperator + assertType({ + smallint: { + $inArray: [1, 2, 3], + }, + integer: { + $notInArray: [1, 2, 3], + }, + serial: { + $inArray: [1, 2, 3], + }, + smallserial: { + $notInArray: [1, 2, 3], + }, + bigserialNumber: { + $inArray: [1, 2, 3], + }, + real: { + $notInArray: [1, 2, 3], + }, + doublePrecision: { + $inArray: [1, 2, 3], + }, + }) + }) + + it('For string fields, it should support StringOperators, NullOperators, EqualityOperator, ListOperator', () => { + // StringOperators + assertType({ + text: { $like: '%' }, + textEnum: { $ilike: '%' }, + varchar: { $notIlike: '%' }, + char: { $like: '%' }, + }) + + // NullOperators + assertType({ + text: '$isNotNull', + textEnum: '$isNull', + varchar: '$isNotNull', + char: '$isNull', + }) + + // EqualityOperator + assertType({ + text: { $eq: 'text' }, + textEnum: { $eq: 'textEnum' }, + varchar: { $eq: 'varchar' }, + char: { $eq: 'char' }, + }) + + // ListOperator + assertType({ + text: { $inArray: ['text', 'textEnum', 'varchar', 'char'] }, + textEnum: { $notInArray: ['text', 'textEnum', 'varchar', 'char'] }, + varchar: { $inArray: ['text', 'textEnum', 'varchar', 'char'] }, + char: { $notInArray: ['text', 'textEnum', 'varchar', 'char'] }, + }) + }) + + it('For boolean fields, it should support NullOperators, EqualityOperator', () => { + // EqualityOperator + assertType({ + boolean: { $eq: true }, + }) + assertType({ + boolean: { $ne: false }, + }) + + // NullOperators + assertType({ + boolean: '$isNotNull', + }) + assertType({ + boolean: '$isNull', + }) + }) + + it('For JSON, JSONB, and Array fields, it should support ArrayOperators, NullOperators, and ListOperator', () => { + // ArrayOperators + assertType({ + json: { $arrayContained: [1, '2'] }, + jsonb: { $arrayContains: [2, true] }, + jsonType: { $arrayOverlaps: [3, 5, null] }, + jsonbType: { $arrayContained: [false, 3] }, + arrayOfBooleans: { $arrayContained: [1, '2'] }, + arrayOfDates: { $arrayContains: [2, true] }, + arrayOfEnums: { $arrayOverlaps: [3, 5, null] }, + arrayOfNumbers: { $arrayContained: [1, '2'] }, + arrayOfText: { $arrayContains: [2, true] }, + arrayOfTimes: { $arrayOverlaps: [3, 5, null] }, + }) + + // NullOperators + assertType({ + json: '$isNull', + jsonb: '$isNotNull', + jsonType: '$isNull', + jsonbType: '$isNotNull', + arrayOfBooleans: '$isNull', + arrayOfDates: '$isNotNull', + arrayOfEnums: '$isNull', + arrayOfNumbers: '$isNull', + arrayOfText: '$isNotNull', + arrayOfTimes: '$isNull', + }) + + // ListOperator + assertType({ + json: { $inArray: [1, false] }, + jsonb: { $notInArray: [0, true] }, + jsonType: { $inArray: [1, false] }, + jsonbType: { $notInArray: [0, true] }, + arrayOfBooleans: { $inArray: [1, false] }, + arrayOfDates: { $notInArray: [0, true] }, + arrayOfEnums: { $inArray: [1, false] }, + arrayOfNumbers: { $notInArray: [1, false] }, + arrayOfText: { $inArray: [0, true] }, + arrayOfTimes: { $notInArray: [1, false] }, + }) + }) + + it('For date, time, datetime fields, it should support CompareOperators, NullOperators, and ListOperator', () => { + // CompareOperators + assertType({ + time: { $between: [new Date(), new Date()] }, + timestamp: { $gt: new Date() }, + date: { $gte: new Date() }, + }) + + // NullOperators + assertType({ + time: { $eq: new Date() }, + timestamp: { $ne: new Date() }, + date: { $eq: new Date() }, + }) + + // ListOperator + assertType({ + time: { $inArray: [new Date()] }, + timestamp: { $notInArray: [new Date()] }, + date: { $inArray: [new Date()] }, + }) + }) +}) + +const tableRelationalConfig = extractTablesRelationalConfig(schema, createTableRelationsHelpers) + +describe('transformQueryObjectToSQL', () => { + const varyTable = tableRelationalConfig.tables['vary'] + const toSQL = transformQueryObjectToSQL(varyTable) + + it('should map $eq expression correctly', () => { + expect(toSQL({ integer: { $eq: 1 } })).toStrictEqual(eq(schema.vary.integer, 1)) + }) + it('should map $ne expression correctly', () => { + expect(toSQL({ integer: { $ne: 1 } })).toStrictEqual(ne(schema.vary.integer, 1)) + }) + it('should map $gt expression correctly', () => { + expect(toSQL({ integer: { $gt: 1 } })).toStrictEqual(gt(schema.vary.integer, 1)) + }) + it('should map $gte expression correctly', () => { + expect(toSQL({ integer: { $gte: 1 } })).toStrictEqual(gte(schema.vary.integer, 1)) + }) + it('should map $lt expression correctly', () => { + expect(toSQL({ integer: { $lt: 1 } })).toStrictEqual(lt(schema.vary.integer, 1)) + }) + it('should map $lte expression correctly', () => { + expect(toSQL({ integer: { $lte: 1 } })).toStrictEqual(lte(schema.vary.integer, 1)) + }) + it('should map $inArray expression correctly', () => { + expect(toSQL({ integer: { $inArray: [1, 2] } })).toStrictEqual( + inArray(schema.vary.integer, [1, 2]) + ) + }) + it('should map $notInArray expression correctly', () => { + expect(toSQL({ integer: { $notInArray: [1, 2] } })).toStrictEqual( + notInArray(schema.vary.integer, [1, 2]) + ) + }) + it('should map $between expression correctly', () => { + expect(toSQL({ integer: { $between: [1, 3] } })).toStrictEqual( + between(schema.vary.integer, 1, 3) + ) + }) + it('should map $notBetween expression correctly', () => { + expect(toSQL({ integer: { $notBetween: [1, 3] } })).toStrictEqual( + notBetween(schema.vary.integer, 1, 3) + ) + }) + it('should map $like expression correctly', () => { + expect(toSQL({ varchar: { $like: '%a%' } })).toStrictEqual(like(schema.vary.varchar, '%a%')) + }) + it('should map $ilike expression correctly', () => { + expect(toSQL({ varchar: { $ilike: '%a%' } })).toStrictEqual(ilike(schema.vary.varchar, '%a%')) + }) + it('should map $notIlike expression correctly', () => { + expect(toSQL({ varchar: { $notIlike: '%a%' } })).toStrictEqual( + notIlike(schema.vary.varchar, '%a%') + ) + }) + it('should map $arrayContains expression correctly', () => { + expect(toSQL({ arrayOfText: { $arrayContains: ['a'] } })).toStrictEqual( + arrayContains(schema.vary.arrayOfText, ['a']) + ) + }) + it('should map $arrayContained expression correctly', () => { + expect(toSQL({ arrayOfText: { $arrayContained: ['a'] } })).toStrictEqual( + arrayContained(schema.vary.arrayOfText, ['a']) + ) + }) + it('should map $arrayOverlaps expression correctly', () => { + expect(toSQL({ arrayOfText: { $arrayOverlaps: ['a'] } })).toStrictEqual( + arrayOverlaps(schema.vary.arrayOfText, ['a']) + ) + }) + + it('should map multiple fields object with $and conjuncture', () => { + expect( + toSQL({ + arrayOfText: { $arrayOverlaps: ['a'] }, + varchar: { $like: '%a%b' }, + integer: { $lte: 1000 }, + }) + ).toStrictEqual( + and( + arrayOverlaps(schema.vary.arrayOfText, ['a']), + like(schema.vary.varchar, '%a%b'), + lte(schema.vary.integer, 1000) + ) + ) + }) + + it('should map $or operation correctly', () => { + expect( + toSQL({ + $or: [ + { real: { $gte: 30 } }, + { text: { $like: '%A%' } }, + { smallint: { $inArray: [1, 2, 3, 4] } }, + ], + }) + ).toStrictEqual( + or( + gte(schema.vary.real, 30), + like(schema.vary.text, '%A%'), + inArray(schema.vary.smallint, [1, 2, 3, 4]) + ) + ) + }) + + it('should map nested query correctly', () => { + expect( + toSQL({ + $or: [ + { real: { $gte: 30 } }, + { text: { $like: '%A%' } }, + { smallint: { $inArray: [1, 2, 3, 4] } }, + ], + boolean: { $eq: true }, + }) + ).toStrictEqual( + and( + or( + gte(schema.vary.real, 30), + like(schema.vary.text, '%A%'), + inArray(schema.vary.smallint, [1, 2, 3, 4]) + ), + eq(schema.vary.boolean, true) + ) + ) + + expect( + toSQL({ + $or: [ + { real: { $gte: 30 } }, + { + $and: [{ text: { $like: '%A%' } }, { smallint: { $inArray: [1, 2, 3, 4] } }], + }, + ], + boolean: { $eq: true }, + }) + ).toStrictEqual( + and( + or( + gte(schema.vary.real, 30), + and(like(schema.vary.text, '%A%'), inArray(schema.vary.smallint, [1, 2, 3, 4])) + ), + eq(schema.vary.boolean, true) + ) + ) + + expect( + toSQL({ + $or: [ + { text: { $like: '%A%' } }, + { $or: [{ smallint: { $inArray: [4, 3, 2, 1] } }, { real: { $gte: 30 } }] }, + ], + boolean: { $eq: true }, + }) + ).toStrictEqual( + and( + or( + like(schema.vary.text, '%A%'), + or(inArray(schema.vary.smallint, [4, 3, 2, 1]), gte(schema.vary.real, 30)) + ), + eq(schema.vary.boolean, true) + ) + ) + + expect( + toSQL({ + $or: [ + { + text: { $like: '%A%' }, + $or: [{ smallint: { $inArray: [9, 8, 7, 6] } }, { real: { $gte: 30 } }], + }, + { boolean: { $eq: true } }, + ], + }) + ).toStrictEqual( + or( + and( + like(schema.vary.text, '%A%'), + or(inArray(schema.vary.smallint, [9, 8, 7, 6]), gte(schema.vary.real, 30)) + ), + eq(schema.vary.boolean, true) + ) + ) + }) +}) diff --git a/packages/react/src/core/filter.ts b/packages/react/src/core/filter.ts new file mode 100644 index 00000000..a4f7748f --- /dev/null +++ b/packages/react/src/core/filter.ts @@ -0,0 +1,346 @@ +import { + and, + arrayContained, + arrayContains, + arrayOverlaps, + between, + eq, + gt, + gte, + ilike, + inArray, + isNotNull, + isNull, + like, + lt, + lte, + ne, + not, + notBetween, + notIlike, + notInArray, + or, + type SQL, + type TableRelationalConfig, +} from 'drizzle-orm' +import { isObjectType } from 'remeda' +import type { IfAny } from 'type-fest' + +import type { AnyFields } from '.' +import type { InferColumnsType } from './collection' +import type { FieldsWithFieldName } from './field' + +/* === Primitive Operators === */ + +type eqOperator = { $eq: Operand } +const isEqOperator = (op: PrimitiveOperators): op is eqOperator => + typeof op === 'object' && '$eq' in op + +type neOperator = { $ne: Operand } +const isNeOperator = (op: PrimitiveOperators): op is neOperator => + typeof op === 'object' && '$ne' in op + +type gtOperator = { $gt: Operand } +const isGtOperator = (op: PrimitiveOperators): op is gtOperator => + typeof op === 'object' && '$gt' in op + +type gteOperator = { $gte: Operand } +const isGteOperator = (op: PrimitiveOperators): op is gteOperator => + typeof op === 'object' && '$gte' in op + +type ltOperator = { $lt: Operand } +const isLtOperator = (op: PrimitiveOperators): op is ltOperator => + typeof op === 'object' && '$lt' in op + +type lteOperator = { $lte: Operand } +const isLteOperator = (op: PrimitiveOperators): op is lteOperator => + typeof op === 'object' && '$lte' in op + +type inArrayOperator = { $inArray: Operand } +const isInArrayOperator = (op: PrimitiveOperators): op is inArrayOperator => + typeof op === 'object' && '$inArray' in op + +type notInArrayOperator = { $notInArray: Operand } +const isNotInArrayOperator = (op: PrimitiveOperators): op is notInArrayOperator => + typeof op === 'object' && '$notInArray' in op + +type betweenOperator = { + $between: Operand +} +const isBetweenOperator = (op: PrimitiveOperators): op is betweenOperator => + typeof op === 'object' && '$between' in op + +type notBetweenOperator = { + $notBetween: Operand +} +const isNotBetweenOperator = (op: PrimitiveOperators): op is notBetweenOperator => + typeof op === 'object' && '$notBetween' in op + +type likeOperator = { $like: string } +const isLikeOperator = (op: PrimitiveOperators): op is likeOperator => + typeof op === 'object' && '$like' in op + +type ilikeOperator = { $ilike: string } +const isIlikeOperator = (op: PrimitiveOperators): op is ilikeOperator => + typeof op === 'object' && '$ilike' in op + +type notIlikeOperator = { $notIlike: string } +const isNotIlikeOperator = (op: PrimitiveOperators): op is notIlikeOperator => + typeof op === 'object' && '$notIlike' in op + +type arrayContainsOperator = { $arrayContains: Operand } +const isArrayContainsOperator = (op: PrimitiveOperators): op is arrayContainsOperator => + typeof op === 'object' && '$arrayContains' in op + +type arrayContainedOperator = { $arrayContained: Operand } +const isArrayContainedOperator = (op: PrimitiveOperators): op is arrayContainedOperator => + typeof op === 'object' && '$arrayContained' in op + +type arrayOverlapsOperator = { $arrayOverlaps: Operand } + +const isArrayOverlapsOperator = (op: PrimitiveOperators): op is arrayOverlapsOperator => + typeof op === 'object' && '$arrayOverlaps' in op + +type isNullOperator = '$isNull' +type isNotNullOperator = '$isNotNull' +type PrimitiveOperators = + | eqOperator + | neOperator + | gtOperator + | gteOperator + | ltOperator + | lteOperator + | isNullOperator + | isNotNullOperator + | inArrayOperator + | notInArrayOperator + | betweenOperator + | notBetweenOperator + | likeOperator + | ilikeOperator + | notIlikeOperator + | arrayContainsOperator + | arrayContainedOperator + | arrayOverlapsOperator + +const isOperators = (obj: unknown): obj is PrimitiveOperators => { + if (!obj) return false + + switch (typeof obj) { + case 'object': { + const [command] = Object.keys(obj) + + return command.startsWith('$') && !['$and', '$or', '$not'].includes(command) + } + + case 'string': + return obj === '$isNull' || obj === '$isNotNull' + + default: + return false + } +} + +/* === Gropped Operators === */ +type EqualityOperator = eqOperator | neOperator + +type CompareOperators = + | EqualityOperator + | gtOperator + | gteOperator + | ltOperator + | lteOperator + | betweenOperator<[Operand, Operand]> + | notBetweenOperator<[Operand, Operand]> + +type NullOperators = isNotNullOperator | isNullOperator + +type ListOperator = + | inArrayOperator + | notInArrayOperator + +type ArrayOperators = + | arrayContainsOperator + | arrayContainedOperator + | arrayOverlapsOperator + +/* === Operations available to column type === */ +type StringOperators = likeOperator | ilikeOperator | notIlikeOperator + +type NumericOperations = NullOperators | CompareOperators | ListOperator +type StringOperations = + | NullOperators + | StringOperators + | EqualityOperator + | ListOperator +type BooleanOperations = NullOperators | EqualityOperator + +type ArrayableOperations = NullOperators | ArrayOperators | ListOperator + +type DateTimeOperations = + | NullOperators + | EqualityOperator + | CompareOperators + | ListOperator + +/* === Groupped PG-columns type */ +type PgNumericType = + | 'PgInteger' + | 'PgNumeric' + | 'PgNumericNumber' + | 'PgNumericBigInt' + | 'PgReal' + | 'PgSerial' + | 'PgSmallInt' + | 'PgSmallSerial' + | 'PgBigInt53' + | 'PgBigInt64' + | 'PgBigSerial53' + | 'PgBigSerial64' + | 'PgDoublePrecision' + +type PgBooleanType = 'PgBoolean' + +type PgStringType = 'PgUUID' | 'PgChar' | 'PgText' | 'PgVarchar' + +type PgPossibleArrayType = 'PgArray' | 'PgJson' | 'PgJsonb' + +type PgDateTimeType = 'PgDate' | 'PgDateString' | 'PgTime' | 'PgTimestamp' | 'PgTimestampString' + +/** + * TODO: Uncategorized types + * + * PgCidr + * PgCustomColumn + * PgEnumObjectColumn + * PgEnumColumn + * PgInet + * PgInterval + * PgLine + * PgLineABC + * PgMacaddr + * PgMacaddr8 + * PgPointTuple + * PgPointObject + * PgGeometry + * PgGeometryObject + * PgBinaryVector + * PgHalfVector + * PgSparseVector + * PgVector + */ + +type OperationForType = TColumnType extends PgNumericType + ? NumericOperations + : TColumnType extends PgStringType + ? StringOperations + : TColumnType extends PgBooleanType + ? BooleanOperations + : TColumnType extends PgPossibleArrayType + ? ArrayableOperations + : TColumnType extends PgDateTimeType + ? DateTimeOperations + : any + +type ColumnExpression< + TFields extends AnyFields, + ColumnTypes extends InferColumnsType = InferColumnsType, +> = + ColumnTypes extends Record + ? Record> + : never + +export type AndExpression = { + $and: WhereExpression[] +} +const isAndExpression = (op: unknown): op is AndExpression => + typeof op === 'object' && !!op && '$and' in op + +export type OrExpression = { + $or: WhereExpression[] +} +const isOrExpression = (op: unknown): op is OrExpression => + typeof op === 'object' && !!op && '$or' in op + +export type NotExpression = { + $not: WhereExpression +} +const isNotExpression = (op: unknown): op is NotExpression => + typeof op === 'object' && !!op && '$not' in op +type BooleanExpressionOperations = + | AndExpression + | OrExpression + | NotExpression + +export type WhereExpression = + TFields extends FieldsWithFieldName + ? IfAny | ColumnExpression> + : never + +const operatorToSQL = + (table: TableRelationalConfig) => + (columnName: keyof typeof table.columns, verb: PrimitiveOperators): SQL => { + const column = table.columns[columnName] + if (!isObjectType(verb)) return verb === '$isNotNull' ? isNotNull(column) : isNull(column) + + switch (true) { + case isEqOperator(verb): + return eq(column, verb['$eq']) + case isNeOperator(verb): + return ne(column, verb['$ne']) + case isGtOperator(verb): + return gt(column, verb['$gt']) + case isGteOperator(verb): + return gte(column, verb['$gte']) + case isLtOperator(verb): + return lt(column, verb['$lt']) + case isLteOperator(verb): + return lte(column, verb['$lte']) + case isInArrayOperator(verb): + return inArray(column, verb['$inArray']) + case isNotInArrayOperator(verb): + return notInArray(column, verb['$notInArray']) + case isBetweenOperator(verb): + return between(column, ...verb['$between']) + case isNotBetweenOperator(verb): + return notBetween(column, ...verb['$notBetween']) + case isLikeOperator(verb): + return like(column, verb['$like']) + case isIlikeOperator(verb): + return ilike(column, verb['$ilike']) + case isNotIlikeOperator(verb): + return notIlike(column, verb['$notIlike']) + case isArrayContainsOperator(verb): + return arrayContains(column, verb['$arrayContains']) + case isArrayContainedOperator(verb): + return arrayContained(column, verb['$arrayContained']) + case isArrayOverlapsOperator(verb): + return arrayOverlaps(column, verb['$arrayOverlaps']) + default: + throw new Error(`Unknown verb: ${columnName}: ${JSON.stringify(verb)}`) + } + } +export const transformQueryObjectToSQL = ( + table: TableRelationalConfig +) => { + const toSQL = operatorToSQL(table) + const sqlize = (exp: WhereExpression): SQL | undefined => { + const sqls = Object.entries(exp).map(([columnOrOperator, operands]) => { + if (isOperators(operands)) return toSQL(columnOrOperator, operands) + const expr = { [columnOrOperator]: operands } + if (isAndExpression(expr)) return and(...expr['$and'].map(sqlize)) + if (isOrExpression(expr)) return or(...expr['$or'].map(sqlize)) + if (isNotExpression(expr)) { + const sqlized = sqlize(expr['$not']) + if (!sqlized) throw new Error(`${expr['$not']} is invalid`) + + return not(sqlized) + } + throw new Error(`Unknown expr: ${expr}`) + }) + + if (sqls.length > 1) return and(...sqls) + return sqls[0] + } + return sqlize +} diff --git a/packages/react/src/react/views/collections/list.tsx b/packages/react/src/react/views/collections/list.tsx index 1541d7f5..f8d103b7 100644 --- a/packages/react/src/react/views/collections/list.tsx +++ b/packages/react/src/react/views/collections/list.tsx @@ -39,6 +39,7 @@ export async function ListView(props: ListViewProps) { offset, orderBy, orderType, + where: undefined, // TODO: filter at front-end side }, }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 09a44c6b..96ccfff9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: '@genseki/rest': specifier: workspace:^ version: link:../../packages/rest + '@hookform/resolvers': + specifier: ^5.0.1 + version: 5.0.1(react-hook-form@7.56.3(react@19.1.0)) '@tailwindcss/postcss': specifier: ^4.1.7 version: 4.1.7 @@ -68,6 +71,9 @@ importers: '@tiptap/starter-kit': specifier: ^2.14.0 version: 2.14.0 + date-fns: + specifier: ^4.1.0 + version: 4.1.0 drizzle-orm: specifier: ^0.41.0 version: 0.41.0(@types/pg@8.15.2)(gel@2.1.0)(kysely@0.28.2)(pg@8.16.0) @@ -89,6 +95,9 @@ importers: react-dom: specifier: 19.1.0 version: 19.1.0(react@19.1.0) + react-hook-form: + specifier: ^7.56.3 + version: 7.56.3(react@19.1.0) tailwindcss: specifier: ^4.1.7 version: 4.1.7 @@ -3250,6 +3259,9 @@ packages: dataloader@1.4.0: resolution: {integrity: sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw==} + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + debug@4.4.1: resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} engines: {node: '>=6.0'} @@ -9019,6 +9031,8 @@ snapshots: dataloader@1.4.0: {} + date-fns@4.1.0: {} + debug@4.4.1: dependencies: ms: 2.1.3