1: <?php
2:
3: namespace Charcoal\Loader;
4:
5: use InvalidArgumentException;
6: use RuntimeException;
7: use ArrayAccess;
8: use Traversable;
9: use PDO;
10:
11: // From PSR-3
12: use Psr\Log\LoggerAwareInterface;
13: use Psr\Log\LoggerAwareTrait;
14: use Psr\Log\NullLogger;
15:
16: // From 'charcoal-factory'
17: use Charcoal\Factory\FactoryInterface;
18:
19: // From 'charcoal-core'
20: use Charcoal\Model\ModelInterface;
21: use Charcoal\Model\Collection;
22: use Charcoal\Source\SourceInterface;
23:
24: /**
25: * Object Collection Loader
26: */
27: class CollectionLoader implements LoggerAwareInterface
28: {
29: use LoggerAwareTrait;
30:
31: /**
32: * The source to load objects from.
33: *
34: * @var SourceInterface
35: */
36: private $source;
37:
38: /**
39: * The model to load the collection from.
40: *
41: * @var ModelInterface
42: */
43: private $model;
44:
45: /**
46: * Store the factory instance for the current class.
47: *
48: * @var FactoryInterface
49: */
50: private $factory;
51:
52: /**
53: * The callback routine applied to every object added to the collection.
54: *
55: * @var callable|null
56: */
57: private $callback;
58:
59: /**
60: * The field which defines the data's model.
61: *
62: * @var string|null
63: */
64: private $dynamicTypeField;
65:
66: /**
67: * The class name of the collection to use.
68: *
69: * Must be a fully-qualified PHP namespace and an implementation of {@see ArrayAccess}.
70: *
71: * @var string
72: */
73: private $collectionClass = Collection::class;
74:
75: /**
76: * Return a new CollectionLoader object.
77: *
78: * @param array $data The loader's dependencies.
79: */
80: public function __construct(array $data)
81: {
82: if (!isset($data['logger'])) {
83: $data['logger'] = new NullLogger();
84: }
85:
86: $this->setLogger($data['logger']);
87:
88: if (isset($data['collection'])) {
89: $this->setCollectionClass($data['collection']);
90: }
91:
92: if (isset($data['factory'])) {
93: $this->setFactory($data['factory']);
94: }
95:
96: if (isset($data['model'])) {
97: $this->setModel($data['model']);
98: }
99: }
100:
101: /**
102: * Set an object model factory.
103: *
104: * @param FactoryInterface $factory The model factory, to create objects.
105: * @return CollectionLoader Chainable
106: */
107: public function setFactory(FactoryInterface $factory)
108: {
109: $this->factory = $factory;
110:
111: return $this;
112: }
113:
114: /**
115: * Retrieve the object model factory.
116: *
117: * @throws RuntimeException If the model factory was not previously set.
118: * @return FactoryInterface
119: */
120: protected function factory()
121: {
122: if ($this->factory === null) {
123: throw new RuntimeException(
124: sprintf('Model Factory is not defined for "%s"', get_class($this))
125: );
126: }
127:
128: return $this->factory;
129: }
130:
131: /**
132: * Set the loader data.
133: *
134: * @param array $data Data to assign to the loader.
135: * @return CollectionLoader Chainable
136: */
137: public function setData(array $data)
138: {
139: foreach ($data as $key => $val) {
140: $setter = $this->setter($key);
141:
142: if (is_callable([$this, $setter])) {
143: $this->{$setter}($val);
144: } else {
145: $this->{$key} = $val;
146: }
147: }
148:
149: return $this;
150: }
151:
152: /**
153: * Retrieve the source to load objects from.
154: *
155: * @throws RuntimeException If no source has been defined.
156: * @return mixed
157: */
158: public function source()
159: {
160: if ($this->source === null) {
161: throw new RuntimeException('No source set.');
162: }
163:
164: return $this->source;
165: }
166:
167: /**
168: * Set the source to load objects from.
169: *
170: * @param SourceInterface $source A data source.
171: * @return CollectionLoader Chainable
172: */
173: public function setSource(SourceInterface $source)
174: {
175: $source->reset();
176:
177: $this->source = $source;
178:
179: return $this;
180: }
181:
182: /**
183: * Reset everything but the model.
184: *
185: * @return CollectionLoader Chainable
186: */
187: public function reset()
188: {
189: if ($this->source) {
190: $this->source()->reset();
191: }
192:
193: $this->callback = null;
194: $this->dynamicTypeField = null;
195:
196: return $this;
197: }
198:
199: /**
200: * Retrieve the object model.
201: *
202: * @throws RuntimeException If no model has been defined.
203: * @return Model
204: */
205: public function model()
206: {
207: if ($this->model === null) {
208: throw new RuntimeException('The collection loader must have a model.');
209: }
210:
211: return $this->model;
212: }
213:
214: /**
215: * Determine if the loader has an object model.
216: *
217: * @return boolean
218: */
219: public function hasModel()
220: {
221: return !!$this->model;
222: }
223:
224: /**
225: * Set the model to use for the loaded objects.
226: *
227: * @param string|ModelInterface $model An object model.
228: * @throws InvalidArgumentException If the given argument is not a model.
229: * @return CollectionLoader CHainable
230: */
231: public function setModel($model)
232: {
233: if (is_string($model)) {
234: $model = $this->factory()->get($model);
235: }
236:
237: if (!$model instanceof ModelInterface) {
238: throw new InvalidArgumentException(
239: sprintf(
240: 'The model must be an instance of "%s"',
241: ModelInterface::class
242: )
243: );
244: }
245:
246: $this->model = $model;
247:
248: $this->setSource($model->source());
249:
250: return $this;
251: }
252:
253: /**
254: * @param string $field The field to use for dynamic object type.
255: * @throws InvalidArgumentException If the field is not a string.
256: * @return CollectionLoader Chainable
257: */
258: public function setDynamicTypeField($field)
259: {
260: if (!is_string($field)) {
261: throw new InvalidArgumentException(
262: 'Dynamic type field must be a string'
263: );
264: }
265:
266: $this->dynamicTypeField = $field;
267:
268: return $this;
269: }
270:
271: /**
272: * Alias of {@see SourceInterface::properties()}
273: *
274: * @return array
275: */
276: public function properties()
277: {
278: return $this->source()->properties();
279: }
280:
281: /**
282: * Alias of {@see SourceInterface::setProperties()}
283: *
284: * @param array $properties An array of property identifiers.
285: * @return CollectionLoader Chainable
286: */
287: public function setProperties(array $properties)
288: {
289: $this->source()->setProperties($properties);
290:
291: return $this;
292: }
293:
294: /**
295: * Alias of {@see SourceInterface::addProperty()}
296: *
297: * @param string $property A property identifier.
298: * @return CollectionLoader Chainable
299: */
300: public function addProperty($property)
301: {
302: $this->source()->addProperty($property);
303:
304: return $this;
305: }
306:
307: /**
308: * Set "search" keywords to filter multiple properties.
309: *
310: * @param array $keywords An array of keywords and properties.
311: * @return CollectionLoader Chainable
312: */
313: public function setKeywords(array $keywords)
314: {
315: foreach ($keywords as $k) {
316: $keyword = $k[0];
317: $properties = (isset($k[1]) ? $k[1] : null);
318: $this->addKeyword($keyword, $properties);
319: }
320:
321: return $this;
322: }
323:
324: /**
325: * Add a "search" keyword filter to multiple properties.
326: *
327: * @param string $keyword A value to match among $properties.
328: * @param array $properties An array of property identifiers.
329: * @return CollectionLoader Chainable
330: */
331: public function addKeyword($keyword, array $properties = null)
332: {
333: if (!is_array($properties) || empty($properties)) {
334: $properties = [];
335: }
336:
337: foreach ($properties as $propertyIdent) {
338: $val = ('%'.$keyword.'%');
339: $this->addFilter([
340: 'property' => $propertyIdent,
341: 'val' => $val,
342: 'operator' => 'LIKE',
343: 'operand' => 'OR'
344: ]);
345: }
346:
347: return $this;
348: }
349:
350: /**
351: * Alias of {@see SourceInterface::filters()}
352: *
353: * @return array
354: */
355: public function filters()
356: {
357: return $this->source()->filters();
358: }
359:
360: /**
361: * Alias of {@see SourceInterface::setFilters()}
362: *
363: * @param array $filters An array of filters.
364: * @return Collection Chainable
365: */
366: public function setFilters(array $filters)
367: {
368: $this->source()->setFilters($filters);
369:
370: return $this;
371: }
372:
373: /**
374: * Alias of {@see SourceInterface::addFilter()}
375: *
376: * @param string|array|Filter $param A property identifier, filter array, or Filter object.
377: * @param mixed $val Optional. The value to match. Only used if the first argument is a string.
378: * @param array $options Optional. Filter options. Only used if the first argument is a string.
379: * @return CollectionLoader Chainable
380: */
381: public function addFilter($param, $val = null, array $options = null)
382: {
383: $this->source()->addFilter($param, $val, $options);
384:
385: return $this;
386: }
387:
388: /**
389: * Alias of {@see SourceInterface::orders()}
390: *
391: * @return array
392: */
393: public function orders()
394: {
395: return $this->source()->orders();
396: }
397:
398: /**
399: * Alias of {@see SourceInterface::setOrders()}
400: *
401: * @param array $orders An array of orders.
402: * @return CollectionLoader Chainable
403: */
404: public function setOrders(array $orders)
405: {
406: $this->source()->setOrders($orders);
407:
408: return $this;
409: }
410:
411: /**
412: * Alias of {@see SourceInterface::addOrder()}
413: *
414: * @param string|array|Order $param A property identifier, order array, or Order object.
415: * @param string $mode Optional. Sort order. Only used if the first argument is a string.
416: * @param array $orderOptions Optional. Filter options. Only used if the first argument is a string.
417: * @return CollectionLoader Chainable
418: */
419: public function addOrder($param, $mode = 'asc', array $orderOptions = null)
420: {
421: $this->source()->addOrder($param, $mode, $orderOptions);
422:
423: return $this;
424: }
425:
426: /**
427: * Alias of {@see SourceInterface::pagination()}
428: *
429: * @return Pagination
430: */
431: public function pagination()
432: {
433: return $this->source()->pagination();
434: }
435:
436: /**
437: * Alias of {@see SourceInterface::setPagination()}
438: *
439: * @param mixed $param An associative array of pagination settings.
440: * @return CollectionLoader Chainable
441: */
442: public function setPagination($param)
443: {
444: $this->source()->setPagination($param);
445:
446: return $this;
447: }
448:
449: /**
450: * Alias of {@see PaginationInterface::page()}
451: *
452: * @return integer
453: */
454: public function page()
455: {
456: return $this->pagination()->page();
457: }
458:
459: /**
460: * Alias of {@see PaginationInterface::pagination()}
461: *
462: * @param integer $page A page number.
463: * @return CollectionLoader Chainable
464: */
465: public function setPage($page)
466: {
467: $this->pagination()->setPage($page);
468:
469: return $this;
470: }
471:
472: /**
473: * Alias of {@see PaginationInterface::numPerPage()}
474: *
475: * @return integer
476: */
477: public function numPerPage()
478: {
479: return $this->pagination()->numPerPage();
480: }
481:
482: /**
483: * Alias of {@see PaginationInterface::setNumPerPage()}
484: *
485: * @param integer $num The number of items to display per page.
486: * @return CollectionLoader Chainable
487: */
488: public function setNumPerPage($num)
489: {
490: $this->pagination()->setNumPerPage($num);
491:
492: return $this;
493: }
494:
495: /**
496: * Set the callback routine applied to every object added to the collection.
497: *
498: * @param callable $callback The callback routine.
499: * @return CollectionLoader Chainable
500: */
501: public function setCallback(callable $callback)
502: {
503: $this->callback = $callback;
504:
505: return $this;
506: }
507:
508: /**
509: * Retrieve the callback routine applied to every object added to the collection.
510: *
511: * @return callable|null
512: */
513: public function callback()
514: {
515: return $this->callback;
516: }
517:
518: /**
519: * Load a collection from source.
520: *
521: * @param string|null $ident Optional. A pre-defined list to use from the model.
522: * @param callable $callback Optional. Apply a callback to every entity of the collection.
523: * Leave blank to use {@see CollectionLoader::callback()}.
524: * @throws Exception If the database connection fails.
525: * @return array|ArrayAccess
526: */
527: public function load($ident = null, callable $callback = null)
528: {
529: // Unused.
530: unset($ident);
531:
532: $query = $this->source()->sqlLoad();
533:
534: return $this->loadFromQuery($query, $callback);
535: }
536:
537: /**
538: * Get the total number of items for this collection query.
539: *
540: * @throws RuntimeException If the database connection fails.
541: * @return integer
542: */
543: public function loadCount()
544: {
545: $query = $this->source()->sqlLoadCount();
546:
547: $db = $this->source()->db();
548: if (!$db) {
549: throw new RuntimeException(
550: 'Could not instanciate a database connection.'
551: );
552: }
553: $this->logger->debug($query);
554:
555: $sth = $db->prepare($query);
556: $sth->execute();
557: $res = $sth->fetchColumn(0);
558:
559: return (int)$res;
560: }
561:
562: /**
563: * Load list from query.
564: *
565: * **Example — Binding values to $query**
566: *
567: * ```php
568: * $this->loadFromQuery([
569: * 'SELECT name, colour, calories FROM fruit WHERE calories < :calories AND colour = :colour',
570: * [
571: * 'calories' => 150,
572: * 'colour' => 'red'
573: * ],
574: * [ 'calories' => PDO::PARAM_INT ]
575: * ]);
576: * ```
577: *
578: * @param string|array $query The SQL query as a string or an array composed of the query,
579: * parameter binds, and types of parameter bindings.
580: * @param callable $callback Optional. Apply a callback to every entity of the collection.
581: * Leave blank to use {@see CollectionLoader::callback()}.
582: * @throws RuntimeException If the database connection fails.
583: * @throws InvalidArgumentException If the SQL string/set is invalid.
584: * @return array|ArrayAccess
585: */
586: public function loadFromQuery($query, callable $callback = null)
587: {
588: $db = $this->source()->db();
589:
590: if (!$db) {
591: throw new RuntimeException(
592: 'Could not instanciate a database connection.'
593: );
594: }
595:
596: /** @todo Filter binds */
597: if (is_string($query)) {
598: $this->logger->debug($query);
599: $sth = $db->prepare($query);
600: $sth->execute();
601: } elseif (is_array($query)) {
602: list($query, $binds, $types) = array_pad($query, 3, []);
603: $sth = $this->source()->dbQuery($query, $binds, $types);
604: } else {
605: throw new InvalidArgumentException(sprintf(
606: 'The SQL query must be a string or an array: '.
607: '[ string $query, array $binds, array $dataTypes ]; '.
608: 'received %s',
609: is_object($query) ? get_class($query) : $query
610: ));
611: }
612:
613: $sth->setFetchMode(PDO::FETCH_ASSOC);
614:
615: return $this->processCollection($sth, $callback);
616: }
617:
618: /**
619: * Process the collection of raw data.
620: *
621: * @param array|Traversable $results The raw result set.
622: * @param callable $callback Optional. Apply a callback to every entity of the collection.
623: * Leave blank to use {@see CollectionLoader::callback()}.
624: * @throws InvalidArgumentException If the SQL string/set is invalid.
625: * @return array|ArrayAccess
626: */
627: protected function processCollection($results, callable $callback = null)
628: {
629: if ($callback === null) {
630: $callback = $this->callback();
631: }
632:
633: $modelObjType = $this->model()->objType();
634: $collection = $this->createCollection();
635: foreach ($results as $objData) {
636: if ($this->dynamicTypeField && isset($objData[$this->dynamicTypeField])) {
637: $objType = $objData[$this->dynamicTypeField];
638: } else {
639: $objType = $modelObjType;
640: }
641:
642:
643: $obj = $this->factory()->create($objType);
644: $obj->setFlatData($objData);
645:
646: if (isset($callback)) {
647: call_user_func_array($callback, [ &$obj ]);
648: }
649:
650: if ($obj instanceof ModelInterface) {
651: $collection[] = $obj;
652: }
653: }
654:
655: return $collection;
656: }
657:
658: /**
659: * Create a collection class or array.
660: *
661: * @throws RuntimeException If the collection class is invalid.
662: * @return array|ArrayAccess
663: */
664: public function createCollection()
665: {
666: $collectClass = $this->collectionClass();
667: if ($collectClass === 'array') {
668: return [];
669: }
670:
671: if (!class_exists($collectClass)) {
672: throw new RuntimeException(sprintf(
673: 'Collection class [%s] does not exist.',
674: $collectClass
675: ));
676: }
677:
678: if (!is_subclass_of($collectClass, ArrayAccess::class)) {
679: throw new RuntimeException(sprintf(
680: 'Collection class [%s] must implement ArrayAccess.',
681: $collectClass
682: ));
683: }
684:
685: $collection = new $collectClass;
686:
687: return $collection;
688: }
689:
690: /**
691: * Set the class name of the collection.
692: *
693: * @param string $className The class name of the collection.
694: * @throws InvalidArgumentException If the class name is not a string.
695: * @return AbstractPropertyDisplay Chainable
696: */
697: public function setCollectionClass($className)
698: {
699: if (!is_string($className)) {
700: throw new InvalidArgumentException(
701: 'Collection class name must be a string.'
702: );
703: }
704:
705: $this->collectionClass = $className;
706:
707: return $this;
708: }
709:
710: /**
711: * Retrieve the class name of the collection.
712: *
713: * @return string
714: */
715: public function collectionClass()
716: {
717: return $this->collectionClass;
718: }
719:
720: /**
721: * Allow an object to define how the key getter are called.
722: *
723: * @param string $key The key to get the getter from.
724: * @return string The getter method name, for a given key.
725: */
726: protected function getter($key)
727: {
728: $getter = $key;
729: return $this->camelize($getter);
730: }
731:
732: /**
733: * Allow an object to define how the key setter are called.
734: *
735: * @param string $key The key to get the setter from.
736: * @return string The setter method name, for a given key.
737: */
738: protected function setter($key)
739: {
740: $setter = 'set_'.$key;
741: return $this->camelize($setter);
742: }
743:
744: /**
745: * Transform a snake_case string to camelCase.
746: *
747: * @param string $str The snake_case string to camelize.
748: * @return string The camelcase'd string.
749: */
750: protected function camelize($str)
751: {
752: return lcfirst(implode('', array_map('ucfirst', explode('_', $str))));
753: }
754: }
755: