From 020d04f7d5b7dd068c1e5495ee010b9a372bf381 Mon Sep 17 00:00:00 2001 From: William Arin Date: Sun, 10 Jul 2022 23:05:51 +0800 Subject: [PATCH] feat: entity duplication --- README.md | 15 ++ src/Attributes/Slug.php | 15 ++ src/Attributes/Unique.php | 15 ++ src/Bridge/Entity/BaseEntity.php | 26 +--- src/Bridge/Entity/DynamicPropertiesTrait.php | 28 ++++ src/Bridge/Entity/PostMeta.php | 4 + src/Bridge/Entity/Product.php | 3 + src/Bridge/Entity/Term.php | 2 + .../Repository/AbstractEntityRepository.php | 56 ++++---- .../Repository/EntityRepositoryInterface.php | 2 + src/Bridge/Repository/PostMetaRepository.php | 23 +++- src/Bridge/Repository/TermRepository.php | 59 +++++++- src/Persistence/DuplicationService.php | 128 ++++++++++++++++++ .../Repository/PostMetaRepositoryTest.php | 49 +++++++ .../Bridge/Repository/TermRepositoryTest.php | 34 +++++ .../Manipulation/DuplicationServiceTest.php | 74 ++++++++++ 16 files changed, 486 insertions(+), 47 deletions(-) create mode 100644 src/Attributes/Slug.php create mode 100644 src/Attributes/Unique.php create mode 100644 src/Bridge/Entity/DynamicPropertiesTrait.php create mode 100644 src/Persistence/DuplicationService.php create mode 100644 test/Test/Manipulation/DuplicationServiceTest.php diff --git a/README.md b/README.md index 0629ce8..c5e05ee 100644 --- a/README.md +++ b/README.md @@ -309,6 +309,21 @@ $repository->persist($post); $manager->persist($post); ``` +### Entity duplication +Duplicate an entity with all its EAV attributes and terms with `DuplicationService`. +The resulting entity is already persisted and has a new ID. + +```php +$duplicationService = new DuplicationService($entityManager, new AsciiSlugger(); + +// Duplicate by ID +$newProduct = $this->duplicationService->duplicate(23, Product::class); + +// Duplicate by object +$product = $manager->getRepository(Product::class)->findOneBySku('woo-hoodie-with-zipper'); +$newProduct = $this->duplicationService->duplicate($product); +``` + ### Available entities and repositories * `Post` and `PostRepository` diff --git a/src/Attributes/Slug.php b/src/Attributes/Slug.php new file mode 100644 index 0000000..1fdb9a6 --- /dev/null +++ b/src/Attributes/Slug.php @@ -0,0 +1,15 @@ +{$property})) { - return; - } - - try { - $expectedType = (new \ReflectionProperty(static::class, $property))->getType()->getName(); - settype($value, $expectedType); - } catch (\ReflectionException) { - } - - $this->{$property} = $value; - } - - public function __get(string $property): mixed - { - return $this->{$property}; - } } diff --git a/src/Bridge/Entity/DynamicPropertiesTrait.php b/src/Bridge/Entity/DynamicPropertiesTrait.php new file mode 100644 index 0000000..c524014 --- /dev/null +++ b/src/Bridge/Entity/DynamicPropertiesTrait.php @@ -0,0 +1,28 @@ +{$property})) { + return; + } + + try { + $expectedType = (new \ReflectionProperty(static::class, $property))->getType()->getName(); + settype($value, $expectedType); + } catch (\ReflectionException) { + } + + $this->{$property} = $value; + } + + public function __get(string $property): mixed + { + return $this->{$property}; + } +} diff --git a/src/Bridge/Entity/PostMeta.php b/src/Bridge/Entity/PostMeta.php index 2152129..230f730 100644 --- a/src/Bridge/Entity/PostMeta.php +++ b/src/Bridge/Entity/PostMeta.php @@ -10,4 +10,8 @@ #[RepositoryClass(PostMetaRepository::class)] class PostMeta { +// public ?int $metaId = null; +// public ?int $postId = null; +// public ?string $metaKey = null; +// public string|array|int|float|bool|null $metaValue = null; } diff --git a/src/Bridge/Entity/Product.php b/src/Bridge/Entity/Product.php index 183e226..f9112a5 100644 --- a/src/Bridge/Entity/Product.php +++ b/src/Bridge/Entity/Product.php @@ -5,6 +5,8 @@ namespace Williarin\WordpressInterop\Bridge\Entity; use Williarin\WordpressInterop\Attributes\RepositoryClass; +use Williarin\WordpressInterop\Attributes\Slug; +use Williarin\WordpressInterop\Attributes\Unique; use Williarin\WordpressInterop\Bridge\Repository\ProductRepository; use Williarin\WordpressInterop\Bridge\Type\GenericData; @@ -22,6 +24,7 @@ #[RepositoryClass(ProductRepository::class)] class Product extends BaseEntity { + #[Unique, Slug] public ?string $sku = null; public ?string $taxStatus = null; public ?string $taxClass = null; diff --git a/src/Bridge/Entity/Term.php b/src/Bridge/Entity/Term.php index 192af24..31fe0f3 100644 --- a/src/Bridge/Entity/Term.php +++ b/src/Bridge/Entity/Term.php @@ -13,6 +13,8 @@ #[RepositoryClass(TermRepository::class)] class Term { + use DynamicPropertiesTrait; + #[Id] #[Groups('base')] public ?int $termId = null; diff --git a/src/Bridge/Repository/AbstractEntityRepository.php b/src/Bridge/Repository/AbstractEntityRepository.php index 2d5cb3c..3760d2a 100644 --- a/src/Bridge/Repository/AbstractEntityRepository.php +++ b/src/Bridge/Repository/AbstractEntityRepository.php @@ -10,6 +10,7 @@ use Symfony\Component\Serializer\Normalizer\PropertyNormalizer; use Symfony\Component\Serializer\SerializerInterface; use Williarin\WordpressInterop\Bridge\Entity\BaseEntity; +use Williarin\WordpressInterop\Bridge\Entity\PostMeta; use Williarin\WordpressInterop\Criteria\NestedCondition; use Williarin\WordpressInterop\Criteria\Operand; use Williarin\WordpressInterop\Criteria\PostRelationshipCondition; @@ -34,6 +35,7 @@ abstract class AbstractEntityRepository implements EntityRepositoryInterface protected const MAPPED_FIELDS = []; protected const TABLE_NAME = 'posts'; protected const TABLE_META_NAME = 'postmeta'; + protected const META_ENTITY_CLASS_NAME = PostMeta::class; protected const TABLE_IDENTIFIER = 'id'; protected const TABLE_META_IDENTIFIER = 'post_id'; protected const FALLBACK_ENTITY = BaseEntity::class; @@ -74,6 +76,11 @@ public function getEntityClassName(): string return $this->entityClassName; } + public function getMetaEntityClassName(): string + { + return static::META_ENTITY_CLASS_NAME; + } + public function setEntityManager(EntityManagerInterface $entityManager): void { $this->entityManager = $entityManager; @@ -187,6 +194,30 @@ public function persist(mixed $entity): void } } + public 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($mappedFields) + || empty($mappedFields) + || !in_array($fieldName, $mappedFields, true) + ) { + return $fieldName; + } + + $key = array_search($fieldName, $mappedFields, true); + + if (is_numeric($key)) { + return sprintf('_%s', $fieldName); + } + + return $key; + } + protected function normalize(string $field, mixed $value): string { $value = $this->validateFieldValue($field, $value); @@ -213,30 +244,6 @@ 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 $entityClassName = null): string - { - $mappedFields = $entityClassName ? (new \ReflectionClassConstant( - $this->entityManager->getRepository($entityClassName), - 'MAPPED_FIELDS', - ))->getValue() : static::MAPPED_FIELDS; - - if ( - !is_array($mappedFields) - || empty($mappedFields) - || !in_array($fieldName, $mappedFields, true) - ) { - return $fieldName; - } - - $key = array_search($fieldName, $mappedFields, true); - - if (is_numeric($key)) { - return sprintf('_%s', $fieldName); - } - - return $key; - } - protected function addSelectForExtraFields(QueryBuilder $queryBuilder): void { $extraFields = $this->getEntityExtraFields(); @@ -502,6 +509,7 @@ private function createPostRelationshipCriteria( ) ; + $this->additionalFieldsToSelect[] = 'tt.term_taxonomy_id'; $this->addPostMetaJoinForPostRelationshipCondition($queryBuilder, $condition, $aliasNumber); $prefixedCriteria = $this->getPrefixedCriteriaForPostRelationshipCondition($condition, $aliasNumber); $normalizedCriteria = $this->normalizeCriteria($prefixedCriteria, $condition->getEntityClassName()); diff --git a/src/Bridge/Repository/EntityRepositoryInterface.php b/src/Bridge/Repository/EntityRepositoryInterface.php index 214b8b2..1049da1 100644 --- a/src/Bridge/Repository/EntityRepositoryInterface.php +++ b/src/Bridge/Repository/EntityRepositoryInterface.php @@ -21,4 +21,6 @@ public function createFindByQueryBuilder(array $criteria, ?array $orderBy): Quer public function updateSingleField(int $id, string $field, mixed $newValue): bool; public function persist(mixed $entity): void; + + public function getMetaEntityClassName(): string; } diff --git a/src/Bridge/Repository/PostMetaRepository.php b/src/Bridge/Repository/PostMetaRepository.php index 21fca85..436ae89 100644 --- a/src/Bridge/Repository/PostMetaRepository.php +++ b/src/Bridge/Repository/PostMetaRepository.php @@ -21,7 +21,7 @@ public function getEntityClassName(): string return PostMeta::class; } - public function find(int $postId, string $metaKey, bool $unserialize = true): string|array|int|bool|null + public function find(int $postId, string $metaKey, bool $unserialize = true): string|array|int|float|bool|null { /** @var string|false $result */ $result = $this->entityManager->getConnection() @@ -46,6 +46,27 @@ public function find(int $postId, string $metaKey, bool $unserialize = true): st return $unserialize ? unserialize_if_needed($result) : $result; } + /** + * @return array + */ + public function findBy(int $postId, bool $unserialize = true): array + { + $result = $this->entityManager->getConnection() + ->createQueryBuilder() + ->select('meta_key', 'meta_value') + ->from($this->entityManager->getTablesPrefix() . 'postmeta') + ->where('post_id = :id') + ->setParameter('id', $postId) + ->executeQuery() + ->fetchAllAssociative() + ; + + return array_map( + static fn (string $value) => $unserialize ? unserialize_if_needed($value) : $value, + array_combine(array_column($result, 'meta_key'), array_column($result, 'meta_value')), + ); + } + public function create(int $postId, string $metaKey, mixed $metaValue): bool { try { diff --git a/src/Bridge/Repository/TermRepository.php b/src/Bridge/Repository/TermRepository.php index 02fb585..3cda571 100644 --- a/src/Bridge/Repository/TermRepository.php +++ b/src/Bridge/Repository/TermRepository.php @@ -5,6 +5,7 @@ namespace Williarin\WordpressInterop\Bridge\Repository; use Doctrine\DBAL\Query\QueryBuilder; +use Williarin\WordpressInterop\Bridge\Entity\BaseEntity; use Williarin\WordpressInterop\Bridge\Entity\Term; use Williarin\WordpressInterop\Criteria\SelectColumns; @@ -34,12 +35,65 @@ public function createFindByQueryBuilder(array $criteria, ?array $orderBy): Quer ; if (\count(array_filter($criteria, static fn ($condition) => $condition instanceof SelectColumns)) === 0) { - $queryBuilder->select($this->getPrefixedFields(['term_id', 'name', 'slug', 'taxonomy', 'count'])); + $extraFields = array_diff( + $queryBuilder->getQueryPart('select'), + $this->getPrefixedFields(['term_id', 'name', 'slug', 'taxonomy', 'count']), + ); + + $queryBuilder->select([ + ...$this->getPrefixedFields(['term_id', 'name', 'slug', 'taxonomy', 'count']), + ...$extraFields, + ]); + } else { + foreach ($this->getPrefixedFields($queryBuilder->getQueryPart('select')) as $field) { + if (!\in_array($field, $queryBuilder->getQueryPart('groupBy'), true)) { + $queryBuilder->addGroupBy($field); + } + } } return $queryBuilder; } + public function addTermsToEntity(BaseEntity $entity, array $terms): void + { + foreach ($terms as $term) { + if (!property_exists($term, 'termTaxonomyId')) { + continue; + } + + $this->entityManager->getConnection() + ->createQueryBuilder() + ->insert($this->entityManager->getTablesPrefix() . 'term_relationships') + ->values([ + 'object_id' => '?', + 'term_taxonomy_id' => '?', + 'term_order' => '0', + ]) + ->setParameters([$entity->id, (int) $term->termTaxonomyId]) + ->executeStatement() + ; + } + + // Recount terms + $subSelect = $this->entityManager->getConnection() + ->createQueryBuilder() + ->select('COUNT(*)') + ->from($this->entityManager->getTablesPrefix() . 'term_relationships', 'tr') + ->leftJoin('tr', $this->entityManager->getTablesPrefix() . 'posts', 'p', 'p.id = tr.object_id') + ->where('tr.term_taxonomy_id = tt.term_taxonomy_id') + ->andWhere("tt.taxonomy NOT IN ('link_category')") + ->andWhere("p.post_status IN ('publish', 'future')") + ; + + $this->entityManager->getConnection() + ->createQueryBuilder() + ->update($this->entityManager->getTablesPrefix() . 'term_taxonomy', 'tt') + ->set('count', sprintf('(%s)', $subSelect->getSQL())) + ->executeStatement() + ; + } + private function getPrefixedFields(array $fields): array { $output = []; @@ -47,7 +101,8 @@ private function getPrefixedFields(array $fields): array foreach ($fields as $field) { $output[] = match ($field) { 'term_id', 'name', 'slug' => sprintf('p.%s', $field), - 'taxonomy', 'count' => sprintf('tt.%s', $field), + 'taxonomy', 'count', 'term_taxonomy_id' => sprintf('tt.%s', $field), + default => $field, }; } diff --git a/src/Persistence/DuplicationService.php b/src/Persistence/DuplicationService.php new file mode 100644 index 0000000..160ead4 --- /dev/null +++ b/src/Persistence/DuplicationService.php @@ -0,0 +1,128 @@ +entityManager->getRepository($entityType) + ->find($entityOrId) + ; + } else { + $entity = $entityOrId; + } + + $clone = clone $entity; + $clone->id = null; + + $properties = $this->getClassPropertyAttributes($clone, BaseEntity::class, $suffix); + + foreach ($properties as $property => $attributes) { + $entity->{$property} .= \in_array(Slug::class, $attributes, true) + ? '-' . $this->slugger->slug($suffix) + ->lower() + ->toString() + : $suffix + ; + } + + $this->entityManager->persist($clone); + $this->duplicateMeta($entity, $clone, $suffix); + $this->duplicateTerms($entity, $clone); + + return $clone; + } + + private function duplicateMeta(BaseEntity $entity, BaseEntity &$clone, string $suffix): void + { + $repository = $this->entityManager->getRepository($entity::class); + $metaRepository = $this->entityManager->getRepository($repository->getMetaEntityClassName()); + + $postMetas = $metaRepository->findBy($entity->id, false); + + $properties = $this->getClassPropertyAttributes($clone, Product::class, $suffix); + + foreach ($properties as $property => $attributes) { + $metaKey = $repository->getMappedMetaKey(property_to_field($property)); + + $postMetas[$metaKey] .= \in_array(Slug::class, $attributes, true) + ? '-' . $this->slugger->slug($suffix) + ->lower() + ->toString() + : $suffix + ; + } + + foreach ($postMetas as $key => $value) { + $metaRepository->create($clone->id, $key, $value); + } + + $clone = $repository->find($clone->id); + } + + private function duplicateTerms(BaseEntity $entity, BaseEntity $clone): void + { + $termRepository = $this->entityManager->getRepository(Term::class); + $terms = $termRepository->findBy([ + new PostRelationshipCondition(Product::class, [ + 'id' => $entity->id, + ]), + ]); + + $termRepository->addTermsToEntity($clone, $terms); + } + + private function getClassPropertyAttributes(BaseEntity $entity, string $className, string $suffix): array + { + $properties = []; + + foreach ((new \ReflectionClass($entity::class))->getProperties() as $property) { + if ($property->class !== $className) { + continue; + } + + if (\count($property->getAttributes(Unique::class)) > 0) { + $properties[$property->getName()] = [Unique::class]; + } + + if (\count($property->getAttributes(Slug::class)) > 0) { + $properties[$property->getName()][] = Slug::class; + } + } + + return $properties; + } +} diff --git a/test/Test/Bridge/Repository/PostMetaRepositoryTest.php b/test/Test/Bridge/Repository/PostMetaRepositoryTest.php index 2d70b89..9f1b872 100644 --- a/test/Test/Bridge/Repository/PostMetaRepositoryTest.php +++ b/test/Test/Bridge/Repository/PostMetaRepositoryTest.php @@ -107,4 +107,53 @@ public function testDeleteNonExistentKeyReturnsFalse(): void { self::assertFalse($this->repository->update(4444, 'another_nonexistent_key', 'hello')); } + + public function testFindBy(): void + { + $postMetas = $this->repository->findBy(23); + + self::assertEquals([ + '_sku' => 'woo-hoodie-with-zipper', + '_regular_price' => '45', + '_sale_price' => '', + '_sale_price_dates_from' => '', + '_sale_price_dates_to' => '', + 'total_sales' => '0', + '_tax_status' => 'taxable', + '_tax_class' => '', + '_manage_stock' => 'no', + '_backorders' => 'no', + '_low_stock_amount' => '', + '_sold_individually' => 'no', + '_weight' => '2', + '_length' => '8', + '_width' => '6', + '_height' => '2', + '_upsell_ids' => [], + '_crosssell_ids' => [], + '_purchase_note' => '', + '_default_attributes' => [], + '_virtual' => 'no', + '_downloadable' => 'no', + '_product_image_gallery' => '', + '_download_limit' => '0', + '_download_expiry' => '0', + '_stock' => '', + '_stock_status' => 'instock', + '_wc_average_rating' => '0', + '_wc_rating_count' => [], + '_wc_review_count' => '0', + '_downloadable_files' => [], + '_product_attributes' => [], + '_product_version' => '3.5.3', + '_price' => '45', + '_thumbnail_id' => '52', + ], $postMetas); + } + + public function testFindByWithNoResult(): void + { + $postMetas = $this->repository->findBy(100); + self::assertEquals([], $postMetas); + } } diff --git a/test/Test/Bridge/Repository/TermRepositoryTest.php b/test/Test/Bridge/Repository/TermRepositoryTest.php index 272e98a..a3b80c3 100644 --- a/test/Test/Bridge/Repository/TermRepositoryTest.php +++ b/test/Test/Bridge/Repository/TermRepositoryTest.php @@ -97,4 +97,38 @@ public function testFindByPostRelationshipCondition(): void 'pa_manufacturer' => 'MegaBrand', ], array_combine(array_column($terms, 'taxonomy'), array_column($terms, 'name'))); } + + public function testAddTermsToEntity(): void + { + $hoodieTerms = $this->repository->findBy([ + 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), + ]); + + $product = $this->manager->getRepository(Product::class) + ->find(37) + ; + + $termsProduct = $this->repository->findBy([ + new PostRelationshipCondition(Product::class, [ + 'id' => $product->id, + ]), + ]); + + self::assertEquals(['external', 'Decor'], array_column($termsProduct, 'name')); + + $this->repository->addTermsToEntity($product, $hoodieTerms); + + $termsProduct = $this->repository->findBy([ + new PostRelationshipCondition(Product::class, [ + 'id' => $product->id, + ]), + ]); + + self::assertEquals(['external', 'Hoodies', 'Decor', 'MegaBrand'], array_column($termsProduct, 'name')); + self::assertEquals([1, 6, 1, 2], array_column($termsProduct, 'count')); + } } diff --git a/test/Test/Manipulation/DuplicationServiceTest.php b/test/Test/Manipulation/DuplicationServiceTest.php new file mode 100644 index 0000000..400f4dc --- /dev/null +++ b/test/Test/Manipulation/DuplicationServiceTest.php @@ -0,0 +1,74 @@ +duplicationService = new DuplicationService($this->manager, new AsciiSlugger()); + } + + public function testDuplicateByIdWithoutEntityClassNameThrowsException(): void + { + $this->expectException(MissingEntityTypeException::class); + $this->duplicationService->duplicate(23); + } + + public function testDuplicateById(): void + { + $newEntity = $this->duplicationService->duplicate(23, Product::class); + $originalTerms = $this->manager->getRepository(Term::class) + ->findBy([ + new PostRelationshipCondition(Product::class, ['id' => 23]), + ]); + + $newTerms = $this->manager->getRepository(Term::class) + ->findBy([ + new PostRelationshipCondition(Product::class, ['id' => $newEntity->id]), + ]); + + self::assertNotSame(23, $newEntity->id); + self::assertIsNumeric($newEntity->id); + self::assertSame($newEntity->sku, 'woo-hoodie-with-zipper-copy'); + self::assertSame(array_column($originalTerms, 'name'), array_column($newTerms, 'name')); + self::assertEquals($originalTerms, $newTerms); + } + + public function testDuplicateByEntity(): void + { + $product = $this->manager->getRepository(Product::class) + ->findOneBySku('woo-hoodie-with-zipper'); + + $newEntity = $this->duplicationService->duplicate($product); + + $originalTerms = $this->manager->getRepository(Term::class) + ->findBy([ + new PostRelationshipCondition(Product::class, ['id' => $product->id]), + ]); + + $newTerms = $this->manager->getRepository(Term::class) + ->findBy([ + new PostRelationshipCondition(Product::class, ['id' => $newEntity->id]), + ]); + + self::assertNotSame($product->id, $newEntity->id); + self::assertIsNumeric($newEntity->id); + self::assertSame($newEntity->sku, 'woo-hoodie-with-zipper-copy'); + self::assertSame(array_column($originalTerms, 'name'), array_column($newTerms, 'name')); + self::assertEquals($originalTerms, $newTerms); + } +}