@vendure/dashboard 3.4.3-master-202509260228 → 3.5.0-minor-202509261210
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/dist/plugin/api/api-extensions.js +11 -14
- package/dist/plugin/api/metrics.resolver.d.ts +2 -2
- package/dist/plugin/api/metrics.resolver.js +2 -2
- package/dist/plugin/config/metrics-strategies.d.ts +9 -9
- package/dist/plugin/config/metrics-strategies.js +6 -6
- package/dist/plugin/constants.d.ts +2 -0
- package/dist/plugin/constants.js +3 -1
- package/dist/plugin/dashboard.plugin.js +13 -0
- package/dist/plugin/service/metrics.service.d.ts +3 -3
- package/dist/plugin/service/metrics.service.js +37 -53
- package/dist/plugin/types.d.ts +9 -12
- package/dist/plugin/types.js +7 -11
- package/dist/vite/vite-plugin-vendure-dashboard.js +2 -2
- package/package.json +4 -4
- package/src/app/routes/_authenticated/_collections/collections.tsx +7 -2
- package/src/app/routes/_authenticated/_collections/collections_.$id.tsx +15 -2
- package/src/app/routes/_authenticated/_facets/facets_.$id.tsx +14 -2
- package/src/app/routes/_authenticated/_product-variants/product-variants.graphql.ts +10 -0
- package/src/app/routes/_authenticated/_products/components/product-option-group-badge.tsx +19 -0
- package/src/app/routes/_authenticated/_products/components/product-options-table.tsx +111 -0
- package/src/app/routes/_authenticated/_products/product-option-groups.graphql.ts +103 -0
- package/src/app/routes/_authenticated/_products/products.graphql.ts +13 -1
- package/src/app/routes/_authenticated/_products/products.tsx +27 -3
- package/src/app/routes/_authenticated/_products/products_.$id.tsx +26 -9
- package/src/app/routes/_authenticated/_products/products_.$productId.option-groups.$id.tsx +181 -0
- package/src/app/routes/_authenticated/_products/products_.$productId.option-groups.$productOptionGroupId.options_.$id.tsx +208 -0
- package/src/app/routes/_authenticated/_zones/components/zone-countries-sheet.tsx +4 -1
- package/src/app/routes/_authenticated/index.tsx +41 -24
- package/src/lib/components/data-display/json.tsx +16 -1
- package/src/lib/components/data-input/index.ts +3 -0
- package/src/lib/components/data-input/slug-input.tsx +296 -0
- package/src/lib/components/data-table/add-filter-menu.tsx +13 -6
- package/src/lib/components/data-table/data-table-bulk-action-item.tsx +38 -1
- package/src/lib/components/data-table/data-table-context.tsx +91 -0
- package/src/lib/components/data-table/data-table-filter-badge.tsx +9 -5
- package/src/lib/components/data-table/data-table-view-options.tsx +17 -8
- package/src/lib/components/data-table/data-table.tsx +146 -94
- package/src/lib/components/data-table/global-views-bar.tsx +97 -0
- package/src/lib/components/data-table/global-views-sheet.tsx +11 -0
- package/src/lib/components/data-table/manage-global-views-button.tsx +26 -0
- package/src/lib/components/data-table/my-views-button.tsx +47 -0
- package/src/lib/components/data-table/refresh-button.tsx +12 -3
- package/src/lib/components/data-table/save-view-button.tsx +45 -0
- package/src/lib/components/data-table/save-view-dialog.tsx +113 -0
- package/src/lib/components/data-table/use-generated-columns.tsx +3 -1
- package/src/lib/components/data-table/user-views-sheet.tsx +11 -0
- package/src/lib/components/data-table/views-sheet.tsx +297 -0
- package/src/lib/components/date-range-picker.tsx +184 -0
- package/src/lib/components/shared/paginated-list-data-table.tsx +59 -32
- package/src/lib/components/ui/button.tsx +1 -1
- package/src/lib/framework/dashboard-widget/latest-orders-widget/index.tsx +29 -2
- package/src/lib/framework/dashboard-widget/metrics-widget/index.tsx +10 -7
- package/src/lib/framework/dashboard-widget/metrics-widget/metrics-widget.graphql.ts +9 -3
- package/src/lib/framework/dashboard-widget/orders-summary/index.tsx +19 -75
- package/src/lib/framework/dashboard-widget/widget-filters-context.tsx +33 -0
- package/src/lib/framework/document-introspection/add-custom-fields.spec.ts +319 -9
- package/src/lib/framework/document-introspection/add-custom-fields.ts +60 -31
- package/src/lib/framework/document-introspection/get-document-structure.spec.ts +1 -159
- package/src/lib/framework/document-introspection/include-only-selected-list-fields.spec.ts +1840 -0
- package/src/lib/framework/document-introspection/include-only-selected-list-fields.ts +940 -0
- package/src/lib/framework/document-introspection/testing-utils.ts +161 -0
- package/src/lib/framework/extension-api/display-component-extensions.tsx +2 -0
- package/src/lib/framework/extension-api/types/data-table.ts +62 -4
- package/src/lib/framework/extension-api/types/navigation.ts +16 -0
- package/src/lib/framework/form-engine/utils.ts +34 -0
- package/src/lib/framework/page/list-page.tsx +289 -4
- package/src/lib/framework/page/use-extended-router.tsx +59 -17
- package/src/lib/graphql/api.ts +4 -2
- package/src/lib/graphql/graphql-env.d.ts +13 -10
- package/src/lib/hooks/use-extended-list-query.ts +5 -0
- package/src/lib/hooks/use-saved-views.ts +230 -0
- package/src/lib/index.ts +15 -0
- package/src/lib/types/saved-views.ts +39 -0
- package/src/lib/utils/saved-views-utils.ts +40 -0
|
@@ -0,0 +1,940 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ArgumentNode,
|
|
3
|
+
DocumentNode,
|
|
4
|
+
FieldNode,
|
|
5
|
+
FragmentDefinitionNode,
|
|
6
|
+
FragmentSpreadNode,
|
|
7
|
+
Kind,
|
|
8
|
+
SelectionNode,
|
|
9
|
+
VariableNode,
|
|
10
|
+
visit,
|
|
11
|
+
} from 'graphql';
|
|
12
|
+
|
|
13
|
+
import { getQueryName } from './get-document-structure.js';
|
|
14
|
+
|
|
15
|
+
// Simple LRU-style cache for memoization
|
|
16
|
+
const filterCache = new Map<string, DocumentNode>();
|
|
17
|
+
const MAX_CACHE_SIZE = 100;
|
|
18
|
+
|
|
19
|
+
// Fast document fingerprinting using WeakMap for reference tracking
|
|
20
|
+
const documentIds = new WeakMap<DocumentNode, string>();
|
|
21
|
+
let documentCounter = 0;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Get a fast, stable ID for a document node
|
|
25
|
+
*/
|
|
26
|
+
function getDocumentId(document: DocumentNode): string {
|
|
27
|
+
let id = documentIds.get(document);
|
|
28
|
+
if (!id) {
|
|
29
|
+
// For new documents, create a lightweight structural hash
|
|
30
|
+
id = createDocumentFingerprint(document);
|
|
31
|
+
documentIds.set(document, id);
|
|
32
|
+
}
|
|
33
|
+
return id;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Create a lightweight fingerprint of document structure (much faster than print())
|
|
38
|
+
*/
|
|
39
|
+
function createDocumentFingerprint(document: DocumentNode): string {
|
|
40
|
+
const parts: string[] = [];
|
|
41
|
+
|
|
42
|
+
for (const def of document.definitions) {
|
|
43
|
+
if (def.kind === Kind.OPERATION_DEFINITION) {
|
|
44
|
+
parts.push(`op:${def.operation}:${def.name?.value || 'anon'}`);
|
|
45
|
+
// Just count selections, don't traverse them
|
|
46
|
+
parts.push(`sel:${def.selectionSet.selections.length}`);
|
|
47
|
+
} else if (def.kind === Kind.FRAGMENT_DEFINITION) {
|
|
48
|
+
parts.push(`frag:${def.name.value}:${def.typeCondition.name.value}`);
|
|
49
|
+
parts.push(`sel:${def.selectionSet.selections.length}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return `doc_${++documentCounter}_${parts.join('_')}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Create a stable cache key from document and selected columns
|
|
58
|
+
*/
|
|
59
|
+
function createCacheKey(
|
|
60
|
+
document: DocumentNode,
|
|
61
|
+
selectedColumns: Array<{ name: string; isCustomField: boolean; dependencies?: string[] }>,
|
|
62
|
+
): string {
|
|
63
|
+
const docId = getDocumentId(document);
|
|
64
|
+
const columnsKey = sortJoin(
|
|
65
|
+
selectedColumns.map(col => {
|
|
66
|
+
const deps = col.dependencies ? sortJoin(col.dependencies, '+') : '';
|
|
67
|
+
const depsPart = deps ? `:deps(${deps})` : '';
|
|
68
|
+
return `${col.name}:${String(col.isCustomField)}${depsPart}`;
|
|
69
|
+
}),
|
|
70
|
+
',',
|
|
71
|
+
);
|
|
72
|
+
return `${docId}|${columnsKey}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function sortJoin<T>(arr: T[], separator: string): string {
|
|
76
|
+
return arr.slice(0).sort().join(separator);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @description
|
|
81
|
+
* This function takes a list query document such as:
|
|
82
|
+
* ```gql
|
|
83
|
+
* query ProductList($options: ProductListOptions) {
|
|
84
|
+
* products(options: $options) {
|
|
85
|
+
* items {
|
|
86
|
+
* id
|
|
87
|
+
* createdAt
|
|
88
|
+
* updatedAt
|
|
89
|
+
* featuredAsset {
|
|
90
|
+
* id
|
|
91
|
+
* preview
|
|
92
|
+
* }
|
|
93
|
+
* name
|
|
94
|
+
* slug
|
|
95
|
+
* enabled
|
|
96
|
+
* }
|
|
97
|
+
* totalItems
|
|
98
|
+
* }
|
|
99
|
+
* }
|
|
100
|
+
* ```
|
|
101
|
+
* and an array of selected columns, and returns a new document which only selects the
|
|
102
|
+
* specified columns. So if `selectedColumns` equals `[{ name: 'id', isCustomField: false }]`,
|
|
103
|
+
* then the resulting document's `items` fields would be `{ id }`.
|
|
104
|
+
*
|
|
105
|
+
* Columns can also declare dependencies on other fields that are required for rendering
|
|
106
|
+
* but not necessarily visible. For example:
|
|
107
|
+
* ```js
|
|
108
|
+
* selectedColumns = [{
|
|
109
|
+
* name: 'name',
|
|
110
|
+
* isCustomField: false,
|
|
111
|
+
* dependencies: ['children', 'breadcrumbs'] // Always include these fields
|
|
112
|
+
* }]
|
|
113
|
+
* ```
|
|
114
|
+
* This ensures that cell renderers can safely access dependent fields even when they're
|
|
115
|
+
* not part of the visible column set.
|
|
116
|
+
*
|
|
117
|
+
* @param listQuery The GraphQL document to filter
|
|
118
|
+
* @param selectedColumns Array of column definitions with optional dependencies
|
|
119
|
+
*/
|
|
120
|
+
export function includeOnlySelectedListFields<T extends DocumentNode>(
|
|
121
|
+
listQuery: T,
|
|
122
|
+
selectedColumns: Array<{
|
|
123
|
+
name: string;
|
|
124
|
+
isCustomField: boolean;
|
|
125
|
+
dependencies?: string[];
|
|
126
|
+
}>,
|
|
127
|
+
): T {
|
|
128
|
+
// If no columns selected, return the original document
|
|
129
|
+
if (selectedColumns.length === 0) {
|
|
130
|
+
return listQuery;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Check cache first
|
|
134
|
+
const cacheKey = createCacheKey(listQuery, selectedColumns);
|
|
135
|
+
if (filterCache.has(cacheKey)) {
|
|
136
|
+
return filterCache.get(cacheKey) as T;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Get the query name to identify the main list query field
|
|
140
|
+
const queryName = getQueryName(listQuery);
|
|
141
|
+
|
|
142
|
+
// Collect all required fields including dependencies
|
|
143
|
+
const allRequiredFields = new Set<string>();
|
|
144
|
+
const customFieldNames = new Set<string>();
|
|
145
|
+
|
|
146
|
+
selectedColumns.forEach(col => {
|
|
147
|
+
allRequiredFields.add(col.name);
|
|
148
|
+
if (col.isCustomField) {
|
|
149
|
+
customFieldNames.add(col.name);
|
|
150
|
+
}
|
|
151
|
+
// Add dependencies
|
|
152
|
+
col.dependencies?.forEach(dep => {
|
|
153
|
+
allRequiredFields.add(dep);
|
|
154
|
+
// Note: Dependencies are assumed to be regular fields unless they start with custom field patterns
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const selectedFieldNames = allRequiredFields;
|
|
159
|
+
|
|
160
|
+
// Collect all fragments from the document
|
|
161
|
+
const fragments = collectFragments(listQuery);
|
|
162
|
+
|
|
163
|
+
// First pass: identify which fragments are directly used by the items field
|
|
164
|
+
const itemsFragments = getItemsFragments(listQuery, queryName);
|
|
165
|
+
|
|
166
|
+
// Visit and transform the document
|
|
167
|
+
const modifiedDocument = visit(listQuery, {
|
|
168
|
+
[Kind.FIELD]: {
|
|
169
|
+
enter(node: FieldNode, key, parent, path, ancestors): FieldNode | undefined {
|
|
170
|
+
// Check if we're at the query root field (e.g., "products")
|
|
171
|
+
const isQueryRoot =
|
|
172
|
+
ancestors.some(
|
|
173
|
+
ancestor =>
|
|
174
|
+
ancestor &&
|
|
175
|
+
typeof ancestor === 'object' &&
|
|
176
|
+
'kind' in ancestor &&
|
|
177
|
+
ancestor.kind === Kind.OPERATION_DEFINITION &&
|
|
178
|
+
ancestor.operation === 'query',
|
|
179
|
+
) && node.name.value === queryName;
|
|
180
|
+
|
|
181
|
+
if (!isQueryRoot) {
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Look for the "items" field within the query root
|
|
186
|
+
if (node.selectionSet) {
|
|
187
|
+
const modifiedSelections = node.selectionSet.selections.map(selection => {
|
|
188
|
+
if (isFieldWithName(selection, 'items')) {
|
|
189
|
+
// Filter the items field to only include selected columns
|
|
190
|
+
return filterItemsField(
|
|
191
|
+
selection,
|
|
192
|
+
selectedFieldNames,
|
|
193
|
+
customFieldNames,
|
|
194
|
+
fragments,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
return selection;
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
...node,
|
|
202
|
+
selectionSet: {
|
|
203
|
+
...node.selectionSet,
|
|
204
|
+
selections: modifiedSelections,
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return undefined;
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
[Kind.FRAGMENT_DEFINITION]: {
|
|
213
|
+
enter(node: FragmentDefinitionNode): FragmentDefinitionNode {
|
|
214
|
+
// Only filter fragments that are directly used by the items field
|
|
215
|
+
if (itemsFragments.has(node.name.value)) {
|
|
216
|
+
return filterFragment(node, selectedFieldNames, customFieldNames, fragments);
|
|
217
|
+
}
|
|
218
|
+
// Leave other fragments untouched
|
|
219
|
+
return node;
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// Remove unused fragments to prevent GraphQL validation errors
|
|
225
|
+
const withoutUnusedFragments = removeUnusedFragments(modifiedDocument);
|
|
226
|
+
|
|
227
|
+
// Remove unused variables to prevent GraphQL validation errors
|
|
228
|
+
const result = removeUnusedVariables(withoutUnusedFragments);
|
|
229
|
+
|
|
230
|
+
// Cache the result with LRU eviction
|
|
231
|
+
if (filterCache.size >= MAX_CACHE_SIZE) {
|
|
232
|
+
const firstKey = filterCache.keys().next().value;
|
|
233
|
+
if (firstKey) {
|
|
234
|
+
filterCache.delete(firstKey);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
filterCache.set(cacheKey, result);
|
|
238
|
+
return result;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Collect all fragments from the document
|
|
243
|
+
*/
|
|
244
|
+
function collectFragments(document: DocumentNode): Record<string, FragmentDefinitionNode> {
|
|
245
|
+
const fragments: Record<string, FragmentDefinitionNode> = {};
|
|
246
|
+
for (const definition of document.definitions) {
|
|
247
|
+
if (definition.kind === Kind.FRAGMENT_DEFINITION) {
|
|
248
|
+
fragments[definition.name.value] = definition;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return fragments;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Check if a selection is a field with the given name
|
|
256
|
+
*/
|
|
257
|
+
function isFieldWithName(selection: SelectionNode, fieldName: string): selection is FieldNode {
|
|
258
|
+
return selection.kind === Kind.FIELD && selection.name.value === fieldName;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Check if a selection is a field with the given name and has a selection set
|
|
263
|
+
*/
|
|
264
|
+
function isFieldWithNameAndSelections(selection: SelectionNode, fieldName: string): selection is FieldNode {
|
|
265
|
+
return isFieldWithName(selection, fieldName) && !!selection.selectionSet;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Collect fragment spreads from a selection set
|
|
270
|
+
*/
|
|
271
|
+
function collectFragmentSpreads(selections: readonly SelectionNode[]): string[] {
|
|
272
|
+
const fragmentNames: string[] = [];
|
|
273
|
+
for (const selection of selections) {
|
|
274
|
+
if (selection.kind === Kind.FRAGMENT_SPREAD) {
|
|
275
|
+
fragmentNames.push(selection.name.value);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return fragmentNames;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Check if a selection is a field node
|
|
283
|
+
*/
|
|
284
|
+
function isField(selection: SelectionNode): selection is FieldNode {
|
|
285
|
+
return selection.kind === Kind.FIELD;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Find the items field within a query field's selections
|
|
290
|
+
*/
|
|
291
|
+
function findItemsFieldFragments(querySelections: readonly SelectionNode[]): string[] {
|
|
292
|
+
for (const selection of querySelections) {
|
|
293
|
+
if (isFieldWithNameAndSelections(selection, 'items') && selection.selectionSet) {
|
|
294
|
+
return collectFragmentSpreads(selection.selectionSet.selections);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return [];
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Find the query field with the given name and process its items field
|
|
302
|
+
*/
|
|
303
|
+
function findQueryFieldFragments(selections: readonly SelectionNode[], queryName: string): string[] {
|
|
304
|
+
for (const selection of selections) {
|
|
305
|
+
if (isFieldWithNameAndSelections(selection, queryName) && selection.selectionSet) {
|
|
306
|
+
return findItemsFieldFragments(selection.selectionSet.selections);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return [];
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Get fragments that are directly used by the items field (not nested fragments)
|
|
314
|
+
*/
|
|
315
|
+
function getItemsFragments(document: DocumentNode, queryName: string): Set<string> {
|
|
316
|
+
const itemsFragments = new Set<string>();
|
|
317
|
+
|
|
318
|
+
for (const definition of document.definitions) {
|
|
319
|
+
if (definition.kind === Kind.OPERATION_DEFINITION && definition.operation === 'query') {
|
|
320
|
+
const fragmentNames = findQueryFieldFragments(definition.selectionSet.selections, queryName);
|
|
321
|
+
fragmentNames.forEach(name => itemsFragments.add(name));
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return itemsFragments;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Context for filtering field selections
|
|
330
|
+
*/
|
|
331
|
+
interface FieldSelectionContext {
|
|
332
|
+
itemsField: FieldNode;
|
|
333
|
+
availableFields: Map<string, SelectionNode>;
|
|
334
|
+
fragmentSpreads: Map<string, FragmentSpreadNode>;
|
|
335
|
+
fragments: Record<string, FragmentDefinitionNode>;
|
|
336
|
+
neededFragments: Set<string>;
|
|
337
|
+
filteredSelections: SelectionNode[];
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Check if a field is selected directly in the items field (not from a fragment)
|
|
342
|
+
*/
|
|
343
|
+
function isDirectField(fieldName: string, itemsField: FieldNode): boolean {
|
|
344
|
+
if (!itemsField.selectionSet) return false;
|
|
345
|
+
return itemsField.selectionSet.selections.some(sel => isFieldWithName(sel, fieldName));
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Add a regular field to filtered selections
|
|
350
|
+
*/
|
|
351
|
+
function addRegularField(fieldName: string, context: FieldSelectionContext): void {
|
|
352
|
+
if (!context.availableFields.has(fieldName)) return;
|
|
353
|
+
|
|
354
|
+
if (isDirectField(fieldName, context.itemsField)) {
|
|
355
|
+
const fieldSelection = context.availableFields.get(fieldName);
|
|
356
|
+
if (fieldSelection) {
|
|
357
|
+
context.filteredSelections.push(fieldSelection);
|
|
358
|
+
}
|
|
359
|
+
} else {
|
|
360
|
+
// Field comes from a fragment - mark fragments as needed
|
|
361
|
+
for (const [fragName] of context.fragmentSpreads) {
|
|
362
|
+
const fragment = context.fragments[fragName];
|
|
363
|
+
if (fragment && fragmentContainsField(fragment, fieldName, context.fragments)) {
|
|
364
|
+
context.neededFragments.add(fragName);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Add custom fields to filtered selections
|
|
372
|
+
*/
|
|
373
|
+
function addCustomFields(customFieldNames: Set<string>, context: FieldSelectionContext): void {
|
|
374
|
+
if (customFieldNames.size === 0 || !context.availableFields.has('customFields')) return;
|
|
375
|
+
|
|
376
|
+
if (isDirectField('customFields', context.itemsField)) {
|
|
377
|
+
const customFieldsSelection = context.availableFields.get('customFields');
|
|
378
|
+
if (customFieldsSelection && customFieldsSelection.kind === Kind.FIELD) {
|
|
379
|
+
const filteredCustomFields = filterCustomFields(customFieldsSelection, customFieldNames);
|
|
380
|
+
if (filteredCustomFields) {
|
|
381
|
+
context.filteredSelections.push(filteredCustomFields);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
} else {
|
|
385
|
+
// customFields comes from a fragment
|
|
386
|
+
for (const [fragName] of context.fragmentSpreads) {
|
|
387
|
+
const fragment = context.fragments[fragName];
|
|
388
|
+
if (fragment && fragmentContainsField(fragment, 'customFields', context.fragments)) {
|
|
389
|
+
context.neededFragments.add(fragName);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Check if context has only __typename as a field (which doesn't count as valid content)
|
|
397
|
+
*/
|
|
398
|
+
function hasOnlyTypenameField(context: FieldSelectionContext): boolean {
|
|
399
|
+
return (
|
|
400
|
+
context.filteredSelections.length === 1 &&
|
|
401
|
+
isFieldWithName(context.filteredSelections[0], '__typename')
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Check if context has valid fields that make the query meaningful
|
|
407
|
+
*/
|
|
408
|
+
function hasValidFields(context: FieldSelectionContext): boolean {
|
|
409
|
+
return context.filteredSelections.length > 0 && !hasOnlyTypenameField(context);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Add id field directly from available fields
|
|
414
|
+
*/
|
|
415
|
+
function addDirectIdField(context: FieldSelectionContext): void {
|
|
416
|
+
const idField = context.availableFields.get('id');
|
|
417
|
+
if (idField) {
|
|
418
|
+
context.filteredSelections.push(idField);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Add id field from a fragment that contains it
|
|
424
|
+
*/
|
|
425
|
+
function addIdFieldFromFragment(context: FieldSelectionContext): void {
|
|
426
|
+
for (const [fragName] of context.fragmentSpreads) {
|
|
427
|
+
const fragment = context.fragments[fragName];
|
|
428
|
+
if (fragment && fragmentContainsField(fragment, 'id', context.fragments)) {
|
|
429
|
+
const fragmentSpread = context.fragmentSpreads.get(fragName);
|
|
430
|
+
if (fragmentSpread) {
|
|
431
|
+
context.filteredSelections.push(fragmentSpread);
|
|
432
|
+
}
|
|
433
|
+
break;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Create a minimal id field when none exists
|
|
440
|
+
*/
|
|
441
|
+
function createMinimalIdField(context: FieldSelectionContext): void {
|
|
442
|
+
context.filteredSelections.push({
|
|
443
|
+
kind: Kind.FIELD,
|
|
444
|
+
name: { kind: Kind.NAME, value: 'id' },
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Ensure at least id field is included to maintain valid query
|
|
450
|
+
*/
|
|
451
|
+
function ensureIdField(context: FieldSelectionContext): void {
|
|
452
|
+
if (hasValidFields(context)) return;
|
|
453
|
+
|
|
454
|
+
if (context.availableFields.has('id')) {
|
|
455
|
+
if (isDirectField('id', context.itemsField)) {
|
|
456
|
+
addDirectIdField(context);
|
|
457
|
+
} else {
|
|
458
|
+
addIdFieldFromFragment(context);
|
|
459
|
+
}
|
|
460
|
+
} else {
|
|
461
|
+
createMinimalIdField(context);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Initialize field selection context
|
|
467
|
+
*/
|
|
468
|
+
function initializeFieldSelectionContext(
|
|
469
|
+
itemsField: FieldNode,
|
|
470
|
+
fragments: Record<string, FragmentDefinitionNode>,
|
|
471
|
+
): FieldSelectionContext {
|
|
472
|
+
return {
|
|
473
|
+
itemsField,
|
|
474
|
+
availableFields: new Map(),
|
|
475
|
+
fragmentSpreads: new Map(),
|
|
476
|
+
fragments,
|
|
477
|
+
neededFragments: new Set(),
|
|
478
|
+
filteredSelections: [],
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Collect available fields and fragment spreads from selections
|
|
484
|
+
*/
|
|
485
|
+
function collectAvailableSelections(
|
|
486
|
+
context: FieldSelectionContext,
|
|
487
|
+
selections: readonly SelectionNode[],
|
|
488
|
+
fragments: Record<string, FragmentDefinitionNode>,
|
|
489
|
+
): void {
|
|
490
|
+
for (const selection of selections) {
|
|
491
|
+
if (isField(selection)) {
|
|
492
|
+
context.availableFields.set(selection.name.value, selection);
|
|
493
|
+
} else if (selection.kind === Kind.FRAGMENT_SPREAD) {
|
|
494
|
+
context.fragmentSpreads.set(selection.name.value, selection);
|
|
495
|
+
const fragment = fragments[selection.name.value];
|
|
496
|
+
if (fragment) {
|
|
497
|
+
collectFieldsFromFragment(fragment, fragments, context.availableFields);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Add __typename field if it exists in original selections
|
|
505
|
+
*/
|
|
506
|
+
function addTypenameField(context: FieldSelectionContext): void {
|
|
507
|
+
if (context.availableFields.has('__typename')) {
|
|
508
|
+
const typenameField = context.availableFields.get('__typename');
|
|
509
|
+
if (typenameField) {
|
|
510
|
+
context.filteredSelections.push(typenameField);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Add all selected regular fields
|
|
517
|
+
*/
|
|
518
|
+
function addSelectedFields(context: FieldSelectionContext, selectedFieldNames: Set<string>): void {
|
|
519
|
+
for (const fieldName of selectedFieldNames) {
|
|
520
|
+
if (fieldName === '__typename') continue;
|
|
521
|
+
addRegularField(fieldName, context);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Add needed fragment spreads to filtered selections
|
|
527
|
+
*/
|
|
528
|
+
function addNeededFragmentSpreads(context: FieldSelectionContext): void {
|
|
529
|
+
for (const fragName of context.neededFragments) {
|
|
530
|
+
const fragmentSpread = context.fragmentSpreads.get(fragName);
|
|
531
|
+
if (fragmentSpread) {
|
|
532
|
+
context.filteredSelections.push(fragmentSpread);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Add inline fragments from original selections
|
|
539
|
+
*/
|
|
540
|
+
function addInlineFragments(context: FieldSelectionContext, selections: readonly SelectionNode[]): void {
|
|
541
|
+
for (const selection of selections) {
|
|
542
|
+
if (selection.kind === Kind.INLINE_FRAGMENT) {
|
|
543
|
+
context.filteredSelections.push(selection);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Filter the items field to only include selected columns
|
|
550
|
+
*/
|
|
551
|
+
function filterItemsField(
|
|
552
|
+
itemsField: FieldNode,
|
|
553
|
+
selectedFieldNames: Set<string>,
|
|
554
|
+
customFieldNames: Set<string>,
|
|
555
|
+
fragments: Record<string, FragmentDefinitionNode>,
|
|
556
|
+
): FieldNode {
|
|
557
|
+
if (!itemsField.selectionSet) {
|
|
558
|
+
return itemsField;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const context = initializeFieldSelectionContext(itemsField, fragments);
|
|
562
|
+
|
|
563
|
+
collectAvailableSelections(context, itemsField.selectionSet.selections, fragments);
|
|
564
|
+
addTypenameField(context);
|
|
565
|
+
addSelectedFields(context, selectedFieldNames);
|
|
566
|
+
addCustomFields(customFieldNames, context);
|
|
567
|
+
addNeededFragmentSpreads(context);
|
|
568
|
+
addInlineFragments(context, itemsField.selectionSet.selections);
|
|
569
|
+
ensureIdField(context);
|
|
570
|
+
|
|
571
|
+
return {
|
|
572
|
+
...itemsField,
|
|
573
|
+
selectionSet: {
|
|
574
|
+
...itemsField.selectionSet,
|
|
575
|
+
selections: context.filteredSelections,
|
|
576
|
+
},
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Collect all fields from a fragment recursively
|
|
582
|
+
*/
|
|
583
|
+
function collectFieldsFromFragment(
|
|
584
|
+
fragment: FragmentDefinitionNode,
|
|
585
|
+
fragments: Record<string, FragmentDefinitionNode>,
|
|
586
|
+
availableFields: Map<string, SelectionNode>,
|
|
587
|
+
): void {
|
|
588
|
+
for (const selection of fragment.selectionSet.selections) {
|
|
589
|
+
if (isField(selection)) {
|
|
590
|
+
availableFields.set(selection.name.value, selection);
|
|
591
|
+
} else if (selection.kind === Kind.FRAGMENT_SPREAD) {
|
|
592
|
+
const nestedFragment = fragments[selection.name.value];
|
|
593
|
+
if (nestedFragment) {
|
|
594
|
+
collectFieldsFromFragment(nestedFragment, fragments, availableFields);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Check if a fragment contains a specific field
|
|
602
|
+
*/
|
|
603
|
+
function fragmentContainsField(
|
|
604
|
+
fragment: FragmentDefinitionNode,
|
|
605
|
+
fieldName: string,
|
|
606
|
+
fragments: Record<string, FragmentDefinitionNode>,
|
|
607
|
+
): boolean {
|
|
608
|
+
for (const selection of fragment.selectionSet.selections) {
|
|
609
|
+
if (isFieldWithName(selection, fieldName)) {
|
|
610
|
+
return true;
|
|
611
|
+
} else if (selection.kind === Kind.FRAGMENT_SPREAD) {
|
|
612
|
+
const nestedFragment = fragments[selection.name.value];
|
|
613
|
+
if (nestedFragment && fragmentContainsField(nestedFragment, fieldName, fragments)) {
|
|
614
|
+
return true;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return false;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Process a field selection for fragment filtering
|
|
623
|
+
*/
|
|
624
|
+
function processFragmentFieldSelection(
|
|
625
|
+
selection: FieldNode,
|
|
626
|
+
selectedFieldNames: Set<string>,
|
|
627
|
+
customFieldNames: Set<string>,
|
|
628
|
+
filteredSelections: SelectionNode[],
|
|
629
|
+
): void {
|
|
630
|
+
const fieldName = selection.name.value;
|
|
631
|
+
|
|
632
|
+
if (fieldName === '__typename') {
|
|
633
|
+
filteredSelections.push(selection);
|
|
634
|
+
} else if (selectedFieldNames.has(fieldName)) {
|
|
635
|
+
filteredSelections.push(selection);
|
|
636
|
+
} else if (fieldName === 'customFields' && customFieldNames.size > 0) {
|
|
637
|
+
const filteredCustomFields = filterCustomFields(selection, customFieldNames);
|
|
638
|
+
if (filteredCustomFields) {
|
|
639
|
+
filteredSelections.push(filteredCustomFields);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Check if a fragment spread contains any selected fields
|
|
646
|
+
*/
|
|
647
|
+
function fragmentSpreadContainsSelectedFields(
|
|
648
|
+
spreadFragment: FragmentDefinitionNode,
|
|
649
|
+
selectedFieldNames: Set<string>,
|
|
650
|
+
fragments: Record<string, FragmentDefinitionNode>,
|
|
651
|
+
): boolean {
|
|
652
|
+
for (const fieldName of selectedFieldNames) {
|
|
653
|
+
if (fragmentContainsField(spreadFragment, fieldName, fragments)) {
|
|
654
|
+
return true;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return false;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Process a fragment spread selection for fragment filtering
|
|
662
|
+
*/
|
|
663
|
+
function processFragmentSpreadSelection(
|
|
664
|
+
selection: FragmentSpreadNode,
|
|
665
|
+
selectedFieldNames: Set<string>,
|
|
666
|
+
fragments: Record<string, FragmentDefinitionNode>,
|
|
667
|
+
filteredSelections: SelectionNode[],
|
|
668
|
+
): void {
|
|
669
|
+
const spreadFragment = fragments[selection.name.value];
|
|
670
|
+
if (
|
|
671
|
+
spreadFragment &&
|
|
672
|
+
fragmentSpreadContainsSelectedFields(spreadFragment, selectedFieldNames, fragments)
|
|
673
|
+
) {
|
|
674
|
+
filteredSelections.push(selection);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Process all selections in a fragment
|
|
680
|
+
*/
|
|
681
|
+
function processFragmentSelections(
|
|
682
|
+
selections: readonly SelectionNode[],
|
|
683
|
+
selectedFieldNames: Set<string>,
|
|
684
|
+
customFieldNames: Set<string>,
|
|
685
|
+
fragments: Record<string, FragmentDefinitionNode>,
|
|
686
|
+
): SelectionNode[] {
|
|
687
|
+
const filteredSelections: SelectionNode[] = [];
|
|
688
|
+
|
|
689
|
+
for (const selection of selections) {
|
|
690
|
+
if (isField(selection)) {
|
|
691
|
+
processFragmentFieldSelection(
|
|
692
|
+
selection,
|
|
693
|
+
selectedFieldNames,
|
|
694
|
+
customFieldNames,
|
|
695
|
+
filteredSelections,
|
|
696
|
+
);
|
|
697
|
+
} else if (selection.kind === Kind.FRAGMENT_SPREAD) {
|
|
698
|
+
processFragmentSpreadSelection(selection, selectedFieldNames, fragments, filteredSelections);
|
|
699
|
+
} else if (selection.kind === Kind.INLINE_FRAGMENT) {
|
|
700
|
+
// Keep inline fragments for now - more complex filtering would need type info
|
|
701
|
+
filteredSelections.push(selection);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
return filteredSelections;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Ensure fragment has at least one field by adding id if available
|
|
710
|
+
*/
|
|
711
|
+
function ensureFragmentHasFields(
|
|
712
|
+
filteredSelections: SelectionNode[],
|
|
713
|
+
originalSelections: readonly SelectionNode[],
|
|
714
|
+
): void {
|
|
715
|
+
if (filteredSelections.length === 0) {
|
|
716
|
+
// Add id if it exists in the original fragment
|
|
717
|
+
for (const selection of originalSelections) {
|
|
718
|
+
if (isFieldWithName(selection, 'id')) {
|
|
719
|
+
filteredSelections.push(selection);
|
|
720
|
+
break;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* Filter a fragment to only include selected fields
|
|
728
|
+
*/
|
|
729
|
+
function filterFragment(
|
|
730
|
+
fragment: FragmentDefinitionNode,
|
|
731
|
+
selectedFieldNames: Set<string>,
|
|
732
|
+
customFieldNames: Set<string>,
|
|
733
|
+
fragments: Record<string, FragmentDefinitionNode>,
|
|
734
|
+
): FragmentDefinitionNode {
|
|
735
|
+
const filteredSelections = processFragmentSelections(
|
|
736
|
+
fragment.selectionSet.selections,
|
|
737
|
+
selectedFieldNames,
|
|
738
|
+
customFieldNames,
|
|
739
|
+
fragments,
|
|
740
|
+
);
|
|
741
|
+
|
|
742
|
+
ensureFragmentHasFields(filteredSelections, fragment.selectionSet.selections);
|
|
743
|
+
|
|
744
|
+
return {
|
|
745
|
+
...fragment,
|
|
746
|
+
selectionSet: {
|
|
747
|
+
...fragment.selectionSet,
|
|
748
|
+
selections: filteredSelections,
|
|
749
|
+
},
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* Filter the customFields selection to only include selected custom fields
|
|
755
|
+
*/
|
|
756
|
+
function filterCustomFields(customFieldsNode: FieldNode, customFieldNames: Set<string>): FieldNode | null {
|
|
757
|
+
if (!customFieldsNode.selectionSet) {
|
|
758
|
+
return customFieldsNode;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const filteredSelections = customFieldsNode.selectionSet.selections.filter(selection => {
|
|
762
|
+
if (isField(selection)) {
|
|
763
|
+
return customFieldNames.has(selection.name.value);
|
|
764
|
+
}
|
|
765
|
+
// Keep fragments as they might contain selected custom fields
|
|
766
|
+
return true;
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
if (filteredSelections.length === 0) {
|
|
770
|
+
return null;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
return {
|
|
774
|
+
...customFieldsNode,
|
|
775
|
+
selectionSet: {
|
|
776
|
+
...customFieldsNode.selectionSet,
|
|
777
|
+
selections: filteredSelections,
|
|
778
|
+
},
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Remove unused fragments from the document to prevent GraphQL validation errors
|
|
784
|
+
*/
|
|
785
|
+
function removeUnusedFragments<T extends DocumentNode>(document: T): T {
|
|
786
|
+
// First, collect all fragment names that are actually used in the document
|
|
787
|
+
const usedFragments = new Set<string>();
|
|
788
|
+
|
|
789
|
+
// Helper function to recursively find fragment spreads
|
|
790
|
+
const findFragmentSpreads = (selections: readonly SelectionNode[]) => {
|
|
791
|
+
for (const selection of selections) {
|
|
792
|
+
if (selection.kind === Kind.FRAGMENT_SPREAD) {
|
|
793
|
+
usedFragments.add(selection.name.value);
|
|
794
|
+
} else if (selection.kind === Kind.INLINE_FRAGMENT && selection.selectionSet) {
|
|
795
|
+
findFragmentSpreads(selection.selectionSet.selections);
|
|
796
|
+
} else if (selection.kind === Kind.FIELD && selection.selectionSet) {
|
|
797
|
+
findFragmentSpreads(selection.selectionSet.selections);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
};
|
|
801
|
+
|
|
802
|
+
// Look through all operations to find used fragments
|
|
803
|
+
for (const definition of document.definitions) {
|
|
804
|
+
if (definition.kind === Kind.OPERATION_DEFINITION) {
|
|
805
|
+
findFragmentSpreads(definition.selectionSet.selections);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// Now we need to handle transitive dependencies - fragments that use other fragments
|
|
810
|
+
let foundNewFragments = true;
|
|
811
|
+
while (foundNewFragments) {
|
|
812
|
+
foundNewFragments = false;
|
|
813
|
+
for (const definition of document.definitions) {
|
|
814
|
+
if (definition.kind === Kind.FRAGMENT_DEFINITION && usedFragments.has(definition.name.value)) {
|
|
815
|
+
const previousSize = usedFragments.size;
|
|
816
|
+
findFragmentSpreads(definition.selectionSet.selections);
|
|
817
|
+
if (usedFragments.size > previousSize) {
|
|
818
|
+
foundNewFragments = true;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// Filter out unused fragment definitions
|
|
825
|
+
const filteredDefinitions = document.definitions.filter(definition => {
|
|
826
|
+
if (definition.kind === Kind.FRAGMENT_DEFINITION) {
|
|
827
|
+
return usedFragments.has(definition.name.value);
|
|
828
|
+
}
|
|
829
|
+
return true;
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
return {
|
|
833
|
+
...document,
|
|
834
|
+
definitions: filteredDefinitions,
|
|
835
|
+
} as T;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* Remove unused variables from the document to prevent GraphQL validation errors
|
|
840
|
+
*/
|
|
841
|
+
function removeUnusedVariables<T extends DocumentNode>(document: T): T {
|
|
842
|
+
const collector = new VariableUsageCollector();
|
|
843
|
+
const usedVariables = collector.collectFromDocument(document);
|
|
844
|
+
|
|
845
|
+
// Filter out unused variable definitions from operations
|
|
846
|
+
const modifiedDefinitions = document.definitions.map(definition => {
|
|
847
|
+
if (definition.kind === Kind.OPERATION_DEFINITION && definition.variableDefinitions) {
|
|
848
|
+
const filteredVariableDefinitions = definition.variableDefinitions.filter(variableDef =>
|
|
849
|
+
usedVariables.has(variableDef.variable.name.value),
|
|
850
|
+
);
|
|
851
|
+
|
|
852
|
+
return {
|
|
853
|
+
...definition,
|
|
854
|
+
variableDefinitions: filteredVariableDefinitions,
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
return definition;
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
return {
|
|
861
|
+
...document,
|
|
862
|
+
definitions: modifiedDefinitions,
|
|
863
|
+
} as T;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* Variable usage collector that traverses GraphQL structures
|
|
868
|
+
*/
|
|
869
|
+
class VariableUsageCollector {
|
|
870
|
+
private readonly usedVariables = new Set<string>();
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* Collect variables from a GraphQL value (recursive)
|
|
874
|
+
*/
|
|
875
|
+
private collectFromValue(value: any): void {
|
|
876
|
+
switch (value.kind) {
|
|
877
|
+
case Kind.VARIABLE:
|
|
878
|
+
this.usedVariables.add((value as VariableNode).name.value);
|
|
879
|
+
break;
|
|
880
|
+
case Kind.LIST:
|
|
881
|
+
value.values.forEach((item: any) => this.collectFromValue(item));
|
|
882
|
+
break;
|
|
883
|
+
case Kind.OBJECT:
|
|
884
|
+
value.fields.forEach((field: any) => this.collectFromValue(field.value));
|
|
885
|
+
break;
|
|
886
|
+
// For other value types (STRING, INT, FLOAT, BOOLEAN, NULL, ENUM), no variables to collect
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* Collect variables from field arguments
|
|
892
|
+
*/
|
|
893
|
+
private collectFromArguments(args: readonly ArgumentNode[]): void {
|
|
894
|
+
args.forEach(arg => this.collectFromValue(arg.value));
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* Collect variables from selection set (recursive)
|
|
899
|
+
*/
|
|
900
|
+
private collectFromSelections(selections: readonly SelectionNode[]): void {
|
|
901
|
+
selections.forEach(selection => {
|
|
902
|
+
switch (selection.kind) {
|
|
903
|
+
case Kind.FIELD:
|
|
904
|
+
if (selection.arguments) {
|
|
905
|
+
this.collectFromArguments(selection.arguments);
|
|
906
|
+
}
|
|
907
|
+
if (selection.selectionSet) {
|
|
908
|
+
this.collectFromSelections(selection.selectionSet.selections);
|
|
909
|
+
}
|
|
910
|
+
break;
|
|
911
|
+
case Kind.INLINE_FRAGMENT:
|
|
912
|
+
if (selection.selectionSet) {
|
|
913
|
+
this.collectFromSelections(selection.selectionSet.selections);
|
|
914
|
+
}
|
|
915
|
+
break;
|
|
916
|
+
case Kind.FRAGMENT_SPREAD:
|
|
917
|
+
// Fragment spreads are handled when processing fragment definitions
|
|
918
|
+
break;
|
|
919
|
+
}
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* Collect all used variables from a document
|
|
925
|
+
*/
|
|
926
|
+
collectFromDocument(document: DocumentNode): Set<string> {
|
|
927
|
+
this.usedVariables.clear();
|
|
928
|
+
|
|
929
|
+
document.definitions.forEach(definition => {
|
|
930
|
+
if (
|
|
931
|
+
definition.kind === Kind.OPERATION_DEFINITION ||
|
|
932
|
+
definition.kind === Kind.FRAGMENT_DEFINITION
|
|
933
|
+
) {
|
|
934
|
+
this.collectFromSelections(definition.selectionSet.selections);
|
|
935
|
+
}
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
return new Set(this.usedVariables);
|
|
939
|
+
}
|
|
940
|
+
}
|