<?php
/**
* @package Grav\Framework\Flex
*
* @copyright Copyright (c) 2015 - 2025 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Framework\Flex;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\Criteria;
use Grav\Common\Debugger;
use Grav\Common\Grav;
use Grav\Common\Inflector;
use Grav\Common\Twig\Twig;
use Grav\Common\User\Interfaces\UserInterface;
use Grav\Common\Utils;
use Grav\Framework\Cache\CacheInterface;
use Grav\Framework\ContentBlock\HtmlBlock;
use Grav\Framework\Flex\Interfaces\FlexIndexInterface;
use Grav\Framework\Flex\Interfaces\FlexObjectInterface;
use Grav\Framework\Object\ObjectCollection;
use Grav\Framework\Flex\Interfaces\FlexCollectionInterface;
use Psr\SimpleCache\InvalidArgumentException;
use RocketTheme\Toolbox\Event\Event;
use Twig\Error\LoaderError;
use Twig\Error\SyntaxError;
use Twig\Template;
use Twig\TemplateWrapper;
use function array_filter;
use function get_class;
use function in_array;
use function is_array;
use function is_scalar;
/**
* Class FlexCollection
* @package Grav\Framework\Flex
* @template T of FlexObjectInterface
* @extends ObjectCollection<string,T>
* @implements FlexCollectionInterface<T>
*/
class FlexCollection extends ObjectCollection implements FlexCollectionInterface
{
/** @var FlexDirectory */
private $_flexDirectory;
/** @var string */
private $_keyField = 'storage_key';
/**
* Get list of cached methods.
*
* @return array Returns a list of methods with their caching information.
*/
public static function getCachedMethods(): array
{
return [
'getTypePrefix' => true,
'getType' => true,
'getFlexDirectory' => true,
'hasFlexFeature' => true,
'getFlexFeatures' => true,
'getCacheKey' => true,
'getCacheChecksum' => false,
'getTimestamp' => true,
'hasProperty' => true,
'getProperty' => true,
'hasNestedProperty' => true,
'getNestedProperty' => true,
'orderBy' => true,
'render' => false,
'isAuthorized' => 'session',
'search' => true,
'sort' => true,
'getDistinctValues' => true
];
}
/**
* {@inheritdoc}
* @see FlexCollectionInterface::createFromArray()
*/
public static function createFromArray(array $entries, FlexDirectory $directory, string $keyField = null)
{
$instance = new static($entries, $directory);
$instance->setKeyField($keyField);
return $instance;
}
/**
* {@inheritdoc}
* @see FlexCollectionInterface::__construct()
*/
public function __construct(array $entries = [], FlexDirectory $directory = null)
{
// @phpstan-ignore-next-line
if (get_class($this) === __CLASS__) {
user_error('Using ' . __CLASS__ . ' directly is deprecated since Grav 1.7, use \Grav\Common\Flex\Types\Generic\GenericCollection or your own class instead', E_USER_DEPRECATED);
}
parent::__construct($entries);
if ($directory) {
$this->setFlexDirectory($directory)->setKey($directory->getFlexType());
}
}
/**
* {@inheritdoc}
* @see FlexCommonInterface::hasFlexFeature()
*/
public function hasFlexFeature(string $name): bool
{
return in_array($name, $this->getFlexFeatures(), true);
}
/**
* {@inheritdoc}
* @see FlexCommonInterface::hasFlexFeature()
*/
public function getFlexFeatures(): array
{
/** @var array $implements */
$implements = class_implements($this);
$list = [];
foreach ($implements as $interface) {
if ($pos = strrpos($interface, '\\')) {
$interface = substr($interface, $pos+1);
}
$list[] = Inflector::hyphenize(str_replace('Interface', '', $interface));
}
return $list;
}
/**
* {@inheritdoc}
* @see FlexCollectionInterface::search()
*/
public function search(string $search, $properties = null, array $options = null)
{
$directory = $this->getFlexDirectory();
$properties = $directory->getSearchProperties($properties);
$options = $directory->getSearchOptions($options);
$matching = $this->call('search', [$search, $properties, $options]);
$matching = array_filter($matching);
if ($matching) {
arsort($matching, SORT_NUMERIC);
}
/** @var string[] $array */
$array = array_keys($matching);
/** @phpstan-var static<T> */
return $this->select($array);
}
/**
* {@inheritdoc}
* @see FlexCollectionInterface::sort()
*/
public function sort(array $order)
{
$criteria = Criteria::create()->orderBy($order);
/** @phpstan-var FlexCollectionInterface<T> $matching */
$matching = $this->matching($criteria);
return $matching;
}
/**
* @param array $filters
* @return static
* @phpstan-return static<T>
*/
public function filterBy(array $filters)
{
$expr = Criteria::expr();
$criteria = Criteria::create();
foreach ($filters as $key => $value) {
$criteria->andWhere($expr->eq($key, $value));
}
/** @phpstan-var static<T> */
return $this->matching($criteria);
}
/**
* {@inheritdoc}
* @see FlexCollectionInterface::getFlexType()
*/
public function getFlexType(): string
{
return $this->_flexDirectory->getFlexType();
}
/**
* {@inheritdoc}
* @see FlexCollectionInterface::getFlexDirectory()
*/
public function getFlexDirectory(): FlexDirectory
{
return $this->_flexDirectory;
}
/**
* {@inheritdoc}
* @see FlexCollectionInterface::getTimestamp()
*/
public function getTimestamp(): int
{
$timestamps = $this->getTimestamps();
return $timestamps ? max($timestamps) : time();
}
/**
* {@inheritdoc}
* @see FlexCollectionInterface::getFlexDirectory()
*/
public function getCacheKey(): string
{
return $this->getTypePrefix() . $this->getFlexType() . '.' . sha1((string)json_encode($this->call('getKey')));
}
/**
* {@inheritdoc}
* @see FlexCollectionInterface::getFlexDirectory()
*/
public function getCacheChecksum(): string
{
$list = [];
/**
* @var string $key
* @var FlexObjectInterface $object
*/
foreach ($this as $key => $object) {
$list[$key] = $object->getCacheChecksum();
}
return sha1((string)json_encode($list));
}
/**
* {@inheritdoc}
* @see FlexCollectionInterface::getFlexDirectory()
*/
public function getTimestamps(): array
{
/** @var int[] $timestamps */
$timestamps = $this->call('getTimestamp');
return $timestamps;
}
/**
* {@inheritdoc}
* @see FlexCollectionInterface::getFlexDirectory()
*/
public function getStorageKeys(): array
{
/** @var string[] $keys */
$keys = $this->call('getStorageKey');
return $keys;
}
/**
* {@inheritdoc}
* @see FlexCollectionInterface::getFlexDirectory()
*/
public function getFlexKeys(): array
{
/** @var string[] $keys */
$keys = $this->call('getFlexKey');
return $keys;
}
/**
* Get all the values in property.
*
* Supports either single scalar values or array of scalar values.
*
* @param string $property Object property to be used to make groups.
* @param string|null $separator Separator, defaults to '.'
* @return array
*/
public function getDistinctValues(string $property, string $separator = null): array
{
$list = [];
/** @var FlexObjectInterface $element */
foreach ($this->getIterator() as $element) {
$value = (array)$element->getNestedProperty($property, null, $separator);
foreach ($value as $v) {
if (is_scalar($v)) {
$t = gettype($v) . (string)$v;
$list[$t] = $v;
}
}
}
return array_values($list);
}
/**
* {@inheritdoc}
* @see FlexCollectionInterface::withKeyField()
*/
public function withKeyField(string $keyField = null)
{
$keyField = $keyField ?: 'key';
if ($keyField === $this->getKeyField()) {
return $this;
}
$entries = [];
foreach ($this as $key => $object) {
// TODO: remove hardcoded logic
if ($keyField === 'storage_key') {
$entries[$object->getStorageKey()] = $object;
} elseif ($keyField === 'flex_key') {
$entries[$object->getFlexKey()] = $object;
} elseif ($keyField === 'key') {
$entries[$object->getKey()] = $object;
}
}
return $this->createFrom($entries, $keyField);
}
/**
* {@inheritdoc}
* @see FlexCollectionInterface::getIndex()
*/
public function getIndex()
{
/** @phpstan-var FlexIndexInterface<T> */
return $this->getFlexDirectory()->getIndex($this->getKeys(), $this->getKeyField());
}
/**
* @inheritdoc}
* @see FlexCollectionInterface::getCollection()
* @return $this
*/
public function getCollection()
{
return $this;
}
/**
* {@inheritdoc}
* @see FlexCollectionInterface::render()
*/
public function render(string $layout = null, array $context = [])
{
if (!$layout) {
$config = $this->getTemplateConfig();
$layout = $config['collection']['defaults']['layout'] ?? 'default';
}
$type = $this->getFlexType();
$grav = Grav::instance();
/** @var Debugger $debugger */
$debugger = $grav['debugger'];
$debugger->startTimer('flex-collection-' . ($debugKey = uniqid($type, false)), 'Render Collection ' . $type . ' (' . $layout . ')');
$key = null;
foreach ($context as $value) {
if (!is_scalar($value)) {
$key = false;
break;
}
}
if ($key !== false) {
$key = md5($this->getCacheKey() . '.' . $layout . json_encode($context));
$cache = $this->getCache('render');
} else {
$cache = null;
}
try {
$data = $cache && $key ? $cache->get($key) : null;
$block = $data ? HtmlBlock::fromArray($data) : null;
} catch (InvalidArgumentException $e) {
$debugger->addException($e);
$block = null;
} catch (\InvalidArgumentException $e) {
$debugger->addException($e);
$block = null;
}
$checksum = $this->getCacheChecksum();
if ($block && $checksum !== $block->getChecksum()) {
$block = null;
}
if (!$block) {
$block = HtmlBlock::create($key ?: null);
$block->setChecksum($checksum);
if (!$key) {
$block->disableCache();
}
$event = new Event([
'type' => 'flex',
'directory' => $this->getFlexDirectory(),
'collection' => $this,
'layout' => &$layout,
'context' => &$context
]);
$this->triggerEvent('onRender', $event);
$output = $this->getTemplate($layout)->render(
[
'grav' => $grav,
'config' => $grav['config'],
'block' => $block,
'directory' => $this->getFlexDirectory(),
'collection' => $this,
'layout' => $layout
] + $context
);
if ($debugger->enabled() &&
!($grav['uri']->getContentType() === 'application/json' || $grav['uri']->extension() === 'json')) {
$output = "\n<!–– START {$type} collection ––>\n{$output}\n<!–– END {$type} collection ––>\n";
}
$block->setContent($output);
try {
$cache && $key && $block->isCached() && $cache->set($key, $block->toArray());
} catch (InvalidArgumentException $e) {
$debugger->addException($e);
}
}
$debugger->stopTimer('flex-collection-' . $debugKey);
return $block;
}
/**
* @param FlexDirectory $type
* @return $this
*/
public function setFlexDirectory(FlexDirectory $type)
{
$this->_flexDirectory = $type;
return $this;
}
/**
* @param string $key
* @return array
*/
public function getMetaData($key): array
{
$object = $this->get($key);
return $object instanceof FlexObjectInterface ? $object->getMetaData() : [];
}
/**
* @param string|null $namespace
* @return CacheInterface
*/
public function getCache(string $namespace = null)
{
return $this->_flexDirectory->getCache($namespace);
}
/**
* @return string
*/
public function getKeyField(): string
{
return $this->_keyField;
}
/**
* @param string $action
* @param string|null $scope
* @param UserInterface|null $user
* @return static
* @phpstan-return static<T>
*/
public function isAuthorized(string $action, string $scope = null, UserInterface $user = null)
{
$list = $this->call('isAuthorized', [$action, $scope, $user]);
$list = array_filter($list);
/** @var string[] $keys */
$keys = array_keys($list);
/** @phpstan-var static<T> */
return $this->select($keys);
}
/**
* @param string $value
* @param string $field
* @return FlexObjectInterface|null
* @phpstan-return T|null
*/
public function find($value, $field = 'id')
{
if ($value) {
foreach ($this as $element) {
if (mb_strtolower($element->getProperty($field)) === mb_strtolower($value)) {
return $element;
}
}
}
return null;
}
/**
* @return array
*/
#[\ReturnTypeWillChange]
public function jsonSerialize()
{
$elements = [];
/**
* @var string $key
* @var array|FlexObject $object
*/
foreach ($this->getElements() as $key => $object) {
$elements[$key] = is_array($object) ? $object : $object->jsonSerialize();
}
return $elements;
}
/**
* @return array
*/
#[\ReturnTypeWillChange]
public function __debugInfo()
{
return [
'type:private' => $this->getFlexType(),
'key:private' => $this->getKey(),
'objects_key:private' => $this->getKeyField(),
'objects:private' => $this->getElements()
];
}
/**
* Creates a new instance from the specified elements.
*
* This method is provided for derived classes to specify how a new
* instance should be created when constructor semantics have changed.
*
* @param array $elements Elements.
* @param string|null $keyField
* @return static
* @phpstan-return static<T>
* @throws \InvalidArgumentException
*/
protected function createFrom(array $elements, $keyField = null)
{
$collection = new static($elements, $this->_flexDirectory);
$collection->setKeyField($keyField ?: $this->_keyField);
return $collection;
}
/**
* @return string
*/
protected function getTypePrefix(): string
{
return 'c.';
}
/**
* @return array
*/
protected function getTemplateConfig(): array
{
$config = $this->getFlexDirectory()->getConfig('site.templates', []);
$defaults = array_replace($config['defaults'] ?? [], $config['collection']['defaults'] ?? []);
$config['collection']['defaults'] = $defaults;
return $config;
}
/**
* @param string $layout
* @return array
*/
protected function getTemplatePaths(string $layout): array
{
$config = $this->getTemplateConfig();
$type = $this->getFlexType();
$defaults = $config['collection']['defaults'] ?? [];
$ext = $defaults['ext'] ?? '.html.twig';
$types = array_unique(array_merge([$type], (array)($defaults['type'] ?? null)));
$paths = $config['collection']['paths'] ?? [
'flex/{TYPE}/collection/{LAYOUT}{EXT}',
'flex-objects/layouts/{TYPE}/collection/{LAYOUT}{EXT}'
];
$table = ['TYPE' => '%1$s', 'LAYOUT' => '%2$s', 'EXT' => '%3$s'];
$lookups = [];
foreach ($paths as $path) {
$path = Utils::simpleTemplate($path, $table);
foreach ($types as $type) {
$lookups[] = sprintf($path, $type, $layout, $ext);
}
}
return array_unique($lookups);
}
/**
* @param string $layout
* @return Template|TemplateWrapper
* @throws LoaderError
* @throws SyntaxError
*/
protected function getTemplate($layout)
{
$grav = Grav::instance();
/** @var Twig $twig */
$twig = $grav['twig'];
try {
return $twig->twig()->resolveTemplate($this->getTemplatePaths($layout));
} catch (LoaderError $e) {
/** @var Debugger $debugger */
$debugger = Grav::instance()['debugger'];
$debugger->addException($e);
return $twig->twig()->resolveTemplate(['flex/404.html.twig']);
}
}
/**
* @param string $type
* @return FlexDirectory
*/
protected function getRelatedDirectory($type): ?FlexDirectory
{
/** @var Flex $flex */
$flex = Grav::instance()['flex'];
return $flex->getDirectory($type);
}
/**
* @param string|null $keyField
* @return void
*/
protected function setKeyField($keyField = null): void
{
$this->_keyField = $keyField ?? 'storage_key';
}
// DEPRECATED METHODS
/**
* @param bool $prefix
* @return string
* @deprecated 1.6 Use `->getFlexType()` instead.
*/
public function getType($prefix = false)
{
user_error(__CLASS__ . '::' . __FUNCTION__ . '() is deprecated since Grav 1.6, use ->getFlexType() method instead', E_USER_DEPRECATED);
$type = $prefix ? $this->getTypePrefix() : '';
return $type . $this->getFlexType();
}
/**
* @param string $name
* @param object|null $event
* @return $this
* @deprecated 1.7, moved to \Grav\Common\Flex\Traits\FlexObjectTrait
*/
public function triggerEvent(string $name, $event = null)
{
user_error(__METHOD__ . '() is deprecated since Grav 1.7, moved to \Grav\Common\Flex\Traits\FlexObjectTrait', E_USER_DEPRECATED);
if (null === $event) {
$event = new Event([
'type' => 'flex',
'directory' => $this->getFlexDirectory(),
'collection' => $this
]);
}
if (strpos($name, 'onFlexCollection') !== 0 && strpos($name, 'on') === 0) {
$name = 'onFlexCollection' . substr($name, 2);
}
$grav = Grav::instance();
if ($event instanceof Event) {
$grav->fireEvent($name, $event);
} else {
$grav->dispatchEvent($event);
}
return $this;
}
}