From f63dbb75e81dd7e5d73677ac797a704e9e69efda Mon Sep 17 00:00:00 2001 From: Geoff Taylor Date: Tue, 24 Mar 2026 20:42:15 -0400 Subject: [PATCH] Improve i18n compatibility for WPML, Polylang, and non-latin characters (#994) * fix: improve i18n compatibility for WPML, Polylang, and non-latin character support - Add Label::get_safe_enum_name() utility with optional transliteration for non-latin tax class/attribute/taxonomy names (#637, #409) - Add "Transliterate non-latin characters" admin setting - Replace get_page_by_path() with WP_Query for product slug resolution so WPML/Polylang can hook into the standard query pipeline (#403, #368) - Split product connections: `products` (toType: Product) and `productsWithVariations` (toType: ProductUnion) so i18n plugins can register language where args on the standard type name (#811, #952) - Add ProductTypesWithVariationsEnum for the ProductUnion connection - Add i18n compatibility tests * chore: Linter compliances met --- includes/admin/class-general.php | 9 + includes/class-wp-graphql-woocommerce.php | 1 + includes/connection/class-products.php | 120 +++++++++--- .../enum/class-product-attribute-enum.php | 8 +- includes/type/enum/class-product-taxonomy.php | 8 +- includes/type/enum/class-product-types.php | 19 ++ includes/type/enum/class-tax-class.php | 8 +- includes/type/interface/class-product.php | 14 +- includes/utils/class-label.php | 49 +++++ tests/wpunit/ConnectionPaginationTest.php | 2 +- tests/wpunit/I18nCompatibilityTest.php | 171 ++++++++++++++++++ tests/wpunit/ProductQueriesTest.php | 2 +- tests/wpunit/ProductVariationQueriesTest.php | 24 +-- 13 files changed, 389 insertions(+), 46 deletions(-) create mode 100644 includes/utils/class-label.php create mode 100644 tests/wpunit/I18nCompatibilityTest.php diff --git a/includes/admin/class-general.php b/includes/admin/class-general.php index 08074e03..44870909 100644 --- a/includes/admin/class-general.php +++ b/includes/admin/class-general.php @@ -122,6 +122,15 @@ class General extends Section { 'default' => 'keep_new_fallback_old', 'disabled' => defined( 'NO_QL_SESSION_HANDLER' ), ], + [ + 'name' => 'enable_transliteration', + 'label' => __( 'Transliterate non-latin characters', 'wp-graphql-woocommerce' ), + 'desc' => __( 'Converts non-latin characters (Cyrillic, Chinese, Arabic, etc.) to their latin equivalents in GraphQL type and enum names. Enable this if your WooCommerce tax classes, product attributes, or taxonomies use non-latin names. Requires the PHP intl extension.', 'wp-graphql-woocommerce' ) + . ( ! function_exists( 'transliterator_transliterate' ) ? __( ' Warning: The PHP intl extension is not available. This setting will have no effect.', 'wp-graphql-woocommerce' ) : '' ), + 'type' => 'checkbox', + 'default' => 'off', + 'disabled' => ! function_exists( 'transliterator_transliterate' ), + ], [ 'name' => 'enable_unsupported_product_type', 'label' => __( 'Enable Unsupported types', 'wp-graphql-woocommerce' ), diff --git a/includes/class-wp-graphql-woocommerce.php b/includes/class-wp-graphql-woocommerce.php index 51bc0cdd..dc3c4aea 100644 --- a/includes/class-wp-graphql-woocommerce.php +++ b/includes/class-wp-graphql-woocommerce.php @@ -162,6 +162,7 @@ if ( ! class_exists( '\WPGraphQL\WooCommerce\WP_GraphQL_WooCommerce' ) ) : $include_directory_path = get_includes_directory(); // Include util class files. + require $include_directory_path . 'utils/class-label.php'; require $include_directory_path . 'utils/class-ql-session-handler.php'; require $include_directory_path . 'utils/class-session-transaction-manager.php'; diff --git a/includes/connection/class-products.php b/includes/connection/class-products.php index afac8519..42cd0835 100644 --- a/includes/connection/class-products.php +++ b/includes/connection/class-products.php @@ -24,8 +24,23 @@ class Products { * @return void */ public static function register_connections() { - // From RootQuery. - register_graphql_connection( self::get_connection_config() ); + // Products connection (toType: Product) — compatible with i18n plugins. + register_graphql_connection( + self::get_connection_config( + [ + 'toType' => 'Product', + 'fromFieldName' => 'products', + 'connectionArgs' => self::get_product_connection_args(), + ] + ) + ); + + // Products with variations connection (toType: ProductUnion). + register_graphql_connection( + self::get_connection_config( + [ 'fromFieldName' => 'productsWithVariations' ] + ) + ); // From Coupon. register_graphql_connection( @@ -266,7 +281,15 @@ class Products { $to_type = $config['toType']; $from_type = $config['fromType']; if ( 'Product' === $to_type ) { - $config['connectionArgs'] = self::get_connection_args(); + $args = self::get_product_connection_args(); + $config['connectionArgs'] = ! empty( $config['connectionArgs'] ) + ? array_merge( $config['connectionArgs'], $args ) + : $args; + } elseif ( 'ProductUnion' === $to_type ) { + $args = self::get_product_union_connection_args(); + $config['connectionArgs'] = ! empty( $config['connectionArgs'] ) + ? array_merge( $config['connectionArgs'], $args ) + : $args; } $taxonomies = self::get_product_connected_taxonomies(); @@ -333,13 +356,11 @@ class Products { } /** - * Returns array of where args. - * - * @param array $extra_args Extra connection args. + * Returns the shared base connection args used by both product connections. * * @return array */ - public static function get_connection_args( $extra_args = [] ): array { + private static function get_base_connection_args(): array { $args = [ 'slugIn' => [ 'type' => [ 'list_of' => 'String' ], @@ -349,18 +370,6 @@ class Products { 'type' => 'String', 'description' => __( 'Limit result set to products assigned a specific status.', 'wp-graphql-woocommerce' ), ], - 'type' => [ - 'type' => 'ProductTypesEnum', - 'description' => __( 'Limit result set to products assigned a specific type.', 'wp-graphql-woocommerce' ), - ], - 'typeIn' => [ - 'type' => [ 'list_of' => 'ProductTypesEnum' ], - 'description' => __( 'Limit result set to products assigned to a group of specific types.', 'wp-graphql-woocommerce' ), - ], - 'typeNotIn' => [ - 'type' => [ 'list_of' => 'ProductTypesEnum' ], - 'description' => __( 'Limit result set to products not assigned to a group of specific types.', 'wp-graphql-woocommerce' ), - ], 'sku' => [ 'type' => 'String', 'description' => __( 'Limit result set to products with specific SKU(s). Use commas to separate.', 'wp-graphql-woocommerce' ), @@ -495,10 +504,6 @@ class Products { 'type' => 'Boolean', 'description' => __( 'Limit result types to types supported by WooGraphQL.', 'wp-graphql-woocommerce' ), ], - 'includeVariations' => [ - 'type' => 'Boolean', - 'description' => __( 'Include variations in the result set.', 'wp-graphql-woocommerce' ), - ], 'rating' => [ 'type' => [ 'list_of' => 'Integer' ], 'description' => __( 'Limit result set to products with a specific average rating. Must be between 1 and 5', 'wp-graphql-woocommerce' ), @@ -512,6 +517,73 @@ class Products { ]; } - return array_merge( get_wc_cpt_connection_args(), $args, $extra_args ); + return array_merge( get_wc_cpt_connection_args(), $args ); + } + + /** + * Returns connection args for the Product connection (excludes variation types). + * + * @return array + */ + public static function get_product_connection_args(): array { + return array_merge( + self::get_base_connection_args(), + [ + 'type' => [ + 'type' => 'ProductTypesEnum', + 'description' => __( 'Limit result set to products assigned a specific type.', 'wp-graphql-woocommerce' ), + ], + 'typeIn' => [ + 'type' => [ 'list_of' => 'ProductTypesEnum' ], + 'description' => __( 'Limit result set to products assigned to a group of specific types.', 'wp-graphql-woocommerce' ), + ], + 'typeNotIn' => [ + 'type' => [ 'list_of' => 'ProductTypesEnum' ], + 'description' => __( 'Limit result set to products not assigned to a group of specific types.', 'wp-graphql-woocommerce' ), + ], + ] + ); + } + + /** + * Returns connection args for the ProductUnion connection (includes variation types). + * + * @return array + */ + public static function get_product_union_connection_args(): array { + return array_merge( + self::get_base_connection_args(), + [ + 'type' => [ + 'type' => 'ProductTypesWithVariationsEnum', + 'description' => __( 'Limit result set to products assigned a specific type.', 'wp-graphql-woocommerce' ), + ], + 'typeIn' => [ + 'type' => [ 'list_of' => 'ProductTypesWithVariationsEnum' ], + 'description' => __( 'Limit result set to products assigned to a group of specific types.', 'wp-graphql-woocommerce' ), + ], + 'typeNotIn' => [ + 'type' => [ 'list_of' => 'ProductTypesWithVariationsEnum' ], + 'description' => __( 'Limit result set to products not assigned to a group of specific types.', 'wp-graphql-woocommerce' ), + ], + 'includeVariations' => [ + 'type' => 'Boolean', + 'description' => __( 'Include variations in the result set.', 'wp-graphql-woocommerce' ), + ], + ] + ); + } + + /** + * Returns array of where args. + * + * @deprecated Use get_product_connection_args() or get_product_union_connection_args() instead. + * + * @param array $extra_args Extra connection args. + * + * @return array + */ + public static function get_connection_args( $extra_args = [] ): array { + return array_merge( self::get_product_union_connection_args(), $extra_args ); } } diff --git a/includes/type/enum/class-product-attribute-enum.php b/includes/type/enum/class-product-attribute-enum.php index db30fdef..99bda67c 100644 --- a/includes/type/enum/class-product-attribute-enum.php +++ b/includes/type/enum/class-product-attribute-enum.php @@ -8,7 +8,7 @@ namespace WPGraphQL\WooCommerce\Type\WPEnum; -use WPGraphQL\Type\WPEnumType; +use WPGraphQL\WooCommerce\Utils\Label; /** * Class Product_Attribute_Enum @@ -28,7 +28,11 @@ class Product_Attribute_Enum { $tax_object = get_taxonomy( $taxonomy ); if ( false !== $tax_object && in_array( 'product', $tax_object->object_type, true ) ) { - $taxonomy_values[ WPEnumType::get_safe_name( $taxonomy ) ] = [ 'value' => $taxonomy ]; + $safe_name = Label::get_safe_enum_name( $taxonomy ); + if ( null === $safe_name ) { + continue; + } + $taxonomy_values[ $safe_name ] = [ 'value' => $taxonomy ]; } } diff --git a/includes/type/enum/class-product-taxonomy.php b/includes/type/enum/class-product-taxonomy.php index a9e46408..f3282e8b 100644 --- a/includes/type/enum/class-product-taxonomy.php +++ b/includes/type/enum/class-product-taxonomy.php @@ -8,7 +8,7 @@ namespace WPGraphQL\WooCommerce\Type\WPEnum; -use WPGraphQL\Type\WPEnumType; +use WPGraphQL\WooCommerce\Utils\Label; /** * Class Product_Taxonomy @@ -28,7 +28,11 @@ class Product_Taxonomy { $tax_object = get_taxonomy( $taxonomy ); if ( false !== $tax_object && in_array( 'product', $tax_object->object_type, true ) ) { - $taxonomy_values[ WPEnumType::get_safe_name( $taxonomy ) ] = [ 'value' => $taxonomy ]; + $safe_name = Label::get_safe_enum_name( $taxonomy ); + if ( null === $safe_name ) { + continue; + } + $taxonomy_values[ $safe_name ] = [ 'value' => $taxonomy ]; } } diff --git a/includes/type/enum/class-product-types.php b/includes/type/enum/class-product-types.php index 3fa0150e..36e4f3c4 100644 --- a/includes/type/enum/class-product-types.php +++ b/includes/type/enum/class-product-types.php @@ -59,5 +59,24 @@ class Product_Types { 'values' => $values, ] ); + + register_graphql_enum_type( + 'ProductTypesWithVariationsEnum', + [ + 'description' => __( 'Product type enumeration including variation types', 'wp-graphql-woocommerce' ), + 'values' => apply_filters( + 'graphql_product_types_with_variations_enum_values', + array_merge( + $values, + [ + 'VARIATION' => [ + 'value' => 'variation', + 'description' => __( 'A product variation', 'wp-graphql-woocommerce' ), + ], + ] + ) + ), + ] + ); } } diff --git a/includes/type/enum/class-tax-class.php b/includes/type/enum/class-tax-class.php index 8620d923..4e53b90b 100644 --- a/includes/type/enum/class-tax-class.php +++ b/includes/type/enum/class-tax-class.php @@ -8,7 +8,7 @@ namespace WPGraphQL\WooCommerce\Type\WPEnum; -use WPGraphQL\Type\WPEnumType; +use WPGraphQL\WooCommerce\Utils\Label; /** * Class Tax_Class @@ -33,7 +33,11 @@ class Tax_Class { $classes = \WC_Tax::get_tax_classes(); foreach ( $classes as $class ) { - $values[ WPEnumType::get_safe_name( $class ) ] = [ 'value' => sanitize_title( $class ) ]; + $safe_name = Label::get_safe_enum_name( $class ); + if ( null === $safe_name ) { + continue; + } + $values[ $safe_name ] = [ 'value' => sanitize_title( $class ) ]; } register_graphql_enum_type( diff --git a/includes/type/interface/class-product.php b/includes/type/interface/class-product.php index ad489e2e..99788d64 100644 --- a/includes/type/interface/class-product.php +++ b/includes/type/interface/class-product.php @@ -53,8 +53,18 @@ class Product { $product_id = \wc_get_product_id_by_sku( $id ); break; case 'slug': - $post = get_page_by_path( $id, OBJECT, 'product' ); - $product_id = ! empty( $post ) ? absint( $post->ID ) : 0; + $query = new \WP_Query( + [ + 'name' => $id, + 'post_type' => 'product', + 'post_status' => 'publish', + 'posts_per_page' => 1, + 'fields' => 'ids', + ] + ); + /** @var int $post_id */ + $post_id = ! empty( $query->posts ) ? $query->posts[0] : 0; + $product_id = absint( $post_id ); break; case 'database_id': $product_id = absint( $id ); diff --git a/includes/utils/class-label.php b/includes/utils/class-label.php new file mode 100644 index 00000000..86d17913 --- /dev/null +++ b/includes/utils/class-label.php @@ -0,0 +1,49 @@ + 'off' ] ) + ); + + // Create tax classes with non-latin characters. + \WC_Tax::create_tax_class( 'Сниженная ставка', 'reduced-cyrillic' ); + \WC_Tax::create_tax_class( '减税率', 'chinese-rate' ); + \WC_Tax::create_tax_class( 'Valid Rate', 'valid-rate' ); + + $this->clearSchema(); + + // Run an introspection query — this should not throw a 500 error. + $query = '{ + __schema { + types { + name + } + } + }'; + + $response = $this->graphql( compact( 'query' ) ); + $this->assertQuerySuccessful( + $response, + [ $this->expectedField( '__schema.types', static::NOT_FALSY ) ] + ); + + // Query the TaxClassEnum values. + $query = '{ + __type(name: "TaxClassEnum") { + enumValues { + name + } + } + }'; + + $response = $this->graphql( compact( 'query' ) ); + $enum_values = $this->lodashGet( $response, 'data.__type.enumValues' ); + $enum_names = array_column( $enum_values, 'name' ); + + // Non-latin names produce underscore-only values when transliteration is off. + // These should be skipped to avoid collisions and meaningless enum entries. + $this->assertNotContains( '_', $enum_names, 'Underscore-only enum values should be skipped.' ); + + // The valid latin class should still be present. + $this->assertContains( 'VALID_RATE', $enum_names, 'Valid latin tax class should be in the enum.' ); + } + + /** + * Test that enabling transliteration converts non-latin tax class names + * into valid, meaningful GraphQL enum values. + * + * @see https://github.com/wp-graphql/wp-graphql-woocommerce/issues/637 + */ + public function testNonLatinTaxClassTransliteratedWhenEnabled() { + if ( ! function_exists( 'transliterator_transliterate' ) ) { + $this->markTestSkipped( 'intl extension not available.' ); + } + + // Enable transliteration setting. + $existing = get_option( 'woographql_settings', [] ); + update_option( + 'woographql_settings', + array_merge( is_array( $existing ) ? $existing : [], [ 'enable_transliteration' => 'on' ] ) + ); + + // Create tax classes with non-latin names. + \WC_Tax::create_tax_class( 'Ставка НДС', 'nds-rate' ); + \WC_Tax::create_tax_class( '减税率', 'chinese-rate' ); + \WC_Tax::create_tax_class( 'Valid Rate', 'valid-rate' ); + + $this->clearSchema(); + + // Query the TaxClassEnum values via introspection. + $query = '{ + __type(name: "TaxClassEnum") { + enumValues { + name + } + } + }'; + + $response = $this->graphql( compact( 'query' ) ); + $this->assertQuerySuccessful( + $response, + [ $this->expectedField( '__type.enumValues', static::NOT_FALSY ) ] + ); + + $enum_values = $this->lodashGet( $response, 'data.__type.enumValues' ); + $enum_names = array_column( $enum_values, 'name' ); + + // Non-latin classes should be transliterated, not produce underscore-only names. + $this->assertNotContains( '_', $enum_names, 'Transliterated enum values should not be underscore-only.' ); + + // Verify all enum values have at least one alphanumeric character. + foreach ( $enum_values as $value ) { + $this->assertMatchesRegularExpression( + '/[A-Za-z0-9]/', + $value['name'], + sprintf( 'Enum value "%s" must contain at least one alphanumeric character.', $value['name'] ) + ); + } + + // The Cyrillic class should be transliterated. + $this->assertContains( 'STAVKA_NDS', $enum_names, 'Cyrillic tax class should be transliterated.' ); + + // The Chinese class should be transliterated. + $this->assertContains( 'JIAN_SHUI_LU', $enum_names, 'Chinese tax class should be transliterated.' ); + + // The valid latin class should still be present. + $this->assertContains( 'VALID_RATE', $enum_names, 'Valid latin tax class should be in the enum.' ); + } + + /** + * Test that product slug resolution uses WordPress's standard query mechanisms + * which i18n plugins (WPML, Polylang) can hook into. + * + * @see https://github.com/wp-graphql/wp-graphql-woocommerce/issues/403 + */ + public function testProductSlugResolutionUsesNodeResolver() { + $product_id = $this->factory->product->createSimple(); + $slug = get_post_field( 'post_name', $product_id ); + + $query = ' + query ($slug: ID!) { + product(id: $slug, idType: SLUG) { + ... on SimpleProduct { + databaseId + name + slug + } + } + } + '; + + $response = $this->graphql( + [ + 'query' => $query, + 'variables' => [ 'slug' => $slug ], + ] + ); + + $this->assertQuerySuccessful( + $response, + [ + $this->expectedField( 'product.databaseId', $product_id ), + $this->expectedField( 'product.slug', $slug ), + ] + ); + } + +} diff --git a/tests/wpunit/ProductQueriesTest.php b/tests/wpunit/ProductQueriesTest.php index b4607c15..0c32643c 100644 --- a/tests/wpunit/ProductQueriesTest.php +++ b/tests/wpunit/ProductQueriesTest.php @@ -1075,7 +1075,7 @@ class ProductQueriesTest extends \Tests\WPGraphQL\WooCommerce\TestCase\WooGraphQ // Create query. $query = ' - query( $where: RootQueryToProductUnionConnectionWhereArgs ) { + query( $where: RootQueryToProductConnectionWhereArgs ) { products( where: $where ) { nodes { ... on SimpleProduct { diff --git a/tests/wpunit/ProductVariationQueriesTest.php b/tests/wpunit/ProductVariationQueriesTest.php index a3e70e8f..78200ca5 100644 --- a/tests/wpunit/ProductVariationQueriesTest.php +++ b/tests/wpunit/ProductVariationQueriesTest.php @@ -283,8 +283,8 @@ class ProductVariationQueriesTest extends \Tests\WPGraphQL\WooCommerce\TestCase\ ); $query = ' - query ( $type: ProductTypesEnum, $typeIn: [ProductTypesEnum], $includeVariations: Boolean ) { - products( where: { type: $type, typeIn: $typeIn includeVariations: $includeVariations } ) { + query ( $type: ProductTypesWithVariationsEnum, $typeIn: [ProductTypesWithVariationsEnum], $includeVariations: Boolean ) { + productsWithVariations( where: { type: $type, typeIn: $typeIn includeVariations: $includeVariations } ) { nodes { id } @@ -297,9 +297,9 @@ class ProductVariationQueriesTest extends \Tests\WPGraphQL\WooCommerce\TestCase\ */ $response = $this->graphql( compact( 'query' ) ); $expected = [ - $this->expectedField( 'products.nodes.0.id', $this->toRelayId( 'post', $product_id ) ), - $this->not()->expectedField( 'products.nodes.#.id', $this->toRelayId( 'post', $variation_id ) ), - $this->not()->expectedField( 'products.nodes.#.id', $this->toRelayId( 'post', $other_variation_id ) ), + $this->expectedField( 'productsWithVariations.nodes.0.id', $this->toRelayId( 'post', $product_id ) ), + $this->not()->expectedField( 'productsWithVariations.nodes.#.id', $this->toRelayId( 'post', $variation_id ) ), + $this->not()->expectedField( 'productsWithVariations.nodes.#.id', $this->toRelayId( 'post', $other_variation_id ) ), ]; $this->assertQuerySuccessful( $response, $expected ); @@ -310,22 +310,22 @@ class ProductVariationQueriesTest extends \Tests\WPGraphQL\WooCommerce\TestCase\ $variables = [ 'type' => 'VARIATION' ]; $response = $this->graphql( compact( 'query', 'variables' ) ); $expected = [ - $this->not()->expectedField( 'products.nodes.#.id', $this->toRelayId( 'post', $product_id ) ), - $this->expectedField( 'products.nodes.#.id', $this->toRelayId( 'post', $variation_id ) ), - $this->expectedField( 'products.nodes.#.id', $this->toRelayId( 'post', $other_variation_id ) ), + $this->not()->expectedField( 'productsWithVariations.nodes.#.id', $this->toRelayId( 'post', $product_id ) ), + $this->expectedField( 'productsWithVariations.nodes.#.id', $this->toRelayId( 'post', $variation_id ) ), + $this->expectedField( 'productsWithVariations.nodes.#.id', $this->toRelayId( 'post', $other_variation_id ) ), ]; $this->assertQuerySuccessful( $response, $expected ); /** - * Assert result with "typeIn" set to "VARIATION" & "VARIATION" products and variations are returned. + * Assert result with "includeVariations" returns products and variations. */ $variables = [ 'includeVariations' => true ]; $response = $this->graphql( compact( 'query', 'variables' ) ); $expected = [ - $this->expectedField( 'products.nodes.#.id', $this->toRelayId( 'post', $product_id ) ), - $this->expectedField( 'products.nodes.#.id', $this->toRelayId( 'post', $variation_id ) ), - $this->expectedField( 'products.nodes.#.id', $this->toRelayId( 'post', $other_variation_id ) ), + $this->expectedField( 'productsWithVariations.nodes.#.id', $this->toRelayId( 'post', $product_id ) ), + $this->expectedField( 'productsWithVariations.nodes.#.id', $this->toRelayId( 'post', $variation_id ) ), + $this->expectedField( 'productsWithVariations.nodes.#.id', $this->toRelayId( 'post', $other_variation_id ) ), ]; $this->assertQuerySuccessful( $response, $expected );