PageRenderTime 58ms CodeModel.GetById 30ms RepoModel.GetById 1ms app.codeStats 0ms

/src/mako/database/midgard/Query.php

https://github.com/mako-framework/framework
PHP | 612 lines | 334 code | 106 blank | 172 comment | 26 complexity | 26c474e72a76cc0493928481e6e52727 MD5 | raw file
  1. <?php
  2. /**
  3. * @copyright Frederic G. Østby
  4. * @license http://www.makoframework.com/license
  5. */
  6. namespace mako\database\midgard;
  7. use Closure;
  8. use Generator;
  9. use mako\database\connections\Connection;
  10. use mako\database\exceptions\NotFoundException;
  11. use mako\database\query\Query as QueryBuilder;
  12. use mako\database\query\Subquery;
  13. use mako\utility\Str;
  14. use PDO;
  15. use function array_filter;
  16. use function array_keys;
  17. use function array_merge;
  18. use function array_udiff;
  19. use function array_unique;
  20. use function explode;
  21. use function in_array;
  22. use function is_int;
  23. use function is_string;
  24. use function stripos;
  25. use function strpos;
  26. use function substr;
  27. /**
  28. * ORM query builder.
  29. *
  30. * @method \mako\database\midgard\ResultSet paginate($itemsPerPage = null, array $options = [])
  31. */
  32. class Query extends QueryBuilder
  33. {
  34. /**
  35. * Class name of the model we're hydrating.
  36. *
  37. * @var string
  38. */
  39. protected $modelClass;
  40. /**
  41. * Relation count subqueries.
  42. *
  43. * @var array
  44. */
  45. protected $relationCounters = [];
  46. /**
  47. * Constructor.
  48. *
  49. * @param \mako\database\connections\Connection $connection Database connection
  50. * @param \mako\database\midgard\ORM $model Model to hydrate
  51. */
  52. public function __construct(
  53. Connection $connection,
  54. protected ORM $model
  55. )
  56. {
  57. parent::__construct($connection);
  58. $this->modelClass = $model->getClass();
  59. $this->table = $model->getTable();
  60. }
  61. /**
  62. * {@inheritDoc}
  63. */
  64. public function getColumns(): array
  65. {
  66. if(empty($this->relationCounters))
  67. {
  68. return $this->columns;
  69. }
  70. return [...$this->columns, ...$this->relationCounters];
  71. }
  72. /**
  73. * Returns the model.
  74. *
  75. * @return \mako\database\midgard\ORM
  76. */
  77. public function getModel()
  78. {
  79. return $this->model;
  80. }
  81. /**
  82. * {@inheritDoc}
  83. */
  84. public function join($table, $column1 = null, $operator = null, $column2 = null, $type = 'INNER', $raw = false)
  85. {
  86. if(empty($this->joins) && $this->columns === ['*'])
  87. {
  88. $this->select(["{$this->table}.*"]);
  89. }
  90. return parent::join($table, $column1, $operator, $column2, $type, $raw);
  91. }
  92. /**
  93. * {@inheritDoc}
  94. */
  95. public function insert(array $values = []): bool
  96. {
  97. // Execute "beforeInsert" hooks
  98. foreach($this->model->getHooks('beforeInsert') as $hook)
  99. {
  100. $values = $hook($values, $this);
  101. }
  102. // Insert record
  103. $inserted = parent::insert($values);
  104. // Execute "afterInsert" hooks
  105. foreach($this->model->getHooks('afterInsert') as $hook)
  106. {
  107. $hook($inserted);
  108. }
  109. // Return insert status
  110. return $inserted;
  111. }
  112. /**
  113. * {@inheritDoc}
  114. */
  115. public function update(array $values): int
  116. {
  117. // Execute "beforeUpdate" hooks
  118. foreach($this->model->getHooks('beforeUpdate') as $hook)
  119. {
  120. $values = $hook($values, $this);
  121. }
  122. // Update record(s)
  123. $updated = parent::update($values);
  124. // Execute "afterUpdate" hooks
  125. foreach($this->model->getHooks('afterUpdate') as $hook)
  126. {
  127. $hook($updated);
  128. }
  129. // Return number of affected rows
  130. return $updated;
  131. }
  132. /**
  133. * {@inheritDoc}
  134. */
  135. public function increment($column, int $increment = 1): int
  136. {
  137. if($this->model->isPersisted())
  138. {
  139. $this->model->$column += $increment;
  140. $this->where($this->model->getPrimaryKey(), '=', $this->model->getPrimaryKeyValue());
  141. }
  142. $updated = parent::increment($column, $increment);
  143. if($this->model->isPersisted())
  144. {
  145. $this->model->synchronize();
  146. }
  147. return $updated;
  148. }
  149. /**
  150. * {@inheritDoc}
  151. */
  152. public function decrement($column, int $decrement = 1): int
  153. {
  154. if($this->model->isPersisted())
  155. {
  156. $this->model->$column -= $decrement;
  157. $this->where($this->model->getPrimaryKey(), '=', $this->model->getPrimaryKeyValue());
  158. }
  159. $updated = parent::decrement($column, $decrement);
  160. if($this->model->isPersisted())
  161. {
  162. $this->model->synchronize();
  163. }
  164. return $updated;
  165. }
  166. /**
  167. * {@inheritDoc}
  168. */
  169. public function delete(): int
  170. {
  171. // Execute "beforeDelete" hooks
  172. foreach($this->model->getHooks('beforeDelete') as $hook)
  173. {
  174. $hook($this);
  175. }
  176. $deleted = parent::delete();
  177. // Execute "afterDelete" hooks
  178. foreach($this->model->getHooks('afterDelete') as $hook)
  179. {
  180. $hook($deleted);
  181. }
  182. return $deleted;
  183. }
  184. /**
  185. * Returns a record using the value of its primary key.
  186. *
  187. * @param int|string $id Primary key
  188. * @param array $columns Columns to select
  189. * @return \mako\database\midgard\ORM|null
  190. */
  191. public function get(int|string $id, array $columns = [])
  192. {
  193. if(!empty($columns))
  194. {
  195. $this->select($columns);
  196. }
  197. return $this->where($this->model->getPrimaryKey(), '=', $id)->first();
  198. }
  199. /**
  200. * Returns a record using the value of its primary key.
  201. *
  202. * @param int|string $id Primary key
  203. * @param array $columns Columns to select
  204. * @param string $exception Exception class
  205. * @return \mako\database\midgard\ORM
  206. */
  207. public function getOrThrow(int|string $id, array $columns = [], string $exception = NotFoundException::class)
  208. {
  209. if(!empty($columns))
  210. {
  211. $this->select($columns);
  212. }
  213. return $this->where($this->model->getPrimaryKey(), '=', $id)->firstOrThrow($exception);
  214. }
  215. /**
  216. * Adds relations to eager load.
  217. *
  218. * @param array|false|string $includes Relation or array of relations to eager load
  219. * @return $this
  220. */
  221. public function including($includes)
  222. {
  223. if($includes === false)
  224. {
  225. $this->model->setIncludes([]);
  226. }
  227. else
  228. {
  229. $includes = (array) $includes;
  230. $currentIncludes = $this->model->getIncludes();
  231. if(!empty($currentIncludes))
  232. {
  233. $withCriterion = array_filter(array_keys($includes), 'is_string');
  234. if(!empty($withCriterion))
  235. {
  236. foreach($currentIncludes as $key => $value)
  237. {
  238. if(in_array($value, $withCriterion))
  239. {
  240. unset($currentIncludes[$key]); // Unset relations that have previously been set without a criterion closure
  241. }
  242. }
  243. }
  244. $includes = array_merge($currentIncludes, $includes);
  245. }
  246. $this->model->setIncludes(array_unique($includes, SORT_REGULAR));
  247. }
  248. return $this;
  249. }
  250. /**
  251. * Removes relations to eager load.
  252. *
  253. * @param array|string|true $excludes Relation or array of relations to exclude from eager loading
  254. * @return $this
  255. */
  256. public function excluding($excludes)
  257. {
  258. if($excludes === true)
  259. {
  260. $this->model->setIncludes([]);
  261. }
  262. else
  263. {
  264. $excludes = (array) $excludes;
  265. $includes = $this->model->getIncludes();
  266. foreach($excludes as $key => $relation)
  267. {
  268. if(is_string($relation) && isset($includes[$relation]))
  269. {
  270. unset($includes[$relation], $excludes[$key]); // Unset relations that may have been set with a criterion closure
  271. }
  272. }
  273. $this->model->setIncludes(array_udiff($includes, $excludes, static fn ($a, $b) => $a === $b ? 0 : -1));
  274. }
  275. return $this;
  276. }
  277. /**
  278. * Parses the count relation name and returns an array consisting of the relation name and the chosen alias.
  279. *
  280. * @param string $relation Relation name
  281. * @return array
  282. */
  283. protected function parseRelationCountName(string $relation): array
  284. {
  285. if(stripos($relation, ' AS ') === false)
  286. {
  287. return [$relation, "{$relation}_count"];
  288. }
  289. [$relation, , $alias] = explode(' ', $relation, 3);
  290. return [$relation, $alias];
  291. }
  292. /**
  293. * Adds subqueries that count the number of related records for the chosen relations.
  294. *
  295. * @param array|string $relations Relation or array of relations to count
  296. * @return $this
  297. */
  298. public function withCountOf(array|string $relations)
  299. {
  300. foreach((array) $relations as $relation => $criteria)
  301. {
  302. if(is_int($relation))
  303. {
  304. $relation = $criteria;
  305. $criteria = null;
  306. }
  307. [$relation, $alias] = $this->parseRelationCountName($relation);
  308. /** @var \mako\database\midgard\relations\Relation $countQuery */
  309. $countQuery = $this->model->$relation()->getRelationCountQuery()->inSubqueryContext();
  310. if($criteria !== null)
  311. {
  312. $criteria($countQuery);
  313. }
  314. $this->relationCounters[] = new Subquery(static function (&$query) use ($countQuery): void
  315. {
  316. $query = $countQuery;
  317. $query->clearOrderings()->count();
  318. }, $alias, true);
  319. }
  320. return $this;
  321. }
  322. /**
  323. * Returns a hydrated model.
  324. *
  325. * @param array $result Database result
  326. * @return \mako\database\midgard\ORM
  327. */
  328. protected function hydrateModel(array $result)
  329. {
  330. $model = $this->modelClass;
  331. return new $model($result, true, false, true);
  332. }
  333. /**
  334. * Parses includes.
  335. *
  336. * @return array
  337. */
  338. protected function parseIncludes(): array
  339. {
  340. $includes = ['this' => [], 'forward' => []];
  341. foreach($this->model->getIncludes() as $include => $criteria)
  342. {
  343. if(is_int($include))
  344. {
  345. $include = $criteria;
  346. $criteria = null;
  347. }
  348. if(($position = strpos($include, '.')) === false)
  349. {
  350. $includes['this'][$include] = $criteria;
  351. }
  352. else
  353. {
  354. if($criteria === null)
  355. {
  356. $includes['forward'][substr($include, 0, $position)][] = substr($include, $position + 1);
  357. }
  358. else
  359. {
  360. $includes['forward'][substr($include, 0, $position)][substr($include, $position + 1)] = $criteria;
  361. }
  362. }
  363. }
  364. return $includes;
  365. }
  366. /**
  367. * Parses include names.
  368. *
  369. * @param string $name Include name
  370. * @return array
  371. */
  372. protected function parseIncludeName(string $name): array
  373. {
  374. if(stripos($name, ' AS ') === false)
  375. {
  376. return [$name, $name];
  377. }
  378. [$name, , $alias] = explode(' ', $name, 3);
  379. return [$name, $alias];
  380. }
  381. /**
  382. * Load includes.
  383. *
  384. * @param array $results Loaded records
  385. */
  386. protected function loadIncludes(array $results): void
  387. {
  388. $includes = $this->parseIncludes();
  389. foreach($includes['this'] as $include => $criteria)
  390. {
  391. [$methodName, $propertyName] = $this->parseIncludeName($include);
  392. $forward = $includes['forward'][$methodName] ?? [];
  393. $results[0]->$methodName()->eagerLoad($results, $propertyName, $criteria, $forward);
  394. }
  395. }
  396. /**
  397. * Returns hydrated models.
  398. *
  399. * @param mixed $results Database results
  400. * @return array
  401. */
  402. protected function hydrateModelsAndLoadIncludes(mixed $results): array
  403. {
  404. $hydrated = [];
  405. foreach($results as $result)
  406. {
  407. $hydrated[] = $this->hydrateModel($result);
  408. }
  409. $this->loadIncludes($hydrated);
  410. return $hydrated;
  411. }
  412. /**
  413. * Returns a single record from the database.
  414. *
  415. * @return \mako\database\midgard\ORM|null
  416. */
  417. public function first(): mixed
  418. {
  419. $result = $this->fetchFirst(PDO::FETCH_ASSOC);
  420. if($result !== null)
  421. {
  422. return $this->hydrateModelsAndLoadIncludes([$result])[0];
  423. }
  424. return null;
  425. }
  426. /**
  427. * Returns a single record from the database.
  428. *
  429. * @param string $exception Exception class
  430. * @return \mako\database\midgard\ORM
  431. */
  432. public function firstOrThrow(string $exception = NotFoundException::class): mixed
  433. {
  434. return $this->hydrateModelsAndLoadIncludes([$this->fetchFirstOrThrow($exception, PDO::FETCH_ASSOC)])[0];
  435. }
  436. /**
  437. * Creates a result set.
  438. *
  439. * @param array $results Results
  440. * @return \mako\database\midgard\ResultSet
  441. */
  442. protected function createResultSet(array $results): ResultSet
  443. {
  444. return new ResultSet($results);
  445. }
  446. /**
  447. * Returns a result set from the database.
  448. *
  449. * @return \mako\database\midgard\ResultSet
  450. */
  451. public function all(): ResultSet
  452. {
  453. $results = $this->fetchAll(false, PDO::FETCH_ASSOC);
  454. if(!empty($results))
  455. {
  456. $results = $this->hydrateModelsAndLoadIncludes($results);
  457. }
  458. return $this->createResultSet($results);
  459. }
  460. /**
  461. * Returns a generator that lets you iterate over the results.
  462. *
  463. * @return \Generator
  464. */
  465. public function yield(): Generator
  466. {
  467. /** @var array $row */
  468. foreach($this->fetchYield(PDO::FETCH_ASSOC) as $row)
  469. {
  470. yield $this->hydrateModel($row);
  471. }
  472. }
  473. /**
  474. * {@inheritDoc}
  475. */
  476. public function batch(Closure $processor, $batchSize = 1000, $offsetStart = 0, $offsetEnd = null): void
  477. {
  478. if(empty($this->orderings))
  479. {
  480. $this->ascending($this->model->getPrimaryKey());
  481. }
  482. parent::batch($processor, $batchSize, $offsetStart, $offsetEnd);
  483. }
  484. /**
  485. * {@inheritDoc}
  486. */
  487. protected function aggregate($function, $column)
  488. {
  489. // Empty "relationCounters" when performing aggregate queries
  490. $this->relationCounters = [];
  491. // Execute parent and return results
  492. return parent::aggregate($function, $column);
  493. }
  494. /**
  495. * Calls a scope method on the model.
  496. *
  497. * @param string $scope Scope
  498. * @param mixed ...$arguments Arguments
  499. * @return $this
  500. */
  501. public function scope(string $scope, mixed ...$arguments)
  502. {
  503. $this->model->{Str::snakeToCamel($scope) . 'Scope'}(...[$this, ...$arguments]);
  504. return $this;
  505. }
  506. }