|
1 | 1 | package graphql.schema.transform |
2 | 2 |
|
| 3 | +import graphql.AssertException |
3 | 4 | import graphql.Scalars |
4 | 5 | import graphql.TestUtil |
5 | 6 | import graphql.schema.GraphQLAppliedDirective |
@@ -1346,5 +1347,126 @@ class FieldVisibilitySchemaTransformationTest extends Specification { |
1346 | 1347 | (restrictedSchema.getType("Input") as GraphQLInputObjectType).getFieldDefinition("toDelete") == null |
1347 | 1348 | } |
1348 | 1349 |
|
| 1350 | + /** |
| 1351 | + * This test reproduces the issue 4133 bug pattern with FieldVisibilitySchemaTransformation. |
| 1352 | + * |
| 1353 | + * The problem occurs when: |
| 1354 | + * 1. A field is marked @private and gets deleted |
| 1355 | + * 2. The traversal doesn't continue to children of deleted nodes |
| 1356 | + * 3. Types only reachable through deleted fields are not visited |
| 1357 | + * 4. Those unvisited types may have circular references using GraphQLTypeReference |
| 1358 | + * 5. During schema rebuild, stale object references point to types with unresolvable TypeReferences |
| 1359 | + * |
| 1360 | + * Schema structure: |
| 1361 | + * - Query.rental @private -> Rental (not visited because parent field deleted) |
| 1362 | + * - Query.customer -> Customer (visited) |
| 1363 | + * - Customer.rental -> TypeReference("Rental") (placeholder, doesn't cause Rental to be visited) |
| 1364 | + * - Rental.customer -> Customer (actual object reference to ORIGINAL unmodified Customer) |
| 1365 | + * - Customer.payment @private -> Payment (deleted, Payment not visited) |
| 1366 | + * - Payment.inventory -> TypeReference("Inventory") |
| 1367 | + * - Inventory not in schema -> ERROR during type reference resolution |
| 1368 | + * |
| 1369 | + * WORKAROUND: Add all types to additionalTypes before applying the transformation. |
| 1370 | + * This ensures all types are visited during the transformation. |
| 1371 | + */ |
| 1372 | + def "issue 4133 - circular references with private fields - demonstrates the bug"() { |
| 1373 | + given: |
| 1374 | + GraphQLSchema schema = TestUtil.schema(""" |
| 1375 | +
|
| 1376 | + directive @private on FIELD_DEFINITION |
| 1377 | +
|
| 1378 | + type Query { |
| 1379 | + rental: Rental @private |
| 1380 | + customer: Customer |
| 1381 | + } |
| 1382 | + |
| 1383 | + type Customer { |
| 1384 | + rental: Rental |
| 1385 | + payment: Payment @private |
| 1386 | + } |
| 1387 | + |
| 1388 | + type Rental { |
| 1389 | + id: ID |
| 1390 | + customer: Customer @private |
| 1391 | + } |
| 1392 | + |
| 1393 | + type Payment { |
| 1394 | + inventory: Inventory @private |
| 1395 | + } |
| 1396 | + |
| 1397 | + type Inventory { |
| 1398 | + payment: Payment @private |
| 1399 | + } |
| 1400 | + """) |
| 1401 | + |
| 1402 | + when: |
| 1403 | + visibilitySchemaTransformation.apply(schema) |
| 1404 | + |
| 1405 | + then: |
| 1406 | + // This demonstrates the bug - without the workaround, we get "type not found" error |
| 1407 | + def e = thrown(AssertException) |
| 1408 | + e.message.contains("not found in schema") |
| 1409 | + } |
| 1410 | + |
| 1411 | + /** |
| 1412 | + * This test shows the workaround for issue 4133 - add all types to additionalTypes |
| 1413 | + * before applying the transformation to ensure all types are visited. |
| 1414 | + */ |
| 1415 | + def "issue 4133 - circular references with private fields - workaround with additionalTypes"() { |
| 1416 | + given: |
| 1417 | + GraphQLSchema schema = TestUtil.schema(""" |
| 1418 | +
|
| 1419 | + directive @private on FIELD_DEFINITION |
| 1420 | +
|
| 1421 | + type Query { |
| 1422 | + rental: Rental @private |
| 1423 | + customer: Customer |
| 1424 | + } |
| 1425 | + |
| 1426 | + type Customer { |
| 1427 | + rental: Rental |
| 1428 | + payment: Payment @private |
| 1429 | + } |
| 1430 | + |
| 1431 | + type Rental { |
| 1432 | + id: ID |
| 1433 | + customer: Customer @private |
| 1434 | + } |
| 1435 | + |
| 1436 | + type Payment { |
| 1437 | + inventory: Inventory @private |
| 1438 | + } |
| 1439 | + |
| 1440 | + type Inventory { |
| 1441 | + payment: Payment @private |
| 1442 | + } |
| 1443 | + """) |
| 1444 | + |
| 1445 | + // WORKAROUND: Add all types to additionalTypes to ensure they are all visited |
| 1446 | + def patchedSchema = schema.transform { builder -> |
| 1447 | + schema.typeMap.each { entry -> |
| 1448 | + def type = entry.value |
| 1449 | + if (type != schema.queryType && type != schema.mutationType && type != schema.subscriptionType) { |
| 1450 | + builder.additionalType(type) |
| 1451 | + } |
| 1452 | + } |
| 1453 | + } |
| 1454 | + |
| 1455 | + when: |
| 1456 | + GraphQLSchema restrictedSchema = visibilitySchemaTransformation.apply(patchedSchema) |
| 1457 | + |
| 1458 | + then: |
| 1459 | + // Query should only have customer field (rental is private) |
| 1460 | + (restrictedSchema.getType("Query") as GraphQLObjectType).getFieldDefinition("rental") == null |
| 1461 | + (restrictedSchema.getType("Query") as GraphQLObjectType).getFieldDefinition("customer") != null |
| 1462 | + |
| 1463 | + // Customer should only have rental field (payment is private) |
| 1464 | + (restrictedSchema.getType("Customer") as GraphQLObjectType).getFieldDefinition("rental") != null |
| 1465 | + (restrictedSchema.getType("Customer") as GraphQLObjectType).getFieldDefinition("payment") == null |
| 1466 | + |
| 1467 | + // Rental should only have id field (customer is private) |
| 1468 | + (restrictedSchema.getType("Rental") as GraphQLObjectType).getFieldDefinition("id") != null |
| 1469 | + (restrictedSchema.getType("Rental") as GraphQLObjectType).getFieldDefinition("customer") == null |
| 1470 | + } |
1349 | 1471 |
|
1350 | 1472 | } |
0 commit comments