@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.
@@ -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
+ });
@@ -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
+ );