├── LICENSE ├── composer.json └── src ├── DI └── NextrasOrmEventsExtension.php ├── Listeners ├── AfterInsertListener.php ├── AfterPersistListener.php ├── AfterRemoveListener.php ├── AfterUpdateListener.php ├── BeforeInsertListener.php ├── BeforePersistListener.php ├── BeforeRemoveListener.php ├── BeforeUpdateListener.php ├── FlushListener.php ├── Impl │ └── AbstractLifecycleListener.php └── LifecycleListener.php └── Utils └── Annotations.php /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Contributte 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "contributte/nextras-orm-events", 3 | "description": "Doctrine-like events for Nextras ORM entity lifecycle", 4 | "keywords": [ 5 | "nextras", 6 | "orm", 7 | "events", 8 | "lifecycle", 9 | "doctrine" 10 | ], 11 | "type": "library", 12 | "license": "MIT", 13 | "homepage": "https://github.com/contributte/nextras-orm-events", 14 | "authors": [ 15 | { 16 | "name": "Milan Felix Šulc", 17 | "homepage": "https://f3l1x.io" 18 | } 19 | ], 20 | "require": { 21 | "php": ">=8.1", 22 | "nette/di": "^3.1.8", 23 | "nextras/orm": "^4.0.7 || ^5.0" 24 | }, 25 | "require-dev": { 26 | "mockery/mockery": "^1.6.6", 27 | "nette/caching": "^3.2.3", 28 | "contributte/qa": "^0.4", 29 | "contributte/tester": "^0.3", 30 | "contributte/phpstan": "^0.1" 31 | }, 32 | "autoload": { 33 | "psr-4": { 34 | "Contributte\\Nextras\\Orm\\Events\\": "src/" 35 | } 36 | }, 37 | "autoload-dev": { 38 | "psr-4": { 39 | "Tests\\": "tests" 40 | } 41 | }, 42 | "minimum-stability": "dev", 43 | "prefer-stable": true, 44 | "config": { 45 | "sort-packages": true, 46 | "allow-plugins": { 47 | "dealerdirect/phpcodesniffer-composer-installer": true 48 | } 49 | }, 50 | "extra": { 51 | "branch-alias": { 52 | "dev-master": "0.10.x-dev" 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/DI/NextrasOrmEventsExtension.php: -------------------------------------------------------------------------------- 1 | [ 27 | 'onBeforeInsert' => BeforeInsertListener::class, 28 | 'onBeforePersist' => BeforePersistListener::class, 29 | 'onBeforeRemove' => BeforeRemoveListener::class, 30 | 'onBeforeUpdate' => BeforeUpdateListener::class, 31 | 'onAfterInsert' => AfterInsertListener::class, 32 | 'onAfterPersist' => AfterPersistListener::class, 33 | 'onAfterRemove' => AfterRemoveListener::class, 34 | 'onAfterUpdate' => AfterUpdateListener::class, 35 | 'onFlush' => FlushListener::class, 36 | ], 37 | 'BeforeInsert' => [ 38 | 'onBeforeInsert' => BeforeInsertListener::class, 39 | ], 40 | 'BeforePersist' => [ 41 | 'onBeforePersist' => BeforePersistListener::class, 42 | ], 43 | 'BeforeRemove' => [ 44 | 'onBeforeRemove' => BeforeRemoveListener::class, 45 | ], 46 | 'BeforeUpdate' => [ 47 | 'onBeforeUpdate' => BeforeUpdateListener::class, 48 | ], 49 | 'AfterInsert' => [ 50 | 'onAfterInsert' => AfterInsertListener::class, 51 | ], 52 | 'AfterPersist' => [ 53 | 'onAfterPersist' => AfterPersistListener::class, 54 | ], 55 | 'AfterRemove' => [ 56 | 'onAfterRemove' => AfterRemoveListener::class, 57 | ], 58 | 'AfterUpdate' => [ 59 | 'onAfterUpdate' => AfterUpdateListener::class, 60 | ], 61 | 'Flush' => [ 62 | 'onFlush' => FlushListener::class, 63 | ], 64 | ]; 65 | 66 | /** 67 | * Decorate services 68 | */ 69 | public function beforeCompile(): void 70 | { 71 | // Find registered IRepositories and parse their entities 72 | $mapping = $this->loadEntityMapping(); 73 | 74 | // Attach listeners 75 | $this->loadListeners($mapping); 76 | } 77 | 78 | /** 79 | * Load entity mapping 80 | * 81 | * @return string[] 82 | */ 83 | private function loadEntityMapping(): array 84 | { 85 | $mapping = []; 86 | 87 | $builder = $this->getContainerBuilder(); 88 | $repositories = $builder->findByType(IRepository::class); 89 | 90 | foreach ($repositories as $repository) { 91 | assert($repository instanceof ServiceDefinition); 92 | 93 | /** @var string $repositoryClass */ 94 | $repositoryClass = $repository->getEntity(); 95 | 96 | // Skip invalid repositoryClass name 97 | if (!class_exists($repositoryClass)) { 98 | throw new ServiceCreationException(sprintf("Repository class '%s' not found", $repositoryClass)); 99 | } 100 | 101 | // Skip invalid subtype ob IRepository 102 | if (!method_exists($repositoryClass, 'getEntityClassNames')) 103 | 104 | continue; 105 | 106 | // Append mapping [repository => [entity1, entity2, entityN] 107 | foreach ($repositoryClass::getEntityClassNames() as $entity) { 108 | $mapping[$entity] = $repositoryClass; 109 | } 110 | } 111 | 112 | return $mapping; 113 | } 114 | 115 | /** 116 | * @param string[] $mapping 117 | */ 118 | private function loadListeners(array $mapping): void 119 | { 120 | $builder = $this->getContainerBuilder(); 121 | 122 | foreach ($mapping as $entity => $repository) { 123 | // Test invalid class name 124 | if (!class_exists($entity)) { 125 | throw new ServiceCreationException(sprintf("Entity class '%s' not found", $entity)); 126 | } 127 | 128 | $types = [$entity]; 129 | $uses = class_uses($entity); 130 | if ($uses !== false) { 131 | $types = $uses + [$entity]; 132 | } 133 | 134 | /** @var class-string $type */ 135 | foreach ($types as $type) { 136 | // Parse annotations from phpDoc 137 | $rf = new ReflectionClass($type); 138 | 139 | // Add entity/trait as dependency 140 | $builder->addDependency($rf); 141 | 142 | // Try all annotations 143 | foreach (self::$annotations as $annotation => $events) { 144 | /** @var class-string|null $listener */ 145 | $listener = Annotations::getAnnotation($rf, $annotation); 146 | if ($listener !== null) { 147 | $this->loadListenerByAnnotation($events, $repository, $listener); 148 | } 149 | } 150 | } 151 | } 152 | } 153 | 154 | /** 155 | * @param string[] $events 156 | * @param class-string $listener 157 | */ 158 | private function loadListenerByAnnotation(array $events, string $repository, string $listener): void 159 | { 160 | $builder = $this->getContainerBuilder(); 161 | 162 | // Skip if repository is not registered in DIC 163 | if (($rsn = $builder->getByType($repository)) === null) { 164 | throw new ServiceCreationException(sprintf("Repository service '%s' not found", $repository)); 165 | } 166 | 167 | // Skip if listener is not registered in DIC 168 | if (($lsn = $builder->getByType($listener)) === null) { 169 | throw new ServiceCreationException(sprintf("Listener service '%s' not found", $listener)); 170 | } 171 | 172 | // Get definitions 173 | $repositoryDef = $builder->getDefinition($rsn); 174 | assert($repositoryDef instanceof ServiceDefinition); 175 | $listenerDef = $builder->getDefinition($lsn); 176 | 177 | // Check implementation 178 | $rf = new ReflectionClass($listener); 179 | 180 | foreach ($events as $event => $interface) { 181 | if ($rf->implementsInterface($interface) === false) { 182 | throw new ServiceCreationException(sprintf("Object '%s' should implement '%s'", $listener, $interface)); 183 | } 184 | 185 | $repositoryDef->addSetup('$service->?[] = function() {call_user_func_array([?, ?], func_get_args());}', [ 186 | $event, 187 | $listenerDef, 188 | $event, 189 | ]); 190 | } 191 | } 192 | 193 | } 194 | -------------------------------------------------------------------------------- /src/Listeners/AfterInsertListener.php: -------------------------------------------------------------------------------- 1 | >>> */ 38 | private static array $cache; 39 | 40 | /** 41 | * @param ReflectionClass|ReflectionMethod|ReflectionProperty|ReflectionFunction $r 42 | */ 43 | public static function hasAnnotation(Reflector $r, string $name): bool 44 | { 45 | return self::getAnnotation($r, $name) !== null; 46 | } 47 | 48 | /** 49 | * @param ReflectionClass|ReflectionMethod|ReflectionProperty|ReflectionFunction $r 50 | */ 51 | public static function getAnnotation(Reflector $r, string $name): mixed 52 | { 53 | $res = self::getAnnotations($r); 54 | 55 | if ($res === []) { 56 | return null; 57 | } 58 | 59 | return isset($res[$name]) ? end($res[$name]) : null; 60 | } 61 | 62 | /** 63 | * @param ReflectionClass|ReflectionMethod|ReflectionProperty|ReflectionFunction $r 64 | * @return array> 65 | */ 66 | public static function getAnnotations(Reflector $r): array 67 | { 68 | if ($r instanceof ReflectionClass) { 69 | $type = $r->getName(); 70 | $member = 'class'; 71 | 72 | } elseif ($r instanceof ReflectionMethod) { 73 | $type = $r->getDeclaringClass()->getName(); 74 | $member = $r->getName(); 75 | 76 | } elseif ($r instanceof ReflectionFunction) { 77 | $type = null; 78 | $member = $r->getName(); 79 | 80 | } else { 81 | $type = $r->getDeclaringClass()->getName(); 82 | $member = '$' . $r->getName(); 83 | } 84 | 85 | if (self::$useReflection === null) { // detects whether is reflection available 86 | self::$useReflection = (bool) (new ReflectionClass(self::class))->getDocComment(); 87 | } 88 | 89 | if (isset(self::$cache[$type][$member])) { // is value cached? 90 | return self::$cache[$type][$member]; 91 | } 92 | 93 | $annotations = self::$useReflection ? self::parseComment((string) $r->getDocComment()) : []; 94 | 95 | // @phpstan-ignore-next-line 96 | if ($r instanceof ReflectionMethod && !$r->isPrivate() && (!$r->isConstructor() || !empty($annotations['inheritdoc'][0])) 97 | ) { 98 | try { 99 | // @phpstan-ignore-next-line 100 | $inherited = self::getAnnotations(new ReflectionMethod((string) get_parent_class($type), $member)); 101 | } catch (ReflectionException $e) { 102 | try { 103 | $inherited = self::getAnnotations($r->getPrototype()); 104 | } catch (ReflectionException $e) { 105 | $inherited = []; 106 | } 107 | } 108 | 109 | $annotations += array_intersect_key($inherited, array_flip(self::$inherited)); 110 | } 111 | 112 | return self::$cache[$type][$member] = $annotations; 113 | } 114 | 115 | /** 116 | * @return array> 117 | */ 118 | private static function parseComment(string $comment): array 119 | { 120 | static $tokens = ['true' => true, 'false' => false, 'null' => null, '' => true]; 121 | 122 | $res = []; 123 | $comment = (string) preg_replace('#^\s*\*\s?#ms', '', trim($comment, '/*')); 124 | $parts = preg_split('#^\s*(?=@' . self::RE_IDENTIFIER . ')#m', $comment, 2); 125 | 126 | if ($parts === false) { 127 | throw new LogicException('Cannot split comment'); 128 | } 129 | 130 | $description = trim($parts[0]); 131 | if ($description !== '') { 132 | $res['description'] = [$description]; 133 | } 134 | 135 | $matches = Strings::matchAll( 136 | $parts[1] ?? '', 137 | '~ 138 | (?<=\s|^)@(' . self::RE_IDENTIFIER . ')[ \t]* ## annotation 139 | ( 140 | \((?>' . self::RE_STRING . '|[^\'")@]+)+\)| ## (value) 141 | [^(@\r\n][^@\r\n]*|) ## value 142 | ~xi' 143 | ); 144 | 145 | foreach ($matches as $match) { 146 | [, $name, $value] = $match; 147 | 148 | if (substr($value, 0, 1) === '(') { 149 | $items = []; 150 | $key = ''; 151 | $val = true; 152 | $value[0] = ','; 153 | while ($m = Strings::match($value, '#\s*,\s*(?>(' . self::RE_IDENTIFIER . ')\s*=\s*)?(' . self::RE_STRING . '|[^\'"),\s][^\'"),]*)#A')) { 154 | $value = substr($value, strlen($m[0])); 155 | [, $key, $val] = $m; 156 | $val = rtrim($val); 157 | if ($val[0] === "'" || $val[0] === '"') { 158 | $val = substr($val, 1, -1); 159 | 160 | } elseif (is_numeric($val)) { 161 | $val = 1 * $val; 162 | 163 | } else { 164 | $lval = strtolower($val); 165 | $val = array_key_exists($lval, $tokens) ? $tokens[$lval] : $val; 166 | } 167 | 168 | if ($key === '') { 169 | $items[] = $val; 170 | 171 | } else { 172 | $items[$key] = $val; 173 | } 174 | } 175 | 176 | $value = count($items) < 2 && $key === '' ? $val : $items; 177 | 178 | } else { 179 | $value = trim($value); 180 | if (is_numeric($value)) { 181 | $value = 1 * $value; 182 | 183 | } else { 184 | $lval = strtolower($value); 185 | $value = array_key_exists($lval, $tokens) ? $tokens[$lval] : $value; 186 | } 187 | } 188 | 189 | $res[$name][] = is_array($value) ? ArrayHash::from($value) : $value; 190 | } 191 | 192 | return $res; 193 | } 194 | 195 | } 196 | --------------------------------------------------------------------------------