@@ -48,9 +48,6 @@ import type {
4848/** Hardcoded import path for functions (always ../functions from db/) */
4949const FUNCTIONS_IMPORT_PATH = "../functions" ;
5050
51- /** Maximum depth to search for arrays in nested types */
52- const MAX_ARRAY_SEARCH_DEPTH = 3 ;
53-
5451/**
5552 * Result of finding an array in a type
5653 */
@@ -132,11 +129,19 @@ export function discoverGraphQLEntities(
132129}
133130
134131/**
135- * Find all queries that return list types (directly or wrapped in objects)
132+ * Find all queries that return list types (directly or wrapped in pagination objects)
133+ *
134+ * This function identifies queries that are suitable for collection generation:
135+ * 1. Fields that return a list directly (e.g., `users: [User!]!`)
136+ * 2. Fields that return a wrapper object with a data/items array (e.g., pagination wrappers)
137+ *
138+ * It does NOT consider nested arrays on returned objects as list queries.
139+ * For example, `user(id: ID!): User` where `User` has `posts: [Post!]!` is NOT
140+ * a list query for Posts - it's a single-item query that happens to have a nested array.
136141 */
137142function findListQueries (
138143 queryType : GraphQLObjectType ,
139- schema : GraphQLSchema ,
144+ _schema : GraphQLSchema ,
140145 documents : ParsedDocuments ,
141146 warnings : string [ ] ,
142147) : ListQueryMatch [ ] {
@@ -150,36 +155,38 @@ function findListQueries(
150155
151156 if ( directListInfo . isList && directListInfo . itemTypeName ) {
152157 // Direct list return - find matching operation
153- const match = findMatchingOperation (
158+ // For direct lists, the selectorPath is just the response key
159+ // e.g., `query { users { id } }` returns `{ users: [...] }` -> selectorPath = "users"
160+ const match = findMatchingOperationForDirectList (
154161 fieldName ,
155162 field ,
156163 directListInfo . itemTypeName ,
157164 documents ,
158- undefined ,
159165 ) ;
160166 if ( match ) {
161167 results . push ( match ) ;
162168 }
163169 continue ;
164170 }
165171
166- // Check if the return type is an object that contains a list (wrapped response)
172+ // Check if the return type is a wrapper object (like a pagination envelope)
173+ // that contains a data/items array field
174+ //
175+ // NOTE: We only look for pagination-style wrappers, NOT arbitrary nested arrays.
176+ // A query like `user(id: ID!): User` where User has `posts: [Post!]!` should NOT
177+ // be treated as a list query for Posts - that would require a dedicated `posts` query.
167178 const unwrappedType = unwrapType ( field . type ) ;
168179 if ( isObjectType ( unwrappedType ) ) {
169- const arrayPath = findArrayInObjectType (
170- unwrappedType ,
171- schema ,
172- warnings ,
173- MAX_ARRAY_SEARCH_DEPTH ,
174- ) ;
180+ // Only look for common pagination wrapper patterns (data, items, edges, nodes, results)
181+ const arrayPath = findPaginationArrayField ( unwrappedType , warnings ) ;
175182 if ( arrayPath ) {
176- // Found a wrapped array - find matching operation
183+ // Found a pagination wrapper - find matching operation
177184 const match = findMatchingOperation (
178185 fieldName ,
179186 field ,
180187 arrayPath . itemTypeName ,
181188 documents ,
182- arrayPath . path ,
189+ ` ${ fieldName } . ${ arrayPath . path } ` ,
183190 ) ;
184191 if ( match ) {
185192 results . push ( match ) ;
@@ -192,40 +199,90 @@ function findListQueries(
192199}
193200
194201/**
195- * Find the matching document operation for a schema field
202+ * Find a pagination-style array field in an object type
203+ * Only looks for common wrapper patterns like { data: [...] }, { items: [...] }, etc.
204+ *
205+ * This is more conservative than findArrayInObjectType - it doesn't recursively
206+ * search nested objects for arrays, which prevents incorrectly treating single-item
207+ * queries with nested arrays as list queries.
196208 */
197- function findMatchingOperation (
209+ function findPaginationArrayField (
210+ type : GraphQLObjectType ,
211+ warnings : string [ ] ,
212+ ) : ArrayPathResult | null {
213+ const fields = type . getFields ( ) ;
214+ const arrayFields : Array < { fieldName : string ; result : ArrayPathResult } > = [ ] ;
215+
216+ // Common pagination wrapper field names
217+ const paginationFieldNames = new Set ( [
218+ "data" ,
219+ "items" ,
220+ "edges" ,
221+ "nodes" ,
222+ "results" ,
223+ "records" ,
224+ "list" ,
225+ "rows" ,
226+ ] ) ;
227+
228+ for ( const [ fieldName , field ] of Object . entries ( fields ) ) {
229+ // Only consider known pagination field names
230+ if ( ! paginationFieldNames . has ( fieldName . toLowerCase ( ) ) ) {
231+ continue ;
232+ }
233+
234+ // Check if this field is a list
235+ const listInfo = analyzeReturnType ( field . type ) ;
236+ if ( listInfo . isList && listInfo . itemTypeName ) {
237+ arrayFields . push ( {
238+ fieldName,
239+ result : { path : fieldName , itemTypeName : listInfo . itemTypeName } ,
240+ } ) ;
241+ }
242+ }
243+
244+ // If multiple arrays found, warn and take the first
245+ if ( arrayFields . length > 1 ) {
246+ const firstField = arrayFields [ 0 ] ;
247+ const fieldNames = arrayFields . map ( ( f ) => f . fieldName ) . join ( ", " ) ;
248+ warnings . push (
249+ `Multiple pagination array fields found in type "${ type . name } ": ${ fieldNames } . Using first found: "${ firstField ?. fieldName } ". ` +
250+ `If this is incorrect, configure selectorPath in overrides.db.collections.` ,
251+ ) ;
252+ }
253+
254+ return arrayFields [ 0 ] ?. result ?? null ;
255+ }
256+
257+ /**
258+ * Find the matching document operation for a schema field that returns a direct list.
259+ * The selectorPath is just the response key (field name or alias).
260+ *
261+ * e.g., `query { users { id } }` returns `{ users: [...] }` -> selectorPath = "users"
262+ */
263+ function findMatchingOperationForDirectList (
198264 schemaFieldName : string ,
199265 field : GraphQLField < unknown , unknown > ,
200266 itemTypeName : string ,
201267 documents : ParsedDocuments ,
202- innerPath : string | undefined ,
203268) : ListQueryMatch | null {
204- // Find operation that queries this field
205269 for ( const op of documents . operations ) {
206270 if ( op . operation !== "query" ) continue ;
207271
208- // Find the field selection that matches this schema field
209272 for ( const sel of op . node . selectionSet . selections ) {
210273 if ( sel . kind !== Kind . FIELD ) continue ;
211274
212275 const fieldNode = sel as FieldNode ;
213- // Check if this selection targets our schema field
214276 if ( fieldNode . name . value === schemaFieldName ) {
215- // Get the response key (alias if present, otherwise field name)
216277 const responseKey = fieldNode . alias ?. value || fieldNode . name . value ;
217278
218- // Build the full selector path
219- const selectorPath = innerPath
220- ? `${ responseKey } .${ innerPath } `
221- : undefined ;
222-
223279 return {
224280 field,
225281 typeName : itemTypeName ,
226282 operation : op ,
227283 responseKey,
228- selectorPath,
284+ // For direct lists, the selectorPath is just the response key
285+ selectorPath : responseKey ,
229286 } ;
230287 }
231288 }
@@ -235,71 +292,48 @@ function findMatchingOperation(
235292}
236293
237294/**
238- * Recursively search an object type for a list field
239- * Returns the path to the list and the item type name
295+ * Find the matching document operation for a schema field with a nested/wrapped array.
296+ * The selectorPath includes the full path to the array.
297+ *
298+ * e.g., pagination wrapper: `query { users { data { id } } }` returns
299+ * `{ users: { data: [...] } }` -> selectorPath = "users.data"
240300 */
241- function findArrayInObjectType (
242- type : GraphQLObjectType ,
243- schema : GraphQLSchema ,
244- warnings : string [ ] ,
245- maxDepth : number ,
246- currentDepth : number = 0 ,
247- visitedTypes : Set < string > = new Set ( ) ,
248- ) : ArrayPathResult | null {
249- if ( currentDepth >= maxDepth ) return null ;
301+ function findMatchingOperation (
302+ schemaFieldName : string ,
303+ field : GraphQLField < unknown , unknown > ,
304+ itemTypeName : string ,
305+ documents : ParsedDocuments ,
306+ selectorPath : string ,
307+ ) : ListQueryMatch | null {
308+ for ( const op of documents . operations ) {
309+ if ( op . operation !== "query" ) continue ;
250310
251- // Prevent infinite recursion on cyclic types
252- if ( visitedTypes . has ( type . name ) ) return null ;
253- visitedTypes . add ( type . name ) ;
311+ for ( const sel of op . node . selectionSet . selections ) {
312+ if ( sel . kind !== Kind . FIELD ) continue ;
254313
255- const fields = type . getFields ( ) ;
256- const arrayFields : Array < { fieldName : string ; result : ArrayPathResult } > = [ ] ;
314+ const fieldNode = sel as FieldNode ;
315+ if ( fieldNode . name . value === schemaFieldName ) {
316+ const responseKey = fieldNode . alias ?. value || fieldNode . name . value ;
257317
258- for ( const [ fieldName , field ] of Object . entries ( fields ) ) {
259- // Check if this field is a list
260- const listInfo = analyzeReturnType ( field . type ) ;
261- if ( listInfo . isList && listInfo . itemTypeName ) {
262- arrayFields . push ( {
263- fieldName,
264- result : { path : fieldName , itemTypeName : listInfo . itemTypeName } ,
265- } ) ;
266- continue ;
267- }
318+ // For wrapped arrays, replace the schema field name with the response key in the path
319+ // e.g., if field is "users" but aliased as "allUsers", and selectorPath is "users.data",
320+ // we want "allUsers.data"
321+ const adjustedPath = selectorPath . startsWith ( schemaFieldName )
322+ ? responseKey + selectorPath . slice ( schemaFieldName . length )
323+ : selectorPath ;
268324
269- // If it's an object type, recurse
270- const unwrapped = unwrapType ( field . type ) ;
271- if ( isObjectType ( unwrapped ) ) {
272- const nested = findArrayInObjectType (
273- unwrapped ,
274- schema ,
275- warnings ,
276- maxDepth ,
277- currentDepth + 1 ,
278- visitedTypes ,
279- ) ;
280- if ( nested ) {
281- arrayFields . push ( {
282- fieldName,
283- result : {
284- path : `${ fieldName } .${ nested . path } ` ,
285- itemTypeName : nested . itemTypeName ,
286- } ,
287- } ) ;
325+ return {
326+ field,
327+ typeName : itemTypeName ,
328+ operation : op ,
329+ responseKey,
330+ selectorPath : adjustedPath ,
331+ } ;
288332 }
289333 }
290334 }
291335
292- // If multiple arrays found at this level, warn and take the first
293- if ( arrayFields . length > 1 ) {
294- const firstField = arrayFields [ 0 ] ;
295- const fieldNames = arrayFields . map ( ( f ) => f . fieldName ) . join ( ", " ) ;
296- warnings . push (
297- `Multiple array fields found in type "${ type . name } ": ${ fieldNames } . Using first found: "${ firstField ?. fieldName } ". ` +
298- `If this is incorrect, configure selectorPath in overrides.db.collections.` ,
299- ) ;
300- }
301-
302- return arrayFields [ 0 ] ?. result ?? null ;
336+ return null ;
303337}
304338
305339/**
0 commit comments