-
Notifications
You must be signed in to change notification settings - Fork 463
Description
Summary
Currently it can be difficult to evolve the schema in a meaningful way, introducing new features, such as the oneOf directive, without inheriting a lot of tech debt and creating a messier schema.
I propose that we explore implementing a "schema transformation layer" that allows WPGraphQL to evolve field arguments while maintaining backward compatibility. This system enables the deprecation of existing arguments while introducing new patterns (like oneOf input types) without breaking existing client queries.
Prior art: https://github.com/graphql-query-rewriter/core
Problem Statement
Currently, WPGraphQL faces a fundamental challenge when evolving schema arguments:
- Non-null arguments cannot be safely deprecated - GraphQL validation requires non-null arguments even if deprecated
- Breaking changes block schema evolution - Removing or changing required arguments breaks existing clients
- Major version requirements - Schema evolution currently requires major version bumps
This blocks the implementation of modern GraphQL patterns like oneOf input types as described in #3416.
We could evolve the Schema by introducing new fields, such as postBy
or getPost
that introduce the new by
argument which would make use of oneOf input types, and these new fields could live alongside the current post
, user
, etc fields, but that feels like we will ultimately have a pretty messy Schema and will inherit a lot of tech debt.
Proposed Solution
Note
I already have a proof-of-concept of this working locally and intend to push up a PR shortly.
Core Concept
A declarative AST-level transformation system that:
- Transforms queries at the AST level before validation - Legacy arguments are rewritten to modern patterns
- Maintains clean schema introspection - Legacy args completely hidden from schema and tooling
- Provides field-level declarative API - Configure transformations directly in field definitions
- Enables seamless backward compatibility - Existing queries work without modification
Architecture
Based on my current p.o.c, we use AST transformation with a declarative field-level API.
This allows for fields that are removing arguments to declare how the legacy args being removed should map to the new args.
This allows for us to safely remove args from the schema to introduce new concepts, while still supporting common arguments for a period of time as client applications migrate to the new args.
// Modern field definition with legacy support
'user' => [
'type' => 'User',
'description' => __( 'Returns a user', 'wp-graphql' ),
'args' => [
// Only modern args appear in schema
'by' => [
'type' => [ 'non_null' => 'UserBy' ], // oneOf input
'description' => 'Arguments for identifying the user',
],
],
'legacy_args' => [
// Legacy args - hidden from introspection but functional
'id' => [
'type' => [ 'non_null' => 'ID' ],
'maps_to' => 'by',
],
'idType' => [
'type' => 'UserNodeIdTypeEnum',
'maps_to' => 'by',
'transform' => static function ( array $legacy_values ): array {
$by_value = [];
if ( isset( $legacy_values['id'] ) ) {
$id_type = $legacy_values['idType'] ?? 'ID';
$id_value = $legacy_values['id'];
switch ( $id_type ) {
case 'database_id':
case 'DATABASE_ID':
$by_value['databaseId'] = $id_value;
break;
case 'global_id':
case 'ID':
case 'id':
default:
$by_value['id'] = $id_value;
break;
case 'email':
case 'EMAIL':
$by_value['email'] = $id_value;
break;
case 'login':
case 'USERNAME':
$by_value['username'] = $id_value;
break;
// ... more cases
}
}
return $by_value;
},
],
],
'resolve' => static function ( $source, array $args, $context ) {
// Resolver always receives modern 'by' argument
$by = $args['by'];
// Handle oneOf input fields...
},
],
How It Works
When a field is removing arguments and introducing new arguments to replace the legacy ones, the field can define the legacy arg mapping at the field level.
Here's what it looks like:
- Field Registration: Fields define
legacy_args
alongside modernargs
- Rule Collection:
WPGraphQL_Legacy_Args_Registry
intercepts field registration and collects transformation rules - AST Transformation:
WPGraphQL_AST_Query_Transformer
parses incoming queries before execution and transforms legacy arguments to modern equivalents - Variable Type Transformation: Variable declarations are automatically updated to match transformed argument types
- Clean Execution: Validation and execution happen on the transformed, modern query
Implementation Status
Based on my local proof-of-concept (which I will be cleaning up and opening a PR for shortly), below is a summary of the state of this feature.
✅ Phase 1: Core Infrastructure - COMPLETE
-
AST-Level Query Transformation
- Complete GraphQL AST parsing and transformation system
- Support for complex argument mapping (e.g.,
id
+idType
→by
) - Variable declaration transformation for type safety
- Full GraphQL-PHP visitor pattern integration
-
Legacy Argument Registry
- Field-level
legacy_args
configuration collection - Dynamic rule loading during request processing
- Support for custom transformation functions
- Integration with WPGraphQL field registration lifecycle
- Field-level
-
Request Lifecycle Integration
pre_graphql_execute_request
filter integration- Transformation occurs after schema building, before validation
- Seamless integration with existing WPGraphQL request flow
- Error handling and fallback to original query on transformation failure
✅ Phase 2: Developer Experience - COMPLETE
-
Declarative API Design
- Clean
legacy_args
configuration syntax - Support for custom transformation functions
maps_to
field mapping specification- Type-safe argument transformation
- Clean
-
Living Example Implementation
RootQuery.user
field converted to use new systemUserBy
oneOf input type implementation- Full backward compatibility with existing
id
/idType
pattern - Comprehensive transformation logic for all user identification methods
-
Schema Introspection Management
- Legacy arguments completely hidden from schema introspection
- Clean, modern schema visible to GraphQL tooling
- No pollution of schema with deprecated arguments
✅ Phase 3: Production Features - COMPLETE
-
Advanced AST Transformations
- Support for variable references in legacy arguments
- Automatic variable type transformation (
$id: ID!
→$id: String!
etc.) - Complex multi-argument to single-argument transformations
- Proper GraphQL AST node creation and manipulation
-
Error Handling & Validation
- Graceful fallback to original query on transformation errors
- Proper error logging for debugging
- Maintains GraphQL validation integrity
- Type-safe transformations with proper error boundaries
Proven Results ✅ TESTED
Technical Validation
- ✅ Zero breaking changes - All existing queries work unchanged
- ✅ Clean schema introspection - Legacy arguments invisible to tooling
- ✅ Type safety maintained - Variable declarations automatically transformed
- ✅ Performance optimized - Transformation only occurs when legacy arguments detected
Working Examples
# Legacy query (still works) - gets transformed under the hood
{ user(id: 1, idType: DATABASE_ID) { id name } }
# Modern query (preferred)
{ user(by: { databaseId: 1 }) { id name } }
# Both produce identical results (with no noticeable performance impact)
Test Results
- ✅ Modern
by
argument:user(by: { databaseId: 1 })
✓ - ✅ Legacy database ID:
user(id: 1, idType: DATABASE_ID)
✓ - ✅ Legacy global ID:
user(id: "dXNlcjox", idType: ID)
✓ - ✅ Legacy email:
user(id: "email", idType: EMAIL)
✓ (with proper permissions) - ✅ Variable transformation:
query($id: ID!) { user(id: $id) }
✓
Benefits Realized
For WPGraphQL Core
- ✅ Enables oneOf implementation - Direct path to #3416
- ✅ Schema evolution without breaking changes - Proven with user field transformation
- ✅ Cleaner schema - Legacy arguments completely hidden from introspection
- ✅ Modern GraphQL patterns - oneOf input types with full backward compatibility
For Plugin Developers
- ✅ Extensible system - Same
legacy_args
API available for all field registrations - ✅ Consistent patterns - Standardized approach across the ecosystem
- ✅ Declarative configuration - Simple, readable transformation definitions
For End Users
- ✅ Zero breaking changes - Existing queries continue working indefinitely (we will likely remove legacy args on fields at some point during a breaking change release, but it provides a manageable way to handle some tech debt for longer periods of time)
- ✅ Better tooling experience - GraphQL IDEs see clean, modern schema only
- ✅ Improved type safety - oneOf inputs provide better validation than id/idType patterns
Implementation Architecture
Core Components
-
WPGraphQL_Legacy_Args_Registry
- Collects
legacy_args
configurations from field definitions - Provides transformation rules to the AST transformer
- Integrates with WPGraphQL field registration lifecycle
- Collects
-
WPGraphQL_AST_Query_Transformer
- Parses GraphQL queries into ASTs using GraphQL-PHP
- Transforms legacy argument patterns to modern equivalents
- Handles variable declaration type updates
- Uses GraphQL-PHP visitor pattern for robust AST manipulation
-
Request Lifecycle Integration
pre_graphql_execute_request
filter intercepts requests- Transformation occurs after schema building, before validation
- Seamless integration with existing WPGraphQL request flow
Technical Highlights
- AST-Level Transformation: More powerful and flexible than resolver-level transformation
- Variable Type Safety: Automatically transforms variable declarations to match new argument types
- GraphQL Spec Compliant: Uses standard GraphQL-PHP AST manipulation
- Performance Optimized: Only transforms queries that contain legacy arguments
- Error Resilient: Falls back to original query if transformation fails
Next Steps
Immediate (Ready for Production)
- Apply pattern to remaining core fields (
post
,page
,category
,tag
, etc.) - Create migration guide for plugin developers
- Performance benchmarking across field types
- Documentation for the
legacy_args
API
Future Enhancements
- Monitoring/analytics for legacy argument usage (this might lead to a bigger story about collecting more telemetry. Not sure we're ready for that)
- Automated migration suggestions
- Support for more complex transformation patterns
- Integration with GraphQL schema evolution best practices
Related Issues
- Parent Issue: #3416 - Implement oneOf "by" arguments - This transformation layer is now ready to enable this
- Related: #3050 - Introduce support for the @OneOf GraphQL Directive - Foundational oneOf support
Status: PROOF OF CONCEPT COMPLETE AND (POSSIBLY CLOSE TO) PRODUCTION-READY 🎉
My p.o.c implementation might provide a foundation for evolving WPGraphQL's schema while maintaining backward compatibility.
I "proved" the system with the user
field, and is ready to be applied across other core WPGraphQL fields (i.e. post
, comment
, page
, category
, tag
, etc) to enable modern GraphQL patterns like oneOf input types.
Metadata
Metadata
Assignees
Labels
Type
Projects
Status