diff --git a/apps/playground/src/app/app.component.ts b/apps/playground/src/app/app.component.ts index d587a130f..cb8d5b095 100644 --- a/apps/playground/src/app/app.component.ts +++ b/apps/playground/src/app/app.component.ts @@ -77,7 +77,7 @@ type Post = { type GetPostQueryArgs = { response: Post; pathParams: { - postId: string; + postId: number; }; }; @@ -198,38 +198,35 @@ export class DynCompComponent { plusOnePage = signal(1); currentPostId = signal(5); - myPost = getPost(E.withArgs(() => ({ pathParams: { postId: this.plusOnePage() as unknown as string } }))); + myPost = getPost(E.withArgs(() => ({ pathParams: { postId: this.plusOnePage() } }))); - posts = getPosts(E.withAutoRefresh({ signalChanges: [this.plusOnePage] })); - - // myPostList = E.createQueryStack( - // () => getPost(E.withArgs(() => ({ pathParams: { postId: `${this.currentPostId()}` } }))), - // { append: true, transform: E.transformArrayResponse }, - // ); + posts = getPosts(E.withAutoRefresh({ onSignalChanges: [this.plusOnePage] })); myPostList = E.createQueryStack({ queryCreator: getPost, - args: () => ({ pathParams: { postId: `${this.currentPostId()}` } }), // could return either one or an array of query args + dependencies: () => ({ myDep: this.plusOnePage() }), + args: ({ myDep }) => [ + { pathParams: { postId: this.currentPostId() * myDep } }, + { pathParams: { postId: this.currentPostId() * myDep + 1 } }, + { pathParams: { postId: this.currentPostId() * myDep + 2 } }, + ], transform: E.transformArrayResponse, append: true, + // features: [ + // E.withPolling({ interval: 5000 }), + // E.withSuccessHandling({ handler: (post) => console.log(post.title) }), + // ], }); - // postAndUserList = E.createQueryStack(() => [ - // getPost(E.withArgs(() => ({ pathParams: { postId: `${this.currentPostId()}` } }))), - // getUser(E.withArgs(() => ({ pathParams: { playerId: `${this.currentPostId()}` } }))), - // ]); - paged = E.createPagedQueryStack({ queryCreator: getPost, - args: (page) => ({ pathParams: { postId: `${page + this.plusOnePage()}` } }), - responseNormalizer: (response) => ({ - items: [response], - totalHits: 10, - totalPages: 10, - currentPage: +response.id, - itemsPerPage: 1, - }), - initialPage: 6, + args: (page) => ({ pathParams: { postId: page + this.plusOnePage() } }), + responseNormalizer: E.fakePaginationAdapter(), + initialPage: 1, + // features: [ + // E.withPolling({ interval: 5000 }), + // E.withSuccessHandling({ handler: (post) => console.log(post.title) }), + // ], }); // id = computed(() => this.myPostQuery1.response()?.id); @@ -246,7 +243,7 @@ export class DynCompComponent { } execAll() { - this.paged.execute(); + this.paged.execute({ allowCache: true }); } // login() { diff --git a/libs/query/src/lib/experimental/paged-query-stack.ts b/libs/query/src/lib/experimental/paged-query-stack.ts index a0eb2b748..bfdfc1b64 100644 --- a/libs/query/src/lib/experimental/paged-query-stack.ts +++ b/libs/query/src/lib/experimental/paged-query-stack.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import { HttpErrorResponse } from '@angular/common/http'; import { computed, effect, isDevMode, Signal, signal, untracked } from '@angular/core'; import { @@ -10,10 +11,11 @@ import { import { AnyQuery, Query, QueryArgs, RequestArgs, ResponseType } from './query'; import { AnyQueryCreator, QueryArgsOf, QueryCreator } from './query-creator'; import { - pagedQueryNextPageCalledWithoutPreviousPage, - pagedQueryPageBiggerThanTotalPages, - pagedQueryPreviousPageCalledButAlreadyAtFirstPage, + pagedQueryStackNextPageCalledWithoutPreviousPage, + pagedQueryStackPageBiggerThanTotalPages, + pagedQueryStackPreviousPageCalledButAlreadyAtFirstPage, } from './query-errors'; +import { QueryFeature } from './query-features'; import { createQueryStack, transformArrayResponse } from './query-stack'; import { shouldRetryRequest } from './query-utils'; @@ -68,6 +70,20 @@ export const contentfulGqlLikePaginationAdapter = (response: ContentfulGqlLik return pagination; }; +export const fakePaginationAdapter = (totalHits = 10) => { + return (response: T) => { + const pagination: NormalizedPagination = { + items: [response], + totalPages: totalHits, + currentPage: 1, + itemsPerPage: 1, + totalHits, + }; + + return pagination; + }; +}; + export type CreatePagedQueryStackOptions = { /** * The normalizer function that will be used to normalize the response to a format that the paged query can understand. @@ -77,6 +93,7 @@ export type CreatePagedQueryStackOptions = { * - `ggLikePaginationAdapter` * - `dynLikePaginationAdapter` * - `contentfulGqlLikePaginationAdapter` + * - `fakePaginationAdapter` (for testing purposes) */ responseNormalizer: (response: ResponseType) => NormalizedPagination>; @@ -95,6 +112,21 @@ export type CreatePagedQueryStackOptions = { */ args: (page: number) => RequestArgs; + /** + * The features that should be used for all queries in the stack. + * + * @throws If the `withArgs` feature is used. This feature is internally used. + * + * @example + * // Due to limitations in TypeScript, you have to manually add the needed generic types if needed. + * features: [E.withSuccessHandling({ handler: (post) => console.log(post.title) })] + * + * @example + * // If typings are not needed, you can use the feature without generics. + * features: [E.withPolling({ interval: 5000 })] + */ + features?: QueryFeature[]; + /** * The page to start this paged query from. * @default 1 @@ -131,6 +163,8 @@ export type PagedQueryStackExecuteOptions = { export type PagedQueryStackDirection = 'next' | 'previous'; +export type AnyPagedQueryStack = PagedQueryStack; + export type PagedQueryStack = { /** * The current pagination state of the paged query. @@ -214,7 +248,7 @@ export type PagedQueryStack = { export const createPagedQueryStack = >( options: CreatePagedQueryStackOptions, ) => { - const { responseNormalizer, queryCreator } = options; + const { responseNormalizer, queryCreator, features } = options; const currentPageArgs = signal | null>(null); const initialPage = signal(options.initialPage ?? 1); @@ -228,6 +262,7 @@ export const createPagedQueryStack = currentPageArgs(), queryCreator, append: true, + features, appendFn: (oldQueries, newQueries) => { const newQuery = newQueries[0]; const dir = pageDirection(); @@ -270,7 +305,7 @@ export const createPagedQueryStack = currentPagination.totalPages) { if (isDevMode()) { - throw pagedQueryPageBiggerThanTotalPages(page, currentPagination.totalPages); + throw pagedQueryStackPageBiggerThanTotalPages(page, currentPagination.totalPages); } return; diff --git a/libs/query/src/lib/experimental/query-creator.ts b/libs/query/src/lib/experimental/query-creator.ts index 57671a14c..d481888d0 100644 --- a/libs/query/src/lib/experimental/query-creator.ts +++ b/libs/query/src/lib/experimental/query-creator.ts @@ -4,7 +4,7 @@ import { QueryClientConfig } from './query-client-config'; import { QueryFeature } from './query-features'; export type RouteType = - PathParamsType extends { [key: string]: string } ? (args: TArgs['pathParams']) => RouteString : RouteString; + PathParamsType extends { [key: string]: unknown } ? (args: TArgs['pathParams']) => RouteString : RouteString; export type RouteString = `/${string}`; diff --git a/libs/query/src/lib/experimental/query-errors.ts b/libs/query/src/lib/experimental/query-errors.ts index 065c056c5..786fa5439 100644 --- a/libs/query/src/lib/experimental/query-errors.ts +++ b/libs/query/src/lib/experimental/query-errors.ts @@ -30,10 +30,13 @@ export const enum RuntimeErrorCode { UNCACHEABLE_REQUEST_HAS_CACHE_KEY_PARAM = 300, UNCACHEABLE_REQUEST_HAS_ALLOW_CACHE_PARAM = 301, - // Paged Query - PAGED_QUERY_PAGE_BIGGER_THAN_TOTAL_PAGES = 400, - PAGED_QUERY_NEXT_PAGE_CALLED_WITHOUT_PREVIOUS_PAGE = 401, - PAGED_QUERY_PREVIOUS_PAGE_CALLED_BUT_ALREADY_AT_FIRST_PAGE = 402, + // Paged Query Stack + PAGED_QUERY_STACK_PAGE_BIGGER_THAN_TOTAL_PAGES = 400, + PAGED_QUERY_STACK_NEXT_PAGE_CALLED_WITHOUT_PREVIOUS_PAGE = 401, + PAGED_QUERY_STACK_PREVIOUS_PAGE_CALLED_BUT_ALREADY_AT_FIRST_PAGE = 402, + + // Query Stack + QUERY_STACK_WITH_ARGS_USED = 500, } export const queryFeatureUsedMultipleTimes = (type: QueryFeatureType) => { @@ -170,23 +173,30 @@ export const uncacheableRequestHasAllowCacheParam = () => { ); }; -export const pagedQueryPageBiggerThanTotalPages = (page: number, totalPages: number) => { +export const pagedQueryStackPageBiggerThanTotalPages = (page: number, totalPages: number) => { return new RuntimeError( - RuntimeErrorCode.PAGED_QUERY_PAGE_BIGGER_THAN_TOTAL_PAGES, + RuntimeErrorCode.PAGED_QUERY_STACK_PAGE_BIGGER_THAN_TOTAL_PAGES, `The page "${page}" is bigger than the total pages "${totalPages}".`, ); }; -export const pagedQueryNextPageCalledWithoutPreviousPage = () => { +export const pagedQueryStackNextPageCalledWithoutPreviousPage = () => { return new RuntimeError( - RuntimeErrorCode.PAGED_QUERY_NEXT_PAGE_CALLED_WITHOUT_PREVIOUS_PAGE, + RuntimeErrorCode.PAGED_QUERY_STACK_NEXT_PAGE_CALLED_WITHOUT_PREVIOUS_PAGE, `fetchNextPage() has been called but the current page is not yet loaded. Please call it after the previous page has been loaded.`, ); }; -export const pagedQueryPreviousPageCalledButAlreadyAtFirstPage = () => { +export const pagedQueryStackPreviousPageCalledButAlreadyAtFirstPage = () => { return new RuntimeError( - RuntimeErrorCode.PAGED_QUERY_PREVIOUS_PAGE_CALLED_BUT_ALREADY_AT_FIRST_PAGE, + RuntimeErrorCode.PAGED_QUERY_STACK_PREVIOUS_PAGE_CALLED_BUT_ALREADY_AT_FIRST_PAGE, `fetchPreviousPage() has been called but the current page is already the first page.`, ); }; + +export const queryStackWithArgsUsed = () => { + return new RuntimeError( + RuntimeErrorCode.QUERY_STACK_WITH_ARGS_USED, + `withArgs() has been used in a query stack or a paged query stack. This is not supported.`, + ); +}; diff --git a/libs/query/src/lib/experimental/query-features.ts b/libs/query/src/lib/experimental/query-features.ts index 700c925bc..fbe1b2ad1 100644 --- a/libs/query/src/lib/experimental/query-features.ts +++ b/libs/query/src/lib/experimental/query-features.ts @@ -1,5 +1,5 @@ import { HttpErrorResponse, HttpEvent } from '@angular/common/http'; -import { computed, effect, Signal, untracked } from '@angular/core'; +import { computed, Signal, untracked } from '@angular/core'; import { QueryArgs, RequestArgs, ResponseType } from './query'; import { CreateQueryCreatorOptions, InternalCreateQueryCreatorOptions, QueryConfig } from './query-creator'; import { QueryDependencies } from './query-dependencies'; @@ -155,7 +155,7 @@ export const withLogging = (options: WithLoggingFeature return createQueryFeature({ type: QueryFeatureType.WithLogging, fn: (context) => { - effect( + queryEffect( () => { const event = context.state.latestHttpEvent(); @@ -165,6 +165,7 @@ export const withLogging = (options: WithLoggingFeature options.logFn(event); }); }, + QUERY_EFFECT_ERROR_MESSAGE, { injector: context.deps.injector }, ); }, @@ -181,7 +182,7 @@ export const withErrorHandling = (options: WithErrorHan return createQueryFeature({ type: QueryFeatureType.WithErrorHandling, fn: (context) => { - effect( + queryEffect( () => { const error = context.state.error(); @@ -191,6 +192,7 @@ export const withErrorHandling = (options: WithErrorHan options.handler(error); }); }, + QUERY_EFFECT_ERROR_MESSAGE, { injector: context.deps.injector }, ); }, @@ -207,7 +209,7 @@ export const withSuccessHandling = (options: WithSucces return createQueryFeature({ type: QueryFeatureType.WithSuccessHandling, fn: (context) => { - effect( + queryEffect( () => { const response = context.state.response(); @@ -217,6 +219,7 @@ export const withSuccessHandling = (options: WithSucces options.handler(response as NonNullable>); }); }, + QUERY_EFFECT_ERROR_MESSAGE, { injector: context.deps.injector }, ); }, @@ -225,7 +228,7 @@ export const withSuccessHandling = (options: WithSucces export type WithAutoRefreshFeatureOptions = { /** The signals that should trigger a refresh of the query */ - signalChanges: Signal[]; + onSignalChanges: Signal[]; /** Whether to ignore the `onlyManualExecution` query config flag */ ignoreOnlyManualExecution?: boolean; @@ -250,7 +253,7 @@ export const withAutoRefresh = (options: WithAutoRefres queryEffect( () => { - for (const signal of options.signalChanges) { + for (const signal of options.onSignalChanges) { signal(); } diff --git a/libs/query/src/lib/experimental/query-repository.ts b/libs/query/src/lib/experimental/query-repository.ts index ec979d51f..f26793cdd 100644 --- a/libs/query/src/lib/experimental/query-repository.ts +++ b/libs/query/src/lib/experimental/query-repository.ts @@ -1,6 +1,5 @@ import { HttpClient } from '@angular/common/http'; import { DestroyRef, inject } from '@angular/core'; -import { buildQueryCacheKey, shouldCacheQuery } from '../query-client'; import { buildRoute } from '../request'; import { CreateHttpRequestClientOptions, HttpRequest, createHttpRequest } from './http-request'; import { QueryArgs, RequestArgs } from './query'; @@ -8,6 +7,7 @@ import { QueryClientConfig } from './query-client-config'; import { QueryMethod, RouteType } from './query-creator'; import { uncacheableRequestHasAllowCacheParam, uncacheableRequestHasCacheKeyParam } from './query-errors'; import { RunQueryExecuteOptions } from './query-execute-utils'; +import { buildQueryCacheKey, shouldCacheQuery } from './query-utils'; export type QueryRepositoryRequestOptions = { /** @@ -104,9 +104,7 @@ export const createQueryRepository = (config: QueryClientConfig): QueryRepositor body: args?.body, queryParams: args?.queryParams, pathParams: args?.pathParams, - - // TODO: remaining props - // headers: args?.headers, + headers: args?.headers, }); const previousKey = options.previousKey; diff --git a/libs/query/src/lib/experimental/query-stack.ts b/libs/query/src/lib/experimental/query-stack.ts index babf877fb..4c89f5e36 100644 --- a/libs/query/src/lib/experimental/query-stack.ts +++ b/libs/query/src/lib/experimental/query-stack.ts @@ -1,8 +1,10 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import { HttpErrorResponse } from '@angular/common/http'; import { computed, effect, inject, Injector, runInInjectionContext, Signal, signal, untracked } from '@angular/core'; import { AnyQuery, RequestArgs, ResponseType } from './query'; import { AnyQueryCreator, QueryArgsOf, RunQueryCreator } from './query-creator'; -import { withArgs } from './query-features'; +import { queryStackWithArgsUsed } from './query-errors'; +import { QueryFeature, QueryFeatureType, withArgs } from './query-features'; export type QueryStack = { /** Contains all queries in the stack. */ @@ -27,11 +29,11 @@ export type QueryStack = { clear: () => void; }; -// eslint-disable-next-line @typescript-eslint/no-explicit-any export type AnyQueryStack = QueryStack; export type CreateQueryStackOptions< TCreator extends AnyQueryCreator, + TDeps extends () => any, TTransform = (ResponseType> | null)[], > = { /** @@ -39,14 +41,53 @@ export type CreateQueryStackOptions< */ queryCreator: TCreator; + /** + * The dependencies that should trigger a new query stack creation. + * This function can be treated like a computed function. It reacts to signal changes. + * The return value of this function will be passed to the `args` function. + * + * This option can be ignored if `append` is false. + */ + dependencies?: TDeps; + + /** + * The features that should be used for all queries in the stack. + * + * @throws If the `withArgs` feature is used. This feature is internally used. + * + * @example + * // Due to limitations in TypeScript, you have to manually add the needed generic types if needed. + * features: [E.withSuccessHandling({ handler: (post) => console.log(post.title) })] + * + * @example + * // If typings are not needed, you can use the feature without generics. + * features: [E.withPolling({ interval: 5000 })] + */ + features?: QueryFeature[]; + /** * The arguments to create queries with. * This function can be treated like a computed function. It reacts to signal changes. * If a signal changes, a new query will be created (and appended to the existing ones if `append` is true). + * If this function returns `null`, the args will be ignored. + * If this function returns an array, multiple queries will be created. + * + * @example + * // One query + * () => ({ queryParams: { postId: myId() } }), + * + * @example + * // Multiple queries (these can of cause also be created using a loop) + * () => [ + * { queryParams: { postId: myId() } }, + * { queryParams: { postId: myOtherId() } }, + * ], * - * @example () => ({ queryParams: { postId: myId() } }), + * @example + * // One query with dependencies + * (deps) => ({ queryParams: { postId: myId(), limit: deps.limit } }), */ - args: () => RequestArgs> | null; + args: (deps: ReturnType) => RequestArgs> | RequestArgs>[] | null; /** * If true, new queries will be appended to the existing ones. Otherwise, existing queries will be destroyed. @@ -86,16 +127,19 @@ export const transformPaginatedResponse = , + TDeps extends () => any, TTransform = (ResponseType | null)[], >( - options: CreateQueryStackOptions, + options: CreateQueryStackOptions, ) => { type QueryType = RunQueryCreator; const { args, queryCreator, + dependencies, append, + features = [], appendFn = (oldQueries: QueryType[], newQueries: QueryType[]) => { const queries = [...oldQueries, ...newQueries]; const lastQuery = newQueries[newQueries.length - 1] ?? oldQueries[oldQueries.length - 1] ?? null; @@ -110,18 +154,35 @@ export const createQueryStack = < const queries = signal([]); const lastQuery = signal(null); + const hasWithArgsFeature = features.some((f) => f.type == QueryFeatureType.WithArgs); + + if (hasWithArgsFeature) { + throw queryStackWithArgsUsed(); + } + + effect(() => { + // Clear the stack if dependencies change + dependencies?.(); + + untracked(() => clear()); + }); + effect(() => { - const newArgs = args(); + const newArgs = args(dependencies?.()); if (newArgs === null) return; - const newQueries = runInInjectionContext(injector, () => [ - queryCreator( - withArgs(() => { - return newArgs; - }), - ) as QueryType, - ]); + const newArgsArray = Array.isArray(newArgs) ? newArgs : [newArgs]; + + const newQueries = runInInjectionContext(injector, () => + newArgsArray.map( + (newArgsEntry) => + queryCreator( + withArgs(() => newArgsEntry), + ...features, + ) as QueryType, + ), + ); untracked(() => { const oldQueries = queries(); diff --git a/libs/query/src/lib/experimental/query-utils.ts b/libs/query/src/lib/experimental/query-utils.ts index 8b173af55..59efdc1bb 100644 --- a/libs/query/src/lib/experimental/query-utils.ts +++ b/libs/query/src/lib/experimental/query-utils.ts @@ -2,7 +2,7 @@ import { HttpErrorResponse, HttpHeaders, HttpStatusCode } from '@angular/common/ import { computed, CreateEffectOptions, effect, isDevMode, Signal } from '@angular/core'; import { getActiveConsumer, setActiveConsumer } from '@angular/core/primitives/signals'; import { isSymfonyPagerfantaOutOfRangeError } from '../symfony'; -import { CreateQueryOptions, Query, QueryArgs, QuerySnapshot } from './query'; +import { CreateQueryOptions, Query, QueryArgs, QuerySnapshot, RequestArgs } from './query'; import { QueryMethod } from './query-creator'; import { queryFeatureUsedMultipleTimes, withArgsQueryFeatureMissingButRouteIsFunction } from './query-errors'; import { QueryExecute } from './query-execute'; @@ -277,3 +277,29 @@ export const normalizeQueryRepositoryKey = (key: Signal) => return k; }); + +export const shouldCacheQuery = (method: QueryMethod) => { + return method === 'GET' || method === 'OPTIONS' || method === 'HEAD'; +}; + +export const buildQueryCacheKey = (route: string, args: RequestArgs | undefined) => { + // const variables = JSON.stringify(args?.variables || {}) + // // replace all curly braces with empty string + // .replace(/{|}/g, '') + // // replace new lines and whitespaces with empty string + // .replace(/\s/g, ''); + + const seed = `${route}`; + + let hash = 0; + + for (const char of seed) { + hash = (Math.imul(31, hash) + char.charCodeAt(0)) << 0; + } + + // Force positive number hash. + // 2147483647 = equivalent of Integer.MAX_VALUE. + hash += 2147483647 + 1; + + return hash.toString(); +};