@luvio/graphql-parser 0.98.0 → 0.99.2

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.
@@ -0,0 +1,230 @@
1
+ import { gql, referenceMap } from '../gql';
2
+ import { metaschemaMapper } from '../metaschema';
3
+
4
+ describe('metaschema mapper', () => {
5
+ it('should produce a equivalent metaschema AST when called with the legacy AST', () => {
6
+ const ref1 = gql`
7
+ query {
8
+ uiapi {
9
+ query {
10
+ Account(where: { Name: { like: "Account1" } }) @connection {
11
+ edges {
12
+ node @resource(type: "Record") {
13
+ Name {
14
+ value
15
+ displayValue
16
+ }
17
+ }
18
+ }
19
+ ...conFragment
20
+ }
21
+ }
22
+ }
23
+ }
24
+
25
+ fragment conFragment on AccountConnection {
26
+ edges {
27
+ node @resource(type: "Record") {
28
+ CreatedBy @resource(type: "Record") {
29
+ Id
30
+ Name {
31
+ value
32
+ displayValue
33
+ }
34
+ }
35
+ }
36
+ }
37
+ }
38
+ `;
39
+
40
+ const doc1 = referenceMap.get(ref1);
41
+
42
+ const ref2 = gql`
43
+ query {
44
+ uiapi {
45
+ query {
46
+ Account(where: { Name: { like: "Account1" } })
47
+ @category(name: "recordQuery") {
48
+ edges {
49
+ node {
50
+ Name {
51
+ value
52
+ displayValue
53
+ }
54
+ }
55
+ }
56
+ ...conFragment
57
+ }
58
+ }
59
+ }
60
+ }
61
+
62
+ fragment conFragment on AccountConnection {
63
+ edges {
64
+ node {
65
+ CreatedBy @category(name: "parentRelationship") {
66
+ Id
67
+ Name {
68
+ value
69
+ displayValue
70
+ }
71
+ }
72
+ }
73
+ }
74
+ }
75
+ `;
76
+ const doc2 = referenceMap.get(ref2);
77
+
78
+ metaschemaMapper(doc1);
79
+
80
+ expect(doc1).toStrictEqual(doc2);
81
+ });
82
+
83
+ it('should produce a equivalent metaschema AST when query has multiple entities', () => {
84
+ const ref1 = gql`
85
+ query {
86
+ uiapi {
87
+ query {
88
+ TimeSheet @connection {
89
+ edges {
90
+ node @resource(type: "Record") {
91
+ CreatedBy @resource(type: "Record") {
92
+ Email {
93
+ value
94
+ displayValue
95
+ }
96
+ }
97
+ }
98
+ }
99
+ }
100
+ User @connection {
101
+ edges {
102
+ node @resource(type: "Record") {
103
+ CreatedBy @resource(type: "Record") {
104
+ Email {
105
+ value
106
+ displayValue
107
+ }
108
+ }
109
+ }
110
+ }
111
+ }
112
+ }
113
+ }
114
+ }
115
+ `;
116
+ const doc1 = referenceMap.get(ref1);
117
+
118
+ const ref2 = gql`
119
+ query {
120
+ uiapi {
121
+ query {
122
+ TimeSheet @category(name: "recordQuery") {
123
+ edges {
124
+ node {
125
+ CreatedBy @category(name: "parentRelationship") {
126
+ Email {
127
+ value
128
+ displayValue
129
+ }
130
+ }
131
+ }
132
+ }
133
+ }
134
+ User @category(name: "recordQuery") {
135
+ edges {
136
+ node {
137
+ CreatedBy @category(name: "parentRelationship") {
138
+ Email {
139
+ value
140
+ displayValue
141
+ }
142
+ }
143
+ }
144
+ }
145
+ }
146
+ }
147
+ }
148
+ }
149
+ `;
150
+ const doc2 = referenceMap.get(ref2);
151
+
152
+ metaschemaMapper(doc1);
153
+
154
+ expect(doc1).toStrictEqual(doc2);
155
+ });
156
+
157
+ it('should leave any non - legacy directives intact', () => {
158
+ const ref1 = gql`
159
+ query myQuery($var1: Boolean!, $var2: Boolean!) {
160
+ uiapi {
161
+ query {
162
+ TimeSheet @include(if: $var1) @connection {
163
+ edges {
164
+ node @resource(type: "Record") {
165
+ CreatedBy @resource(type: "Record") {
166
+ Email {
167
+ value
168
+ displayValue
169
+ }
170
+ }
171
+ }
172
+ }
173
+ }
174
+ User @connection @skip(if: $var2) {
175
+ edges {
176
+ node @resource(type: "Record") {
177
+ CreatedBy @resource(type: "Record") {
178
+ Email {
179
+ value
180
+ displayValue
181
+ }
182
+ }
183
+ }
184
+ }
185
+ }
186
+ }
187
+ }
188
+ }
189
+ `;
190
+ const doc1 = referenceMap.get(ref1);
191
+
192
+ const ref2 = gql`
193
+ query myQuery($var1: Boolean!, $var2: Boolean!) {
194
+ uiapi {
195
+ query {
196
+ TimeSheet @include(if: $var1) @category(name: "recordQuery") {
197
+ edges {
198
+ node {
199
+ CreatedBy @category(name: "parentRelationship") {
200
+ Email {
201
+ value
202
+ displayValue
203
+ }
204
+ }
205
+ }
206
+ }
207
+ }
208
+ User @category(name: "recordQuery") @skip(if: $var2) {
209
+ edges {
210
+ node {
211
+ CreatedBy @category(name: "parentRelationship") {
212
+ Email {
213
+ value
214
+ displayValue
215
+ }
216
+ }
217
+ }
218
+ }
219
+ }
220
+ }
221
+ }
222
+ }
223
+ `;
224
+ const doc2 = referenceMap.get(ref2);
225
+
226
+ metaschemaMapper(doc1);
227
+
228
+ expect(doc1).toStrictEqual(doc2);
229
+ });
230
+ });
package/src/gql.ts ADDED
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Exposes a tagged template literal to parse graphql operation string
3
+ * "gql" is the only publicly exposed method
4
+ * @module gql
5
+ */
6
+ import type { DefinitionNode, DocumentNode } from 'graphql/language';
7
+ import { parse } from 'graphql/language';
8
+ import { stripIgnoredCharacters } from 'graphql/utilities';
9
+ import { ObjectCreate, ObjectKeys } from './util/language';
10
+ import { metaschemaMapper } from './metaschema';
11
+
12
+ export type AstResolver = (astReference: any) => DocumentNode | undefined;
13
+
14
+ /**
15
+ * we should look into optimizing this before it turns into a memory hog
16
+ * weakmaps, or limiting the size of the cache, or something
17
+ */
18
+ export const docMap = new Map<string, DocumentNode>();
19
+ /**
20
+ * Opaque reference map to return keys to userland
21
+ * As a user shouldn't have access to the Document
22
+ */
23
+ export const referenceMap = new WeakMap<Object, DocumentNode>();
24
+
25
+ export let addMetaschemaDirectives: boolean = false;
26
+
27
+ /**
28
+ * Strips characters that are not significant to the validity or execution
29
+ * of a GraphQL document:
30
+ * - UnicodeBOM
31
+ * - WhiteSpace
32
+ * - LineTerminator
33
+ * - Comment
34
+ * - Comma
35
+ * - BlockString indentation
36
+ */
37
+ function operationKeyBuilder(inputString: string): string {
38
+ return stripIgnoredCharacters(inputString);
39
+ }
40
+
41
+ /**
42
+ * Returns document node if cached or else update the cache and return the document node
43
+ * @param inputString - operation string
44
+ * @returns DocumentNode
45
+ */
46
+ function parseDocument(inputString: string): DocumentNode | null {
47
+ const operationKey = operationKeyBuilder(inputString);
48
+ const cachedDoc = docMap.get(operationKey);
49
+ if (cachedDoc !== undefined) {
50
+ return cachedDoc;
51
+ }
52
+ // parse throws an GraphQLError in case of invalid query, should this be in try/catch?
53
+ const parsedDoc = parse(inputString);
54
+ if (!parsedDoc || parsedDoc.kind !== 'Document') {
55
+ if (process.env.NODE_ENV !== 'production') {
56
+ throw new Error('Invalid graphql doc');
57
+ }
58
+ return null;
59
+ }
60
+
61
+ // in-place substitution for removal of legacy and adding metaschema directives
62
+ if (addMetaschemaDirectives) {
63
+ metaschemaMapper(parsedDoc);
64
+ }
65
+
66
+ const parsedDocNoLoc = stripLocation(parsedDoc);
67
+
68
+ docMap.set(operationKey, parsedDocNoLoc);
69
+
70
+ return parsedDocNoLoc;
71
+ }
72
+
73
+ /**
74
+ * If the input string has fragment substitution
75
+ * Insert the fragments AST to the query document node
76
+ */
77
+ function insertFragments(doc: DocumentNode, fragments: DefinitionNode[]): DocumentNode {
78
+ fragments.forEach((fragment) => {
79
+ // @ts-ignore
80
+ // graphql describes definitions as "readonly"
81
+ // so we aren't supposed to mutate the document node
82
+ // but instead of parsing the fragment again, we substitute it's definition in the document node
83
+ doc.definitions.push(fragment);
84
+ });
85
+ return doc;
86
+ }
87
+
88
+ function createReference(document: DocumentNode): object {
89
+ const key = ObjectCreate(null);
90
+ referenceMap.set(key, document);
91
+
92
+ return key;
93
+ }
94
+
95
+ /**
96
+ * Insert string and fragment substitutions with the actual nodes
97
+ * @param inputString
98
+ * @param substitutions - string | fragment DocumentNode
99
+ * @returns { operation string, fragment docs [] }
100
+ */
101
+ export function processSubstitutions(
102
+ inputString: ReadonlyArray<string>,
103
+ substitutions: (string | object)[]
104
+ ): { operationString: string; fragments: DefinitionNode[] } | null {
105
+ let outputString = '';
106
+ const fragments: DefinitionNode[] = [];
107
+ const subLength = substitutions.length;
108
+
109
+ for (let i = 0; i < subLength; i++) {
110
+ const substitution = substitutions[i];
111
+ outputString += inputString[i];
112
+
113
+ if (typeof substitution === 'string' || typeof substitution === 'number') {
114
+ outputString += substitution;
115
+ } else if (typeof substitution === 'object') {
116
+ const doc = referenceMap.get(substitution);
117
+
118
+ if (doc === undefined) {
119
+ if (process.env.NODE_ENV !== 'production') {
120
+ throw new Error('Invalid substitution fragment');
121
+ }
122
+ return null;
123
+ }
124
+
125
+ for (const def of doc.definitions) {
126
+ fragments.push(def);
127
+ }
128
+ } else {
129
+ if (process.env.NODE_ENV !== 'production') {
130
+ throw new Error('Unsupported substitution type');
131
+ }
132
+ return null;
133
+ }
134
+ }
135
+
136
+ return {
137
+ operationString: outputString + inputString[subLength],
138
+ fragments,
139
+ };
140
+ }
141
+
142
+ /**
143
+ * strips Document node and nested definitions of location references
144
+ */
145
+ export function stripLocation(node: any) {
146
+ if (node.loc) {
147
+ delete node.loc;
148
+ }
149
+ const keys = ObjectKeys(node);
150
+ const keysLength = keys.length;
151
+
152
+ if (keysLength === 0) {
153
+ return node;
154
+ }
155
+
156
+ for (const key of keys) {
157
+ const subNode = node[key];
158
+ if (subNode && typeof subNode === 'object') {
159
+ stripLocation(subNode);
160
+ }
161
+ }
162
+
163
+ return node;
164
+ }
165
+
166
+ /**
167
+ *
168
+ * @param astReference - ast reference passed from user land
169
+ */
170
+ export const astResolver: AstResolver = function (astReference: any) {
171
+ return referenceMap.get(astReference);
172
+ };
173
+
174
+ /**
175
+ *
176
+ * @param literals - operation query string
177
+ * @param subs - all other substitutions
178
+ * @returns an opaque reference to the parsed document
179
+ */
180
+ export function gql(
181
+ literals: ReadonlyArray<string> | string,
182
+ ...subs: (string | object)[]
183
+ ): object | null {
184
+ let inputString: string;
185
+ let inputSubstitutionFragments: DefinitionNode[];
186
+
187
+ if (!literals) {
188
+ if (process.env.NODE_ENV !== 'production') {
189
+ throw new Error('Invalid query');
190
+ }
191
+ return null;
192
+ } else if (typeof literals === 'string') {
193
+ inputString = literals.trim();
194
+ inputSubstitutionFragments = [];
195
+ } else {
196
+ // called as template literal
197
+ const sub = processSubstitutions(literals, subs);
198
+ // if invalid fragment references found
199
+ if (sub === null) {
200
+ return null;
201
+ }
202
+
203
+ const { operationString, fragments } = sub;
204
+ inputString = operationString.trim();
205
+ inputSubstitutionFragments = fragments;
206
+ }
207
+
208
+ if (inputString.length === 0) {
209
+ if (process.env.NODE_ENV !== 'production') {
210
+ throw new Error('Invalid query');
211
+ }
212
+ return null;
213
+ }
214
+
215
+ const document = parseDocument(inputString);
216
+
217
+ if (document === null) {
218
+ return null;
219
+ }
220
+
221
+ if (inputSubstitutionFragments.length === 0) {
222
+ return createReference(document);
223
+ }
224
+
225
+ return createReference(insertFragments(document, inputSubstitutionFragments));
226
+ }
227
+
228
+ /**
229
+ * Enable the parser to add corresponding metaschema directives for backwards compatibility
230
+ */
231
+ export function enableAddMetaschemaDirective() {
232
+ addMetaschemaDirectives = true;
233
+ }
234
+
235
+ export function disableAddMetaschemaDirective() {
236
+ addMetaschemaDirectives = false;
237
+ }
package/src/main.ts CHANGED
@@ -64,6 +64,13 @@ export type {
64
64
  export { ASTVisitor, parse, Kind, print, visit } from 'graphql/language';
65
65
  export { isScalarType } from 'graphql/type';
66
66
  export { stripIgnoredCharacters } from 'graphql/utilities';
67
+ export {
68
+ gql,
69
+ enableAddMetaschemaDirective,
70
+ disableAddMetaschemaDirective,
71
+ astResolver,
72
+ } from './gql';
73
+ export type { AstResolver } from './gql';
67
74
 
68
75
  /**
69
76
  * @deprecated - Schema-backed adapters will use standard graphql types re-exported from @luvio/graphql
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Add metaschema annotations to their corresponding custom notation counterparts
3
+ * @module metaschemaMapper
4
+ */
5
+ import type {
6
+ DefinitionNode,
7
+ DirectiveNode,
8
+ DocumentNode,
9
+ FragmentSpreadNode,
10
+ SelectionNode,
11
+ SelectionSetNode,
12
+ TypeSystemDefinitionNode,
13
+ TypeSystemExtensionNode,
14
+ } from 'graphql/language';
15
+ import { CUSTOM_DIRECTIVE_CONNECTION, CUSTOM_DIRECTIVE_RESOURCE } from './constants';
16
+
17
+ const DIRECTIVE_RECORD_CATEGORY = {
18
+ kind: 'Directive',
19
+ name: {
20
+ kind: 'Name',
21
+ value: 'category',
22
+ },
23
+ arguments: [
24
+ {
25
+ kind: 'Argument',
26
+ name: {
27
+ kind: 'Name',
28
+ value: 'name',
29
+ },
30
+ value: {
31
+ kind: 'StringValue',
32
+ value: 'recordQuery',
33
+ block: false,
34
+ },
35
+ },
36
+ ],
37
+ };
38
+
39
+ const DIRECTIVE_PARENT_CATEGORY = {
40
+ kind: 'Directive',
41
+ name: {
42
+ kind: 'Name',
43
+ value: 'category',
44
+ },
45
+ arguments: [
46
+ {
47
+ kind: 'Argument',
48
+ name: {
49
+ kind: 'Name',
50
+ value: 'name',
51
+ },
52
+ value: {
53
+ kind: 'StringValue',
54
+ value: 'parentRelationship',
55
+ block: false,
56
+ },
57
+ },
58
+ ],
59
+ };
60
+
61
+ function substituteDirectives(
62
+ directives: ReadonlyArray<DirectiveNode>,
63
+ index: number,
64
+ nodeName: string | undefined
65
+ ): void {
66
+ if (directives[index].name.value === CUSTOM_DIRECTIVE_CONNECTION) {
67
+ // replace the custom directive node with the metaschema directive node
68
+ // @ts-ignore - Document is read only
69
+ directives[index] = DIRECTIVE_RECORD_CATEGORY;
70
+ } else if (directives[index].name.value === CUSTOM_DIRECTIVE_RESOURCE) {
71
+ // node gets its type from @category recordQuery
72
+ if (nodeName === 'node') {
73
+ // @ts-ignore - Document is read only
74
+ directives.splice(index, 1);
75
+ } else {
76
+ // @ts-ignore - Document is read only
77
+ directives[index] = DIRECTIVE_PARENT_CATEGORY;
78
+ }
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Returns true if the directive node is of legacy type
84
+ * @param node : Directive node
85
+ * @returns
86
+ */
87
+ function isCustomDirective(node: DirectiveNode): boolean {
88
+ return (
89
+ node.name.value === CUSTOM_DIRECTIVE_CONNECTION ||
90
+ node.name.value === CUSTOM_DIRECTIVE_RESOURCE
91
+ );
92
+ }
93
+
94
+ type TraverseSelectionSetNode = SelectionSetNode | undefined;
95
+
96
+ /**
97
+ * Traverses a selection set and it's nested selections,
98
+ * to find any legacy custom directives and substitute them with metaschema directives
99
+ * @param node - SelectionSetNode
100
+ * @returns SelectionSetNode
101
+ */
102
+ function traverseSelectionSet(node: TraverseSelectionSetNode): TraverseSelectionSetNode {
103
+ if (node === undefined) {
104
+ return;
105
+ }
106
+
107
+ for (const selection of node.selections) {
108
+ // FragmentSpreadNode doesn't have a selection set
109
+ // which should be handled at this methods entry point
110
+ const { directives, selectionSet } = selection as Exclude<
111
+ SelectionNode,
112
+ FragmentSpreadNode
113
+ >;
114
+ let selectionName: string | undefined;
115
+
116
+ if (selection.kind !== 'InlineFragment') {
117
+ selectionName = selection.name.value;
118
+ }
119
+
120
+ if (directives !== undefined && directives.length > 0) {
121
+ // we follow this pattern instead of map to preserve the order of directives
122
+ // order of directives may be significant as per graphql spec
123
+ const index = directives.findIndex(isCustomDirective);
124
+
125
+ if (index !== -1) {
126
+ substituteDirectives(directives, index, selectionName);
127
+ }
128
+ }
129
+
130
+ traverseSelectionSet(selectionSet);
131
+ }
132
+ return node;
133
+ }
134
+
135
+ /**
136
+ * Accepts a document node and replaces the legacy custom directives with metaschema directives "in-place"
137
+ * @param doc
138
+ */
139
+ export function metaschemaMapper(doc: DocumentNode): void {
140
+ // this method is only callable for Executable definitions
141
+ // such as Operations and Fragments
142
+ // so we have to explicitly cast the definitions for ts
143
+ const { definitions }: { definitions: unknown } = doc;
144
+
145
+ for (const def of definitions as ReadonlyArray<
146
+ Exclude<DefinitionNode, TypeSystemDefinitionNode | TypeSystemExtensionNode>
147
+ >) {
148
+ const { directives, selectionSet } = def;
149
+
150
+ if (directives !== undefined && directives.length > 0) {
151
+ // we are making an assumption here that only one custom directive can be applied to a node
152
+ // we can revisit if this condition changes
153
+ const index = directives.findIndex(isCustomDirective);
154
+
155
+ if (index !== -1) {
156
+ substituteDirectives(directives, index, undefined);
157
+ }
158
+ }
159
+
160
+ traverseSelectionSet(selectionSet);
161
+ }
162
+ }
@@ -0,0 +1,10 @@
1
+ const { isArray } = Array;
2
+ const { create, keys } = Object;
3
+
4
+ export {
5
+ // Array
6
+ isArray as ArrayIsArray,
7
+ // Object
8
+ keys as ObjectKeys,
9
+ create as ObjectCreate,
10
+ };