@stonecrop/graphql-client 0.10.16 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/query.ts ADDED
@@ -0,0 +1,303 @@
1
+ import type {
2
+ DoctypeMeta,
3
+ GetRecordOptions,
4
+ GetRecordsOptions,
5
+ LazyFetch,
6
+ LinkDeclaration,
7
+ SyncFetch,
8
+ } from '@stonecrop/schema'
9
+ import { toPascalCase } from '@stonecrop/schema'
10
+ import pluralize from 'pluralize'
11
+
12
+ /**
13
+ * Default sync limit for many-cardinality links
14
+ */
15
+ const DEFAULT_SYNC_LIMIT = 50
16
+
17
+ /**
18
+ * Field types that are not scalar queryable fields.
19
+ * Link fields are handled separately via sub-selections; relationship fields live in `links`.
20
+ */
21
+ const RELATION_FIELDTYPES = new Set(['Link'])
22
+
23
+ /**
24
+ * Build a GraphQL connection query to fetch a list of records.
25
+ *
26
+ * Only declares variables ($limit, $offset, $orderBy) that are actually used,
27
+ * avoiding GraphQL spec violations from unused variable declarations.
28
+ *
29
+ * @param meta - Doctype metadata
30
+ * @param connectionFieldName - Function to derive the connection field name from a table name
31
+ * @param orderByTypeName - Function to derive the order-by type name from a table name
32
+ * @param options - Query options (limit, offset, orderBy)
33
+ * @returns GraphQL query string
34
+ *
35
+ * @public
36
+ */
37
+ export function buildListQuery(
38
+ meta: DoctypeMeta,
39
+ connectionFieldName: (t: string) => string,
40
+ orderByTypeName: (t: string) => string,
41
+ options?: GetRecordsOptions
42
+ ): string {
43
+ const fieldNames = queryableFieldNames(meta)
44
+ const connectionName = connectionFieldName(meta.tableName!)
45
+ const orderByType = orderByTypeName(meta.tableName!)
46
+
47
+ const varDecls: string[] = []
48
+ const queryArgs: string[] = []
49
+ if (options?.limit) {
50
+ varDecls.push('$limit: Int')
51
+ queryArgs.push(`first: $limit`)
52
+ }
53
+ if (options?.offset) {
54
+ varDecls.push('$offset: Int')
55
+ queryArgs.push(`offset: $offset`)
56
+ }
57
+ if (options?.orderBy) {
58
+ varDecls.push(`$orderBy: [${orderByType}!]`)
59
+ queryArgs.push(`orderBy: $orderBy`)
60
+ }
61
+
62
+ const varStr = varDecls.length > 0 ? `(${varDecls.join(', ')})` : ''
63
+ const argsStr = queryArgs.length > 0 ? `(${queryArgs.join(', ')})` : ''
64
+
65
+ return `
66
+ query GetRecords${varStr} {
67
+ ${connectionName}${argsStr} {
68
+ nodes {
69
+ ${fieldNames}
70
+ }
71
+ }
72
+ }
73
+ `
74
+ }
75
+
76
+ /**
77
+ * Build a GraphQL query string from doctype metadata.
78
+ *
79
+ * Generates scalar field selections. When `includeNested` is set,
80
+ * recursively includes descendant link sub-selections derived from
81
+ * the doctype's `links` object.
82
+ *
83
+ * @param meta - Doctype metadata to build the query from
84
+ * @param recordFieldName - Function to derive the query field name from a table name
85
+ * @param recordArgName - Function to derive the argument name from a table name
86
+ * @param recordArgType - Function to derive the argument type from a table name
87
+ * @param registry - Doctype registry for resolving link targets. Required when includeNested is set.
88
+ * @param options - Query options (includeNested, maxDepth)
89
+ * @returns GraphQL query string
90
+ *
91
+ * @public
92
+ */
93
+ export function buildRecordQuery(
94
+ meta: DoctypeMeta,
95
+ recordFieldName: (t: string) => string,
96
+ recordArgName: (t: string) => string,
97
+ recordArgType: (t: string) => string,
98
+ registry?: Map<string, DoctypeMeta>,
99
+ options?: GetRecordOptions
100
+ ): string {
101
+ const queryName = recordFieldName(meta.tableName!)
102
+ const argName = recordArgName(meta.tableName!)
103
+ const argType = recordArgType(meta.tableName!)
104
+
105
+ const seen = new Set<string>([meta.slug || meta.name])
106
+
107
+ let selection = queryableFieldNames(meta)
108
+
109
+ if (options?.includeNested && meta.links && registry) {
110
+ const includeSet = Array.isArray(options.includeNested) ? new Set(options.includeNested) : null
111
+
112
+ const nestedSelections = buildNestedSelections(
113
+ meta.links,
114
+ meta.tableName!,
115
+ includeSet,
116
+ registry,
117
+ seen,
118
+ 0,
119
+ options.maxDepth
120
+ )
121
+
122
+ if (nestedSelections) {
123
+ selection += '\n ' + nestedSelections
124
+ }
125
+ }
126
+
127
+ return `
128
+ query GetRecord($${argName}: ${argType}) {
129
+ ${queryName}(${argName}: $${argName}) {
130
+ ${selection}
131
+ }
132
+ }
133
+ `
134
+ }
135
+
136
+ /**
137
+ * Build nested sub-selections for descendant links
138
+ * @internal
139
+ */
140
+ function buildNestedSelections(
141
+ links: Record<string, LinkDeclaration>,
142
+ parentTableName: string,
143
+ includeSet: Set<string> | null,
144
+ registry: Map<string, DoctypeMeta>,
145
+ seen: Set<string>,
146
+ depth: number,
147
+ maxDepth?: number
148
+ ): string {
149
+ if (maxDepth !== undefined && depth >= maxDepth) return ''
150
+
151
+ const selections: string[] = []
152
+
153
+ for (const [fieldname, link] of Object.entries(links)) {
154
+ if (maxDepth !== undefined && depth >= maxDepth) break
155
+
156
+ // Check blockWorkflows first - if true, it overrides the includeSet filter
157
+ const effectiveBlockWorkflows = getEffectiveBlockWorkflows(link)
158
+ const linkBlockWorkflowsExplicitTrue = link.blockWorkflows === true
159
+
160
+ // Check includeSet filter - but blockWorkflows: true bypasses this filter
161
+ if (includeSet && !includeSet.has(fieldname) && !linkBlockWorkflowsExplicitTrue) {
162
+ continue
163
+ }
164
+
165
+ // Check fetch strategy - skip if not sync (unless blockWorkflows overrides)
166
+ const effectiveFetch = getEffectiveFetchStrategy(link)
167
+ const shouldSkip =
168
+ effectiveBlockWorkflows === false || (effectiveFetch.method !== 'sync' && !linkBlockWorkflowsExplicitTrue)
169
+ if (shouldSkip) {
170
+ continue
171
+ }
172
+
173
+ // TODO: When blockWorkflows is true with custom fetch, this currently forces the link into
174
+ // GraphQL queries, bypassing the custom handler. This is a workaround — custom handlers
175
+ // should be able to satisfy blockWorkflows on their own schedule. Future enhancement:
176
+ // - Option: Add SchemaValidator error for custom + blockWorkflows (blocking is impossible)
177
+ // - Option: Track pending custom fetches and only unblock when all custom handlers complete
178
+ // See: relationships.md Phase 6 "Open Question: blockWorkflows + custom fetch"
179
+
180
+ const targetMeta = registry.get(link.target)
181
+ if (!targetMeta) continue
182
+
183
+ const alreadySeen = seen.has(link.target)
184
+ if (alreadySeen) {
185
+ // Self-referential: include scalar fields only, don't modify seen
186
+ } else {
187
+ seen.add(link.target)
188
+ }
189
+ const scalarFields = queryableFieldNames(targetMeta)
190
+
191
+ let nestedLinks = ''
192
+ if (!alreadySeen && targetMeta.links && targetMeta.tableName && (maxDepth === undefined || depth + 1 < maxDepth)) {
193
+ const innerSelections = buildNestedSelections(
194
+ targetMeta.links,
195
+ targetMeta.tableName,
196
+ null,
197
+ registry,
198
+ seen,
199
+ depth + 1,
200
+ maxDepth
201
+ )
202
+ if (innerSelections) {
203
+ nestedLinks = '\n ' + innerSelections
204
+ }
205
+ seen.delete(link.target)
206
+ }
207
+
208
+ const fullSelection = scalarFields + nestedLinks
209
+
210
+ if (isManyCardinality(link.cardinality)) {
211
+ const connectionField = getConnectionFieldName(targetMeta, parentTableName)
212
+ const limitArg =
213
+ effectiveFetch.method === 'sync' && effectiveFetch.limit !== undefined
214
+ ? `first: ${effectiveFetch.limit}`
215
+ : effectiveFetch.method === 'sync'
216
+ ? `first: ${DEFAULT_SYNC_LIMIT}`
217
+ : ''
218
+ selections.push(`
219
+ ${connectionField}${limitArg ? `(${limitArg})` : ''} {
220
+ nodes {
221
+ ${fullSelection}
222
+ }
223
+ }`)
224
+ } else {
225
+ selections.push(`
226
+ ${fieldname} {
227
+ ${fullSelection}
228
+ }`)
229
+ }
230
+ }
231
+
232
+ return selections.join('')
233
+ }
234
+
235
+ /**
236
+ * Get the effective fetch strategy for a link, applying cardinality-based defaults.
237
+ *
238
+ * - `fetch` explicitly set → use it
239
+ * - `fetch` absent → apply defaults:
240
+ * - `noneOrMany`/`atLeastOne` → `{ method: 'sync', limit: 50 }`
241
+ * - `one`/`atMostOne` → `{ method: 'lazy' }`
242
+ *
243
+ * @internal
244
+ */
245
+ function getEffectiveFetchStrategy(link: LinkDeclaration): SyncFetch | LazyFetch {
246
+ if (link.fetch !== undefined) {
247
+ return link.fetch as SyncFetch | LazyFetch
248
+ }
249
+
250
+ // Apply cardinality-based defaults
251
+ if (isManyCardinality(link.cardinality)) {
252
+ return { method: 'sync', limit: DEFAULT_SYNC_LIMIT }
253
+ } else {
254
+ return { method: 'lazy' }
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Get the effective blockWorkflows value for a link.
260
+ * Returns true if blockWorkflows is explicitly true, or if it's absent and fetch method is 'sync'.
261
+ * @internal
262
+ */
263
+ function getEffectiveBlockWorkflows(link: LinkDeclaration): boolean {
264
+ if (link.blockWorkflows !== undefined) {
265
+ return link.blockWorkflows
266
+ }
267
+ const effectiveFetch = getEffectiveFetchStrategy(link)
268
+ return effectiveFetch.method === 'sync'
269
+ }
270
+
271
+ /**
272
+ * Get scalar field names for a doctype, excluding Link and Doctype fields
273
+ * @internal
274
+ */
275
+ function queryableFieldNames(meta: DoctypeMeta): string {
276
+ return meta.fields
277
+ .filter(f => !RELATION_FIELDTYPES.has(f.fieldtype))
278
+ .map(f => f.fieldname)
279
+ .join('\n ')
280
+ }
281
+
282
+ /**
283
+ * Check if a cardinality value represents a 1:many relationship
284
+ * @internal
285
+ */
286
+ function isManyCardinality(cardinality: string): boolean {
287
+ return cardinality === 'noneOrMany' || cardinality === 'atLeastOne'
288
+ }
289
+
290
+ /**
291
+ * Derive a PostGraphile connection field name from a target doctype and parent table name.
292
+ *
293
+ * PostGraphile convention: `{targetPlural}By{ParentTablePascal}Id`
294
+ * Example: recipe_task with parent recipe → `recipeTasksByRecipeId`
295
+ *
296
+ * @internal
297
+ */
298
+ function getConnectionFieldName(targetMeta: DoctypeMeta, parentTableName: string): string {
299
+ const targetPlural = pluralize.plural(targetMeta.tableName!)
300
+ const targetPascal = toPascalCase(targetPlural)
301
+ const fkPascal = toPascalCase(parentTableName) + 'Id'
302
+ return `${targetPascal}By${fkPascal}`
303
+ }