@graphql-codegen/python-operations 0.0.0-stage → 0.0.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/README.md +105 -2
- package/dist/README.md +106 -0
- package/dist/cjs/config.js +0 -0
- package/dist/cjs/index.js +25 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/python-helpers.js +32 -0
- package/dist/cjs/visitor.js +306 -0
- package/dist/esm/config.js +0 -0
- package/dist/esm/index.js +20 -0
- package/dist/esm/python-helpers.js +24 -0
- package/dist/esm/visitor.js +302 -0
- package/dist/package.json +44 -0
- package/dist/typings/config.d.cts +71 -0
- package/dist/typings/config.d.ts +71 -0
- package/dist/typings/index.d.cts +11 -0
- package/dist/typings/index.d.ts +11 -0
- package/dist/typings/python-helpers.d.cts +18 -0
- package/dist/typings/python-helpers.d.ts +18 -0
- package/dist/typings/visitor.d.cts +81 -0
- package/dist/typings/visitor.d.ts +81 -0
- package/package.json +52 -4
- package/src/config.ts +72 -0
- package/src/index.ts +34 -0
- package/src/python-helpers.ts +34 -0
- package/src/visitor.ts +395 -0
- package/tests/__snapshots__/python-operations.spec.ts.snap +26 -0
- package/tests/python-operations.spec.ts +376 -0
- package/vitest.config.ts +12 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
2
|
+
|
|
3
|
+
exports[`python-operations plugin > matches the full snapshot for the target example 1`] = `
|
|
4
|
+
"from typing import Optional, TypedDict
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class GetUsersQueryUsersFriends(BaseModel):
|
|
9
|
+
id: 'str'
|
|
10
|
+
name: 'str'
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GetUsersQueryUsers(BaseModel):
|
|
14
|
+
id: 'str'
|
|
15
|
+
name: 'str'
|
|
16
|
+
role: 'Role'
|
|
17
|
+
friends: 'list[GetUsersQueryUsersFriends]'
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class GetUsersQuery(BaseModel):
|
|
21
|
+
users: 'list[GetUsersQueryUsers]'
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class GetUsersQueryVariables(TypedDict):
|
|
25
|
+
role: 'Optional[Role]'"
|
|
26
|
+
`;
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import { buildSchema, parse } from 'graphql';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import type { PythonOperationsPluginConfig } from '../src/config.js';
|
|
4
|
+
import { plugin } from '../src/index.js';
|
|
5
|
+
|
|
6
|
+
const SCHEMA = buildSchema(/* GraphQL */ `
|
|
7
|
+
enum Role {
|
|
8
|
+
ADMIN
|
|
9
|
+
USER
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type User {
|
|
13
|
+
id: ID!
|
|
14
|
+
name: String!
|
|
15
|
+
role: Role!
|
|
16
|
+
friends: [User!]!
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
input CreateUserInput {
|
|
20
|
+
name: String!
|
|
21
|
+
role: Role
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type Query {
|
|
25
|
+
users(role: Role): [User!]!
|
|
26
|
+
me: User
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type Mutation {
|
|
30
|
+
createUser(input: CreateUserInput!): User!
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type Message {
|
|
34
|
+
id: ID!
|
|
35
|
+
from: String!
|
|
36
|
+
body: String!
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type Feed {
|
|
40
|
+
messages: [Message!]!
|
|
41
|
+
}
|
|
42
|
+
`);
|
|
43
|
+
|
|
44
|
+
function run(op: string, config: PythonOperationsPluginConfig = {}): string {
|
|
45
|
+
const documents = [{ location: 'test.graphql', document: parse(op) }];
|
|
46
|
+
const result = plugin(SCHEMA, documents, config) as {
|
|
47
|
+
prepend: string[];
|
|
48
|
+
content: string;
|
|
49
|
+
};
|
|
50
|
+
return [...result.prepend, '', '', result.content].join('\n');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('python-operations plugin', () => {
|
|
54
|
+
it('emits the target example: nested named BaseModel classes + variables TypedDict (deepest first)', () => {
|
|
55
|
+
const out = run(/* GraphQL */ `
|
|
56
|
+
query GetUsers($role: Role) {
|
|
57
|
+
users(role: $role) {
|
|
58
|
+
id
|
|
59
|
+
name
|
|
60
|
+
role
|
|
61
|
+
friends {
|
|
62
|
+
id
|
|
63
|
+
name
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
`);
|
|
68
|
+
|
|
69
|
+
expect(out).toContain('from typing import Optional, TypedDict');
|
|
70
|
+
expect(out).toContain('from pydantic import BaseModel');
|
|
71
|
+
|
|
72
|
+
expect(out).toContain(
|
|
73
|
+
[`class GetUsersQueryUsersFriends(BaseModel):`, ` id: 'str'`, ` name: 'str'`].join(
|
|
74
|
+
'\n',
|
|
75
|
+
),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
expect(out).toContain(
|
|
79
|
+
[
|
|
80
|
+
`class GetUsersQueryUsers(BaseModel):`,
|
|
81
|
+
` id: 'str'`,
|
|
82
|
+
` name: 'str'`,
|
|
83
|
+
` role: 'Role'`,
|
|
84
|
+
` friends: 'list[GetUsersQueryUsersFriends]'`,
|
|
85
|
+
].join('\n'),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
expect(out).toContain(
|
|
89
|
+
[`class GetUsersQuery(BaseModel):`, ` users: 'list[GetUsersQueryUsers]'`].join('\n'),
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
// Variables are a TypedDict keyed by the WIRE variable name.
|
|
93
|
+
expect(out).toContain(
|
|
94
|
+
[`class GetUsersQueryVariables(TypedDict):`, ` role: 'Optional[Role]'`].join('\n'),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
// Deepest type must appear before its parent.
|
|
98
|
+
expect(out.indexOf('class GetUsersQueryUsersFriends')).toBeLessThan(
|
|
99
|
+
out.indexOf('class GetUsersQueryUsers('),
|
|
100
|
+
);
|
|
101
|
+
expect(out.indexOf('class GetUsersQueryUsers(')).toBeLessThan(
|
|
102
|
+
out.indexOf('class GetUsersQuery('),
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('handles mutations with input-object variables', () => {
|
|
107
|
+
const out = run(/* GraphQL */ `
|
|
108
|
+
mutation CreateUser($input: CreateUserInput!) {
|
|
109
|
+
createUser(input: $input) {
|
|
110
|
+
id
|
|
111
|
+
name
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
`);
|
|
115
|
+
|
|
116
|
+
expect(out).toContain(
|
|
117
|
+
[`class CreateUserMutationCreateUser(BaseModel):`, ` id: 'str'`, ` name: 'str'`].join(
|
|
118
|
+
'\n',
|
|
119
|
+
),
|
|
120
|
+
);
|
|
121
|
+
// Response key `createUser` -> snake_case attribute `create_user`, pinned to
|
|
122
|
+
// the wire key with `Field(alias=...)` (+ model_config for populate_by_name).
|
|
123
|
+
expect(out).toContain(
|
|
124
|
+
[
|
|
125
|
+
`class CreateUserMutation(BaseModel):`,
|
|
126
|
+
` model_config = ConfigDict(populate_by_name=True)`,
|
|
127
|
+
` create_user: 'CreateUserMutationCreateUser' = Field(alias='createUser')`,
|
|
128
|
+
].join('\n'),
|
|
129
|
+
);
|
|
130
|
+
// Variables keep the WIRE name `input`.
|
|
131
|
+
expect(out).toContain(
|
|
132
|
+
[`class CreateUserMutationVariables(TypedDict):`, ` input: 'CreateUserInput'`].join('\n'),
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('supports field aliases (alias is the response key) and snake_cases them', () => {
|
|
137
|
+
const out = run(/* GraphQL */ `
|
|
138
|
+
query Aliased {
|
|
139
|
+
currentUser: me {
|
|
140
|
+
fullName: name
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
`);
|
|
144
|
+
|
|
145
|
+
// Nested type name is derived from the alias (PascalCase).
|
|
146
|
+
expect(out).toContain(`class AliasedQueryCurrentUser(BaseModel):`);
|
|
147
|
+
// Response keys are snake_cased by default.
|
|
148
|
+
expect(out).toContain(` full_name: 'str'`);
|
|
149
|
+
expect(out).toContain(` current_user: 'Optional[AliasedQueryCurrentUser]'`);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('supports __typename selections (emitted as typename__ with Field(alias="__typename"))', () => {
|
|
153
|
+
const out = run(/* GraphQL */ `
|
|
154
|
+
query WithTypename {
|
|
155
|
+
me {
|
|
156
|
+
__typename
|
|
157
|
+
id
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
`);
|
|
161
|
+
|
|
162
|
+
expect(out).toContain('from typing import Optional, TypedDict, Literal');
|
|
163
|
+
// pydantic forbids a leading-underscore field name, so `__typename` maps to
|
|
164
|
+
// `typename__` pinned with an alias.
|
|
165
|
+
expect(out).toContain(` typename__: 'Literal["User"]' = Field(alias='__typename')`);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const MESSAGE_SCHEMA = buildSchema(/* GraphQL */ `
|
|
169
|
+
type Message {
|
|
170
|
+
id: ID!
|
|
171
|
+
from: String!
|
|
172
|
+
body: String!
|
|
173
|
+
}
|
|
174
|
+
type Query {
|
|
175
|
+
message: Message
|
|
176
|
+
}
|
|
177
|
+
`);
|
|
178
|
+
const GET_MESSAGE_OP = /* GraphQL */ `
|
|
179
|
+
query GetMessage {
|
|
180
|
+
message {
|
|
181
|
+
id
|
|
182
|
+
from
|
|
183
|
+
body
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
`;
|
|
187
|
+
|
|
188
|
+
it('escapes the reserved key "from" to from_ and pins it with Field(alias="from") (default snakeCase)', () => {
|
|
189
|
+
const documents = [{ location: 'test.graphql', document: parse(GET_MESSAGE_OP) }];
|
|
190
|
+
const result = plugin(MESSAGE_SCHEMA, documents, {}) as {
|
|
191
|
+
prepend: string[];
|
|
192
|
+
content: string;
|
|
193
|
+
};
|
|
194
|
+
const out = [...result.prepend, '', '', result.content].join('\n');
|
|
195
|
+
|
|
196
|
+
// Result types are BaseModel classes -> `from` becomes `from_` + alias.
|
|
197
|
+
expect(out).toContain(`class GetMessageQueryMessage(BaseModel):`);
|
|
198
|
+
expect(out).toContain(` from_: 'str' = Field(alias='from')`);
|
|
199
|
+
expect(out).not.toContain(`def __init__`);
|
|
200
|
+
expect(out).not.toContain(`TypedDict('GetMessageQueryMessage'`);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('namingConvention: keep still escapes the reserved key "from" to from_ (BaseModel class)', () => {
|
|
204
|
+
const documents = [{ location: 'test.graphql', document: parse(GET_MESSAGE_OP) }];
|
|
205
|
+
const result = plugin(MESSAGE_SCHEMA, documents, {
|
|
206
|
+
namingConvention: 'keep',
|
|
207
|
+
}) as { prepend: string[]; content: string };
|
|
208
|
+
const out = [...result.prepend, '', '', result.content].join('\n');
|
|
209
|
+
|
|
210
|
+
expect(out).toContain(`class GetMessageQueryMessage(BaseModel):`);
|
|
211
|
+
expect(out).toContain(` from_: 'str' = Field(alias='from')`);
|
|
212
|
+
expect(out).not.toContain(`def __init__`);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('namingConvention: keep leaves non-keyword response keys verbatim', () => {
|
|
216
|
+
const out = run(
|
|
217
|
+
/* GraphQL */ `
|
|
218
|
+
query Aliased {
|
|
219
|
+
currentUser: me {
|
|
220
|
+
fullName: name
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
`,
|
|
224
|
+
{ namingConvention: 'keep' },
|
|
225
|
+
);
|
|
226
|
+
expect(out).toContain(` fullName: 'str'`);
|
|
227
|
+
expect(out).toContain(` currentUser: 'Optional[AliasedQueryCurrentUser]'`);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('emits an empty (pass) variables TypedDict when there are no variables', () => {
|
|
231
|
+
const out = run(/* GraphQL */ `
|
|
232
|
+
query NoVars {
|
|
233
|
+
me {
|
|
234
|
+
id
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
`);
|
|
238
|
+
|
|
239
|
+
expect(out).toContain([`class NoVarsQueryVariables(TypedDict):`, ` pass`].join('\n'));
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('handles nested lists and nullability in variables + fields', () => {
|
|
243
|
+
const schema = buildSchema(/* GraphQL */ `
|
|
244
|
+
type Query {
|
|
245
|
+
matrix(rows: [[Int]], tags: [String!]): [[Int!]]
|
|
246
|
+
}
|
|
247
|
+
`);
|
|
248
|
+
const documents = [
|
|
249
|
+
{
|
|
250
|
+
location: 'test.graphql',
|
|
251
|
+
document: parse(/* GraphQL */ `
|
|
252
|
+
query Grid($rows: [[Int]], $tags: [String!]) {
|
|
253
|
+
matrix(rows: $rows, tags: $tags)
|
|
254
|
+
}
|
|
255
|
+
`),
|
|
256
|
+
},
|
|
257
|
+
];
|
|
258
|
+
const result = plugin(schema, documents, {}) as { prepend: string[]; content: string };
|
|
259
|
+
const out = [...result.prepend, '', '', result.content].join('\n');
|
|
260
|
+
|
|
261
|
+
// field: [[Int!]] -> Optional[list[Optional[list[int]]]]
|
|
262
|
+
expect(out).toContain(` matrix: 'Optional[list[Optional[list[int]]]]'`);
|
|
263
|
+
// var rows: [[Int]] -> Optional[list[Optional[list[Optional[int]]]]]
|
|
264
|
+
expect(out).toContain(` rows: 'Optional[list[Optional[list[Optional[int]]]]]'`);
|
|
265
|
+
// var tags: [String!] -> Optional[list[str]]
|
|
266
|
+
expect(out).toContain(` tags: 'Optional[list[str]]'`);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('emits Any for custom scalars', () => {
|
|
270
|
+
const schema = buildSchema(/* GraphQL */ `
|
|
271
|
+
scalar DateTime
|
|
272
|
+
type Event {
|
|
273
|
+
id: ID!
|
|
274
|
+
at: DateTime!
|
|
275
|
+
}
|
|
276
|
+
type Query {
|
|
277
|
+
event: Event
|
|
278
|
+
}
|
|
279
|
+
`);
|
|
280
|
+
const documents = [
|
|
281
|
+
{
|
|
282
|
+
location: 'test.graphql',
|
|
283
|
+
document: parse(/* GraphQL */ `
|
|
284
|
+
query GetEvent {
|
|
285
|
+
event {
|
|
286
|
+
id
|
|
287
|
+
at
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
`),
|
|
291
|
+
},
|
|
292
|
+
];
|
|
293
|
+
const result = plugin(schema, documents, {}) as { prepend: string[]; content: string };
|
|
294
|
+
const out = [...result.prepend, '', '', result.content].join('\n');
|
|
295
|
+
|
|
296
|
+
expect(out).toContain(`from typing import Optional, TypedDict, Any`);
|
|
297
|
+
expect(out).toContain(` at: 'Any'`);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('prepends a star import when typesImport is set', () => {
|
|
301
|
+
const out = run(
|
|
302
|
+
/* GraphQL */ `
|
|
303
|
+
query GetUsers($role: Role) {
|
|
304
|
+
users(role: $role) {
|
|
305
|
+
id
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
`,
|
|
309
|
+
{ typesImport: 'my_pkg.schema_types' },
|
|
310
|
+
);
|
|
311
|
+
expect(out).toContain('from my_pkg.schema_types import *');
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('honors baseModel and runtimeImport config', () => {
|
|
315
|
+
const out = run(
|
|
316
|
+
/* GraphQL */ `
|
|
317
|
+
query GetUsers {
|
|
318
|
+
users {
|
|
319
|
+
id
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
`,
|
|
323
|
+
{ baseModel: 'PydModel', runtimeImport: 'myapp.models' },
|
|
324
|
+
);
|
|
325
|
+
expect(out).toContain('class GetUsersQueryUsers(PydModel):');
|
|
326
|
+
expect(out).toContain('from myapp.models import PydModel');
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('matches the full snapshot for the target example', () => {
|
|
330
|
+
const out = run(/* GraphQL */ `
|
|
331
|
+
query GetUsers($role: Role) {
|
|
332
|
+
users(role: $role) {
|
|
333
|
+
id
|
|
334
|
+
name
|
|
335
|
+
role
|
|
336
|
+
friends {
|
|
337
|
+
id
|
|
338
|
+
name
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
`);
|
|
343
|
+
expect(out).toMatchSnapshot();
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('throws a clear error on a fragment spread instead of silently omitting its fields', () => {
|
|
347
|
+
expect(() =>
|
|
348
|
+
run(/* GraphQL */ `
|
|
349
|
+
fragment UserFields on User {
|
|
350
|
+
id
|
|
351
|
+
name
|
|
352
|
+
}
|
|
353
|
+
query GetUsers {
|
|
354
|
+
users {
|
|
355
|
+
...UserFields
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
`),
|
|
359
|
+
).toThrow(/fragment spread "...UserFields"/);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('throws a clear error on an inline fragment instead of silently omitting its fields', () => {
|
|
363
|
+
expect(() =>
|
|
364
|
+
run(/* GraphQL */ `
|
|
365
|
+
query GetUsers {
|
|
366
|
+
users {
|
|
367
|
+
... on User {
|
|
368
|
+
id
|
|
369
|
+
name
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
`),
|
|
374
|
+
).toThrow(/inline fragment "... on User"/);
|
|
375
|
+
});
|
|
376
|
+
});
|
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { defineProject, mergeConfig } from 'vitest/config';
|
|
2
|
+
import { sharedConfig } from '../../../../vitest.config.js';
|
|
3
|
+
|
|
4
|
+
export default mergeConfig(
|
|
5
|
+
sharedConfig,
|
|
6
|
+
defineProject({
|
|
7
|
+
test: {
|
|
8
|
+
name: 'python-operations',
|
|
9
|
+
include: ['**/*.spec.ts'],
|
|
10
|
+
},
|
|
11
|
+
}),
|
|
12
|
+
);
|