Skip to content

Commit e347ef5

Browse files
authored
Merge branch 'master' into fix/angular-multi-content-query-params-3102
2 parents 05c6881 + b111eb7 commit e347ef5

47 files changed

Lines changed: 3084 additions & 22 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/core/src/writers/split-tags-mode.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,47 @@ export async function writeSplitTagsMode({
8484
)
8585
: '../' + filename + '.schemas';
8686

87+
// In tags-split mode, each tag lives in its own subdirectory
88+
// (dirname/tag/tag.ext). Imports with a custom `importPath` (from the
89+
// `file` option in mutationInvalidates) are specified relative to the
90+
// output root (dirname) but the consuming file is one level deeper.
91+
// Resolve these paths so that the generated import is correct.
92+
const tagNames = new Set(tagEntries.map(([t]) => t));
93+
const serviceSuffix =
94+
OutputClient.ANGULAR === output.client ? '.service' : '';
95+
96+
const adjustedImports = imports.map((imp) => {
97+
if (!imp.importPath) return imp;
98+
99+
// Only adjust relative paths (./foo, ../bar). Package imports
100+
// like 'rxjs' or '@tanstack/react-query' must be left as-is.
101+
if (!imp.importPath.startsWith('.')) return imp;
102+
103+
const resolvedPath = path.resolve(dirname, imp.importPath);
104+
const targetBasename = path.basename(resolvedPath);
105+
106+
let targetFile: string;
107+
if (tagNames.has(targetBasename)) {
108+
// Target is a known tag directory. Use the real generated
109+
// filename which includes the Angular `.service` suffix when
110+
// applicable (e.g. dirname/health/health.service.ts).
111+
const tagFilename = targetBasename + serviceSuffix + extension;
112+
targetFile = path.join(resolvedPath, tagFilename);
113+
} else {
114+
targetFile = resolvedPath + extension;
115+
}
116+
117+
const adjustedPath = upath.getRelativeImportPath(
118+
importerPath,
119+
targetFile,
120+
);
121+
122+
return { ...imp, importPath: adjustedPath };
123+
});
124+
87125
const importsForBuilder = generateImportsForBuilder(
88126
output,
89-
imports,
127+
adjustedImports,
90128
relativeSchemasPath,
91129
);
92130

packages/query/src/mutation-generator.ts

Lines changed: 177 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,127 @@ const serializeTarget = (target: NormalizedTarget): string =>
4848
file: target.file ?? '',
4949
});
5050

51+
const HTTP_METHODS = [
52+
'get',
53+
'post',
54+
'put',
55+
'delete',
56+
'patch',
57+
'options',
58+
'head',
59+
'trace',
60+
];
61+
62+
interface OperationRouteInfo {
63+
route: string;
64+
/** true when the route has path params that lack a default value */
65+
hasRequiredPathParams: boolean;
66+
}
67+
68+
interface SpecPathItem {
69+
parameters?: SpecParameter[];
70+
[method: string]: unknown;
71+
}
72+
73+
interface SpecOperation {
74+
operationId?: string;
75+
parameters?: SpecParameter[];
76+
}
77+
78+
interface SpecParameter {
79+
in?: string;
80+
default?: unknown;
81+
schema?: { default?: unknown };
82+
}
83+
84+
/**
85+
* Look up an operation's route and path-parameter metadata from the OpenAPI
86+
* spec. Matches against both the raw `operationId` and its camelCase form
87+
* so that renamed/overridden operations are still found.
88+
*/
89+
const findOperationInfo = (
90+
spec: Record<string, unknown> | undefined,
91+
operationName: string,
92+
): OperationRouteInfo | undefined => {
93+
const paths = spec?.paths;
94+
if (!paths || typeof paths !== 'object') return undefined;
95+
96+
for (const [routePath, rawPathItem] of Object.entries(
97+
paths as Record<string, unknown>,
98+
)) {
99+
if (!rawPathItem || typeof rawPathItem !== 'object') continue;
100+
const pathItem = rawPathItem as SpecPathItem;
101+
102+
for (const method of HTTP_METHODS) {
103+
const operation = pathItem[method] as SpecOperation | undefined;
104+
const opId = operation?.operationId;
105+
if (!opId) continue;
106+
// Match both raw operationId and its camelCase generated name
107+
if (opId !== operationName && camel(opId) !== operationName) continue;
108+
109+
if (!routePath.includes('{')) {
110+
return { route: routePath, hasRequiredPathParams: false };
111+
}
112+
113+
// Collect path parameters from both path-level and operation-level
114+
const pathParams = [
115+
...(Array.isArray(pathItem.parameters) ? pathItem.parameters : []),
116+
...(Array.isArray(operation.parameters) ? operation.parameters : []),
117+
].filter((p) => p.in === 'path');
118+
119+
const hasRequiredPathParams = pathParams.some(
120+
(p) => p.schema?.default === undefined && p.default === undefined,
121+
);
122+
123+
return { route: routePath, hasRequiredPathParams };
124+
}
125+
}
126+
return undefined;
127+
};
128+
129+
/**
130+
* Extract the static route prefix before the first path parameter.
131+
* e.g. "/pets/{petId}" → "/pets/", "/pets" → "/pets"
132+
*
133+
* Returns `undefined` when the prefix contains no meaningful literal
134+
* segments (e.g. "/{tenantId}/pets") to avoid overly-broad invalidation.
135+
*/
136+
const getStaticRoutePrefix = (route: string): string | undefined => {
137+
const idx = route.indexOf('{');
138+
if (idx === -1) return route;
139+
const prefix = route.slice(0, idx);
140+
// Guard: a prefix like "/" has no stable literal segment and would
141+
// match every route-style query key – fall back to the zero-arg call.
142+
const hasLiteralSegment = prefix
143+
.split('/')
144+
.some((segment) => segment.length > 0);
145+
return hasLiteralSegment ? prefix : undefined;
146+
};
147+
148+
/**
149+
* Check whether the target invalidation needs to call the query key function.
150+
* Returns false when no params are specified and the route has required path
151+
* parameters (without defaults), meaning we should use predicate-based broad
152+
* invalidation instead of calling the function without the required arguments.
153+
*/
154+
const hasNonEmptyParams = (
155+
params: string[] | Record<string, string> | undefined,
156+
): params is string[] | Record<string, string> => {
157+
if (!params) return false;
158+
if (Array.isArray(params)) return params.length > 0;
159+
return Object.keys(params).length > 0;
160+
};
161+
162+
const needsQueryKeyFnCall = (
163+
target: NormalizedTarget,
164+
spec: Record<string, unknown> | undefined,
165+
): boolean => {
166+
if (hasNonEmptyParams(target.params)) return true;
167+
const info = findOperationInfo(spec, target.query);
168+
if (info?.hasRequiredPathParams) return false;
169+
return true;
170+
};
171+
51172
const generateVariableRef = (varName: string): string => {
52173
const parts = varName.split('.');
53174
if (parts.length === 1) {
@@ -67,10 +188,57 @@ const generateParamArgs = (
67188
.join(', ');
68189
};
69190

70-
const generateInvalidateCall = (target: NormalizedTarget): string => {
71-
const queryKeyFn = camel(`get-${target.query}-query-key`);
72-
const args = target.params ? generateParamArgs(target.params) : '';
73-
return ` queryClient.${target.invalidateMode === 'reset' ? 'resetQueries' : 'invalidateQueries'}({ queryKey: ${queryKeyFn}(${args}) });`;
191+
/**
192+
* Create a generateInvalidateCall function that has access to the OpenAPI spec
193+
* for intelligent route-based invalidation when params are not specified.
194+
*/
195+
const createGenerateInvalidateCall = (
196+
spec: Record<string, unknown> | undefined,
197+
shouldSplitQueryKey: boolean,
198+
) => {
199+
return (target: NormalizedTarget): string => {
200+
const method =
201+
target.invalidateMode === 'reset' ? 'resetQueries' : 'invalidateQueries';
202+
const queryKeyFn = camel(`get-${target.query}-query-key`);
203+
204+
if (hasNonEmptyParams(target.params)) {
205+
const args = generateParamArgs(target.params);
206+
return ` queryClient.${method}({ queryKey: ${queryKeyFn}(${args}) });`;
207+
}
208+
209+
// No params specified – check if the target query has required path params
210+
const info = findOperationInfo(spec, target.query);
211+
212+
if (info?.hasRequiredPathParams) {
213+
// Route has required path parameters (no defaults) – use broad
214+
// invalidation instead of calling the query key function without
215+
// the required arguments.
216+
const prefix = getStaticRoutePrefix(info.route);
217+
218+
// When the prefix has no meaningful literal segments (e.g. route
219+
// starts with a path param like /{tenantId}/...), fall through to
220+
// the zero-arg call rather than generating an overly-broad match.
221+
if (prefix !== undefined) {
222+
if (shouldSplitQueryKey) {
223+
// Split-key mode: query keys are arrays like ['pets', petId].
224+
// Use partial key matching with static route segments.
225+
const segments = prefix
226+
.split('/')
227+
.filter((s) => s !== '')
228+
.map((s) => `'${s}'`)
229+
.join(', ');
230+
return ` queryClient.${method}({ queryKey: [${segments}] });`;
231+
}
232+
233+
// Default mode: query keys are template strings like ['/pets/${petId}'].
234+
// Use predicate with startsWith for broad matching.
235+
return ` queryClient.${method}({ predicate: (query) => typeof query.queryKey[0] === 'string' && query.queryKey[0].startsWith('${prefix}') });`;
236+
}
237+
}
238+
239+
// No path params or route not found – call query key function without args
240+
return ` queryClient.${method}({ queryKey: ${queryKeyFn}() });`;
241+
};
74242
};
75243

76244
export interface MutationHookContext {
@@ -236,7 +404,10 @@ ${
236404
operationName,
237405
definitions,
238406
isRequestOptions,
239-
generateInvalidateCall,
407+
generateInvalidateCall: createGenerateInvalidateCall(
408+
context.spec,
409+
!!query.shouldSplitQueryKey,
410+
),
240411
uniqueInvalidates,
241412
})
242413
: ''
@@ -327,7 +498,7 @@ ${mutationHookBody}
327498

328499
const imports: GeneratorImport[] = hasInvalidation
329500
? uniqueInvalidates
330-
.filter((i) => !!i.file)
501+
.filter((i) => !!i.file && needsQueryKeyFnCall(i, context.spec))
331502
.map<GeneratorImport>((i) => ({
332503
name: camel(`get-${i.query}-query-key`),
333504
importPath: i.file,

0 commit comments

Comments
 (0)