An empty array if validation pass or an
120 | * associative array with field names as keys and error messages as values
121 | */
122 | protected function validate(
123 | array $data,
124 | array $rules,
125 | array $labels = [],
126 | array $messages = [],
127 | string $instance = 'default'
128 | ) : array {
129 | $validation = App::validation($instance);
130 | return $validation->setRules($rules)->setLabels($labels)
131 | ->setMessages($messages)->validate($data)
132 | ? []
133 | : $validation->getErrors();
134 | }
135 | }
136 |
--------------------------------------------------------------------------------
/src/Debug/AppCollection.php:
--------------------------------------------------------------------------------
1 |
6 | *
7 | * For the full copyright and license information, please view the LICENSE
8 | * file that was distributed with this source code.
9 | */
10 | namespace Framework\MVC\Debug;
11 |
12 | use Framework\Debug\Collection;
13 |
14 | /**
15 | * Class AppCollection.
16 | *
17 | * @package mvc
18 | */
19 | class AppCollection extends Collection
20 | {
21 | protected string $iconPath = __DIR__ . '/icons/app.svg';
22 | }
23 |
--------------------------------------------------------------------------------
/src/Debug/AppCollector.php:
--------------------------------------------------------------------------------
1 |
6 | *
7 | * For the full copyright and license information, please view the LICENSE
8 | * file that was distributed with this source code.
9 | */
10 | namespace Framework\MVC\Debug;
11 |
12 | use Framework\Debug\Collector;
13 | use Framework\Debug\Debugger;
14 | use Framework\MVC\App;
15 | use ReflectionClass;
16 | use ReflectionMethod;
17 |
18 | /**
19 | * Class AppCollector.
20 | *
21 | * @package mvc
22 | */
23 | class AppCollector extends Collector
24 | {
25 | protected App $app;
26 | protected float $startTime;
27 | protected float $endTime;
28 | protected int $startMemory;
29 | protected int $endMemory;
30 |
31 | public function setApp(App $app) : static
32 | {
33 | $this->app = $app;
34 | if (!isset($this->startTime)) {
35 | $this->setStartTime();
36 | }
37 | if (!isset($this->startMemory)) {
38 | $this->setStartMemory();
39 | }
40 | return $this;
41 | }
42 |
43 | public function setStartTime(?float $microtime = null) : static
44 | {
45 | $this->startTime = $microtime ?? \microtime(true);
46 | return $this;
47 | }
48 |
49 | public function setEndTime(?float $microtime = null) : static
50 | {
51 | $this->endTime = $microtime ?? \microtime(true);
52 | return $this;
53 | }
54 |
55 | public function setStartMemory(?int $memoryUsage = null) : static
56 | {
57 | $this->startMemory = $memoryUsage ?? \memory_get_usage();
58 | return $this;
59 | }
60 |
61 | public function setEndMemory(?int $memoryUsage = null) : static
62 | {
63 | $this->endMemory = $memoryUsage ?? \memory_get_usage();
64 | return $this;
65 | }
66 |
67 | public function getActivities() : array
68 | {
69 | $activities = [];
70 | $activities[] = [
71 | 'collection' => 'App',
72 | 'collector' => $this->getName(),
73 | 'class' => static::class,
74 | 'description' => 'Initialization',
75 | 'start' => $_SERVER['REQUEST_TIME_FLOAT'],
76 | 'end' => $this->startTime,
77 | ];
78 | $activities[] = [
79 | 'collector' => $this->getName(),
80 | 'class' => static::class,
81 | 'description' => 'Runtime',
82 | 'start' => $this->startTime,
83 | 'end' => $this->endTime,
84 | ];
85 | foreach ($this->getServices() as $service => $data) {
86 | foreach ($data as $item) {
87 | $activities[] = [
88 | 'collector' => $this->getName(),
89 | 'class' => static::class,
90 | 'description' => 'Load service ' . $service . ':' . $item['name'],
91 | 'start' => $item['start'],
92 | 'end' => $item['end'],
93 | ];
94 | }
95 | }
96 | return $activities;
97 | }
98 |
99 | public function getContents() : string
100 | {
101 | if (!isset($this->endTime)) {
102 | $this->setEndTime(\microtime(true));
103 | }
104 | if (!isset($this->endMemory)) {
105 | $this->setEndMemory(\memory_get_usage());
106 | }
107 | \ob_start(); ?>
108 | Started at: = \date('r', (int) $this->startTime) ?>
109 | Runtime: = Debugger::roundSecondsToMilliseconds($this->endTime - $this->startTime) ?> ms
110 |
111 |
112 | Memory: =
113 | Debugger::convertSize($this->endMemory - $this->startMemory) ?>
114 |
115 | Services
116 | Loaded Service Instances
117 | = $this->renderLoadedServices() ?>
118 | Available Services
119 | renderAvailableServices();
121 | return \ob_get_clean(); // @phpstan-ignore-line
122 | }
123 |
124 | protected function renderLoadedServices() : string
125 | {
126 | $services = $this->getServices();
127 | $total = 0;
128 | foreach ($services as $data) {
129 | $total += \count($data);
130 | }
131 | if ($total === 0) {
132 | return 'No service instance has been loaded.
';
133 | }
134 | \ob_start(); ?>
135 | Total of = $total ?> service instance= $total !== 1 ? 's' : '' ?> loaded.
136 |
137 |
138 |
139 | Service |
140 | Instances |
141 | Time to Load |
142 |
143 |
144 |
145 | $data): ?>
146 |
147 |
148 | = $service ?> |
149 | = $data[0]['name'] ?> |
150 | = Debugger::roundSecondsToMilliseconds($data[0]['end'] - $data[0]['start']) ?> |
151 |
152 |
153 |
154 | = $data[$i]['name'] ?> |
155 | = Debugger::roundSecondsToMilliseconds($data[$i]['end'] - $data[$i]['start']) ?> |
156 |
157 |
158 |
159 |
160 |
161 |
167 | */
168 | protected function getServices() : array
169 | {
170 | $result = [];
171 | foreach ($this->getData() as $data) {
172 | if (!isset($result[$data['service']])) {
173 | $result[$data['service']] = [];
174 | }
175 | $result[$data['service']][] = [
176 | 'name' => $data['instance'],
177 | 'start' => $data['start'],
178 | 'end' => $data['end'],
179 | ];
180 | }
181 | return $result;
182 | }
183 |
184 | protected function renderAvailableServices() : string
185 | {
186 | \ob_start();
187 | $services = [];
188 | $class = new ReflectionClass($this->app);
189 | $methods = $class->getMethods(ReflectionMethod::IS_STATIC);
190 | foreach ($methods as $method) {
191 | if (!$method->isPublic()) {
192 | continue;
193 | }
194 | $name = $method->getName();
195 | if (\in_array($name, [
196 | 'config',
197 | 'getService',
198 | 'setService',
199 | 'removeService',
200 | 'isCli',
201 | 'setIsCli',
202 | 'isDebugging',
203 | ], true)) {
204 | continue;
205 | }
206 | $param = $method->getParameters()[0] ?? null;
207 | if (!$param || $param->getName() !== 'instance') {
208 | continue;
209 | }
210 | if ($param->getType()?->getName() !== 'string') { // @phpstan-ignore-line
211 | continue;
212 | }
213 | $instances = [];
214 | if ($param->isDefaultValueAvailable()) {
215 | $instances[] = $param->getDefaultValue();
216 | }
217 | foreach ((array) $this->app::config()->getInstances($name) as $inst => $s) {
218 | $instances[] = $inst;
219 | }
220 | $instances = \array_unique($instances);
221 | \sort($instances);
222 | $services[$name] = [
223 | 'returnType' => $method->getReturnType()?->getName(), // @phpstan-ignore-line
224 | 'instances' => $instances,
225 | ];
226 | }
227 | \ksort($services);
228 | $countServices = \count($services);
229 | $s = 0; ?>
230 | There are = $countServices ?> services available.
231 |
232 |
233 |
234 | # |
235 | Service |
236 | Config Instances |
237 | Return Type |
238 |
239 |
240 |
241 | $data): ?>
242 |
243 |
244 | = ++$s ?> |
245 | = $name ?> |
246 | = $data['instances'][0] ?> |
247 | = $data['returnType'] ?> |
248 |
249 |
250 |
251 | = $data['instances'][$i] ?> |
252 |
253 |
254 |
255 |
256 |
257 |
12 |
38 |
288 |
--------------------------------------------------------------------------------
/src/Debug/ViewsCollection.php:
--------------------------------------------------------------------------------
1 |
6 | *
7 | * For the full copyright and license information, please view the LICENSE
8 | * file that was distributed with this source code.
9 | */
10 | namespace Framework\MVC\Debug;
11 |
12 | use Framework\Debug\Collection;
13 | use Framework\Helpers\Isolation;
14 |
15 | /**
16 | * Class ViewsCollection.
17 | *
18 | * @package mvc
19 | */
20 | class ViewsCollection extends Collection
21 | {
22 | protected string $iconPath = __DIR__ . '/icons/views.svg';
23 |
24 | protected function prepare() : void
25 | {
26 | parent::prepare();
27 | $this->addAction($this->makeActionToggleViewsHints());
28 | }
29 |
30 | protected function makeActionToggleViewsHints() : string
31 | {
32 | \ob_start();
33 | Isolation::require(__DIR__ . '/Views/toggle-views-hints.php');
34 | return \ob_get_clean(); // @phpstan-ignore-line
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/Debug/ViewsCollector.php:
--------------------------------------------------------------------------------
1 |
6 | *
7 | * For the full copyright and license information, please view the LICENSE
8 | * file that was distributed with this source code.
9 | */
10 | namespace Framework\MVC\Debug;
11 |
12 | use Framework\Debug\Collector;
13 | use Framework\Debug\Debugger;
14 | use Framework\MVC\View;
15 |
16 | /**
17 | * Class ViewsCollector.
18 | *
19 | * @package mvc
20 | */
21 | class ViewsCollector extends Collector
22 | {
23 | protected View $view;
24 |
25 | public function setView(View $view) : static
26 | {
27 | $this->view = $view;
28 | return $this;
29 | }
30 |
31 | public function getActivities() : array
32 | {
33 | $activities = [];
34 | foreach ($this->getSortedData() as $index => $data) {
35 | $activities[] = [
36 | 'collector' => $this->getName(),
37 | 'class' => static::class,
38 | 'description' => 'Render view ' . ($index + 1),
39 | 'start' => $data['start'],
40 | 'end' => $data['end'],
41 | ];
42 | }
43 | return $activities;
44 | }
45 |
46 | public function getContents() : string
47 | {
48 | $baseDir = $this->view->getBaseDir();
49 | $extension = $this->view->getExtension();
50 | $layoutPrefix = $this->view->getLayoutPrefix();
51 | $includePrefix = $this->view->getIncludePrefix();
52 | \ob_start();
53 | if (isset($baseDir)): ?>
54 | Base Directory: = \htmlentities($baseDir) ?>
55 |
57 | Extension: = \htmlentities($extension) ?>
58 |
60 | Layout Prefix: = \htmlentities($layoutPrefix) ?>
61 |
62 |
65 | Include Prefix: = \htmlentities($includePrefix) ?>
66 |
67 |
69 | Rendered Views
70 | renderRenderedViews();
72 | return \ob_get_clean(); // @phpstan-ignore-line
73 | }
74 |
75 | protected function renderRenderedViews() : string
76 | {
77 | if (!$this->hasData()) {
78 | return 'No view has been rendered.
';
79 | }
80 | $data = $this->getSortedData();
81 | \ob_start();
82 | $count = \count($data); ?>
83 | Total of = $count ?> rendered view file= $count > 1 ? 's' : '' ?>.
84 |
85 |
86 |
87 | # |
88 | File |
89 | Type |
90 | Time to Render |
91 |
92 |
93 |
94 | $item): ?>
95 |
96 | = $index + 1 ?> |
97 | = \htmlentities($item['file']) ?> |
98 | = \htmlentities($item['type']) ?> |
99 | = Debugger::roundSecondsToMilliseconds($item['end'] - $item['start']) ?> |
100 |
101 |
102 |
103 |
104 |
110 | */
111 | protected function getSortedData() : array
112 | {
113 | $data = $this->getData();
114 | \usort($data, static function ($d1, $d2) {
115 | return $d1['start'] <=> $d2['start'];
116 | });
117 | return $data;
118 | }
119 | }
120 |
--------------------------------------------------------------------------------
/src/Debug/icons/app.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/Debug/icons/views.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/Entity.php:
--------------------------------------------------------------------------------
1 |
6 | *
7 | * For the full copyright and license information, please view the LICENSE
8 | * file that was distributed with this source code.
9 | */
10 | namespace Framework\MVC;
11 |
12 | use DateTime;
13 | use DateTimeInterface;
14 | use DateTimeZone;
15 | use Exception;
16 | use Framework\Date\Date;
17 | use Framework\HTTP\URL;
18 | use JsonException;
19 | use OutOfBoundsException;
20 | use ReflectionProperty;
21 | use stdClass;
22 |
23 | /**
24 | * Class Entity.
25 | *
26 | * @todo In PHP 8.4 add property hooks to validate config properties.
27 | *
28 | * @package mvc
29 | */
30 | abstract class Entity implements \JsonSerializable, \Stringable
31 | {
32 | /**
33 | * Sets the flags that will be used to encode/decode JSON in internal
34 | * methods of this Entity class.
35 | */
36 | public int $_jsonFlags = \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE
37 | | \JSON_PRESERVE_ZERO_FRACTION | \JSON_THROW_ON_ERROR;
38 | /**
39 | * Sets the name of the properties that will be visible when this Entity is
40 | * JSON encoded.
41 | *
42 | * @var array
43 | */
44 | public array $_jsonVars = [];
45 | /**
46 | * This timezone is used to convert times in the {@see Entity::toModel()}
47 | * method.
48 | *
49 | * Note that it must be the same timezone as the database configurations.
50 | *
51 | * @see Model::timezone()
52 | */
53 | public string $_timezone = '+00:00';
54 |
55 | /**
56 | * @param array $properties
57 | */
58 | public function __construct(array $properties)
59 | {
60 | $this->populate($properties);
61 | $this->init();
62 | }
63 |
64 | public function __isset(string $property) : bool
65 | {
66 | return isset($this->{$property});
67 | }
68 |
69 | public function __unset(string $property) : void
70 | {
71 | unset($this->{$property});
72 | }
73 |
74 | /**
75 | * @param string $property
76 | * @param mixed $value
77 | *
78 | * @throws OutOfBoundsException If property is not defined
79 | */
80 | public function __set(string $property, mixed $value) : void
81 | {
82 | $method = $this->renderMethodName('set', $property);
83 | if (\method_exists($this, $method)) {
84 | $this->{$method}($value);
85 | return;
86 | }
87 | if (\property_exists($this, $property)) {
88 | $this->{$property} = $value;
89 | return;
90 | }
91 | throw $this->propertyNotDefined($property);
92 | }
93 |
94 | /**
95 | * @param string $property
96 | *
97 | * @throws OutOfBoundsException If property is not defined
98 | *
99 | * @return mixed
100 | */
101 | public function __get(string $property) : mixed
102 | {
103 | $method = $this->renderMethodName('get', $property);
104 | if (\method_exists($this, $method)) {
105 | return $this->{$method}();
106 | }
107 | if (\property_exists($this, $property)) {
108 | return $this->{$property};
109 | }
110 | throw $this->propertyNotDefined($property);
111 | }
112 |
113 | /**
114 | * Converts the entity to a JSON string.
115 | * All properties will be included.
116 | * Please note that sensitive property data may be exposed!
117 | *
118 | * @return string
119 | */
120 | public function __toString() : string
121 | {
122 | $origin = $this->_jsonVars;
123 | $all = \array_keys($this->getObjectVars());
124 | $this->_jsonVars = $all;
125 | $json = \json_encode($this, $this->_jsonFlags);
126 | $this->_jsonVars = $origin;
127 | return $json; // @phpstan-ignore-line
128 | }
129 |
130 | protected function propertyNotDefined(string $property) : OutOfBoundsException
131 | {
132 | return new OutOfBoundsException('Property not defined: ' . $property);
133 | }
134 |
135 | /**
136 | * Used to initialize settings, set custom properties, etc.
137 | * Called in the constructor just after the properties be populated.
138 | */
139 | protected function init() : void
140 | {
141 | }
142 |
143 | /**
144 | * @param string $type get or set
145 | * @param string $property Property name
146 | *
147 | * @return string
148 | */
149 | protected function renderMethodName(string $type, string $property) : string
150 | {
151 | static $properties;
152 | if (isset($properties[$property])) {
153 | return $type . $properties[$property];
154 | }
155 | $name = \ucwords($property, '_');
156 | $name = \strtr($name, ['_' => '']);
157 | $properties[$property] = $name;
158 | return $type . $name;
159 | }
160 |
161 | /**
162 | * @param array $properties
163 | */
164 | protected function populate(array $properties) : void
165 | {
166 | foreach ($properties as $property => $value) {
167 | $method = $this->renderMethodName('set', $property);
168 | if (\method_exists($this, $method)) {
169 | $this->{$method}($value);
170 | continue;
171 | }
172 | $this->setProperty($property, $value);
173 | }
174 | }
175 |
176 | protected function setProperty(string $name, mixed $value) : void
177 | {
178 | if (!\property_exists($this, $name)) {
179 | throw $this->propertyNotDefined($name);
180 | }
181 | if ($value !== null) {
182 | $rp = new ReflectionProperty($this, $name);
183 | $propertyType = $rp->getType()?->getName(); // @phpstan-ignore-line
184 | if ($propertyType !== null) {
185 | $value = $this->typeHint($propertyType, $value);
186 | }
187 | }
188 | $this->{$name} = $value;
189 | }
190 |
191 | /**
192 | * Tries to convert the value according to the property type.
193 | *
194 | * @param string $propertyType
195 | * @param mixed $value
196 | *
197 | * @return mixed
198 | */
199 | protected function typeHint(string $propertyType, mixed $value) : mixed
200 | {
201 | $valueType = \get_debug_type($value);
202 | $newValue = $this->typeHintCustom($propertyType, $valueType, $value);
203 | if ($newValue === null) {
204 | $newValue = $this->typeHintNative($propertyType, $valueType, $value);
205 | }
206 | if ($newValue === null) {
207 | $newValue = $this->typeHintAplus($propertyType, $valueType, $value);
208 | }
209 | return $newValue ?? $value;
210 | }
211 |
212 | /**
213 | * Override this method to set customizable property types.
214 | *
215 | * @param string $propertyType
216 | * @param string $valueType
217 | * @param mixed $value
218 | *
219 | * @return mixed
220 | */
221 | protected function typeHintCustom(string $propertyType, string $valueType, mixed $value) : mixed
222 | {
223 | return null;
224 | }
225 |
226 | /**
227 | * Tries to convert the property value to native PHP types.
228 | *
229 | * @param string $propertyType
230 | * @param string $valueType
231 | * @param mixed $value
232 | *
233 | * @return mixed
234 | */
235 | protected function typeHintNative(string $propertyType, string $valueType, mixed $value) : mixed
236 | {
237 | if ($propertyType === 'array') {
238 | return $valueType === 'string'
239 | ? \json_decode($value, true, flags: $this->_jsonFlags)
240 | : (array) $value;
241 | }
242 | if ($propertyType === 'bool') {
243 | return (bool) $value;
244 | }
245 | if ($propertyType === 'float') {
246 | return (float) $value;
247 | }
248 | if ($propertyType === 'int') {
249 | return (int) $value;
250 | }
251 | if ($propertyType === 'string') {
252 | return (string) $value;
253 | }
254 | if ($propertyType === stdClass::class) {
255 | return $valueType === 'string'
256 | ? (object) \json_decode($value, flags: $this->_jsonFlags)
257 | : (object) $value;
258 | }
259 | return null;
260 | }
261 |
262 | /**
263 | * Tries to convert the property value using Aplus Framework types.
264 | *
265 | * @param string $propertyType
266 | * @param string $valueType
267 | * @param mixed $value
268 | *
269 | * @throws Exception
270 | *
271 | * @return mixed
272 | */
273 | protected function typeHintAplus(string $propertyType, string $valueType, mixed $value) : mixed
274 | {
275 | if ($propertyType === Date::class) {
276 | return new Date((string) $value);
277 | }
278 | if ($propertyType === URL::class) {
279 | return new URL((string) $value);
280 | }
281 | return null;
282 | }
283 |
284 | /**
285 | * Convert the Entity to an associative array accepted by Model methods.
286 | *
287 | * @throws Exception in case of error creating DateTimeZone
288 | * @throws JsonException in case of error while encoding/decoding JSON
289 | *
290 | * @return array
291 | */
292 | public function toModel() : array
293 | {
294 | $jsonVars = $this->_jsonVars;
295 | $this->_jsonVars = \array_keys($this->getObjectVars());
296 | // @phpstan-ignore-next-line
297 | $data = \json_decode(\json_encode($this, $this->_jsonFlags), true, 512, $this->_jsonFlags);
298 | foreach ($data as $property => &$value) {
299 | if (\is_array($value)) {
300 | $value = \json_encode($value, $this->_jsonFlags);
301 | continue;
302 | }
303 | $type = \get_debug_type($this->{$property});
304 | if (\is_subclass_of($type, DateTimeInterface::class)) {
305 | $datetime = DateTime::createFromFormat(DateTimeInterface::ATOM, $value);
306 | // @phpstan-ignore-next-line
307 | $datetime->setTimezone(new DateTimeZone($this->_timezone));
308 | $value = $datetime->format('Y-m-d H:i:s'); // @phpstan-ignore-line
309 | }
310 | }
311 | unset($value);
312 | $this->_jsonVars = $jsonVars;
313 | return $data;
314 | }
315 |
316 | public function jsonSerialize() : stdClass
317 | {
318 | if (!$this->_jsonVars) {
319 | return new stdClass();
320 | }
321 | $allowed = \array_flip($this->_jsonVars);
322 | $filtered = \array_intersect_key($this->getObjectVars(), $allowed);
323 | $allowed = \array_intersect_key($allowed, $filtered);
324 | $ordered = \array_replace($allowed, $filtered);
325 | return (object) $ordered;
326 | }
327 |
328 | /**
329 | * @return array
330 | */
331 | protected function getObjectVars() : array
332 | {
333 | $result = [];
334 | foreach (\get_object_vars($this) as $key => $value) {
335 | if (!\str_starts_with($key, '_')) {
336 | $result[$key] = $value;
337 | }
338 | }
339 | return $result;
340 | }
341 | }
342 |
--------------------------------------------------------------------------------
/src/Languages/en/validation.php:
--------------------------------------------------------------------------------
1 |
6 | *
7 | * For the full copyright and license information, please view the LICENSE
8 | * file that was distributed with this source code.
9 | */
10 | return [
11 | 'exist' => 'The {field} field does not exist.',
12 | 'existMany' => 'The {field} field has at least one value that does not exist.',
13 | 'notUnique' => 'The {field} field is not registered.',
14 | 'unique' => 'The {field} field has already been registered.',
15 | ];
16 |
--------------------------------------------------------------------------------
/src/Languages/es/validation.php:
--------------------------------------------------------------------------------
1 |
6 | *
7 | * For the full copyright and license information, please view the LICENSE
8 | * file that was distributed with this source code.
9 | */
10 | return [
11 | 'exist' => 'El campo {field} no existe.',
12 | 'existMany' => 'El campo {field} tiene al menos un valor que no existe.',
13 | 'notUnique' => 'El campo {field} no está registrado.',
14 | 'unique' => 'El campo {field} ya se ha registrado. ',
15 | ];
16 |
--------------------------------------------------------------------------------
/src/Languages/pt-br/validation.php:
--------------------------------------------------------------------------------
1 |
6 | *
7 | * For the full copyright and license information, please view the LICENSE
8 | * file that was distributed with this source code.
9 | */
10 | return [
11 | 'exist' => 'O campo {field} não existe.',
12 | 'existMany' => 'O campo {field} tem pelo menos um valor que não existe.',
13 | 'notUnique' => 'O campo {field} não está registrado.',
14 | 'unique' => 'O campo {field} já foi registrado.',
15 | ];
16 |
--------------------------------------------------------------------------------
/src/Model.php:
--------------------------------------------------------------------------------
1 |
6 | *
7 | * For the full copyright and license information, please view the LICENSE
8 | * file that was distributed with this source code.
9 | */
10 | namespace Framework\MVC;
11 |
12 | use BadMethodCallException;
13 | use DateTime;
14 | use DateTimeZone;
15 | use Exception;
16 | use Framework\Cache\Cache;
17 | use Framework\Database\Database;
18 | use Framework\Database\Manipulation\Traits\Where;
19 | use Framework\Language\Language;
20 | use Framework\Pagination\Pager;
21 | use Framework\Validation\Debug\ValidationCollector;
22 | use Framework\Validation\FilesValidator;
23 | use Framework\Validation\Validation;
24 | use InvalidArgumentException;
25 | use JetBrains\PhpStorm\ArrayShape;
26 | use JetBrains\PhpStorm\Pure;
27 | use LogicException;
28 | use mysqli_sql_exception;
29 | use RuntimeException;
30 | use stdClass;
31 |
32 | /**
33 | * Class Model.
34 | *
35 | * @package mvc
36 | *
37 | * @method false|int|string createById(Entity|array|stdClass $data) Create a new row and return the
38 | * id.
39 | * @method Entity|array|stdClass|null readById(int|string $id) Read a row by id.
40 | * @method Entity|array|stdClass|null findById(int|string $id) Find a row by id.
41 | * @method false|int|string updateById(int|string $id, Entity|array|stdClass $data) Update rows by
42 | * id.
43 | * @method false|int|string deleteById(int|string $id) Delete rows by id.
44 | * @method false|int|string replaceById(int|string $id, Entity|array|stdClass $data) Replace rows
45 | * by id.
46 | */
47 | abstract class Model implements ModelInterface
48 | {
49 | /**
50 | * @var array
51 | */
52 | protected static array $models = [];
53 | /**
54 | * Database connection instance name for read operations.
55 | *
56 | * @var string
57 | */
58 | protected string $connectionRead = 'default';
59 | /**
60 | * Database connection instance name for write operations.
61 | *
62 | * @var string
63 | */
64 | protected string $connectionWrite = 'default';
65 | /**
66 | * Table name.
67 | *
68 | * @var string
69 | */
70 | protected string $table;
71 | /**
72 | * Table Primary Key.
73 | *
74 | * @var string
75 | */
76 | protected string $primaryKey = 'id';
77 | /**
78 | * Prevents Primary Key changes on INSERT and UPDATE.
79 | *
80 | * @var bool
81 | */
82 | protected bool $protectPrimaryKey = true;
83 | /**
84 | * Fetched item return type.
85 | *
86 | * Array, object or the classname of an Entity instance.
87 | *
88 | * @see Entity
89 | *
90 | * @var string
91 | */
92 | protected string $returnType = stdClass::class;
93 | /**
94 | * Allowed columns for INSERT and UPDATE.
95 | *
96 | * @var array
97 | */
98 | protected array $allowedFields;
99 | /**
100 | * Auto set timestamp fields.
101 | *
102 | * @var bool
103 | */
104 | protected bool $autoTimestamps = false;
105 | /**
106 | * The timestamp field for 'created at' time when $autoTimestamps is true.
107 | *
108 | * @var string
109 | */
110 | protected string $fieldCreated = 'createdAt';
111 | /**
112 | * The timestamp field for 'updated at' time when $autoTimestamps is true.
113 | *
114 | * @var string
115 | */
116 | protected string $fieldUpdated = 'updatedAt';
117 | /**
118 | * The timestamp format used on database write operations.
119 | *
120 | * @var string
121 | */
122 | protected string $timestampFormat = 'Y-m-d H:i:s';
123 | /**
124 | * The Model Validation instance.
125 | */
126 | protected Validation $validation;
127 | /**
128 | * Validation field labels.
129 | *
130 | * @var array
131 | */
132 | protected array $validationLabels;
133 | /**
134 | * Validation error messages.
135 | *
136 | * @var array>
137 | */
138 | protected array $validationMessages;
139 | /**
140 | * Validation rules.
141 | *
142 | * @see Validation::setRules
143 | *
144 | * @var array|string>
145 | */
146 | protected array $validationRules;
147 | /**
148 | * Validation Validators.
149 | *
150 | * @var array
151 | */
152 | protected array $validationValidators = [
153 | Validator::class,
154 | FilesValidator::class,
155 | ];
156 | /**
157 | * The Pager instance.
158 | *
159 | * Instantiated when calling the paginate method.
160 | *
161 | * @see Model::paginate
162 | *
163 | * @var Pager
164 | */
165 | protected Pager $pager;
166 | /**
167 | * Default pager view.
168 | *
169 | * @var string
170 | */
171 | protected string $pagerView;
172 | /**
173 | * @var string
174 | */
175 | protected string $pagerQuery;
176 | /**
177 | * @var array|null
178 | */
179 | protected ?array $pagerAllowedQueries = null;
180 | /**
181 | * Pager URL.
182 | *
183 | * @var string
184 | */
185 | protected string $pagerUrl;
186 | protected bool $cacheActive = false;
187 | protected string $cacheInstance = 'default';
188 | protected int $cacheTtl = 60;
189 | protected int | string $cacheDataNotFound = 0;
190 | protected string $languageInstance = 'default';
191 | protected string $columnCase = 'camel';
192 |
193 | /**
194 | * @param string $method
195 | * @param array $arguments
196 | *
197 | * @return mixed
198 | */
199 | public function __call(string $method, array $arguments) : mixed
200 | {
201 | if (\str_starts_with($method, 'createBy')) {
202 | $method = \substr($method, 8);
203 | $method = $this->convertCase($method, $this->columnCase);
204 | return $this->createBy($method, $arguments[0]); // @phpstan-ignore-line
205 | }
206 | if (\str_starts_with($method, 'readBy')) {
207 | $method = \substr($method, 6);
208 | $method = $this->convertCase($method, $this->columnCase);
209 | return $this->readBy($method, $arguments[0]); // @phpstan-ignore-line
210 | }
211 | if (\str_starts_with($method, 'updateBy')) {
212 | $method = \substr($method, 8);
213 | $method = $this->convertCase($method, $this->columnCase);
214 | return $this->updateBy($method, $arguments[0], $arguments[1]); // @phpstan-ignore-line
215 | }
216 | if (\str_starts_with($method, 'deleteBy')) {
217 | $method = \substr($method, 8);
218 | $method = $this->convertCase($method, $this->columnCase);
219 | return $this->deleteBy($method, $arguments[0]); // @phpstan-ignore-line
220 | }
221 | if (\str_starts_with($method, 'replaceBy')) {
222 | $method = \substr($method, 9);
223 | $method = $this->convertCase($method, $this->columnCase);
224 | return $this->replaceBy($method, $arguments[0], $arguments[1]); // @phpstan-ignore-line
225 | }
226 | if (\str_starts_with($method, 'findBy')) {
227 | $method = \substr($method, 6);
228 | $method = $this->convertCase($method, $this->columnCase);
229 | return $this->findBy($method, $arguments[0]); // @phpstan-ignore-line
230 | }
231 | $class = static::class;
232 | if (\method_exists($this, $method)) {
233 | throw new BadMethodCallException(
234 | "Method not allowed: {$class}::{$method}"
235 | );
236 | }
237 | throw new BadMethodCallException("Method not found: {$class}::{$method}");
238 | }
239 |
240 | /**
241 | * Convert a value to specific case.
242 | *
243 | * @param string $value
244 | * @param string $case camel, pascal or snake
245 | *
246 | * @return string The converted value
247 | */
248 | protected function convertCase(string $value, string $case) : string
249 | {
250 | if ($case === 'camel' || $case === 'pascal') {
251 | $value = \preg_replace('/([a-z])([A-Z])/', '\1 \2', $value);
252 | $value = \preg_replace('@[^a-zA-Z0-9\-_ ]+@', '', $value);
253 | $value = \str_replace(['-', '_'], ' ', $value);
254 | $value = \str_replace(' ', '', \ucwords(\strtolower($value)));
255 | $value = \strtolower($value[0]) . \substr($value, 1);
256 | return $case === 'camel' ? \lcfirst($value) : \ucfirst($value);
257 | }
258 | if ($case === 'snake') {
259 | $value = \preg_replace('/([a-z])([A-Z])/', '\1_\2', $value);
260 | return \strtolower($value);
261 | }
262 | throw new InvalidArgumentException('Invalid case: ' . $case);
263 | }
264 |
265 | #[Pure]
266 | protected function getConnectionRead() : string
267 | {
268 | return $this->connectionRead;
269 | }
270 |
271 | #[Pure]
272 | protected function getConnectionWrite() : string
273 | {
274 | return $this->connectionWrite;
275 | }
276 |
277 | protected function getTable() : string
278 | {
279 | return $this->table ??= $this->makeTableName();
280 | }
281 |
282 | protected function makeTableName() : string
283 | {
284 | $name = static::class;
285 | $pos = \strrpos($name, '\\');
286 | if ($pos) {
287 | $name = \substr($name, $pos + 1);
288 | }
289 | if (\str_ends_with($name, 'Model')) {
290 | $name = \substr($name, 0, -5);
291 | }
292 | return $name;
293 | }
294 |
295 | #[Pure]
296 | protected function getPrimaryKey() : string
297 | {
298 | return $this->primaryKey;
299 | }
300 |
301 | #[Pure]
302 | protected function isProtectPrimaryKey() : bool
303 | {
304 | return $this->protectPrimaryKey;
305 | }
306 |
307 | #[Pure]
308 | protected function getReturnType() : string
309 | {
310 | return $this->returnType;
311 | }
312 |
313 | /**
314 | * @return array
315 | */
316 | protected function getAllowedFields() : array
317 | {
318 | if (empty($this->allowedFields)) {
319 | throw new LogicException(
320 | 'Allowed fields not defined for database writes'
321 | );
322 | }
323 | return $this->allowedFields;
324 | }
325 |
326 | #[Pure]
327 | protected function isAutoTimestamps() : bool
328 | {
329 | return $this->autoTimestamps;
330 | }
331 |
332 | #[Pure]
333 | protected function getFieldCreated() : string
334 | {
335 | return $this->fieldCreated;
336 | }
337 |
338 | #[Pure]
339 | protected function getFieldUpdated() : string
340 | {
341 | return $this->fieldUpdated;
342 | }
343 |
344 | #[Pure]
345 | protected function getTimestampFormat() : string
346 | {
347 | return $this->timestampFormat;
348 | }
349 |
350 | protected function getLanguageInstance() : string
351 | {
352 | return $this->languageInstance;
353 | }
354 |
355 | protected function getLanguage() : Language
356 | {
357 | return App::language($this->getLanguageInstance());
358 | }
359 |
360 | protected function checkPrimaryKey(int | string $id) : void
361 | {
362 | if (empty($id)) {
363 | throw new InvalidArgumentException(
364 | 'Primary Key can not be empty'
365 | );
366 | }
367 | }
368 |
369 | /**
370 | * @template T
371 | *
372 | * @param array $data
373 | *
374 | * @return array
375 | */
376 | protected function filterAllowedFields(array $data) : array
377 | {
378 | $fields = \array_intersect_key($data, \array_flip($this->getAllowedFields()));
379 | if ($this->isProtectPrimaryKey() !== false
380 | && \array_key_exists($this->getPrimaryKey(), $fields)
381 | ) {
382 | throw new LogicException(
383 | 'Protected Primary Key field can not be SET'
384 | );
385 | }
386 | return $fields;
387 | }
388 |
389 | /**
390 | * @see Model::$connectionRead
391 | *
392 | * @return Database
393 | */
394 | protected function getDatabaseToRead() : Database
395 | {
396 | return App::database($this->getConnectionRead());
397 | }
398 |
399 | /**
400 | * @see Model::$connectionWrite
401 | *
402 | * @return Database
403 | */
404 | protected function getDatabaseToWrite() : Database
405 | {
406 | return App::database($this->getConnectionWrite());
407 | }
408 |
409 | /**
410 | * A basic function to count rows in the table.
411 | *
412 | * @param array> $where Array in this format: `[['id', '=', 25]]`
413 | *
414 | * @see Where
415 | *
416 | * @return int
417 | */
418 | public function count(array $where = []) : int
419 | {
420 | $select = $this->getDatabaseToRead()
421 | ->select()
422 | ->expressions([
423 | 'count' => static function () : string {
424 | return 'COUNT(*)';
425 | },
426 | ])
427 | ->from($this->getTable());
428 | foreach ($where as $args) {
429 | $select->where(...$args);
430 | }
431 | return $select->run()->fetch()->count; // @phpstan-ignore-line
432 | }
433 |
434 | /**
435 | * @param int $page
436 | * @param int $perPage
437 | *
438 | * @see Model::paginate
439 | *
440 | * @return array
441 | */
442 | #[ArrayShape([0 => 'int', 1 => 'int|null'])]
443 | #[Pure]
444 | protected function makePageLimitAndOffset(int $page, int $perPage = 10) : array
445 | {
446 | $page = $this->sanitizePageNumber($page);
447 | $perPage = $this->sanitizePageNumber($perPage);
448 | $page = $page <= 1 ? null : $page * $perPage - $perPage;
449 | if ($page > \PHP_INT_MAX) {
450 | $page = \PHP_INT_MAX;
451 | }
452 | if ($perPage === \PHP_INT_MAX && $page !== null) {
453 | $page = \PHP_INT_MAX;
454 | }
455 | return [
456 | $perPage,
457 | $page,
458 | ];
459 | }
460 |
461 | protected function sanitizePageNumber(int $number) : int
462 | {
463 | if ($number < 0) {
464 | if ($number === \PHP_INT_MIN) {
465 | $number++;
466 | }
467 | $number *= -1;
468 | }
469 | return $number;
470 | }
471 |
472 | /**
473 | * A basic function to paginate all rows of the table.
474 | *
475 | * @param mixed $page The current page
476 | * @param mixed $perPage Items per page
477 | * @param array|string $orderBy Order by columns
478 | * @param string $orderByDirection asc or desc
479 | * @param array> $where Array in this format: `[['id', '=', 25]]`
480 | *
481 | * @see Where
482 | *
483 | * @return array|stdClass>
484 | */
485 | public function paginate(
486 | mixed $page,
487 | mixed $perPage = 10,
488 | array $where = [],
489 | array | string | null $orderBy = null,
490 | string $orderByDirection = 'asc',
491 | ) : array {
492 | $page = Pager::sanitize($page);
493 | $perPage = Pager::sanitize($perPage);
494 | $select = $this->getDatabaseToRead()
495 | ->select()
496 | ->from($this->getTable())
497 | ->limit(...$this->makePageLimitAndOffset($page, $perPage));
498 | if ($where) {
499 | foreach ($where as $args) {
500 | $select->where(...$args);
501 | }
502 | }
503 | if ($orderBy !== null) {
504 | $orderBy = (array) $orderBy;
505 | $orderByDir = \strtolower($orderByDirection);
506 | if (!\in_array($orderByDir, [
507 | 'asc',
508 | 'desc',
509 | ])) {
510 | throw new InvalidArgumentException(
511 | 'Invalid ORDER BY direction: ' . $orderByDirection
512 | );
513 | }
514 | $orderByDir === 'asc'
515 | ? $select->orderByAsc(...$orderBy)
516 | : $select->orderByDesc(...$orderBy);
517 | }
518 | $data = $select->run()->fetchArrayAll();
519 | foreach ($data as &$row) {
520 | $row = $this->makeEntity($row);
521 | }
522 | unset($row);
523 | $this->setPager(new Pager($page, $perPage, $this->count($where)));
524 | return $data;
525 | }
526 |
527 | /**
528 | * Set the Pager.
529 | *
530 | * @param Pager $pager
531 | *
532 | * @return static
533 | */
534 | protected function setPager(Pager $pager) : static
535 | {
536 | $pager->setLanguage($this->getLanguage());
537 | $temp = $this->getPagerQuery();
538 | if (isset($temp)) {
539 | $pager->setQuery($temp);
540 | }
541 | $temp = $this->getPagerUrl();
542 | if (isset($temp)) {
543 | $pager->setUrl($temp);
544 | }
545 | $pager->setAllowedQueries($this->getPagerAllowedQueries());
546 | $temp = $this->getPagerView();
547 | if (isset($temp)) {
548 | $pager->setDefaultView($temp);
549 | }
550 | $this->pager = $pager;
551 | return $this;
552 | }
553 |
554 | /**
555 | * Get the custom URL to be used in the Pager.
556 | *
557 | * @return string|null
558 | */
559 | protected function getPagerUrl() : ?string
560 | {
561 | return $this->pagerUrl ?? null;
562 | }
563 |
564 | /**
565 | * Get the custom view to be used in the Pager.
566 | *
567 | * @return string|null
568 | */
569 | protected function getPagerView() : ?string
570 | {
571 | return $this->pagerView ?? null;
572 | }
573 |
574 | /**
575 | * Get the custom query to be used in the Pager.
576 | *
577 | * @return string|null
578 | */
579 | protected function getPagerQuery() : ?string
580 | {
581 | return $this->pagerQuery ?? null;
582 | }
583 |
584 | /**
585 | * Get allowed queries to be used in the Pager.
586 | *
587 | * @return array|null
588 | */
589 | protected function getPagerAllowedQueries() : ?array
590 | {
591 | return $this->pagerAllowedQueries;
592 | }
593 |
594 | /**
595 | * Get the Pager.
596 | *
597 | * Allowed only after calling a method that sets the Pager.
598 | *
599 | * @see Model::paginate()
600 | *
601 | * @return Pager
602 | */
603 | public function getPager() : Pager
604 | {
605 | return $this->pager;
606 | }
607 |
608 | /**
609 | * Read a row by column name and value.
610 | *
611 | * @since 3.6
612 | *
613 | * @param int|string $value
614 | * @param string $column
615 | *
616 | * @return Entity|array|stdClass|null
617 | */
618 | public function readBy(
619 | string $column,
620 | int | string $value
621 | ) : Entity | array | stdClass | null {
622 | if ($this->isCacheActive()) {
623 | return $this->readWithCache($column, $value);
624 | }
625 | $data = $this->readRow($column, $value);
626 | return $data ? $this->makeEntity($data) : null;
627 | }
628 |
629 | /**
630 | * Alias of {@see Model::readBy()}.
631 | *
632 | * Find a row by column name and value.
633 | *
634 | * @param string $column
635 | * @param int|string $value
636 | *
637 | * @return Entity|array|stdClass|null
638 | */
639 | public function findBy(
640 | string $column,
641 | int | string $value
642 | ) : Entity | array | stdClass | null {
643 | return $this->readBy($column, $value);
644 | }
645 |
646 | /**
647 | * Read a row based on Primary Key.
648 | *
649 | * @since 3.6
650 | *
651 | * @param int|string $id
652 | *
653 | * @return Entity|array|stdClass|null The
654 | * selected row as configured on $returnType property or null if row was
655 | * not found
656 | */
657 | public function read(int | string $id) : Entity | array | stdClass | null
658 | {
659 | $this->checkPrimaryKey($id);
660 | return $this->readBy($this->getPrimaryKey(), $id);
661 | }
662 |
663 | /**
664 | * Alias of {@see Model::read()}.
665 | *
666 | * @param int|string $id
667 | *
668 | * @return Entity|array|float[]|int[]|null[]|stdClass|string[]|null
669 | */
670 | public function find(int | string $id) : Entity | array | stdClass | null
671 | {
672 | return $this->read($id);
673 | }
674 |
675 | /**
676 | * @since 3.6
677 | *
678 | * @param int|string $value
679 | * @param string $column
680 | *
681 | * @return array|null
682 | */
683 | protected function readRow(string $column, int | string $value) : ?array
684 | {
685 | return $this->getDatabaseToRead()
686 | ->select()
687 | ->from($this->getTable())
688 | ->whereEqual($column, $value)
689 | ->limit(1)
690 | ->run()
691 | ->fetchArray();
692 | }
693 |
694 | /**
695 | * @since 3.6
696 | *
697 | * @param int|string $value
698 | * @param string $column
699 | *
700 | * @return Entity|array|stdClass|null
701 | */
702 | protected function readWithCache(string $column, int | string $value) : Entity | array | stdClass | null
703 | {
704 | $cacheKey = $this->getCacheKey([
705 | $column => $value,
706 | ]);
707 | $data = $this->getCache()->get($cacheKey);
708 | if ($data === $this->getCacheDataNotFound()) {
709 | return null;
710 | }
711 | if (\is_array($data)) {
712 | return $this->makeEntity($data);
713 | }
714 | $data = $this->readRow($column, $value);
715 | if ($data === null) {
716 | $data = $this->getCacheDataNotFound();
717 | }
718 | $this->getCache()->set($cacheKey, $data, $this->getCacheTtl());
719 | return \is_array($data) ? $this->makeEntity($data) : null;
720 | }
721 |
722 | /**
723 | * Alias of {@see Model::list()}.
724 | *
725 | * Find all rows with limit and offset.
726 | *
727 | * @param int|null $limit
728 | * @param int|null $offset
729 | *
730 | * @return array|stdClass>
731 | */
732 | public function findAll(?int $limit = null, ?int $offset = null) : array
733 | {
734 | return $this->list($limit, $offset);
735 | }
736 |
737 | /**
738 | * List rows, optionally with limit and offset.
739 | *
740 | * @since 3.6
741 | *
742 | * @param int|null $offset
743 | * @param int|null $limit
744 | *
745 | * @return array|stdClass>
746 | */
747 | public function list(?int $limit = null, ?int $offset = null) : array
748 | {
749 | $data = $this->getDatabaseToRead()
750 | ->select()
751 | ->from($this->getTable());
752 | if ($limit !== null) {
753 | $data->limit($limit, $offset);
754 | }
755 | $data = $data->run()->fetchArrayAll();
756 | foreach ($data as &$row) {
757 | $row = $this->makeEntity($row);
758 | }
759 | unset($row);
760 | return $data;
761 | }
762 |
763 | /**
764 | * @param array $data
765 | *
766 | * @return Entity|array|stdClass
767 | */
768 | protected function makeEntity(array $data) : Entity | array | stdClass
769 | {
770 | $returnType = $this->getReturnType();
771 | if ($returnType === 'array') {
772 | return $data;
773 | }
774 | if ($returnType === 'object' || $returnType === stdClass::class) {
775 | return (object) $data;
776 | }
777 | return new $returnType($data); // @phpstan-ignore-line
778 | }
779 |
780 | /**
781 | * @param Entity|array|stdClass $data
782 | *
783 | * @return array
784 | */
785 | protected function makeArray(Entity | array | stdClass $data) : array
786 | {
787 | return $data instanceof Entity
788 | ? $data->toModel()
789 | : (array) $data;
790 | }
791 |
792 | /**
793 | * Used to auto set the timestamp fields.
794 | *
795 | * @throws Exception if a DateTime error occur
796 | *
797 | * @return string The timestamp in the $timestampFormat property format
798 | */
799 | protected function getTimestamp() : string
800 | {
801 | return (new DateTime('now', $this->timezone()))->format(
802 | $this->getTimestampFormat()
803 | );
804 | }
805 |
806 | /**
807 | * Get the timezone from database write connection config. As fallback, uses
808 | * the UTC timezone.
809 | *
810 | * @throws Exception if database config has a bad timezone
811 | *
812 | * @return DateTimeZone
813 | */
814 | protected function timezone() : DateTimeZone
815 | {
816 | $timezone = $this->getDatabaseToWrite()->getConfig()['timezone'] ?? '+00:00';
817 | return new DateTimeZone($timezone);
818 | }
819 |
820 | /**
821 | * Insert a new row.
822 | *
823 | * @param Entity|array|stdClass $data
824 | *
825 | * @return false|int|string The LAST_INSERT_ID() on success or false if
826 | * validation fail
827 | */
828 | public function create(Entity | array | stdClass $data) : false | int | string
829 | {
830 | $data = $this->makeArray($data);
831 | if ($this->getValidation()->validate($data) === false) {
832 | return false;
833 | }
834 | $data = $this->filterAllowedFields($data);
835 | if ($this->isAutoTimestamps()) {
836 | $timestamp = $this->getTimestamp();
837 | $data[$this->getFieldCreated()] ??= $timestamp;
838 | $data[$this->getFieldUpdated()] ??= $timestamp;
839 | }
840 | $database = $this->getDatabaseToWrite();
841 | try {
842 | $affectedRows = $database->insert()
843 | ->into($this->getTable())
844 | ->set($data)
845 | ->run();
846 | } catch (mysqli_sql_exception $exception) {
847 | $this->checkMysqliException($exception);
848 | return false;
849 | }
850 | $insertId = $affectedRows > 0 // $affectedRows is -1 if fail with MYSQLI_REPORT_OFF
851 | ? $database->getInsertId()
852 | : false;
853 | if ($insertId && $this->isCacheActive()) {
854 | $this->updateCachedRow($this->getPrimaryKey(), $insertId);
855 | }
856 | return $insertId;
857 | }
858 |
859 | /**
860 | * Insert a new row and return the inserted column value.
861 | *
862 | * @param string $column Column name
863 | * @param Entity|array|stdClass $data
864 | *
865 | * @return false|int|string The value from the column data or false if
866 | * validation fail
867 | */
868 | public function createBy(string $column, Entity | array | stdClass $data) : false | int | string
869 | {
870 | $data = $this->makeArray($data);
871 | if ($this->getValidation()->validate($data) === false) {
872 | return false;
873 | }
874 | $data = $this->filterAllowedFields($data);
875 | if (!isset($data[$column])) {
876 | throw new LogicException('Value of column ' . $column . ' is not set');
877 | }
878 | if ($this->isAutoTimestamps()) {
879 | $timestamp = $this->getTimestamp();
880 | $data[$this->getFieldCreated()] ??= $timestamp;
881 | $data[$this->getFieldUpdated()] ??= $timestamp;
882 | }
883 | try {
884 | $this->getDatabaseToWrite()->insert()
885 | ->into($this->getTable())
886 | ->set($data)
887 | ->run();
888 | } catch (mysqli_sql_exception $exception) {
889 | $this->checkMysqliException($exception);
890 | return false;
891 | }
892 | if ($this->isCacheActive()) {
893 | $this->updateCachedRow($column, $data[$column]);
894 | }
895 | return $data[$column];
896 | }
897 |
898 | /**
899 | * @param mysqli_sql_exception $exception
900 | *
901 | * @throws mysqli_sql_exception if message is not for duplicate entry
902 | */
903 | protected function checkMysqliException(mysqli_sql_exception $exception) : void
904 | {
905 | $message = $exception->getMessage();
906 | if (\str_starts_with($message, 'Duplicate entry')) {
907 | $this->setDuplicateEntryError($message);
908 | return;
909 | }
910 | throw $exception;
911 | }
912 |
913 | /**
914 | * Set "Duplicate entry" as 'unique' error in the Validation.
915 | *
916 | * NOTE: We will get the index key name and not the column name. Usually the
917 | * names are the same. If table have different column and index names,
918 | * override this method and get the column name from the information_schema
919 | * table.
920 | *
921 | * @param string $message The "Duplicate entry" message from the mysqli_sql_exception
922 | */
923 | protected function setDuplicateEntryError(string $message) : void
924 | {
925 | $field = \rtrim($message, "'");
926 | $field = \substr($field, \strrpos($field, "'") + 1);
927 | if ($field === 'PRIMARY') {
928 | $field = $this->getPrimaryKey();
929 | }
930 | $validation = $this->getValidation();
931 | $validation->setError($field, 'unique');
932 | $validation->getDebugCollector()
933 | ?->setErrorInDebugData($field, $validation->getError($field));
934 | }
935 |
936 | /**
937 | * @param string $column
938 | * @param int|string $value
939 | */
940 | protected function updateCachedRow(string $column, int | string $value) : void
941 | {
942 | $data = $this->readRow($column, $value);
943 | if ($data === null) {
944 | $data = $this->getCacheDataNotFound();
945 | }
946 | $this->getCache()->set(
947 | $this->getCacheKey([$column => $value]),
948 | $data,
949 | $this->getCacheTtl()
950 | );
951 | }
952 |
953 | /**
954 | * Save a row. Update if the Primary Key is present, otherwise
955 | * insert a new row.
956 | *
957 | * @param Entity|array|stdClass $data
958 | *
959 | * @return false|int|string The number of affected rows on updates as int, the
960 | * LAST_INSERT_ID() as int on inserts or false if validation fails
961 | */
962 | public function save(Entity | array | stdClass $data) : false | int | string
963 | {
964 | $data = $this->makeArray($data);
965 | $id = $data[$this->getPrimaryKey()] ?? null;
966 | $data = $this->filterAllowedFields($data);
967 | if ($id !== null) {
968 | return $this->update($id, $data);
969 | }
970 | return $this->create($data);
971 | }
972 |
973 | /**
974 | * Update based on Primary Key and return the number of affected rows.
975 | *
976 | * @param int|string $id
977 | * @param Entity|array|stdClass $data
978 | *
979 | * @return false|int|string The number of affected rows or false if
980 | * validation fails
981 | */
982 | public function update(int | string $id, Entity | array | stdClass $data) : false | int | string
983 | {
984 | $this->checkPrimaryKey($id);
985 | return $this->updateBy($this->getPrimaryKey(), $id, $data);
986 | }
987 |
988 | /**
989 | * Update based on column value and return the number of affected rows.
990 | *
991 | * @param string $column
992 | * @param int|string $value
993 | * @param Entity|array|stdClass $data
994 | *
995 | * @return false|int|string The number of affected rows or false if
996 | * validation fails
997 | */
998 | public function updateBy(
999 | string $column,
1000 | int | string $value,
1001 | Entity | array | stdClass $data
1002 | ) : false | int | string {
1003 | $data = $this->makeArray($data);
1004 | $data[$column] ??= $value;
1005 | if ($this->getValidation()->validateOnly($data) === false) {
1006 | return false;
1007 | }
1008 | $data = $this->filterAllowedFields($data);
1009 | if ($this->isAutoTimestamps()) {
1010 | $data[$this->getFieldUpdated()] ??= $this->getTimestamp();
1011 | }
1012 | try {
1013 | $affectedRows = $this->getDatabaseToWrite()
1014 | ->update()
1015 | ->table($this->getTable())
1016 | ->set($data)
1017 | ->whereEqual($column, $value)
1018 | ->run();
1019 | } catch (mysqli_sql_exception $exception) {
1020 | $this->checkMysqliException($exception);
1021 | return false;
1022 | }
1023 | if ($this->isCacheActive()) {
1024 | $this->updateCachedRow($column, $value);
1025 | }
1026 | return $affectedRows;
1027 | }
1028 |
1029 | /**
1030 | * Replace based on Primary Key and return the number of affected rows.
1031 | *
1032 | * Most used with HTTP PUT method.
1033 | *
1034 | * @param int|string $id
1035 | * @param Entity|array|stdClass $data
1036 | *
1037 | * @return false|int|string The number of affected rows or false if
1038 | * validation fails
1039 | */
1040 | public function replace(int | string $id, Entity | array | stdClass $data) : false | int | string
1041 | {
1042 | $this->checkPrimaryKey($id);
1043 | return $this->replaceBy($this->getPrimaryKey(), $id, $data);
1044 | }
1045 |
1046 | /**
1047 | * Replace based on column value and return the number of affected rows.
1048 | *
1049 | * @param string $column
1050 | * @param int|string $value
1051 | * @param Entity|array|stdClass $data
1052 | *
1053 | * @return false|int|string The number of affected rows or false if
1054 | * validation fails
1055 | */
1056 | public function replaceBy(
1057 | string $column,
1058 | int | string $value,
1059 | Entity | array | stdClass $data
1060 | ) : false | int | string {
1061 | $data = $this->makeArray($data);
1062 | $data[$column] ??= $value;
1063 | if ($this->getValidation()->validate($data) === false) {
1064 | return false;
1065 | }
1066 | $data = $this->filterAllowedFields($data);
1067 | $data[$column] = $value;
1068 | if ($this->isAutoTimestamps()) {
1069 | $timestamp = $this->getTimestamp();
1070 | $data[$this->getFieldCreated()] ??= $timestamp;
1071 | $data[$this->getFieldUpdated()] ??= $timestamp;
1072 | }
1073 | $affectedRows = $this->getDatabaseToWrite()
1074 | ->replace()
1075 | ->into($this->getTable())
1076 | ->set($data)
1077 | ->run();
1078 | if ($this->isCacheActive()) {
1079 | $this->updateCachedRow($column, $value);
1080 | }
1081 | return $affectedRows;
1082 | }
1083 |
1084 | /**
1085 | * Delete based on Primary Key.
1086 | *
1087 | * @param int|string $id
1088 | *
1089 | * @return false|int|string The number of affected rows
1090 | */
1091 | public function delete(int | string $id) : false | int | string
1092 | {
1093 | $this->checkPrimaryKey($id);
1094 | return $this->deleteBy($this->getPrimaryKey(), $id);
1095 | }
1096 |
1097 | /**
1098 | * Delete based on column value.
1099 | *
1100 | * @param string $column
1101 | * @param int|string $value
1102 | *
1103 | * @return false|int|string The number of affected rows
1104 | */
1105 | public function deleteBy(string $column, int | string $value) : false | int | string
1106 | {
1107 | $affectedRows = $this->getDatabaseToWrite()
1108 | ->delete()
1109 | ->from($this->getTable())
1110 | ->whereEqual($column, $value)
1111 | ->run();
1112 | if ($this->isCacheActive()) {
1113 | $this->getCache()->delete(
1114 | $this->getCacheKey([$column => $value])
1115 | );
1116 | }
1117 | return $affectedRows;
1118 | }
1119 |
1120 | protected function getValidation() : Validation
1121 | {
1122 | if (isset($this->validation)) {
1123 | return $this->validation;
1124 | }
1125 | $this->validation = new Validation(
1126 | $this->getValidationValidators(),
1127 | $this->getLanguage()
1128 | );
1129 | $this->validation->setRules($this->getValidationRules())
1130 | ->setLabels($this->getValidationLabels())
1131 | ->setMessages($this->getValidationMessages());
1132 | if (App::isDebugging()) {
1133 | $name = 'model ' . static::class;
1134 | $collector = new ValidationCollector($name);
1135 | App::debugger()->addCollector($collector, 'Validation');
1136 | $this->validation->setDebugCollector($collector);
1137 | }
1138 | return $this->validation;
1139 | }
1140 |
1141 | /**
1142 | * @return array
1143 | */
1144 | protected function getValidationLabels() : array
1145 | {
1146 | return $this->validationLabels ?? [];
1147 | }
1148 |
1149 | /**
1150 | * @return array>
1151 | */
1152 | public function getValidationMessages() : array
1153 | {
1154 | return $this->validationMessages ?? [];
1155 | }
1156 |
1157 | /**
1158 | * @return array|string>
1159 | */
1160 | protected function getValidationRules() : array
1161 | {
1162 | if (!isset($this->validationRules)) {
1163 | throw new RuntimeException('Validation rules are not set');
1164 | }
1165 | return $this->validationRules;
1166 | }
1167 |
1168 | /**
1169 | * @return array
1170 | */
1171 | public function getValidationValidators() : array
1172 | {
1173 | return $this->validationValidators;
1174 | }
1175 |
1176 | /**
1177 | * Get Validation errors.
1178 | *
1179 | * @return array
1180 | */
1181 | public function getErrors() : array
1182 | {
1183 | return $this->getValidation()->getErrors();
1184 | }
1185 |
1186 | #[Pure]
1187 | protected function isCacheActive() : bool
1188 | {
1189 | return $this->cacheActive;
1190 | }
1191 |
1192 | #[Pure]
1193 | protected function getCacheInstance() : string
1194 | {
1195 | return $this->cacheInstance;
1196 | }
1197 |
1198 | #[Pure]
1199 | protected function getCacheTtl() : int
1200 | {
1201 | return $this->cacheTtl;
1202 | }
1203 |
1204 | #[Pure]
1205 | protected function getCacheDataNotFound() : int | string
1206 | {
1207 | return $this->cacheDataNotFound;
1208 | }
1209 |
1210 | protected function getCache() : Cache
1211 | {
1212 | return App::cache($this->getCacheInstance());
1213 | }
1214 |
1215 | /**
1216 | * @param array $fields
1217 | *
1218 | * @return string
1219 | */
1220 | protected function getCacheKey(array $fields) : string
1221 | {
1222 | \ksort($fields);
1223 | $suffix = [];
1224 | foreach ($fields as $field => $value) {
1225 | $suffix[] = $field . '=' . $value;
1226 | }
1227 | $suffix = \implode(';', $suffix);
1228 | return 'Model:' . static::class . '::' . $suffix;
1229 | }
1230 |
1231 | /**
1232 | * Get same Model instance.
1233 | *
1234 | * @template T of Model
1235 | *
1236 | * @since 4
1237 | *
1238 | * @param class-string $class
1239 | *
1240 | * @return T
1241 | */
1242 | public static function get(string $class) : Model
1243 | {
1244 | if (!isset(static::$models[$class])) {
1245 | static::$models[$class] = new $class();
1246 | }
1247 | return static::$models[$class];
1248 | }
1249 | }
1250 |
--------------------------------------------------------------------------------
/src/ModelInterface.php:
--------------------------------------------------------------------------------
1 |
6 | *
7 | * For the full copyright and license information, please view the LICENSE
8 | * file that was distributed with this source code.
9 | */
10 | namespace Framework\MVC;
11 |
12 | use stdClass;
13 |
14 | /**
15 | * Interface ModelInterface.
16 | *
17 | * @package mvc
18 | *
19 | * @see https://en.wikipedia.org/wiki/Create,_read,_update_and_delete
20 | */
21 | interface ModelInterface
22 | {
23 | /**
24 | * Create a new item.
25 | *
26 | * @param Entity|array|stdClass $data
27 | *
28 | * @return false|int|string The created item id on success or false if it could not
29 | * be created
30 | */
31 | public function create(Entity | array | stdClass $data) : false | int | string;
32 |
33 | /**
34 | * Read an item based on id.
35 | *
36 | * @since 3.6
37 | *
38 | * @param int|string $id
39 | *
40 | * @return Entity|array|stdClass|null The
41 | * item as array, Entity or stdClass or null if the item was not found
42 | */
43 | public function read(int | string $id) : Entity | array | stdClass | null;
44 |
45 | /**
46 | * Update based on id and return the number of updated items.
47 | *
48 | * @param int|string $id
49 | * @param Entity|array|stdClass $data
50 | *
51 | * @return false|int|string The number of updated items or false if it could
52 | * not be updated
53 | */
54 | public function update(int | string $id, Entity | array | stdClass $data) : false | int | string;
55 |
56 | /**
57 | * Delete based on id.
58 | *
59 | * @param int|string $id
60 | *
61 | * @return false|int|string The number of deleted items or false if it could not be
62 | * deleted
63 | */
64 | public function delete(int | string $id) : false | int | string;
65 | }
66 |
--------------------------------------------------------------------------------
/src/Validator.php:
--------------------------------------------------------------------------------
1 |
6 | *
7 | * For the full copyright and license information, please view the LICENSE
8 | * file that was distributed with this source code.
9 | */
10 | namespace Framework\MVC;
11 |
12 | use Framework\Helpers\ArraySimple;
13 | use LogicException;
14 |
15 | /**
16 | * Class Validator.
17 | *
18 | * @package mvc
19 | */
20 | class Validator extends \Framework\Validation\Validator
21 | {
22 | /**
23 | * Validates database table not unique value.
24 | *
25 | * @param string $field
26 | * @param array $data
27 | * @param string $tableColumn
28 | * @param string $ignoreColumn
29 | * @param int|string $ignoreValue
30 | * @param string $connection
31 | *
32 | * @return bool
33 | */
34 | public static function notUnique(
35 | string $field,
36 | array $data,
37 | string $tableColumn,
38 | string $ignoreColumn = '',
39 | int | string $ignoreValue = '',
40 | string $connection = 'default'
41 | ) : bool {
42 | return !static::unique(
43 | $field,
44 | $data,
45 | $tableColumn,
46 | $ignoreColumn,
47 | $ignoreValue,
48 | $connection
49 | );
50 | }
51 |
52 | /**
53 | * Validates database table unique value.
54 | *
55 | * You can ignore rows where a column has a certain value.
56 | * Useful when updating a row in the database.
57 | *
58 | * @param string $field
59 | * @param array $data
60 | * @param string $tableColumn
61 | * @param string $ignoreColumn
62 | * @param int|string $ignoreValue
63 | * @param string $connection
64 | *
65 | * @return bool
66 | */
67 | public static function unique(
68 | string $field,
69 | array $data,
70 | string $tableColumn,
71 | string $ignoreColumn = '',
72 | int | string $ignoreValue = '',
73 | string $connection = 'default'
74 | ) : bool {
75 | $value = static::getData($field, $data);
76 | if ($value === null) {
77 | return false;
78 | }
79 | $ignoreValue = (string) $ignoreValue;
80 | [$table, $column] = \array_pad(\explode('.', $tableColumn, 2), 2, '');
81 | if ($column === '') {
82 | $column = $field;
83 | }
84 | if ($connection === '') {
85 | throw new LogicException(
86 | 'The connection parameter must be set to be able to connect the database'
87 | );
88 | }
89 | $statement = App::database($connection)
90 | ->select()
91 | ->expressions(['count' => static fn () => 'COUNT(*)'])
92 | ->from($table)
93 | ->whereEqual($column, $value);
94 | if ($ignoreColumn !== '' && !\preg_match('#^{(\w+)}$#', $ignoreValue)) {
95 | $statement->whereNotEqual($ignoreColumn, $ignoreValue);
96 | }
97 | return $statement->limit(1)->run()->fetch()->count < 1; // @phpstan-ignore-line
98 | }
99 |
100 | /**
101 | * Validates value exists in database table.
102 | *
103 | * @since 3.3
104 | *
105 | * @param string $field
106 | * @param array $data
107 | * @param string $tableColumn
108 | * @param string $connection
109 | *
110 | * @return bool
111 | */
112 | public static function exist(
113 | string $field,
114 | array $data,
115 | string $tableColumn,
116 | string $connection = 'default'
117 | ) : bool {
118 | $value = static::getData($field, $data);
119 | if ($value === null) {
120 | return false;
121 | }
122 | [$table, $column] = \array_pad(\explode('.', $tableColumn, 2), 2, '');
123 | if ($column === '') {
124 | $column = $field;
125 | }
126 | if ($connection === '') {
127 | throw new LogicException(
128 | 'The connection parameter must be set to be able to connect the database'
129 | );
130 | }
131 | return App::database($connection) // @phpstan-ignore-line
132 | ->select()
133 | ->expressions(['count' => static fn () => 'COUNT(*)'])
134 | ->from($table)
135 | ->whereEqual($column, $value)
136 | ->limit(1)
137 | ->run()
138 | ->fetch()->count > 0;
139 | }
140 |
141 | /**
142 | * Validates many values exists in database table.
143 | *
144 | * @since 3.10
145 | *
146 | * @param string $field
147 | * @param array $data
148 | * @param string $tableColumn
149 | * @param string $connection
150 | *
151 | * @return bool
152 | */
153 | public static function existMany(
154 | string $field,
155 | array $data,
156 | string $tableColumn,
157 | string $connection = 'default'
158 | ) : bool {
159 | $values = ArraySimple::value($field, $data);
160 | if ($values === null) {
161 | return true;
162 | }
163 | if (!\is_array($values)) {
164 | return false;
165 | }
166 | foreach ($values as $value) {
167 | if (!\is_scalar($value)) {
168 | return false;
169 | }
170 | }
171 | [$table, $column] = \array_pad(\explode('.', $tableColumn, 2), 2, '');
172 | if ($column === '') {
173 | $column = $field;
174 | }
175 | if ($connection === '') {
176 | throw new LogicException(
177 | 'The connection parameter must be set to be able to connect the database'
178 | );
179 | }
180 | $database = App::database($connection);
181 | foreach ($values as $value) {
182 | $count = $database // @phpstan-ignore-line
183 | ->select()
184 | ->expressions(['count' => static fn () => 'COUNT(*)'])
185 | ->from($table)
186 | ->whereEqual($column, $value)
187 | ->limit(1)
188 | ->run()
189 | ->fetch()->count;
190 | if ($count < 1) {
191 | return false;
192 | }
193 | }
194 | return true;
195 | }
196 | }
197 |
--------------------------------------------------------------------------------
/src/View.php:
--------------------------------------------------------------------------------
1 |
6 | *
7 | * For the full copyright and license information, please view the LICENSE
8 | * file that was distributed with this source code.
9 | */
10 | namespace Framework\MVC;
11 |
12 | use Framework\Helpers\Isolation;
13 | use Framework\MVC\Debug\ViewsCollector;
14 | use InvalidArgumentException;
15 | use LogicException;
16 |
17 | /**
18 | * Class View.
19 | *
20 | * @package mvc
21 | */
22 | class View
23 | {
24 | protected ?string $baseDir = null;
25 | protected string $extension;
26 | protected string $layout;
27 | protected ?string $openBlock;
28 | /**
29 | * @var array
30 | */
31 | protected array $openBlocks = [];
32 | /**
33 | * @var array
34 | */
35 | protected array $layoutsOpen = [];
36 | /**
37 | * @var array
38 | */
39 | protected array $blocks;
40 | protected string $currentView;
41 | protected ViewsCollector $debugCollector;
42 | protected string $layoutPrefix = '';
43 | protected string $includePrefix = '';
44 | protected bool $inInclude = false;
45 | protected bool $showDebugComments = true;
46 | /**
47 | * @var array
48 | */
49 | protected array $viewsPaths = [];
50 | protected string $instanceName;
51 | protected bool $throwExceptionsInDestructor = true;
52 |
53 | public function __construct(?string $baseDir = null, string $extension = '.php')
54 | {
55 | if ($baseDir !== null) {
56 | $this->setBaseDir($baseDir);
57 | }
58 | $this->setExtension($extension);
59 | }
60 |
61 | public function __destruct()
62 | {
63 | if ($this->isThrowExceptionsInDestructor() && $this->openBlocks) {
64 | throw new LogicException(
65 | 'Trying to destruct a View instance while the following blocks stayed open: '
66 | . \implode(', ', \array_map(static function ($name) {
67 | return "'{$name}'";
68 | }, $this->openBlocks))
69 | );
70 | }
71 | }
72 |
73 | /**
74 | * Tells whether it is able to throw exceptions in the destructor.
75 | *
76 | * @since 4.2
77 | *
78 | * @return bool
79 | */
80 | public function isThrowExceptionsInDestructor() : bool
81 | {
82 | return $this->throwExceptionsInDestructor;
83 | }
84 |
85 | /**
86 | * Enables/disables exceptions in the destructor.
87 | *
88 | * @since 4.2
89 | *
90 | * @param bool $active True to throw exceptions, false otherwise
91 | *
92 | * @return static
93 | */
94 | public function setThrowExceptionsInDestructor(bool $active = true) : static
95 | {
96 | $this->throwExceptionsInDestructor = $active;
97 | return $this;
98 | }
99 |
100 | /**
101 | * Sets the base directory where the views files are located.
102 | *
103 | * @param string $baseDir
104 | *
105 | * @return static
106 | */
107 | public function setBaseDir(string $baseDir) : static
108 | {
109 | $real = \realpath($baseDir);
110 | if (!$real || !\is_dir($real)) {
111 | throw new InvalidArgumentException("View base dir is not a valid directory: {$baseDir} ");
112 | }
113 | $this->baseDir = \rtrim($real, '\/ ') . \DIRECTORY_SEPARATOR;
114 | return $this;
115 | }
116 |
117 | /**
118 | * Get the base directory.
119 | *
120 | * @return string|null
121 | */
122 | public function getBaseDir() : ?string
123 | {
124 | return $this->baseDir;
125 | }
126 |
127 | /**
128 | * Set the extension of views files.
129 | *
130 | * @param string $extension
131 | *
132 | * @return static
133 | */
134 | public function setExtension(string $extension) : static
135 | {
136 | $this->extension = '.' . \ltrim($extension, '.');
137 | return $this;
138 | }
139 |
140 | /**
141 | * Get the extension of view files.
142 | *
143 | * @return string
144 | */
145 | public function getExtension() : string
146 | {
147 | return $this->extension;
148 | }
149 |
150 | /**
151 | * Set the name of a directory for layouts within the base directory.
152 | *
153 | * @param string $prefix
154 | *
155 | * @return static
156 | */
157 | public function setLayoutPrefix(string $prefix) : static
158 | {
159 | $this->layoutPrefix = $this->makeDirectoryPrefix($prefix);
160 | return $this;
161 | }
162 |
163 | /**
164 | * Get the name of the layouts directory.
165 | *
166 | * @return string
167 | */
168 | public function getLayoutPrefix() : string
169 | {
170 | return $this->layoutPrefix;
171 | }
172 |
173 | protected function makeDirectoryPrefix(string $prefix) : string
174 | {
175 | return $prefix === ''
176 | ? ''
177 | : \trim($prefix, '\/') . \DIRECTORY_SEPARATOR;
178 | }
179 |
180 | /**
181 | * Set the name of a directory for includes within the base directory.
182 | *
183 | * @param string $prefix
184 | *
185 | * @return static
186 | */
187 | public function setIncludePrefix(string $prefix) : static
188 | {
189 | $this->includePrefix = $this->makeDirectoryPrefix($prefix);
190 | return $this;
191 | }
192 |
193 | /**
194 | * Get the name of the includes directory.
195 | *
196 | * @return string
197 | */
198 | public function getIncludePrefix() : string
199 | {
200 | return $this->includePrefix;
201 | }
202 |
203 | protected function getNamespacedFilepath(string $view) : string
204 | {
205 | $path = App::locator()->getNamespacedFilepath($view, $this->getExtension());
206 | if ($path) {
207 | return $path;
208 | }
209 | throw new InvalidArgumentException("Namespaced view path does not match a file: {$view}");
210 | }
211 |
212 | protected function getFilepath(string $view) : string
213 | {
214 | if (isset($view[0]) && $view[0] === '\\') {
215 | return $this->getNamespacedFilepath($view);
216 | }
217 | $view = $this->getBaseDir() . $view . $this->getExtension();
218 | $real = \realpath($view);
219 | if (!$real || !\is_file($real)) {
220 | throw new InvalidArgumentException("View path does not match a file: {$view}");
221 | }
222 | if ($this->getBaseDir() && !\str_starts_with($real, $this->getBaseDir())) {
223 | throw new InvalidArgumentException("View path out of base directory: {$real}");
224 | }
225 | return $real;
226 | }
227 |
228 | /**
229 | * Render a view file.
230 | *
231 | * @param string $view View path within the base directory
232 | * @param array $data Data passed to the view. The array keys
233 | * will be variables
234 | *
235 | * @return string
236 | */
237 | public function render(string $view, array $data = []) : string
238 | {
239 | $debug = isset($this->debugCollector);
240 | if ($debug) {
241 | $start = \microtime(true);
242 | }
243 | $this->currentView = $view;
244 | $contents = $this->getContents($view, $data);
245 | if (isset($this->layout)) {
246 | $layout = $this->layout;
247 | unset($this->layout);
248 | $this->layoutsOpen[] = $layout;
249 | $contents = $this->render($layout, $data);
250 | }
251 | if ($debug) {
252 | $type = 'render';
253 | if ($this->layoutsOpen) {
254 | \array_shift($this->layoutsOpen);
255 | $type = 'layout';
256 | }
257 | $this->setDebugData($view, $start, $type);
258 | if ($this->isShowingDebugComments()) {
259 | $path = $this->getCommentPath($view);
260 | $contents = ''
261 | . \PHP_EOL . $contents . \PHP_EOL
262 | . '';
263 | }
264 | }
265 | return $contents;
266 | }
267 |
268 | protected function setDebugData(string $file, float $start, string $type) : static
269 | {
270 | $end = \microtime(true);
271 | $this->debugCollector->addData([
272 | 'start' => $start,
273 | 'end' => $end,
274 | 'file' => $file,
275 | 'filepath' => $this->getFilepath($file),
276 | 'type' => $type,
277 | ]);
278 | return $this;
279 | }
280 |
281 | /**
282 | * Extends a layout.
283 | *
284 | * @param string $layout The name of the file within the layouts directory
285 | * @param string|null $openBlock Optionally opens and closes this block automatically
286 | *
287 | * @return static
288 | */
289 | public function extends(string $layout, ?string $openBlock = null) : static
290 | {
291 | $this->layout = $this->getLayoutPrefix() . $layout;
292 | $this->openBlock = $openBlock;
293 | if ($openBlock !== null) {
294 | $this->block($openBlock);
295 | }
296 | return $this;
297 | }
298 |
299 | /**
300 | * Extends a layout without prefix.
301 | *
302 | * @param string $layout The name of the file within the base directory
303 | *
304 | * @return static
305 | */
306 | public function extendsWithoutPrefix(string $layout) : static
307 | {
308 | $this->layout = $layout;
309 | return $this;
310 | }
311 |
312 | /**
313 | * Tells whether the current contents is inside a layout.
314 | *
315 | * @param string $layout
316 | *
317 | * @return bool
318 | */
319 | public function inLayout(string $layout) : bool
320 | {
321 | return isset($this->layout) && $this->layout === $layout;
322 | }
323 |
324 | /**
325 | * Open a block.
326 | *
327 | * @param string $name Block name
328 | *
329 | * @return static
330 | */
331 | public function block(string $name) : static
332 | {
333 | $this->openBlocks[] = $name;
334 | \ob_start();
335 | if (isset($this->debugCollector) && $this->isShowingDebugComments()) {
336 | if (isset($this->currentView)) {
337 | $name = $this->currentView . '::' . $name;
338 | $name = $this->getInstanceNameWithPath($name);
339 | }
340 | echo \PHP_EOL . '' . \PHP_EOL;
341 | }
342 | return $this;
343 | }
344 |
345 | /**
346 | * Close an open block.
347 | *
348 | * @return static
349 | */
350 | public function endBlock() : static
351 | {
352 | if (empty($this->openBlocks)) {
353 | throw new LogicException('Trying to end a view block when none is open');
354 | }
355 | $name = \array_pop($this->openBlocks);
356 | if (isset($this->debugCollector) && $this->isShowingDebugComments()) {
357 | $block = $name;
358 | if (isset($this->currentView)) {
359 | $block = $this->currentView . '::' . $name;
360 | $block = $this->getInstanceNameWithPath($block);
361 | }
362 | echo \PHP_EOL . '' . \PHP_EOL;
363 | }
364 | $contents = \ob_get_clean();
365 | if (!isset($this->blocks[$name])) {
366 | $this->blocks[$name] = $contents; // @phpstan-ignore-line
367 | }
368 | return $this;
369 | }
370 |
371 | /**
372 | * Render a block.
373 | *
374 | * @param string $name Block name
375 | *
376 | * @return string|null
377 | */
378 | public function renderBlock(string $name) : ?string
379 | {
380 | return $this->blocks[$name] ?? null;
381 | }
382 |
383 | /**
384 | * Remove a block.
385 | *
386 | * @param string $name Block name
387 | *
388 | * @return static
389 | */
390 | public function removeBlock(string $name) : static
391 | {
392 | unset($this->blocks[$name]);
393 | return $this;
394 | }
395 |
396 | /**
397 | * Tells whether a given block is set.
398 | *
399 | * @param string $name Block name
400 | *
401 | * @return bool
402 | */
403 | public function hasBlock(string $name) : bool
404 | {
405 | return isset($this->blocks[$name]);
406 | }
407 |
408 | /**
409 | * Tells whether the current content is inside a block.
410 | *
411 | * @param string $name Block name
412 | *
413 | * @return bool
414 | */
415 | public function inBlock(string $name) : bool
416 | {
417 | return $this->currentBlock() === $name;
418 | }
419 |
420 | /**
421 | * Tells the name of the current block.
422 | *
423 | * @return string|null
424 | */
425 | public function currentBlock() : ?string
426 | {
427 | if ($this->openBlocks) {
428 | return $this->openBlocks[\array_key_last($this->openBlocks)];
429 | }
430 | return null;
431 | }
432 |
433 | /**
434 | * Returns the contents of an include.
435 | *
436 | * @param string $view The path of the file within the includes directory
437 | * @param array $data Data passed to the view. The array keys
438 | * will be variables
439 | *
440 | * @return string
441 | */
442 | public function include(string $view, array $data = []) : string
443 | {
444 | $view = $this->getIncludePrefix() . $view;
445 | if (isset($this->debugCollector)) {
446 | return $this->getIncludeContentsWithDebug($view, $data);
447 | }
448 | return $this->getIncludeContents($view, $data);
449 | }
450 |
451 | protected function involveInclude(string $view, string $contents) : string
452 | {
453 | $path = $this->getCommentPath($view);
454 | return \PHP_EOL . ''
455 | . \PHP_EOL . $contents . \PHP_EOL
456 | . '' . \PHP_EOL;
457 | }
458 |
459 | /**
460 | * Returns the contents of an include without prefix.
461 | *
462 | * @param string $view The path of the file within the base directory
463 | * @param array $data Data passed to the view. The array keys
464 | * will be variables
465 | *
466 | * @return string
467 | */
468 | public function includeWithoutPrefix(string $view, array $data = []) : string
469 | {
470 | if (isset($this->debugCollector)) {
471 | return $this->getIncludeContentsWithDebug($view, $data);
472 | }
473 | return $this->getIncludeContents($view, $data);
474 | }
475 |
476 | /**
477 | * @param string $view
478 | * @param array $data
479 | *
480 | * @return string
481 | */
482 | protected function getIncludeContentsWithDebug(string $view, array $data = []) : string
483 | {
484 | $start = \microtime(true);
485 | $this->inInclude = true;
486 | $contents = $this->getContents($view, $data);
487 | $this->inInclude = false;
488 | $this->setDebugData($view, $start, 'include');
489 | if (!$this->isShowingDebugComments()) {
490 | return $contents;
491 | }
492 | return $this->involveInclude($view, $contents);
493 | }
494 |
495 | /**
496 | * @param string $view
497 | * @param array $data
498 | *
499 | * @return string
500 | */
501 | protected function getIncludeContents(string $view, array $data = []) : string
502 | {
503 | $this->inInclude = true;
504 | $contents = $this->getContents($view, $data);
505 | $this->inInclude = false;
506 | return $contents;
507 | }
508 |
509 | /**
510 | * @param string $view
511 | * @param array $data
512 | *
513 | * @return string
514 | */
515 | protected function getContents(string $view, array $data) : string
516 | {
517 | $data['view'] = $this;
518 | \ob_start();
519 | Isolation::require($this->getFilepath($view), $data);
520 | if (isset($this->openBlock) && !$this->inInclude) {
521 | $this->openBlock = null;
522 | $this->endBlock();
523 | }
524 | return \ob_get_clean(); // @phpstan-ignore-line
525 | }
526 |
527 | public function getInstanceName() : string
528 | {
529 | return $this->instanceName;
530 | }
531 |
532 | public function getInstanceNameWithPath(string $name) : string
533 | {
534 | return $this->getInstanceName() . ':' . $name;
535 | }
536 |
537 | public function setInstanceName(string $instanceName) : static
538 | {
539 | $this->instanceName = $instanceName;
540 | return $this;
541 | }
542 |
543 | protected function getCommentPath(string $name) : string
544 | {
545 | $count = null;
546 | foreach ($this->viewsPaths as $view) {
547 | if ($view === $name) {
548 | $count++;
549 | }
550 | }
551 | $this->viewsPaths[] = $name;
552 | if ($count) {
553 | $count = ':' . ($count + 1);
554 | }
555 | return $this->getInstanceNameWithPath($name) . $count;
556 | }
557 |
558 | public function setDebugCollector(ViewsCollector $debugCollector) : static
559 | {
560 | $this->debugCollector = $debugCollector;
561 | $this->debugCollector->setView($this);
562 | return $this;
563 | }
564 |
565 | /**
566 | * Tells if it is showing debug comments when in debug mode.
567 | *
568 | * @since 3.2
569 | *
570 | * @return bool
571 | */
572 | public function isShowingDebugComments() : bool
573 | {
574 | return $this->showDebugComments;
575 | }
576 |
577 | /**
578 | * Enable debug comments when in debug mode.
579 | *
580 | * @since 3.2
581 | *
582 | * @return static
583 | */
584 | public function enableDebugComments() : static
585 | {
586 | $this->showDebugComments = true;
587 | return $this;
588 | }
589 |
590 | /**
591 | * Disable debug comments when in debug mode.
592 | *
593 | * @since 3.2
594 | *
595 | * @return static
596 | */
597 | public function disableDebugComments() : static
598 | {
599 | $this->showDebugComments = false;
600 | return $this;
601 | }
602 | }
603 |
--------------------------------------------------------------------------------