blob: c75afa2e0d3bdc5b698031184e83da8c10adfcbb [file] [log] [blame]
Matthias Andreas Benkard7b2a3a12021-08-16 10:57:25 +02001<?php
2
3namespace LdapRecord\Query;
4
5use BadMethodCallException;
6use Closure;
7use DateTimeInterface;
8use InvalidArgumentException;
9use LdapRecord\Connection;
10use LdapRecord\Container;
11use LdapRecord\EscapesValues;
12use LdapRecord\LdapInterface;
13use LdapRecord\LdapRecordException;
14use LdapRecord\Models\Model;
15use LdapRecord\Query\Events\QueryExecuted;
16use LdapRecord\Query\Model\Builder as ModelBuilder;
17use LdapRecord\Query\Pagination\LazyPaginator;
18use LdapRecord\Query\Pagination\Paginator;
19use LdapRecord\Support\Arr;
20use LdapRecord\Utilities;
21
22class Builder
23{
24 use EscapesValues;
25
26 /**
27 * The selected columns to retrieve on the query.
28 *
29 * @var array
30 */
31 public $columns;
32
33 /**
34 * The query filters.
35 *
36 * @var array
37 */
38 public $filters = [
39 'and' => [],
40 'or' => [],
41 'raw' => [],
42 ];
43
44 /**
45 * The LDAP server controls to be sent.
46 *
47 * @var array
48 */
49 public $controls = [];
50
51 /**
52 * The size limit of the query.
53 *
54 * @var int
55 */
56 public $limit = 0;
57
58 /**
59 * Determines whether the current query is paginated.
60 *
61 * @var bool
62 */
63 public $paginated = false;
64
65 /**
66 * The distinguished name to perform searches upon.
67 *
68 * @var string|null
69 */
70 protected $dn;
71
72 /**
73 * The base distinguished name to perform searches inside.
74 *
75 * @var string|null
76 */
77 protected $baseDn;
78
79 /**
80 * The default query type.
81 *
82 * @var string
83 */
84 protected $type = 'search';
85
86 /**
87 * Determines whether the query is nested.
88 *
89 * @var bool
90 */
91 protected $nested = false;
92
93 /**
94 * Determines whether the query should be cached.
95 *
96 * @var bool
97 */
98 protected $caching = false;
99
100 /**
101 * How long the query should be cached until.
102 *
103 * @var DateTimeInterface|null
104 */
105 protected $cacheUntil = null;
106
107 /**
108 * Determines whether the query cache must be flushed.
109 *
110 * @var bool
111 */
112 protected $flushCache = false;
113
114 /**
115 * The current connection instance.
116 *
117 * @var Connection
118 */
119 protected $connection;
120
121 /**
122 * The current grammar instance.
123 *
124 * @var Grammar
125 */
126 protected $grammar;
127
128 /**
129 * The current cache instance.
130 *
131 * @var Cache|null
132 */
133 protected $cache;
134
135 /**
136 * Constructor.
137 *
138 * @param Connection $connection
139 */
140 public function __construct(Connection $connection)
141 {
142 $this->connection = $connection;
143 $this->grammar = new Grammar();
144 }
145
146 /**
147 * Set the current connection.
148 *
149 * @param Connection $connection
150 *
151 * @return $this
152 */
153 public function setConnection(Connection $connection)
154 {
155 $this->connection = $connection;
156
157 return $this;
158 }
159
160 /**
161 * Set the current filter grammar.
162 *
163 * @param Grammar $grammar
164 *
165 * @return $this
166 */
167 public function setGrammar(Grammar $grammar)
168 {
169 $this->grammar = $grammar;
170
171 return $this;
172 }
173
174 /**
175 * Set the cache to store query results.
176 *
177 * @param Cache|null $cache
178 *
179 * @return $this
180 */
181 public function setCache(Cache $cache = null)
182 {
183 $this->cache = $cache;
184
185 return $this;
186 }
187
188 /**
189 * Returns a new Query Builder instance.
190 *
191 * @param string $baseDn
192 *
193 * @return $this
194 */
195 public function newInstance($baseDn = null)
196 {
197 // We'll set the base DN of the new Builder so
198 // developers don't need to do this manually.
199 $dn = is_null($baseDn) ? $this->getDn() : $baseDn;
200
201 return (new static($this->connection))->setDn($dn);
202 }
203
204 /**
205 * Returns a new nested Query Builder instance.
206 *
207 * @param Closure|null $closure
208 *
209 * @return $this
210 */
211 public function newNestedInstance(Closure $closure = null)
212 {
213 $query = $this->newInstance()->nested();
214
215 if ($closure) {
216 $closure($query);
217 }
218
219 return $query;
220 }
221
222 /**
223 * Executes the LDAP query.
224 *
225 * @param string|array $columns
226 *
227 * @return Collection|array
228 */
229 public function get($columns = ['*'])
230 {
231 return $this->onceWithColumns(Arr::wrap($columns), function () {
232 return $this->query($this->getQuery());
233 });
234 }
235
236 /**
237 * Execute the given callback while selecting the given columns.
238 *
239 * After running the callback, the columns are reset to the original value.
240 *
241 * @param array $columns
242 * @param callable $callback
243 *
244 * @return mixed
245 */
246 protected function onceWithColumns($columns, $callback)
247 {
248 $original = $this->columns;
249
250 if (is_null($original)) {
251 $this->columns = $columns;
252 }
253
254 $result = $callback();
255
256 $this->columns = $original;
257
258 return $result;
259 }
260
261 /**
262 * Compiles and returns the current query string.
263 *
264 * @return string
265 */
266 public function getQuery()
267 {
268 // We need to ensure we have at least one filter, as
269 // no query results will be returned otherwise.
270 if (count(array_filter($this->filters)) === 0) {
271 $this->whereHas('objectclass');
272 }
273
274 return $this->grammar->compile($this);
275 }
276
277 /**
278 * Returns the unescaped query.
279 *
280 * @return string
281 */
282 public function getUnescapedQuery()
283 {
284 return Utilities::unescape($this->getQuery());
285 }
286
287 /**
288 * Returns the current Grammar instance.
289 *
290 * @return Grammar
291 */
292 public function getGrammar()
293 {
294 return $this->grammar;
295 }
296
297 /**
298 * Returns the current Cache instance.
299 *
300 * @return Cache|null
301 */
302 public function getCache()
303 {
304 return $this->cache;
305 }
306
307 /**
308 * Returns the current Connection instance.
309 *
310 * @return Connection
311 */
312 public function getConnection()
313 {
314 return $this->connection;
315 }
316
317 /**
318 * Returns the query type.
319 *
320 * @return string
321 */
322 public function getType()
323 {
324 return $this->type;
325 }
326
327 /**
328 * Set the base distinguished name of the query.
329 *
330 * @param Model|string $dn
331 *
332 * @return $this
333 */
334 public function setBaseDn($dn)
335 {
336 $this->baseDn = $this->substituteBaseInDn($dn);
337
338 return $this;
339 }
340
341 /**
342 * Get the base distinguished name of the query.
343 *
344 * @return string|null
345 */
346 public function getBaseDn()
347 {
348 return $this->baseDn;
349 }
350
351 /**
352 * Get the distinguished name of the query.
353 *
354 * @return string
355 */
356 public function getDn()
357 {
358 return $this->dn;
359 }
360
361 /**
362 * Set the distinguished name for the query.
363 *
364 * @param string|Model|null $dn
365 *
366 * @return $this
367 */
368 public function setDn($dn = null)
369 {
370 $this->dn = $this->substituteBaseInDn($dn);
371
372 return $this;
373 }
374
375 /**
376 * Substitute the base DN string template for the current base.
377 *
378 * @param Model|string $dn
379 *
380 * @return string
381 */
382 protected function substituteBaseInDn($dn)
383 {
384 return str_replace(
385 '{base}',
386 $this->baseDn,
387 $dn instanceof Model ? $dn->getDn() : $dn
388 );
389 }
390
391 /**
392 * Alias for setting the distinguished name for the query.
393 *
394 * @param string|Model|null $dn
395 *
396 * @return $this
397 */
398 public function in($dn = null)
399 {
400 return $this->setDn($dn);
401 }
402
403 /**
404 * Set the size limit of the current query.
405 *
406 * @param int $limit
407 *
408 * @return $this
409 */
410 public function limit($limit = 0)
411 {
412 $this->limit = $limit;
413
414 return $this;
415 }
416
417 /**
418 * Returns a new query for the given model.
419 *
420 * @param Model $model
421 *
422 * @return ModelBuilder
423 */
424 public function model(Model $model)
425 {
426 return $model->newQueryBuilder($this->connection)
427 ->setCache($this->connection->getCache())
428 ->setBaseDn($this->baseDn)
429 ->setModel($model);
430 }
431
432 /**
433 * Performs the specified query on the current LDAP connection.
434 *
435 * @param string $query
436 *
437 * @return Collection|array
438 */
439 public function query($query)
440 {
441 $start = microtime(true);
442
443 // Here we will create the execution callback. This allows us
444 // to only execute an LDAP request if caching is disabled
445 // or if no cache of the given query exists yet.
446 $callback = function () use ($query) {
447 return $this->parse($this->run($query));
448 };
449
450 $results = $this->getCachedResponse($query, $callback);
451
452 $this->logQuery($this, $this->type, $this->getElapsedTime($start));
453
454 return $this->process($results);
455 }
456
457 /**
458 * Paginates the current LDAP query.
459 *
460 * @param int $pageSize
461 * @param bool $isCritical
462 *
463 * @return Collection|array
464 */
465 public function paginate($pageSize = 1000, $isCritical = false)
466 {
467 $this->paginated = true;
468
469 $start = microtime(true);
470
471 $query = $this->getQuery();
472
473 // Here we will create the pagination callback. This allows us
474 // to only execute an LDAP request if caching is disabled
475 // or if no cache of the given query exists yet.
476 $callback = function () use ($query, $pageSize, $isCritical) {
477 return $this->runPaginate($query, $pageSize, $isCritical);
478 };
479
480 $pages = $this->getCachedResponse($query, $callback);
481
482 $this->logQuery($this, 'paginate', $this->getElapsedTime($start));
483
484 return $this->process($pages);
485 }
486
487 /**
488 * Runs the paginate operation with the given filter.
489 *
490 * @param string $filter
491 * @param int $perPage
492 * @param bool $isCritical
493 *
494 * @return array
495 */
496 protected function runPaginate($filter, $perPage, $isCritical)
497 {
498 return $this->connection->run(function (LdapInterface $ldap) use ($filter, $perPage, $isCritical) {
499 return (new Paginator($this, $filter, $perPage, $isCritical))->execute($ldap);
500 });
501 }
502
503 /**
504 * Chunk the results of a paginated LDAP query.
505 *
506 * @param int $pageSize
507 * @param Closure $callback
508 * @param bool $isCritical
509 *
510 * @return void
511 */
512 public function chunk($pageSize, Closure $callback, $isCritical = false)
513 {
514 $start = microtime(true);
515
516 $query = $this->getQuery();
517
518 foreach ($this->runChunk($query, $pageSize, $isCritical) as $chunk) {
519 $callback($this->process($chunk));
520 }
521
522 $this->logQuery($this, 'chunk', $this->getElapsedTime($start));
523 }
524
525 /**
526 * Runs the chunk operation with the given filter.
527 *
528 * @param string $filter
529 * @param int $perPage
530 * @param bool $isCritical
531 *
532 * @return array
533 */
534 protected function runChunk($filter, $perPage, $isCritical)
535 {
536 return $this->connection->run(function (LdapInterface $ldap) use ($filter, $perPage, $isCritical) {
537 return (new LazyPaginator($this, $filter, $perPage, $isCritical))->execute($ldap);
538 });
539 }
540
541 /**
542 * Processes and converts the given LDAP results into models.
543 *
544 * @param array $results
545 *
546 * @return array
547 */
548 protected function process(array $results)
549 {
550 unset($results['count']);
551
552 return $this->paginated ? $this->flattenPages($results) : $results;
553 }
554
555 /**
556 * Flattens LDAP paged results into a single array.
557 *
558 * @param array $pages
559 *
560 * @return array
561 */
562 protected function flattenPages(array $pages)
563 {
564 $records = [];
565
566 foreach ($pages as $page) {
567 unset($page['count']);
568
569 $records = array_merge($records, $page);
570 }
571
572 return $records;
573 }
574
575 /**
576 * Get the cached response or execute and cache the callback value.
577 *
578 * @param string $query
579 * @param Closure $callback
580 *
581 * @return mixed
582 */
583 protected function getCachedResponse($query, Closure $callback)
584 {
585 // If caching is enabled and we have a cache instance available,
586 // we will try to retrieve the cached results instead.
587 if ($this->caching && $this->cache) {
588 $key = $this->getCacheKey($query);
589
590 if ($this->flushCache) {
591 $this->cache->delete($key);
592 }
593
594 return $this->cache->remember($key, $this->cacheUntil, $callback);
595 }
596
597 // Otherwise, we will simply execute the callback.
598 return $callback();
599 }
600
601 /**
602 * Runs the query operation with the given filter.
603 *
604 * @param string $filter
605 *
606 * @return resource
607 */
608 public function run($filter)
609 {
610 return $this->connection->run(function (LdapInterface $ldap) use ($filter) {
611 // We will avoid setting the controls during any pagination
612 // requests as it will clear the cookie we need to send
613 // to the server upon retrieving every page.
614 if (! $this->paginated) {
615 // Before running the query, we will set the LDAP server controls. This
616 // allows the controls to be automatically reset upon each new query
617 // that is conducted on the same connection during each request.
618 $ldap->setOption(LDAP_OPT_SERVER_CONTROLS, $this->controls);
619 }
620
621 return $ldap->{$this->type}(
622 $this->dn ?? $this->baseDn,
623 $filter,
624 $this->getSelects(),
625 $onlyAttributes = false,
626 $this->limit
627 );
628 });
629 }
630
631 /**
632 * Parses the given LDAP resource by retrieving its entries.
633 *
634 * @param resource $resource
635 *
636 * @return array
637 */
638 public function parse($resource)
639 {
640 if (! $resource) {
641 return [];
642 }
643
644 return $this->connection->run(function (LdapInterface $ldap) use ($resource) {
645 $entries = $ldap->getEntries($resource);
646
647 // Free up memory.
648 if (is_resource($resource)) {
649 $ldap->freeResult($resource);
650 }
651
652 return $entries;
653 });
654 }
655
656 /**
657 * Returns the cache key.
658 *
659 * @param string $query
660 *
661 * @return string
662 */
663 protected function getCacheKey($query)
664 {
665 $host = $this->connection->getLdapConnection()->getHost();
666
667 $key = $host
668 .$this->type
669 .$this->getDn()
670 .$query
671 .implode($this->getSelects())
672 .$this->limit
673 .$this->paginated;
674
675 return md5($key);
676 }
677
678 /**
679 * Returns the first entry in a search result.
680 *
681 * @param array|string $columns
682 *
683 * @return Model|null
684 */
685 public function first($columns = ['*'])
686 {
687 return Arr::get($this->limit(1)->get($columns), 0);
688 }
689
690 /**
691 * Returns the first entry in a search result.
692 *
693 * If no entry is found, an exception is thrown.
694 *
695 * @param array|string $columns
696 *
697 * @throws ObjectNotFoundException
698 *
699 * @return Model|static
700 */
701 public function firstOrFail($columns = ['*'])
702 {
703 if (! $record = $this->first($columns)) {
704 $this->throwNotFoundException($this->getUnescapedQuery(), $this->dn);
705 }
706
707 return $record;
708 }
709
710 /**
711 * Throws a not found exception.
712 *
713 * @param string $query
714 * @param string $dn
715 *
716 * @throws ObjectNotFoundException
717 */
718 protected function throwNotFoundException($query, $dn)
719 {
720 throw ObjectNotFoundException::forQuery($query, $dn);
721 }
722
723 /**
724 * Finds a record by the specified attribute and value.
725 *
726 * @param string $attribute
727 * @param string $value
728 * @param array|string $columns
729 *
730 * @return Model|static|null
731 */
732 public function findBy($attribute, $value, $columns = ['*'])
733 {
734 try {
735 return $this->findByOrFail($attribute, $value, $columns);
736 } catch (ObjectNotFoundException $e) {
737 return;
738 }
739 }
740
741 /**
742 * Finds a record by the specified attribute and value.
743 *
744 * If no record is found an exception is thrown.
745 *
746 * @param string $attribute
747 * @param string $value
748 * @param array|string $columns
749 *
750 * @throws ObjectNotFoundException
751 *
752 * @return Model
753 */
754 public function findByOrFail($attribute, $value, $columns = ['*'])
755 {
756 return $this->whereEquals($attribute, $value)->firstOrFail($columns);
757 }
758
759 /**
760 * Find many records by distinguished name.
761 *
762 * @param array $dns
763 * @param array $columns
764 *
765 * @return array|Collection
766 */
767 public function findMany($dns, $columns = ['*'])
768 {
769 if (empty($dns)) {
770 return $this->process([]);
771 }
772
773 $objects = [];
774
775 foreach ($dns as $dn) {
776 if (! is_null($object = $this->find($dn, $columns))) {
777 $objects[] = $object;
778 }
779 }
780
781 return $this->process($objects);
782 }
783
784 /**
785 * Finds many records by the specified attribute.
786 *
787 * @param string $attribute
788 * @param array $values
789 * @param array $columns
790 *
791 * @return Collection
792 */
793 public function findManyBy($attribute, array $values = [], $columns = ['*'])
794 {
795 $query = $this->select($columns);
796
797 foreach ($values as $value) {
798 $query->orWhere([$attribute => $value]);
799 }
800
801 return $query->get();
802 }
803
804 /**
805 * Finds a record by its distinguished name.
806 *
807 * @param string|array $dn
808 * @param array|string $columns
809 *
810 * @return Model|static|array|Collection|null
811 */
812 public function find($dn, $columns = ['*'])
813 {
814 if (is_array($dn)) {
815 return $this->findMany($dn, $columns);
816 }
817
818 try {
819 return $this->findOrFail($dn, $columns);
820 } catch (ObjectNotFoundException $e) {
821 return;
822 }
823 }
824
825 /**
826 * Finds a record by its distinguished name.
827 *
828 * Fails upon no records returned.
829 *
830 * @param string $dn
831 * @param array|string $columns
832 *
833 * @throws ObjectNotFoundException
834 *
835 * @return Model|static
836 */
837 public function findOrFail($dn, $columns = ['*'])
838 {
839 return $this->setDn($dn)
840 ->read()
841 ->whereHas('objectclass')
842 ->firstOrFail($columns);
843 }
844
845 /**
846 * Adds the inserted fields to query on the current LDAP connection.
847 *
848 * @param array|string $columns
849 *
850 * @return $this
851 */
852 public function select($columns = ['*'])
853 {
854 $columns = is_array($columns) ? $columns : func_get_args();
855
856 if (! empty($columns)) {
857 $this->columns = $columns;
858 }
859
860 return $this;
861 }
862
863 /**
864 * Add a new select column to the query.
865 *
866 * @param array|mixed $column
867 *
868 * @return $this
869 */
870 public function addSelect($column)
871 {
872 $column = is_array($column) ? $column : func_get_args();
873
874 $this->columns = array_merge((array) $this->columns, $column);
875
876 return $this;
877 }
878
879 /**
880 * Adds a raw filter to the current query.
881 *
882 * @param array|string $filters
883 *
884 * @return $this
885 */
886 public function rawFilter($filters = [])
887 {
888 $filters = is_array($filters) ? $filters : func_get_args();
889
890 foreach ($filters as $filter) {
891 $this->filters['raw'][] = $filter;
892 }
893
894 return $this;
895 }
896
897 /**
898 * Adds a nested 'and' filter to the current query.
899 *
900 * @param Closure $closure
901 *
902 * @return $this
903 */
904 public function andFilter(Closure $closure)
905 {
906 $query = $this->newNestedInstance($closure);
907
908 return $this->rawFilter(
909 $this->grammar->compileAnd($query->getQuery())
910 );
911 }
912
913 /**
914 * Adds a nested 'or' filter to the current query.
915 *
916 * @param Closure $closure
917 *
918 * @return $this
919 */
920 public function orFilter(Closure $closure)
921 {
922 $query = $this->newNestedInstance($closure);
923
924 return $this->rawFilter(
925 $this->grammar->compileOr($query->getQuery())
926 );
927 }
928
929 /**
930 * Adds a nested 'not' filter to the current query.
931 *
932 * @param Closure $closure
933 *
934 * @return $this
935 */
936 public function notFilter(Closure $closure)
937 {
938 $query = $this->newNestedInstance($closure);
939
940 return $this->rawFilter(
941 $this->grammar->compileNot($query->getQuery())
942 );
943 }
944
945 /**
946 * Adds a where clause to the current query.
947 *
948 * @param string|array $field
949 * @param string $operator
950 * @param string $value
951 * @param string $boolean
952 * @param bool $raw
953 *
954 * @throws InvalidArgumentException
955 *
956 * @return $this
957 */
958 public function where($field, $operator = null, $value = null, $boolean = 'and', $raw = false)
959 {
960 if (is_array($field)) {
961 // If the field is an array, we will assume we have been
962 // provided with an array of key-value pairs and can
963 // add them each as their own seperate where clause.
964 return $this->addArrayOfWheres($field, $boolean, $raw);
965 }
966
967 // If we have been provided with two arguments not a "has" or
968 // "not has" operator, we'll assume the developer is creating
969 // an "equals" clause and set the proper operator in place.
970 if (func_num_args() === 2 && ! in_array($operator, ['*', '!*'])) {
971 [$value, $operator] = [$operator, '='];
972 }
973
974 if (! in_array($operator, $this->grammar->getOperators())) {
975 throw new InvalidArgumentException("Invalid LDAP filter operator [$operator]");
976 }
977
978 // We'll escape the value if raw isn't requested.
979 $value = $this->prepareWhereValue($field, $value, $raw);
980
981 $field = $this->escape($field)->both()->get();
982
983 $this->addFilter($boolean, compact('field', 'operator', 'value'));
984
985 return $this;
986 }
987
988 /**
989 * Prepare the value for being queried.
990 *
991 * @param string $field
992 * @param string $value
993 * @param bool $raw
994 *
995 * @return string
996 */
997 protected function prepareWhereValue($field, $value, $raw = false)
998 {
999 return $raw ? $value : $this->escape($value);
1000 }
1001
1002 /**
1003 * Adds a raw where clause to the current query.
1004 *
1005 * Values given to this method are not escaped.
1006 *
1007 * @param string|array $field
1008 * @param string $operator
1009 * @param string $value
1010 *
1011 * @return $this
1012 */
1013 public function whereRaw($field, $operator = null, $value = null)
1014 {
1015 return $this->where($field, $operator, $value, 'and', true);
1016 }
1017
1018 /**
1019 * Adds a 'where equals' clause to the current query.
1020 *
1021 * @param string $field
1022 * @param string $value
1023 *
1024 * @return $this
1025 */
1026 public function whereEquals($field, $value)
1027 {
1028 return $this->where($field, '=', $value);
1029 }
1030
1031 /**
1032 * Adds a 'where not equals' clause to the current query.
1033 *
1034 * @param string $field
1035 * @param string $value
1036 *
1037 * @return $this
1038 */
1039 public function whereNotEquals($field, $value)
1040 {
1041 return $this->where($field, '!', $value);
1042 }
1043
1044 /**
1045 * Adds a 'where approximately equals' clause to the current query.
1046 *
1047 * @param string $field
1048 * @param string $value
1049 *
1050 * @return $this
1051 */
1052 public function whereApproximatelyEquals($field, $value)
1053 {
1054 return $this->where($field, '~=', $value);
1055 }
1056
1057 /**
1058 * Adds a 'where has' clause to the current query.
1059 *
1060 * @param string $field
1061 *
1062 * @return $this
1063 */
1064 public function whereHas($field)
1065 {
1066 return $this->where($field, '*');
1067 }
1068
1069 /**
1070 * Adds a 'where not has' clause to the current query.
1071 *
1072 * @param string $field
1073 *
1074 * @return $this
1075 */
1076 public function whereNotHas($field)
1077 {
1078 return $this->where($field, '!*');
1079 }
1080
1081 /**
1082 * Adds a 'where contains' clause to the current query.
1083 *
1084 * @param string $field
1085 * @param string $value
1086 *
1087 * @return $this
1088 */
1089 public function whereContains($field, $value)
1090 {
1091 return $this->where($field, 'contains', $value);
1092 }
1093
1094 /**
1095 * Adds a 'where contains' clause to the current query.
1096 *
1097 * @param string $field
1098 * @param string $value
1099 *
1100 * @return $this
1101 */
1102 public function whereNotContains($field, $value)
1103 {
1104 return $this->where($field, 'not_contains', $value);
1105 }
1106
1107 /**
1108 * Query for entries that match any of the values provided for the given field.
1109 *
1110 * @param string $field
1111 * @param array $values
1112 *
1113 * @return $this
1114 */
1115 public function whereIn($field, array $values)
1116 {
1117 return $this->orFilter(function (self $query) use ($field, $values) {
1118 foreach ($values as $value) {
1119 $query->whereEquals($field, $value);
1120 }
1121 });
1122 }
1123
1124 /**
1125 * Adds a 'between' clause to the current query.
1126 *
1127 * @param string $field
1128 * @param array $values
1129 *
1130 * @return $this
1131 */
1132 public function whereBetween($field, array $values)
1133 {
1134 return $this->where([
1135 [$field, '>=', $values[0]],
1136 [$field, '<=', $values[1]],
1137 ]);
1138 }
1139
1140 /**
1141 * Adds a 'where starts with' clause to the current query.
1142 *
1143 * @param string $field
1144 * @param string $value
1145 *
1146 * @return $this
1147 */
1148 public function whereStartsWith($field, $value)
1149 {
1150 return $this->where($field, 'starts_with', $value);
1151 }
1152
1153 /**
1154 * Adds a 'where *not* starts with' clause to the current query.
1155 *
1156 * @param string $field
1157 * @param string $value
1158 *
1159 * @return $this
1160 */
1161 public function whereNotStartsWith($field, $value)
1162 {
1163 return $this->where($field, 'not_starts_with', $value);
1164 }
1165
1166 /**
1167 * Adds a 'where ends with' clause to the current query.
1168 *
1169 * @param string $field
1170 * @param string $value
1171 *
1172 * @return $this
1173 */
1174 public function whereEndsWith($field, $value)
1175 {
1176 return $this->where($field, 'ends_with', $value);
1177 }
1178
1179 /**
1180 * Adds a 'where *not* ends with' clause to the current query.
1181 *
1182 * @param string $field
1183 * @param string $value
1184 *
1185 * @return $this
1186 */
1187 public function whereNotEndsWith($field, $value)
1188 {
1189 return $this->where($field, 'not_ends_with', $value);
1190 }
1191
1192 /**
1193 * Only include deleted models in the results.
1194 *
1195 * @return $this
1196 */
1197 public function whereDeleted()
1198 {
1199 return $this->withDeleted()->whereEquals('isDeleted', 'TRUE');
1200 }
1201
1202 /**
1203 * Set the LDAP control option to include deleted LDAP models.
1204 *
1205 * @return $this
1206 */
1207 public function withDeleted()
1208 {
1209 return $this->addControl(LdapInterface::OID_SERVER_SHOW_DELETED, $isCritical = true);
1210 }
1211
1212 /**
1213 * Add a server control to the query.
1214 *
1215 * @param string $oid
1216 * @param bool $isCritical
1217 * @param mixed $value
1218 *
1219 * @return $this
1220 */
1221 public function addControl($oid, $isCritical = false, $value = null)
1222 {
1223 $this->controls[$oid] = compact('oid', 'isCritical', 'value');
1224
1225 return $this;
1226 }
1227
1228 /**
1229 * Determine if the server control exists on the query.
1230 *
1231 * @param string $oid
1232 *
1233 * @return bool
1234 */
1235 public function hasControl($oid)
1236 {
1237 return array_key_exists($oid, $this->controls);
1238 }
1239
1240 /**
1241 * Adds an 'or where' clause to the current query.
1242 *
1243 * @param array|string $field
1244 * @param string|null $operator
1245 * @param string|null $value
1246 *
1247 * @return $this
1248 */
1249 public function orWhere($field, $operator = null, $value = null)
1250 {
1251 return $this->where($field, $operator, $value, 'or');
1252 }
1253
1254 /**
1255 * Adds a raw or where clause to the current query.
1256 *
1257 * Values given to this method are not escaped.
1258 *
1259 * @param string $field
1260 * @param string $operator
1261 * @param string $value
1262 *
1263 * @return $this
1264 */
1265 public function orWhereRaw($field, $operator = null, $value = null)
1266 {
1267 return $this->where($field, $operator, $value, 'or', true);
1268 }
1269
1270 /**
1271 * Adds an 'or where has' clause to the current query.
1272 *
1273 * @param string $field
1274 *
1275 * @return $this
1276 */
1277 public function orWhereHas($field)
1278 {
1279 return $this->orWhere($field, '*');
1280 }
1281
1282 /**
1283 * Adds a 'where not has' clause to the current query.
1284 *
1285 * @param string $field
1286 *
1287 * @return $this
1288 */
1289 public function orWhereNotHas($field)
1290 {
1291 return $this->orWhere($field, '!*');
1292 }
1293
1294 /**
1295 * Adds an 'or where equals' clause to the current query.
1296 *
1297 * @param string $field
1298 * @param string $value
1299 *
1300 * @return $this
1301 */
1302 public function orWhereEquals($field, $value)
1303 {
1304 return $this->orWhere($field, '=', $value);
1305 }
1306
1307 /**
1308 * Adds an 'or where not equals' clause to the current query.
1309 *
1310 * @param string $field
1311 * @param string $value
1312 *
1313 * @return $this
1314 */
1315 public function orWhereNotEquals($field, $value)
1316 {
1317 return $this->orWhere($field, '!', $value);
1318 }
1319
1320 /**
1321 * Adds a 'or where approximately equals' clause to the current query.
1322 *
1323 * @param string $field
1324 * @param string $value
1325 *
1326 * @return $this
1327 */
1328 public function orWhereApproximatelyEquals($field, $value)
1329 {
1330 return $this->orWhere($field, '~=', $value);
1331 }
1332
1333 /**
1334 * Adds an 'or where contains' clause to the current query.
1335 *
1336 * @param string $field
1337 * @param string $value
1338 *
1339 * @return $this
1340 */
1341 public function orWhereContains($field, $value)
1342 {
1343 return $this->orWhere($field, 'contains', $value);
1344 }
1345
1346 /**
1347 * Adds an 'or where *not* contains' clause to the current query.
1348 *
1349 * @param string $field
1350 * @param string $value
1351 *
1352 * @return $this
1353 */
1354 public function orWhereNotContains($field, $value)
1355 {
1356 return $this->orWhere($field, 'not_contains', $value);
1357 }
1358
1359 /**
1360 * Adds an 'or where starts with' clause to the current query.
1361 *
1362 * @param string $field
1363 * @param string $value
1364 *
1365 * @return $this
1366 */
1367 public function orWhereStartsWith($field, $value)
1368 {
1369 return $this->orWhere($field, 'starts_with', $value);
1370 }
1371
1372 /**
1373 * Adds an 'or where *not* starts with' clause to the current query.
1374 *
1375 * @param string $field
1376 * @param string $value
1377 *
1378 * @return $this
1379 */
1380 public function orWhereNotStartsWith($field, $value)
1381 {
1382 return $this->orWhere($field, 'not_starts_with', $value);
1383 }
1384
1385 /**
1386 * Adds an 'or where ends with' clause to the current query.
1387 *
1388 * @param string $field
1389 * @param string $value
1390 *
1391 * @return $this
1392 */
1393 public function orWhereEndsWith($field, $value)
1394 {
1395 return $this->orWhere($field, 'ends_with', $value);
1396 }
1397
1398 /**
1399 * Adds an 'or where *not* ends with' clause to the current query.
1400 *
1401 * @param string $field
1402 * @param string $value
1403 *
1404 * @return $this
1405 */
1406 public function orWhereNotEndsWith($field, $value)
1407 {
1408 return $this->orWhere($field, 'not_ends_with', $value);
1409 }
1410
1411 /**
1412 * Adds a filter binding onto the current query.
1413 *
1414 * @param string $type The type of filter to add.
1415 * @param array $bindings The bindings of the filter.
1416 *
1417 * @throws InvalidArgumentException
1418 *
1419 * @return $this
1420 */
1421 public function addFilter($type, array $bindings)
1422 {
1423 if (! array_key_exists($type, $this->filters)) {
1424 throw new InvalidArgumentException("Filter type: [$type] is invalid.");
1425 }
1426
1427 // Each filter clause require key bindings to be set. We
1428 // will validate this here to ensure all of them have
1429 // been provided, or throw an exception otherwise.
1430 if ($missing = $this->missingBindingKeys($bindings)) {
1431 $keys = implode(', ', $missing);
1432
1433 throw new InvalidArgumentException("Invalid filter bindings. Missing: [$keys] keys.");
1434 }
1435
1436 $this->filters[$type][] = $bindings;
1437
1438 return $this;
1439 }
1440
1441 /**
1442 * Extract any missing required binding keys.
1443 *
1444 * @param array $bindings
1445 *
1446 * @return array
1447 */
1448 protected function missingBindingKeys($bindings)
1449 {
1450 $required = array_flip(['field', 'operator', 'value']);
1451
1452 $existing = array_intersect_key($required, $bindings);
1453
1454 return array_keys(array_diff_key($required, $existing));
1455 }
1456
1457 /**
1458 * Get all the filters on the query.
1459 *
1460 * @return array
1461 */
1462 public function getFilters()
1463 {
1464 return $this->filters;
1465 }
1466
1467 /**
1468 * Clear the query filters.
1469 *
1470 * @return $this
1471 */
1472 public function clearFilters()
1473 {
1474 foreach (array_keys($this->filters) as $type) {
1475 $this->filters[$type] = [];
1476 }
1477
1478 return $this;
1479 }
1480
1481 /**
1482 * Determine if the query has attributes selected.
1483 *
1484 * @return bool
1485 */
1486 public function hasSelects()
1487 {
1488 return count($this->columns) > 0;
1489 }
1490
1491 /**
1492 * Get the attributes to select on the search.
1493 *
1494 * @return array
1495 */
1496 public function getSelects()
1497 {
1498 $selects = $this->columns ?? ['*'];
1499
1500 if (in_array('*', $selects)) {
1501 return $selects;
1502 }
1503
1504 if (in_array('objectclass', $selects)) {
1505 return $selects;
1506 }
1507
1508 // If the * character is not provided in the selected columns,
1509 // we need to ensure we always select the object class, as
1510 // this is used for constructing models properly.
1511 $selects[] = 'objectclass';
1512
1513 return $selects;
1514 }
1515
1516 /**
1517 * Set the query to search on the base distinguished name.
1518 *
1519 * This will result in one record being returned.
1520 *
1521 * @return $this
1522 */
1523 public function read()
1524 {
1525 $this->type = 'read';
1526
1527 return $this;
1528 }
1529
1530 /**
1531 * Set the query to search one level on the base distinguished name.
1532 *
1533 * @return $this
1534 */
1535 public function listing()
1536 {
1537 $this->type = 'listing';
1538
1539 return $this;
1540 }
1541
1542 /**
1543 * Set the query to search the entire directory on the base distinguished name.
1544 *
1545 * @return $this
1546 */
1547 public function recursive()
1548 {
1549 $this->type = 'search';
1550
1551 return $this;
1552 }
1553
1554 /**
1555 * Whether to mark the current query as nested.
1556 *
1557 * @param bool $nested
1558 *
1559 * @return $this
1560 */
1561 public function nested($nested = true)
1562 {
1563 $this->nested = (bool) $nested;
1564
1565 return $this;
1566 }
1567
1568 /**
1569 * Enables caching on the current query until the given date.
1570 *
1571 * If flushing is enabled, the query cache will be flushed and then re-cached.
1572 *
1573 * @param DateTimeInterface $until When to expire the query cache.
1574 * @param bool $flush Whether to force-flush the query cache.
1575 *
1576 * @return $this
1577 */
1578 public function cache(DateTimeInterface $until = null, $flush = false)
1579 {
1580 $this->caching = true;
1581 $this->cacheUntil = $until;
1582 $this->flushCache = $flush;
1583
1584 return $this;
1585 }
1586
1587 /**
1588 * Determine if the query is nested.
1589 *
1590 * @return bool
1591 */
1592 public function isNested()
1593 {
1594 return $this->nested === true;
1595 }
1596
1597 /**
1598 * Determine whether the query is paginated.
1599 *
1600 * @return bool
1601 */
1602 public function isPaginated()
1603 {
1604 return $this->paginated;
1605 }
1606
1607 /**
1608 * Insert an entry into the directory.
1609 *
1610 * @param string $dn
1611 * @param array $attributes
1612 *
1613 * @throws LdapRecordException
1614 *
1615 * @return bool
1616 */
1617 public function insert($dn, array $attributes)
1618 {
1619 if (empty($dn)) {
1620 throw new LdapRecordException('A new LDAP object must have a distinguished name (dn).');
1621 }
1622
1623 if (! array_key_exists('objectclass', $attributes)) {
1624 throw new LdapRecordException(
1625 'A new LDAP object must contain at least one object class (objectclass) to be created.'
1626 );
1627 }
1628
1629 return $this->connection->run(function (LdapInterface $ldap) use ($dn, $attributes) {
1630 return $ldap->add($dn, $attributes);
1631 });
1632 }
1633
1634 /**
1635 * Create attributes on the entry in the directory.
1636 *
1637 * @param string $dn
1638 * @param array $attributes
1639 *
1640 * @return bool
1641 */
1642 public function insertAttributes($dn, array $attributes)
1643 {
1644 return $this->connection->run(function (LdapInterface $ldap) use ($dn, $attributes) {
1645 return $ldap->modAdd($dn, $attributes);
1646 });
1647 }
1648
1649 /**
1650 * Update the entry with the given modifications.
1651 *
1652 * @param string $dn
1653 * @param array $modifications
1654 *
1655 * @return bool
1656 */
1657 public function update($dn, array $modifications)
1658 {
1659 return $this->connection->run(function (LdapInterface $ldap) use ($dn, $modifications) {
1660 return $ldap->modifyBatch($dn, $modifications);
1661 });
1662 }
1663
1664 /**
1665 * Update an entries attribute in the directory.
1666 *
1667 * @param string $dn
1668 * @param array $attributes
1669 *
1670 * @return bool
1671 */
1672 public function updateAttributes($dn, array $attributes)
1673 {
1674 return $this->connection->run(function (LdapInterface $ldap) use ($dn, $attributes) {
1675 return $ldap->modReplace($dn, $attributes);
1676 });
1677 }
1678
1679 /**
1680 * Delete an entry from the directory.
1681 *
1682 * @param string $dn
1683 *
1684 * @return bool
1685 */
1686 public function delete($dn)
1687 {
1688 return $this->connection->run(function (LdapInterface $ldap) use ($dn) {
1689 return $ldap->delete($dn);
1690 });
1691 }
1692
1693 /**
1694 * Delete attributes on the entry in the directory.
1695 *
1696 * @param string $dn
1697 * @param array $attributes
1698 *
1699 * @return bool
1700 */
1701 public function deleteAttributes($dn, array $attributes)
1702 {
1703 return $this->connection->run(function (LdapInterface $ldap) use ($dn, $attributes) {
1704 return $ldap->modDelete($dn, $attributes);
1705 });
1706 }
1707
1708 /**
1709 * Rename an entry in the directory.
1710 *
1711 * @param string $dn
1712 * @param string $rdn
1713 * @param string $newParentDn
1714 * @param bool $deleteOldRdn
1715 *
1716 * @return bool
1717 */
1718 public function rename($dn, $rdn, $newParentDn, $deleteOldRdn = true)
1719 {
1720 return $this->connection->run(function (LdapInterface $ldap) use ($dn, $rdn, $newParentDn, $deleteOldRdn) {
1721 return $ldap->rename($dn, $rdn, $newParentDn, $deleteOldRdn);
1722 });
1723 }
1724
1725 /**
1726 * Handle dynamic method calls on the query builder.
1727 *
1728 * @param string $method
1729 * @param array $parameters
1730 *
1731 * @throws BadMethodCallException
1732 *
1733 * @return mixed
1734 */
1735 public function __call($method, $parameters)
1736 {
1737 // If the beginning of the method being called contains
1738 // 'where', we will assume a dynamic 'where' clause is
1739 // being performed and pass the parameters to it.
1740 if (substr($method, 0, 5) === 'where') {
1741 return $this->dynamicWhere($method, $parameters);
1742 }
1743
1744 throw new BadMethodCallException(sprintf(
1745 'Call to undefined method %s::%s()',
1746 static::class,
1747 $method
1748 ));
1749 }
1750
1751 /**
1752 * Handles dynamic "where" clauses to the query.
1753 *
1754 * @param string $method
1755 * @param array $parameters
1756 *
1757 * @return $this
1758 */
1759 public function dynamicWhere($method, $parameters)
1760 {
1761 $finder = substr($method, 5);
1762
1763 $segments = preg_split('/(And|Or)(?=[A-Z])/', $finder, -1, PREG_SPLIT_DELIM_CAPTURE);
1764
1765 // The connector variable will determine which connector will be used for the
1766 // query condition. We will change it as we come across new boolean values
1767 // in the dynamic method strings, which could contain a number of these.
1768 $connector = 'and';
1769
1770 $index = 0;
1771
1772 foreach ($segments as $segment) {
1773 // If the segment is not a boolean connector, we can assume it is a column's name
1774 // and we will add it to the query as a new constraint as a where clause, then
1775 // we can keep iterating through the dynamic method string's segments again.
1776 if ($segment != 'And' && $segment != 'Or') {
1777 $this->addDynamic($segment, $connector, $parameters, $index);
1778
1779 $index++;
1780 }
1781
1782 // Otherwise, we will store the connector so we know how the next where clause we
1783 // find in the query should be connected to the previous ones, meaning we will
1784 // have the proper boolean connector to connect the next where clause found.
1785 else {
1786 $connector = $segment;
1787 }
1788 }
1789
1790 return $this;
1791 }
1792
1793 /**
1794 * Adds an array of wheres to the current query.
1795 *
1796 * @param array $wheres
1797 * @param string $boolean
1798 * @param bool $raw
1799 *
1800 * @return $this
1801 */
1802 protected function addArrayOfWheres($wheres, $boolean, $raw)
1803 {
1804 foreach ($wheres as $key => $value) {
1805 if (is_numeric($key) && is_array($value)) {
1806 // If the key is numeric and the value is an array, we'll
1807 // assume we've been given an array with conditionals.
1808 [$field, $condition] = $value;
1809
1810 // Since a value is optional for some conditionals, we will
1811 // try and retrieve the third parameter from the array,
1812 // but is entirely optional.
1813 $value = Arr::get($value, 2);
1814
1815 $this->where($field, $condition, $value, $boolean);
1816 } else {
1817 // If the value is not an array, we will assume an equals clause.
1818 $this->where($key, '=', $value, $boolean, $raw);
1819 }
1820 }
1821
1822 return $this;
1823 }
1824
1825 /**
1826 * Add a single dynamic where clause statement to the query.
1827 *
1828 * @param string $segment
1829 * @param string $connector
1830 * @param array $parameters
1831 * @param int $index
1832 *
1833 * @return void
1834 */
1835 protected function addDynamic($segment, $connector, $parameters, $index)
1836 {
1837 // If no parameters were given to the dynamic where clause,
1838 // we can assume a "has" attribute filter is being added.
1839 if (count($parameters) === 0) {
1840 $this->where(strtolower($segment), '*', null, strtolower($connector));
1841 } else {
1842 $this->where(strtolower($segment), '=', $parameters[$index], strtolower($connector));
1843 }
1844 }
1845
1846 /**
1847 * Logs the given executed query information by firing its query event.
1848 *
1849 * @param Builder $query
1850 * @param string $type
1851 * @param null|float $time
1852 *
1853 * @return void
1854 */
1855 protected function logQuery($query, $type, $time = null)
1856 {
1857 $args = [$query, $time];
1858
1859 switch ($type) {
1860 case 'listing':
1861 $event = new Events\Listing(...$args);
1862 break;
1863 case 'read':
1864 $event = new Events\Read(...$args);
1865 break;
1866 case 'chunk':
1867 $event = new Events\Chunk(...$args);
1868 break;
1869 case 'paginate':
1870 $event = new Events\Paginate(...$args);
1871 break;
1872 default:
1873 $event = new Events\Search(...$args);
1874 break;
1875 }
1876
1877 $this->fireQueryEvent($event);
1878 }
1879
1880 /**
1881 * Fires the given query event.
1882 *
1883 * @param QueryExecuted $event
1884 *
1885 * @return void
1886 */
1887 protected function fireQueryEvent(QueryExecuted $event)
1888 {
1889 Container::getInstance()->getEventDispatcher()->fire($event);
1890 }
1891
1892 /**
1893 * Get the elapsed time since a given starting point.
1894 *
1895 * @param int $start
1896 *
1897 * @return float
1898 */
1899 protected function getElapsedTime($start)
1900 {
1901 return round((microtime(true) - $start) * 1000, 2);
1902 }
1903}