src/menu/Query.php
<?php
namespace luya\cms\menu;
use luya\admin\models\TagRelation;
use luya\cms\Exception;
use luya\cms\Menu;
use luya\cms\models\Nav;
use luya\helpers\ArrayHelper;
use Yii;
use yii\base\BaseObject;
/**
* Menu Query Builder.
*
* Ability to create menu query condition similar behavior, changing the language container and define with
* specification to fit your needs.
*
* Basic example of making a menu selection:
*
* ```php
* $items = (new \luya\cms\menu\Query())->where([self::FIELD_PARENTNAVID => 0])->all();
* ```
*
* By default the Menu Query will get the default language, or the current active language. To force
* a specific language use the `lang()` method in your query chain:
*
* ```php
* $items = (new \luya\cms\menu\Query())->where([self::FIELD_PARENTNAVID => 0])->lang('en')->all();
* ```
*
* You can also find one element instead of all
*
* ```php
* $item = (new \luya\cms\menu\Query())->where([self::ID => 1])->one();
* ```
*
* To include hidden pages to your selection use with:
*
* ```php
* $items = (new \luya\cms\menu\Query())->where([self::FIELD_PARENTNAVID => 0])->with(['hidden'])->all();
* ```
*
* Attention: When you append the `with['hidden']` state, the visibility of the item will be overriden, even when you
* change them with event inject. So take care of using with hidden when protecting items for beeing seen by guest users
* (in example of protected several items for not logged in users).
*
* @property \luya\cms\Menu $menu Application menu component object.
*
* @author Basil Suter <basil@nadar.io>
* @since 1.0.0
*/
class Query extends BaseObject implements QueryOperatorFieldInterface
{
/**
* @var array An array with all available where operators.
*/
protected $whereOperators = ['<', '<=', '>', '>=', '=', '!=', '==', 'in'];
private $_menu;
/**
* Getter method to return menu component
*
* @return \luya\cms\Menu Menu Container object
*/
public function getMenu()
{
if ($this->_menu === null) {
$this->_menu = Yii::$app->get('menu');
}
return $this->_menu;
}
/**
* Setter method for menu Container.
*/
public function setMenu(Menu $menu)
{
$this->_menu = $menu;
}
/**
* Helper method to retrieve only the root elements for a given query.
*
* @return \luya\cms\menu\Query
*/
public function root()
{
return $this->where([self::FIELD_PARENTNAVID => 0]);
}
/**
* Helper method to define the container to retrieve all elements from.
*
* @param string $alias The alias name from a given container to retrieve items from.
* @return \luya\cms\menu\Query
*/
public function container($alias)
{
return $this->where([self::FIELD_CONTAINER => $alias]);
}
private array $_where = [];
/**
* Query where similar behavior of filtering items.
*
* **Key Value Filtering**
*
* When using key value where condition, the operator equal (`=`) will be used by default.
*
* ```php
* where(['field' => 'value'])
* ```
*
* which is equals to in operator mode:
*
* ```php
* where(['=', 'field', 'value']);
* ```
*
* Its also possible to have multiple AND where conditions with equal (`=`) operator:
*
* ``php
* where(['field' => 'value', 'anotherfield' => 'anothervalue']);
* ```
*
* **Operator Filtering**
*
* ```php
* where(['operator', 'field', 'value']);
* ```
*
* Available compare operators:
* + **<** expression where field is smaller then value.
* + **>** expression where field is bigger then value.
* + **=** expression where field is equal value.
* + **<=** expression where field is small or equal then value.
* + **>=** expression where field is bigger or equal then value.
* + **==** expression where field is equal to the value and even the type must be equal.
* + **in** expression where the second value is an array with values to look inside.
*
* Only one operator speific argument can be provided, to chain another expression
* use the `andWhere()` method.
*
* **Multi Dimension Filtering**
*
* The most common case for filtering items is the equal expression combined with
* add statements.
*
* For example the following expression
*
* ```php
* where(['=', 'parent_nav_id', 0])->andWhere(['=', 'container', 'footer']);
* ```
*
* is equal to the short form multi deimnsion filtering expression
*
* ```php
* where(['parent_nav_id' => 0, 'container' => 'footer']);
* ```
*
* Its **not possibile** to make where conditions on the **same column name** (id in this example).
*
* ```php
* where(['>', 'id', 1])->andWHere(['<', 'id', 3]);
* ```
*
* This will only append the first condition where id is bigger then 1 and ignore the second one.
*
* Example using in operator
*
* ```php
* where(['in', 'container', ['default', 'footer']); // querys all items from the containers `default` and `footer`.
* ```
*
* @param array $args The where defintion can be either an key-value pairing or a condition representen as array.
* @return Query
* @throws Exception
*/
public function where(array $args)
{
if (ArrayHelper::isAssociative($args, false)) {
// ensure: ['container' => 'default', 'parent_nav_id' => 0] is possible
foreach ($args as $key => $value) {
$this->_where[] = ['op' => '=', 'field' => $key, 'value' => $value];
}
} else {
if (count($args) !== 3) {
throw new Exception("Where operator format requires at least 3 elements. [operator, attribute, value]");
}
if (!in_array($args[0], $this->whereOperators, true)) {
throw new Exception(sprintf("The given where operator '%s' does not exists. https://luya.io/api/luya-cms-menu-Query#where()-detail for all available conditions.", $args[0]));
}
$this->_where[] = ['op' => $args[0], 'field' => $args[1], 'value' => $args[2]];
}
return $this;
}
/**
* Add another where statement to the existing, this is the case when using compare operators, as then only
* one where definition can bet set.
*
* @see {{Query::where()}}
* @return \luya\cms\menu\Query
*/
public function andWhere(array $args)
{
return $this->where($args);
}
private $_lang;
/**
* Changeing the container in where the data should be collection, by default the composition
* `langShortCode` is the default language code. This represents the current active language,
* or the default language if no information is presented.
*
* @param string $langShortCode Language Short Code e.g. de or en
* @return \luya\cms\menu\Query
*/
public function lang($langShortCode)
{
$this->_lang = $langShortCode;
return $this;
}
private array $_with = ['hidden' => false];
/**
* With/Without expression to hidde or display data from the Menu Query.
*
* @param string|array $types can be a string containg "hidden" or an array with multiple with statements
* for example `['hidden']`.
* @return \luya\cms\menu\Query
*/
public function with(string|array $types)
{
$types = (array) $types;
foreach ($types as $type) {
if (isset($this->_with[$type])) {
$this->_with[$type] = true;
}
}
return $this;
}
private bool $_preloadModels = false;
/**
* Preload models for the given Menu Query.
*
* When menu item method {{luya\cms\menu\Item::getModel()}} is called, it will lazy load the given {{luya\cms\models\Nav}} model.
* This can be slow on large menus, therfore you can preload those models for the given Menu Query by enabling this method.
*
* @param boolean $preloadModels Whether to preload all {{luya\cms\menu\Item}} models for {{luya\cms\menu\Item::getModel()}} or not.
* @return \luya\cms\menu\Query
*/
public function preloadModels($preloadModels = true)
{
$this->_preloadModels = $preloadModels;
return $this;
}
/**
* Return the current language from composition if not set via `lang()`.
*
* @return string
*/
public function getLang()
{
if ($this->_lang === null) {
$this->_lang = $this->menu->composition['langShortCode'];
}
return $this->_lang;
}
private $_limit;
/**
* Set a limition for the amount of results.
*
* @param integer $count The number of rows to return
* @return \luya\cms\menu\Query
*/
public function limit($count)
{
if (is_numeric($count)) {
$this->_limit = $count;
}
return $this;
}
private $_offset;
/**
* Define offset start for the rows, if you defined offset to be 5 and you have 11 rows, the
* first 5 rows will be skiped. This is commonly used to make pagination function in combination
* with the limit() function.
*
* @param integer $offset Defines the amount of offset start position.
* @return Query
*/
public function offset($offset)
{
if (is_numeric($offset)) {
$this->_offset = $offset;
}
return $this;
}
private $_order;
/**
* Order the query by one or multiple fields asc or desc.
*
* Use following PHP constants for directions:
*
* + SORT_ASC: 1..10, A..Z
* + SORT_DESC: 10..1, Z..A
*
* Example using orderBy:
*
* ```php
* $query = new Query()->orderBy([Query::FIELD_TIMESTAMPCREATE => SORT_ASC, Query::FIELD_ALIAS => SORT_DESC'])->all();
* ```
*
* @param array $order An array with fields to sort where key is the field and value the direction.
* @return Query
* @since 1.0.2
*/
public function orderBy(array $order)
{
$orderBy = ['keys' => [], 'directions' => []];
foreach ($order as $key => $direction) {
$orderBy['keys'][] = $key;
$orderBy['directions'][] = $direction;
}
$this->_order = $orderBy;
return $this;
}
/**
* Filter by Tag IDs.
*
* An example of how to filter a menu based on tag ids:
*
* ```php
* foreach (Yii::$app->menu->find()->container('default')->tags([1,2])->limit(3)->al() as $item) {
* echo $item->title;
* }
* ```
*
* Returns all pages in the default container with tag ids 1 & 2 limited by 3 entries.
*
* @param string|array $tags This can be either a string with a tag id or an array with tag ids.
* @return Query
* @since 2.2.0
*/
public function tags(string|array $tags)
{
$ids = TagRelation::find()
->select(['pk_id'])
->where([
'and',
['=', 'table_name', Nav::tableName()],
['in', 'tag_id', (array) $tags]
])
->column();
return $this->where(['in', self::FIELD_NAVID, $ids]);
}
/**
* Retrieve only one result for your query, even if there are more rows then one, it will
* just pick the first row from the filtered result and return the item object. If the filtering
* based on the query settings does not return any result, the return will be false.
*
* @return \luya\cms\menu\Item|boolean Returns the Item object or false if nothing found.
*/
public function one(): \luya\cms\menu\Item|bool
{
$data = $this->filter($this->menu[$this->getLang()], $this->_where, $this->_with);
if (count($data) == 0) {
return false;
}
return static::createItemObject(array_values($data)[0], $this->getLang());
}
/**
* Retrieve all found rows based on the filtering options and returns the the QueryIterator object
* which is represents an array.
*
* @return \luya\cms\menu\QueryIteratorFilter Returns the QueryIterator object.
*/
public function all()
{
return static::createArrayIterator($this->filter($this->menu[$this->getLang()], $this->_where, $this->_with), $this->getLang(), $this->_with, $this->_preloadModels);
}
/**
* Returns the count for the provided filter options.
*
* @return integer The number of rows for your filtering options.
*/
public function count()
{
return count($this->filter($this->menu[$this->getLang()], $this->_where, $this->_with));
}
/**
* Static method to create an iterator object based on the provided array data with
* optional language context.
*
* @param array $data The filtere results where the iterator object should be created with
* @param string $langContext The language short code context, if any.
* @param array $with An array with keys to include or not, f.e. `['hidden' => true]` means include hidden elements or `['hidden' => false]` means to not include hidden elements which is default.
* @param boolean $preloadModels Whether the models should be preload or not.
* @return \luya\cms\menu\QueryIterator
*/
public static function createArrayIterator(array $data, $langContext, array $with = [], $preloadModels = false)
{
return (new QueryIteratorFilter(new QueryIterator(['data' => $data, 'lang' => $langContext, 'with' => $with, 'preloadModels' => $preloadModels])));
}
/**
* Static method to create the item object itself, is used for the one() method and in the current() method
* of the QueryIterator class.
*
* @param array $itemArray The item array data for the object
* @param string $langContext The language short code context, if any.
* @param null|\luya\cms\models\Nav The nav model from the preload stage.
* @return \luya\cms\menu\Item
*/
public static function createItemObject(array $itemArray, $langContext, $model = null)
{
return new Item(['itemArray' => $itemArray, 'lang' => $langContext, 'model' => $model]);
}
/**
* Filtering data based on a where expression.
*
* @param array $containerData The data to filter from
* @param array $whereExpression An array with `[['op' => '=', 'field' => 'fieldName', 'value' => 'comparevalue'],[]]`
* @param array $withCondition An array with with conditions `$with['hidden']`.
* @return array
*/
private function filter(array $containerData, array $whereExpression, array $withCondition)
{
$data = array_filter($containerData, function ($item) use ($whereExpression, $withCondition) {
foreach ($item as $field => $value) {
if (!$this->arrayFilter($value, $field, $whereExpression, $withCondition)) {
return false;
}
}
return true;
});
if ($this->_order !== null) {
ArrayHelper::multisort($data, $this->_order['keys'], $this->_order['directions']);
}
if ($this->_offset !== null) {
$data = array_slice($data, $this->_offset, null, true);
}
if ($this->_limit !== null) {
$data = array_slice($data, 0, $this->_limit, true);
}
return $data;
}
/**
* Filter an array item based on the where expression.
*
* @param string $value
* @param string $field
* @return boolean
*/
private function arrayFilter($value, $field, array $where, array $with)
{
if ($field == 'is_hidden' && $with['hidden'] === false && $value == 1) {
return false;
}
foreach ($where as $expression) {
if ($expression['field'] == $field) {
return match ($expression['op']) {
'==' => $value === $expression['value'],
'>' => $value > $expression['value'],
'>=' => $value >= $expression['value'],
'<' => $value < $expression['value'],
'<=' => $value <= $expression['value'],
'in' => in_array($value, $expression['value']),
'!=' => $value != $expression['value'],
default => $value == $expression['value'],
};
}
}
return true;
}
}