@@ -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+
51172const 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
76244export interface MutationHookContext {
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