From a09663d6a340e48a855eeb9daa14a94810963d38 Mon Sep 17 00:00:00 2001 From: William Arin Date: Thu, 17 Mar 2022 00:06:46 +0800 Subject: [PATCH] feat: query terms with post relationship condition --- Makefile | 7 + README.md | 21 +++ .../Repository/AbstractEntityRepository.php | 128 ++++++++++++++++-- .../Repository/FieldValidationTrait.php | 16 ++- src/Bridge/Repository/FindByTrait.php | 52 +++---- src/Bridge/Repository/NormalizerTrait.php | 6 +- src/Criteria/PostRelationshipCondition.php | 24 ++++ .../Repository/ProductRepositoryTest.php | 11 +- .../Bridge/Repository/TermRepositoryTest.php | 21 +++ 9 files changed, 245 insertions(+), 41 deletions(-) create mode 100644 src/Criteria/PostRelationshipCondition.php diff --git a/Makefile b/Makefile index 5627007..e544d7d 100644 --- a/Makefile +++ b/Makefile @@ -45,6 +45,13 @@ reset-woocommerce: @$(CLI) wc shop_order create --user=1 --customer_id=0 --line_items='[{"product_id":20},{"product_id":22}]' \ --billing='{"first_name":"Trudie","last_name":"Metz","company":"Amazon","address_1":"135 Wyandot Ave","city":"Marion","state":"Ohio","postcode":"43302","country":"United States","email":"trudie@woo.local","phone":"(740) 383-4031"}' \ --shipping='{"first_name":"Trudie","last_name":"Metz","company":"Amazon","address_1":"135 Wyandot Ave","city":"Marion","state":"Ohio","postcode":"43302","country":"United States","email":"trudie@woo.local","phone":"(740) 383-4031"}' + @$(CLI) wc product_attribute create --name="Manufacturer" --user=1 + @$(CLI) wc product_attribute_term create 1 --name="SuperBrand" --user=1 + @$(CLI) wc product_attribute_term create 1 --name="MegaBrand" --user=1 + @$(CLI) wc product create --name="Special Forces Hoodie" --description="Military hoodie with logo" \ + --type=simple --regular_price=500 --user=1 --categories='[{"id": 17}]' --sku="super-forces-hoodie" \ + --attributes='[{"name": "pa_manufacturer", "visible": true, "options": ["MegaBrand"]}]' + @$(CLI) post term add 64 pa_manufacturer megabrand .PHONY: test test: diff --git a/README.md b/README.md index 51999fb..9deea71 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,27 @@ $products = $manager->getRepository(Product::class) ]); ``` +### Post relationship conditions + +Query terms based on their posts relationships. + +```php +// Fetch all terms of the product with SKU "super-forces-hoodie" +// belonging to all taxonomies except "product_tag", "product_type", "product_visibility". +$terms = $manager->getRepository(Term::class) + ->findBy([ + new SelectColumns(['taxonomy', 'name']), + new PostRelationshipCondition(Product::class, [ + 'post_status' => new Operand(['publish', 'private'], Operand::OPERATOR_IN), + 'sku' => 'super-forces-hoodie', + ]), + 'taxonomy' => new Operand( + ['product_tag', 'product_type', 'product_visibility'], + Operand::OPERATOR_NOT_IN, + ), + ]); +``` + ### Restrict selected columns Querying all columns at once is slow, especially if you have a lot of entities to retrieve. diff --git a/src/Bridge/Repository/AbstractEntityRepository.php b/src/Bridge/Repository/AbstractEntityRepository.php index 6ac31f5..95a9f5c 100644 --- a/src/Bridge/Repository/AbstractEntityRepository.php +++ b/src/Bridge/Repository/AbstractEntityRepository.php @@ -12,6 +12,7 @@ use Williarin\WordpressInterop\Bridge\Entity\BaseEntity; use Williarin\WordpressInterop\Criteria\NestedCondition; use Williarin\WordpressInterop\Criteria\Operand; +use Williarin\WordpressInterop\Criteria\PostRelationshipCondition; use Williarin\WordpressInterop\Criteria\RelationshipCondition; use Williarin\WordpressInterop\Criteria\SelectColumns; use Williarin\WordpressInterop\Criteria\TermRelationshipCondition; @@ -160,17 +161,22 @@ protected function doUpdate(string $name, array $arguments): bool return $this->updateSingleField($arguments[0], property_to_field(substr($name, 6)), $arguments[1]); } - protected function getMappedMetaKey(mixed $fieldName): string + protected function getMappedMetaKey(mixed $fieldName, string $entityClassName = null): string { + $mappedFields = $entityClassName ? (new \ReflectionClassConstant( + $this->entityManager->getRepository($entityClassName), + 'MAPPED_FIELDS', + ))->getValue() : static::MAPPED_FIELDS; + if ( - !is_array(static::MAPPED_FIELDS) - || empty(static::MAPPED_FIELDS) - || !in_array($fieldName, static::MAPPED_FIELDS, true) + !is_array($mappedFields) + || empty($mappedFields) + || !in_array($fieldName, $mappedFields, true) ) { return $fieldName; } - $key = array_search($fieldName, static::MAPPED_FIELDS, true); + $key = array_search($fieldName, $mappedFields, true); if (is_numeric($key)) { return sprintf('_%s', $fieldName); @@ -234,6 +240,12 @@ protected function handleSpecialCriteria( return self::IS_SPECIAL_CRITERIA; } + if ($value instanceof PostRelationshipCondition) { + $this->createPostRelationshipCriteria($queryBuilder, $value); + + return self::IS_SPECIAL_CRITERIA; + } + return 0; } @@ -242,6 +254,8 @@ protected function handleRegularCriteria( array $criteria, string $field, mixed $value, + string $entityClassName = null, + int $aliasNumber = null, ): void { $snakeField = u($field) ->snake() @@ -278,7 +292,23 @@ protected function handleRegularCriteria( } } - if (in_array(substr($field, (strpos($field, '.') ?: -1) + 1), $this->getEntityExtraFields(), true)) { + if ( + $entityClassName !== null + && $aliasNumber !== null + && in_array( + substr($field, (strpos($field, '.') ?: -1) + 1), + $this->getEntityExtraFields($entityClassName), + true, + ) + ) { + $exprKey = sprintf('pm_%d.meta_key = :%s_key', $aliasNumber, $snakeField); + $queryBuilder->andWhere($exprKey) + ->setParameter(sprintf('%s_key', $snakeField), $this->getMappedMetaKey($field, $entityClassName)) + ; + + $exprValue = sprintf('pm_%d.meta_value %s %s', $aliasNumber, $operator, $parameter); + $queryBuilder->andWhere($exprValue); + } elseif (in_array(substr($field, (strpos($field, '.') ?: -1) + 1), $this->getEntityExtraFields(), true)) { $expr = sprintf('%s %s %s', $field, $operator, $parameter); $queryBuilder->andHaving($expr); } else { @@ -363,6 +393,71 @@ private function createTermRelationshipCriteria( ++$aliasNumber; } + private function createPostRelationshipCriteria( + QueryBuilder $queryBuilder, + PostRelationshipCondition $condition, + ): void { + static $aliasNumber = 0; + + $queryBuilder + ->join( + 'tt', + $this->entityManager->getTablesPrefix() . 'term_relationships', + sprintf('tr_%d', $aliasNumber), + sprintf('tt.term_taxonomy_id = tr_%s.term_taxonomy_id', $aliasNumber), + ) + ->join( + sprintf('tr_%d', $aliasNumber), + $this->entityManager->getTablesPrefix() . 'posts', + sprintf('p_%d', $aliasNumber), + sprintf('tr_%d.object_id = p_%d.id', $aliasNumber, $aliasNumber), + ) + ; + + $this->addPostMetaJoinForPostRelationshipCondition($queryBuilder, $condition, $aliasNumber); + $prefixedCriteria = $this->getPrefixedCriteriaForPostRelationshipCondition($condition, $aliasNumber); + $normalizedCriteria = $this->normalizeCriteria($prefixedCriteria, $condition->getEntityClassName()); + + foreach ($normalizedCriteria as $field => $value) { + $this->handleRegularCriteria( + $queryBuilder, + $prefixedCriteria, + $field, + $value, + $condition->getEntityClassName(), + $aliasNumber, + ); + } + + ++$aliasNumber; + } + + private function addPostMetaJoinForPostRelationshipCondition( + QueryBuilder $queryBuilder, + PostRelationshipCondition $condition, + int $aliasNumber, + ): void { + $extraFields = $this->getEntityExtraFields($condition->getEntityClassName()); + + if (!empty($extraFields)) { + $alias = sprintf('pm_%d', $aliasNumber); + $this->tableAliases[$alias] = []; + + $queryBuilder->leftJoin( + sprintf('p_%d', $aliasNumber), + $this->entityManager->getTablesPrefix() . 'postmeta', + $alias, + sprintf('p_%d.id = pm_%d.post_id', $aliasNumber, $aliasNumber), + ); + + foreach ($extraFields as $extraField) { + $this->tableAliases[$alias][] = property_to_field($extraField); + } + + $queryBuilder->addGroupBy(sprintf('p_%d.id', $aliasNumber)); + } + } + private function getPrefixedCriteriaForTermRelationshipCondition(array $criteria, int $aliasNumber): array { $output = []; @@ -379,6 +474,25 @@ private function getPrefixedCriteriaForTermRelationshipCondition(array $criteria return $output; } + private function getPrefixedCriteriaForPostRelationshipCondition( + PostRelationshipCondition $condition, + int $aliasNumber + ): array { + $output = []; + + foreach ($condition->getCriteria() as $field => $value) { + $prefixedField = $field; + + if (in_array($field, $this->getEntityBaseFields($condition->getEntityClassName()), true)) { + $prefixedField = sprintf('p_%d.%s', $aliasNumber, $field); + } + + $output[$prefixedField] = $value; + } + + return $output; + } + private function selectColumns(QueryBuilder $queryBuilder, array $extraFields, SelectColumns $value): void { $selects = []; @@ -407,8 +521,6 @@ private function selectColumns(QueryBuilder $queryBuilder, array $extraFields, S $queryBuilder->select(...$selects); if (!$hasExtraFields) { - $queryBuilder->resetQueryPart('groupBy'); - $joinQueryPart = $queryBuilder->getQueryPart('join'); if (empty($joinQueryPart)) { diff --git a/src/Bridge/Repository/FieldValidationTrait.php b/src/Bridge/Repository/FieldValidationTrait.php index 4b5dc1c..9c69809 100644 --- a/src/Bridge/Repository/FieldValidationTrait.php +++ b/src/Bridge/Repository/FieldValidationTrait.php @@ -40,10 +40,20 @@ private function validateFieldName(string $fieldName, string $fallbackEntity, st return $expectedType->getName(); } - private function validateFieldValue(string $field, mixed $value): mixed + private function validateFieldValue(string $field, mixed $value, string $entityClassName = null): mixed { - $fallbackEntity = get_parent_class(static::class) ?: static::class; - $expectedType = $this->validateFieldName($field, $fallbackEntity, static::TABLE_NAME); + if (!$entityClassName) { + $fallbackEntity = get_parent_class(static::class) ?: static::class; + $expectedType = $this->validateFieldName($field, $fallbackEntity, static::TABLE_NAME); + } else { + $expectedType = $this->validateFieldName( + $field, + $entityClassName, + (new \ReflectionClassConstant($this->entityManager->getRepository($entityClassName), 'TABLE_NAME')) + ->getValue() + ); + } + $resolvedValue = $value instanceof Operand ? $value->getOperand() : $value; if ($value instanceof Operand && $value->isLooseOperator()) { diff --git a/src/Bridge/Repository/FindByTrait.php b/src/Bridge/Repository/FindByTrait.php index 7ad0748..4537dc8 100644 --- a/src/Bridge/Repository/FindByTrait.php +++ b/src/Bridge/Repository/FindByTrait.php @@ -23,9 +23,9 @@ trait FindByTrait { use NormalizerTrait; - protected ?array $entityBaseFields = null; - protected ?array $entityExternalFields = null; - protected ?array $entityExtraFields = null; + protected array $entityBaseFields = []; + protected array $entityExternalFields = []; + protected array $entityExtraFields = []; public function find(int $id): mixed { @@ -72,47 +72,53 @@ public function findBy(array $criteria, array $orderBy = null, ?int $limit = nul /** * @return string[] */ - protected function getEntityBaseFields(): array + protected function getEntityBaseFields(string $entityClassName = null): array { - if ($this->entityBaseFields === null) { - $entityClassName = $this->getEntityClassName(); - $this->entityBaseFields = array_keys($this->serializer->normalize(new $entityClassName(), null, [ - 'groups' => ['base'], - ])); + $entityClassName = $entityClassName ?? $this->getEntityClassName(); + + if (!array_key_exists($entityClassName, $this->entityBaseFields)) { + $this->entityBaseFields[$entityClassName] = array_keys( + $this->serializer->normalize(new $entityClassName(), null, [ + 'groups' => ['base'], + ]) + ); } - return $this->entityBaseFields; + return $this->entityBaseFields[$entityClassName]; } - protected function getExternalFields(): array + protected function getExternalFields(string $entityClassName = null): array { - if ($this->entityExternalFields === null) { - $this->entityExternalFields = []; + $entityClassName = $entityClassName ?? $this->getEntityClassName(); - foreach ((new \ReflectionClass($this->getEntityClassName()))->getProperties() as $property) { + if (!array_key_exists($entityClassName, $this->entityExternalFields)) { + $this->entityExternalFields[$entityClassName] = []; + + foreach ((new \ReflectionClass($entityClassName))->getProperties() as $property) { if (\count($property->getAttributes(External::class)) > 0) { - $this->entityExternalFields[] = property_to_field($property->getName()); + $this->entityExternalFields[$entityClassName][] = property_to_field($property->getName()); } } } - return $this->entityExternalFields; + return $this->entityExternalFields[$entityClassName]; } /** * @return string[] */ - protected function getEntityExtraFields(): array + protected function getEntityExtraFields(string $entityClassName = null): array { - if ($this->entityExtraFields === null) { - $baseFields = $this->getEntityBaseFields(); - $externalFields = $this->getExternalFields(); - $entityClassName = $this->getEntityClassName(); + $entityClassName = $entityClassName ?? $this->getEntityClassName(); + + if (!array_key_exists($entityClassName, $this->entityExtraFields)) { + $baseFields = $this->getEntityBaseFields($entityClassName); + $externalFields = $this->getExternalFields($entityClassName); $allFields = array_keys($this->propertyNormalizer->normalize(new $entityClassName())); - $this->entityExtraFields = array_diff($allFields, $baseFields, $externalFields); + $this->entityExtraFields[$entityClassName] = array_diff($allFields, $baseFields, $externalFields); } - return $this->entityExtraFields; + return $this->entityExtraFields[$entityClassName]; } /** diff --git a/src/Bridge/Repository/NormalizerTrait.php b/src/Bridge/Repository/NormalizerTrait.php index ed6e536..890dce9 100644 --- a/src/Bridge/Repository/NormalizerTrait.php +++ b/src/Bridge/Repository/NormalizerTrait.php @@ -8,6 +8,7 @@ use Symfony\Component\Serializer\SerializerInterface; use Williarin\WordpressInterop\Criteria\NestedCondition; use Williarin\WordpressInterop\Criteria\Operand; +use Williarin\WordpressInterop\Criteria\PostRelationshipCondition; use Williarin\WordpressInterop\Criteria\RelationshipCondition; use Williarin\WordpressInterop\Criteria\SelectColumns; use Williarin\WordpressInterop\Criteria\TermRelationshipCondition; @@ -26,7 +27,7 @@ public function denormalize(mixed $data, string $type): mixed ]); } - protected function normalizeCriteria(array $criteria): array + protected function normalizeCriteria(array $criteria, string $entityClassName = null): array { $output = []; @@ -36,13 +37,14 @@ protected function normalizeCriteria(array $criteria): array } elseif ( $value instanceof RelationshipCondition || $value instanceof TermRelationshipCondition + || $value instanceof PostRelationshipCondition || $value instanceof SelectColumns ) { $output[] = $value; } elseif ($value instanceof Operand && $value->isLooseOperator()) { $output[$field] = $value->getOperand(); } else { - $resolvedValue = $this->validateFieldValue($field, $value); + $resolvedValue = $this->validateFieldValue($field, $value, $entityClassName); $output[$field] = (string) $this->serializer->normalize($resolvedValue); } } diff --git a/src/Criteria/PostRelationshipCondition.php b/src/Criteria/PostRelationshipCondition.php new file mode 100644 index 0000000..9dfad39 --- /dev/null +++ b/src/Criteria/PostRelationshipCondition.php @@ -0,0 +1,24 @@ +entityClassName; + } + + public function getCriteria(): array + { + return $this->criteria; + } +} diff --git a/test/Test/Bridge/Repository/ProductRepositoryTest.php b/test/Test/Bridge/Repository/ProductRepositoryTest.php index be3a6be..a94864d 100644 --- a/test/Test/Bridge/Repository/ProductRepositoryTest.php +++ b/test/Test/Bridge/Repository/ProductRepositoryTest.php @@ -46,7 +46,7 @@ public function testFindAllReturnsCorrectNumberOfPosts(): void { $products = $this->repository->findAll(); self::assertContainsOnlyInstancesOf(Product::class, $products); - self::assertCount(18, $products); + self::assertCount(19, $products); } public function testFindBySku(): void @@ -82,13 +82,13 @@ public function testLatestPublishedProductInStock(): void ['post_date' => 'DESC'], ); - self::assertSame(37, $product->id); + self::assertSame(64, $product->id); } public function testFindAllPublishedProducts(): void { $products = $this->repository->findByPostStatus('publish'); self::assertIsArray($products); - self::assertCount(18, $products); + self::assertCount(19, $products); self::assertContainsOnlyInstancesOf(Product::class, $products); } @@ -235,7 +235,7 @@ public function testTermRelationshipConditionWithTaxonomyOnly(): void ]), ]); self::assertIsArray($products); - self::assertCount(18, $products); + self::assertCount(19, $products); self::assertContainsOnlyInstancesOf(Product::class, $products); } @@ -248,13 +248,14 @@ public function testTermRelationshipConditionWithTaxonomyAndTerm(): void ]), ]); self::assertIsArray($products); - self::assertCount(4, $products); + self::assertCount(5, $products); self::assertContainsOnlyInstancesOf(Product::class, $products); self::assertEquals([ 'Hoodie', 'Hoodie with Logo', 'Hoodie with Pocket', 'Hoodie with Zipper', + 'Special Forces Hoodie', ], array_column($products, 'postTitle')); } diff --git a/test/Test/Bridge/Repository/TermRepositoryTest.php b/test/Test/Bridge/Repository/TermRepositoryTest.php index e84528b..272e98a 100644 --- a/test/Test/Bridge/Repository/TermRepositoryTest.php +++ b/test/Test/Bridge/Repository/TermRepositoryTest.php @@ -4,9 +4,13 @@ namespace Williarin\WordpressInterop\Test\Bridge\Repository; +use Williarin\WordpressInterop\Bridge\Entity\Product; use Williarin\WordpressInterop\Bridge\Entity\Term; use Williarin\WordpressInterop\Bridge\Repository\RepositoryInterface; use Williarin\WordpressInterop\Bridge\Repository\TermRepository; +use Williarin\WordpressInterop\Criteria\NestedCondition; +use Williarin\WordpressInterop\Criteria\Operand; +use Williarin\WordpressInterop\Criteria\PostRelationshipCondition; use Williarin\WordpressInterop\Criteria\SelectColumns; use Williarin\WordpressInterop\Test\TestCase; @@ -76,4 +80,21 @@ public function testFindOneBySelectColumn(): void self::assertEquals($expected, $term); } + + public function testFindByPostRelationshipCondition(): void + { + $terms = $this->repository->findBy([ + new SelectColumns(['taxonomy', 'name']), + new PostRelationshipCondition(Product::class, [ + 'post_status' => new Operand(['publish', 'private'], Operand::OPERATOR_IN), + 'sku' => 'super-forces-hoodie', + ]), + 'taxonomy' => new Operand(['product_tag', 'product_type', 'product_visibility'], Operand::OPERATOR_NOT_IN), + ]); + + self::assertEquals([ + 'product_cat' => 'Hoodies', + 'pa_manufacturer' => 'MegaBrand', + ], array_combine(array_column($terms, 'taxonomy'), array_column($terms, 'name'))); + } }