Skip to content

Implement "Legacy Args Transformation" for Schema Evolution #3417

@jasonbahl

Description

@jasonbahl

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:

  1. Transforms queries at the AST level before validation - Legacy arguments are rewritten to modern patterns
  2. Maintains clean schema introspection - Legacy args completely hidden from schema and tooling
  3. Provides field-level declarative API - Configure transformations directly in field definitions
  4. 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:

  1. Field Registration: Fields define legacy_args alongside modern args
  2. Rule Collection: WPGraphQL_Legacy_Args_Registry intercepts field registration and collects transformation rules
  3. AST Transformation: WPGraphQL_AST_Query_Transformer parses incoming queries before execution and transforms legacy arguments to modern equivalents
  4. Variable Type Transformation: Variable declarations are automatically updated to match transformed argument types
  5. 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 + idTypeby)
    • 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
  • 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
  • Living Example Implementation

    • RootQuery.user field converted to use new system
    • UserBy 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

  1. WPGraphQL_Legacy_Args_Registry

    • Collects legacy_args configurations from field definitions
    • Provides transformation rules to the AST transformer
    • Integrates with WPGraphQL field registration lifecycle
  2. 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
  3. 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


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

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status

    🆕 New

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions