*/ private array $rows = []; /** * Nested eager-loaded data which applies to relations on records contained in this list * @var array */ private array $eagerLoadedData = []; private array $extraFields = []; private array $limitOffset = [null, 0]; private string|array $sort = []; /** * Stored here so we can use it when constructing new lists based on this one */ private array $manyManyComponent = []; public function __construct(string $dataClass, string $dataListClass, int|array|null $foreignID = null, array $manyManyComponent = []) { if (!is_a($dataListClass, DataList::class, true)) { throw new LogicException('$dataListClass must be an instanceof DataList'); } // relation lists require a valid foreignID or set of IDs if (is_a($dataListClass, RelationList::class, true) && !$this->isValidForeignID($foreignID)) { throw new InvalidArgumentException('$foreignID must be a valid ID for eager loaded relation lists'); } $this->dataClass = $dataClass; $this->foreignID = $foreignID; $this->manyManyComponent = $manyManyComponent; // many_many relation lists have extra constructor args that don't apply for has_many or non-relations if (is_a($dataListClass, ManyManyThroughList::class, true)) { $this->dataList = ManyManyThroughList::create( $dataClass, // If someone instantiates one of these and passes DataObjectSchema::manyManyComponent() directly // the class will be in here as 'join' $manyManyComponent['joinClass'] ?? $manyManyComponent['join'], $manyManyComponent['childField'], $manyManyComponent['parentField'], $manyManyComponent['extraFields'], $dataClass, $manyManyComponent['parentClass'] ); } elseif (is_a($dataListClass, ManyManyList::class, true)) { $this->dataList = ManyManyList::create( $dataClass, $manyManyComponent['join'], $manyManyComponent['childField'], $manyManyComponent['parentField'], $manyManyComponent['extraFields'] ); } else { $this->dataList = $dataListClass::create($dataClass, ''); } if (isset($manyManyComponent['extraFields'])) { $this->extraFields = $manyManyComponent['extraFields']; } } /** * Returns true if the variable passed in is valid for use in $this->dataList->forForeignID() on relation lists */ private function isValidForeignID(int|array|null $foreignID): bool { // For an array, only return true if the array contains only integers and isn't empty if (is_array($foreignID)) { if (empty($foreignID)) { return false; } foreach ($foreignID as $id) { if (!is_int($id)) { return false; } } return true; } // ID must be a valid ID int return $foreignID !== null && $foreignID >= 1; } /** * Pass in any eager-loaded data which applies to relations on a specific record in this list * * @return $this */ public function addEagerLoadedData(string $relation, int $id, self|DataObject $data): static { $this->eagerLoadedData[$id][$relation] = $data; return $this; } /** * Get the dataClass name for this list, ie the DataObject ClassName */ public function dataClass(): string { return $this->dataClass; } public function dbObject($fieldName): ?DBField { return singleton($this->dataClass)->dbObject($fieldName); } public function getIDList(): array { $ids = $this->column('ID'); return array_combine($ids, $ids); } /** * Sets the ComponentSet to be the given ID list * @throws BadMethodCallException */ public function setByIDList($idList): void { throw new BadMethodCallException("Can't set the ComponentSet on an EagerLoadedList"); } /** * Returns a copy of this list with the relationship linked to the given foreign ID * @throws BadMethodCallException */ public function forForeignID($id): void { throw new BadMethodCallException("Can't change the foreign ID for an EagerLoadedList"); } public function getIterator(): Traversable { $limitedRows = $this->getFinalisedRows(); foreach ($limitedRows as $row) { yield $this->createDataObject($row); } } /** * Get the raw data rows for the records in this list. * Doesn't include nested eagerloaded data. */ public function getRows(): array { return array_values($this->rows); } public function toArray(): array { $result = []; foreach ($this as $item) { $result[] = $item; } return $result; } public function toNestedArray(): array { $result = []; foreach ($this as $item) { $result[] = $item->toMap(); } return $result; } /** * Add a single row of database data. * * @throws InvalidArgumentException if there is no ID in $row */ public function addRow(array $row): static { if (!array_key_exists('ID', $row) || $row['ID'] === null || $row['ID'] === '' || is_array($row['ID'])) { throw new InvalidArgumentException('$row must have a valid ID'); } $this->rows[$row['ID']] = $row; return $this; } /** * Add multiple rows of database data. * * @throws InvalidArgumentException if any row is missing an ID */ public function addRows(array $rows): static { foreach ($rows as $row) { $this->addRow($row); } return $this; } /** * Not implemented - use addRow instead. */ public function add($item) { throw new BadMethodCallException('Cannot add a DataObject record to EagerLoadedList. Use addRow() to add database rows.'); } /** * Removes a record from the list. Note that the record will not be removed from the * database - this list is read-only. */ public function remove($item): static { $id = $item->ID; if (array_key_exists($id, $this->rows)) { unset($this->rows[$id]); } return $this; } public function first(): ?DataObject { $rows = $this->getFinalisedRows(); if (count($rows) === 0) { return null; } return $this->byID(array_key_first($rows)); } public function last(): ?DataObject { $rows = $this->getFinalisedRows(); if (count($rows) === 0) { return null; } return $this->byID(array_key_last($rows)); } public function map($keyField = 'ID', $titleField = 'Title'): Map { return new Map($this, $keyField, $titleField); } public function column($colName = 'ID'): array { $rows = $this->getFinalisedRows(); if (count($rows) === 0) { return []; } if ($colName === 'id') { return array_keys($rows); } // Don't allow non-existent columns - see DataQuery::column() $id = array_key_first($rows); if (!array_key_exists($colName, $rows[$id])) { throw new InvalidArgumentException('Invalid column name ' . $colName); } return array_column($rows, $colName); } /** * Returns a unique array of a single field value for all the items in the list * * @param string $colName */ public function columnUnique($colName = 'ID'): array { return array_unique($this->column($colName)); } public function each($callback): static { foreach ($this as $row) { $callback($row); } return $this; } public function debug() { // Same implementation as DataList::debug() $val = '

' . static::class . '

'; return $val; } /** * Returns whether an item with offset $key exists */ public function offsetExists(mixed $key): bool { $count = $this->count(); if (!is_int($key) || $count === 0 || $key >= $count) { return false; } if ($key < 0) { throw new InvalidArgumentException('$key can not be negative. -1 was provided.'); } return true; } /** * Returns item stored in list with offset $key */ public function offsetGet(mixed $key): ?DataObject { if (!is_int($key)) { return null; } return $this->limit(1, $key)->first(); } /** * Set an item with the key in $key * @throws BadMethodCallException */ public function offsetSet(mixed $key, mixed $value): void { // Throw exception for compatability with DataList throw new BadMethodCallException("Can't alter items in an EagerLoadedList using array-access"); } /** * Unset an item with the key in $key * @throws BadMethodCallException */ public function offsetUnset(mixed $key): void { // Throw exception for compatability with DataList throw new BadMethodCallException("Can't alter items in an EagerLoadedList using array-access"); } public function count(): int { return count($this->getFinalisedRows()); } /** * Return the maximum value of the given field in this list * * @param string $fieldName */ public function max($fieldName): mixed { return max($this->column($fieldName)); } /** * Return the minimum value of the given field in this list * * @param string $fieldName */ public function min($fieldName): mixed { return min($this->column($fieldName)); } /** * Return the average value of the given field in this list * * @param string $fieldName */ public function avg($fieldName): mixed { // We have to rely on the database to either give us the right answer or throw the right exception. // MySQL does wiether things with sum, e.g. an average for the Text field "1_1" is 1 - but // other database implementations could behave differently. $list = $this->foreignID ? $this->dataList->forForeignID($this->foreignID) : $this->dataList; return $list->byIDs($this->column('ID'))->avg($fieldName); } /** * Return the sum of the values of the given field in this list * * @param string $fieldName */ public function sum($fieldName): int|float { // We have to rely on the database to either give us the right answer or throw the right exception. // MySQL does wiether things with sum, e.g. a sum for the Text field "1_1" is 1 - but // other database implementations could behave differently. $list = $this->foreignID ? $this->dataList->forForeignID($this->foreignID) : $this->dataList; return $list->byIDs($this->column('ID'))->sum($fieldName); } /** * Returns true if this list has items */ public function exists(): bool { return $this->count() !== 0; } public function canFilterBy($fieldName): bool { if (!is_string($fieldName) || empty($this->rows)) { return false; } $id = array_key_first($this->rows); return array_key_exists($fieldName, $this->rows[$id]); } public function canSortBy($fieldName): bool { return $this->canFilterBy($fieldName); } public function find($key, $value): ?DataObject { return $this->filter($key, $value)->first(); } public function filter(...$args): static { $filters = $this->normaliseFilterArgs($args, __FUNCTION__); $list = clone $this; $list->rows = $this->getMatches($filters); return $list; } public function filterAny(...$args): static { $filters = $this->normaliseFilterArgs($args, __FUNCTION__); $list = clone $this; $list->rows = $this->getMatches($filters, true); return $list; } public function exclude(...$args): static { $filters = $this->normaliseFilterArgs($args, __FUNCTION__); $toRemove = $this->getMatches($filters); $list = clone $this; foreach ($toRemove as $id => $row) { unset($list->rows[$id]); } return $list; } /** * Return a copy of this list which does not contain any items with any of these params */ public function excludeAny(...$args): static { $filters = $this->normaliseFilterArgs($args, __FUNCTION__); $toRemove = $this->getMatches($filters, true); $list = clone $this; foreach ($toRemove as $id => $row) { unset($list->rows[$id]); } return $list; } /** * Return a new instance of the list with an added filter * * @param array $filterArray */ public function addFilter($filterArray): static { $list = clone $this; $list->rows = $this->getMatches($filterArray); return $list; } /** * This method returns a copy of this list that does not contain any DataObjects that exists in $list * * The $list passed needs to contain the same dataclass as $this * * @throws InvalidArgumentException */ public function subtract(DataList $list): static { if ($this->dataClass() != $list->dataClass()) { throw new InvalidArgumentException('The list passed must have the same dataclass as this class'); } return $this->exclude('ID', $list->column('ID')); } /** * Validate and process arguments - see DataList::filter(), DataList::exclude(), etc. */ private function normaliseFilterArgs(array $arguments, string $function): array { switch (count($arguments)) { case 1: $filter = $arguments[0]; break; case 2: $filter = [$arguments[0] => $arguments[1]]; break; default: throw new InvalidArgumentException("Incorrect number of arguments passed to $function"); } foreach (array_keys($filter) as $column) { if (!$this->canFilterBy(explode(':', $column)[0])) { throw new InvalidArgumentException("Can't filter by column '$column'"); } } return $filter; } /** * Get all rows which match the given filters. * If $any is false, all filters in the $filters array must match. * If $any is true, ANY filter in the $filters array must match. */ private function getMatches($filters, bool $any = false): array { $matches = []; $searchFilters = []; foreach ($filters as $filterKey => $filterValue) { $searchFilters[$filterKey] = $this->createSearchFilter($filterKey, $filterValue); } foreach ($this->rows as $id => $row) { $doesMatch = true; foreach ($filters as $column => $value) { // Throw exception for empty $value arrays to match ExactMatchFilter::manyFilter if (is_array($value) && empty($value)) { throw new InvalidArgumentException("Cannot filter $column against an empty set"); } /** @var SearchFilter $searchFilter */ $searchFilter = $searchFilters[$column]; $extractedValue = $this->extractValue($row, $this->standardiseColumn($searchFilter->getFullName())); $doesMatch = $searchFilter->matches($extractedValue); if (!$any && !$doesMatch) { $doesMatch = false; break; } if ($any && $doesMatch) { break; } } if ($doesMatch) { $matches[$id] = $row; } } return $matches; } /** * Extracts a value from an item in the list, where the item is either an * object or array. * * @param string $key They key for the value to be extracted. Implied mixed type * for compatability with DataList. */ private function extractValue(array $row, $key): mixed { if (array_key_exists($key, $row)) { return $row[$key]; } return null; } public function filterByCallback($callback): ArrayList { if (!is_callable($callback)) { throw new LogicException(sprintf( "SS_Filterable::filterByCallback() passed callback must be callable, '%s' given", gettype($callback) )); } $output = ArrayList::create(); foreach ($this as $item) { if (call_user_func($callback, $item, $this)) { $output->push($item); } } return $output; } public function byID($id): ?DataObject { $rows = $this->getFinalisedRows(); if (!array_key_exists($id, $rows)) { return null; } return $this->createDataObject($rows[$id]); } public function byIDs($ids): static { $list = clone $this; $ids = array_map('intval', (array) $ids); $list->rows = ArrayLib::filter_keys($list->rows, $ids); return $list; } public function sort(...$args): static { $count = count($args); if ($count == 0) { return $this; } if ($count > 2) { throw new InvalidArgumentException('This method takes zero, one or two arguments'); } if ($count == 2) { list($column, $direction) = $args; $sort = [$this->standardiseColumn($column) => $direction]; } else { $sort = $args[0]; if (!is_string($sort) && !is_array($sort) && !is_null($sort)) { throw new InvalidArgumentException('sort() arguments must either be a string, an array, or null'); } if (is_null($sort)) { // Setting sort to null means we just use the default sort order. $list = clone $this; $list->sort = []; return $list; } elseif (empty($sort)) { throw new InvalidArgumentException('Invalid sort parameter'); } // If $sort is string then convert string to array to allow for validation if (is_string($sort)) { $newSort = []; // Making the assumption here there are no commas in column names // Other parts of silverstripe will break if there are commas in column names foreach (explode(',', $sort) as $colDir) { // Using regex instead of explode(' ') in case column name includes spaces if (preg_match('/^(.+) ([^"]+)$/i', trim($colDir), $matches)) { list($column, $direction) = [$matches[1], $matches[2]]; } else { list($column, $direction) = [$colDir, 'ASC']; } $newSort[$this->standardiseColumn($column)] = $direction; } $sort = $newSort; } } foreach ($sort as $column => $direction) { // validate and normalise sort column $this->validateSortColumn($column); // validate sort direction if (!in_array(strtolower($direction), ['asc', 'desc'])) { throw new InvalidArgumentException("Invalid sort direction $direction"); } } $list = clone $this; $list->sort = $sort; return $list; } /** * Shuffle the items in this list */ public function shuffle(): static { $list = clone $this; $list->sort = 'shuffle'; return $list; } private function standardiseColumn(string $column): string { // Strip whitespace and double quotes from single field names e.g. '"Title"' $column = trim($column); if (preg_match('#^"[^"]+"$#', $column)) { $column = str_replace('"', '', $column); } return $column; } private function validateSortColumn(string $column): void { $columnName = $column; if (preg_match('/^[A-Z0-9\._]+$/i', $column ?? '')) { $relations = explode('.', $column ?? ''); $fieldName = array_pop($relations); $relationModelClass = $this->dataClass(); foreach ($relations as $relation) { $prevModelClass = $relationModelClass; /** @var DataObject $singleton */ $singleton = singleton($relationModelClass); $relationModelClass = $singleton->getRelationClass($relation); // See DataQuery::applyRelation() which is called indirectly from DataList::validateSortColumn() // for context on these exceptions. if ($relationModelClass === null) { throw new InvalidArgumentException("$relation is not a relation on model $prevModelClass"); } if (in_array($singleton->getRelationType($relation), ['has_many', 'many_many', 'belongs_many_many'])) { throw new InvalidArgumentException("$relation is not a linear relation on model $prevModelClass"); } } if (strpos($column, '.') === false) { if (!singleton($relationModelClass)->hasDatabaseField($column)) { throw new DatabaseException("Unknown column \"$column\""); } $columnName = '"' . $column . '"'; } else { // Find the db field the relation belongs to - It will be returned in quoted SQL "TableName"."ColumnName" notation // Note that sqlColumnForField() throws an expected exception if the field doesn't exist on the relation $relationPrefix = DataQuery::applyRelationPrefix($relations); $columnName = DataObject::getSchema()->sqlColumnForField($relationModelClass, $fieldName, $relationPrefix); } // All of the above is necessary to ensure the expected exceptions are thrown for invalid relations // But we still need to ultimately throw an exception here, because sorting by relations isn't // currently supported at all for this class. if (!empty($relations)) { throw new InvalidArgumentException('Cannot sort by relations on EagerLoadedList'); } } // If $columnName is equal to $col it means that it was orginally raw sql or otherwise invalid. if ($columnName === $column) { throw new InvalidArgumentException("Invalid sort column $column"); } } public function reverse(): static { // No-op if we're gonna shuffle the list anyway if ($this->sort === 'shuffle') { return $this; } // Set the sort order for each clause to be reversed // This is how DataList reverses its list order as well $list = clone $this; foreach ($list->sort as $clause => &$dir) { $dir = (strtoupper($dir) == 'DESC') ? 'ASC' : 'DESC'; } return $list; } public function limit(?int $length, int $offset = 0): static { if ($length !== null && $length < 0) { throw new InvalidArgumentException("\$length can not be negative. $length was provided."); } if ($offset < 0) { throw new InvalidArgumentException("\$offset can not be negative. $offset was provided."); } // We don't actually apply the limit immediately, for compatability with the way it works in DataList $list = clone $this; $list->limitOffset = [$length, $offset]; return $list; } /** * Check if this list has an item with the given ID */ public function hasID(int $id): bool { return array_key_exists($id, $this->getFinalisedRows()); } public function relation($relationName): ?Relation { $ids = $this->column('ID'); $prototypicalList = null; // If we've already got that data loaded, don't trigger a new DB query $relations = []; foreach ($ids as $id) { if (!isset($this->eagerLoadedData[$id][$relationName])) { continue; } $data = $this->eagerLoadedData[$id][$relationName]; if (!($data instanceof self)) { // There's no clean way to get the rows back out of DataObject records, // and if it's not a DataObject then we don't know how to handle it, // so fall back to a new DB query break; } $prototypicalList = $data; $relations = array_merge($relations, $data->getRows()); } if (!empty($relations)) { $relation = EagerLoadedList::create( $prototypicalList->dataClass(), get_class($prototypicalList->dataList), $ids, $prototypicalList->manyManyComponent ); $relation->addRows($relations); return $relation; } // Trigger a new DB query if needed - see DataList::relation() $singleton = DataObject::singleton($this->dataClass); $relation = $singleton->$relationName($ids); return $relation; } /** * Create a DataObject from the given SQL row. * At a minimum, $row['ID'] must be set. Unsaved records cannot be eager loaded. * * @param array $row */ public function createDataObject($row): DataObject { if (!array_key_exists('ID', $row)) { throw new InvalidArgumentException('$row must have an ID'); } $record = $this->dataList->createDataObject($row); $this->setDataObjectEagerLoadedData($row['ID'], $record); return $record; } /** * Find the extra field data for a single row of the relationship join * table for many_many relations, given the known child ID. * * @param string $componentName The name of the component (unused, but kept for compatability with ManyManyList) * @param int|string $itemID The ID of the child for the relationship * * @return array Map of fieldName => fieldValue * @throws BadMethodCallException if the relation type for this list is not many_many * @throws InvalidArgumentException if $itemID is not numeric */ public function getExtraData($componentName, int|string $itemID): array { if (!($this->dataList instanceof ManyManyList) && !($this->dataList instanceof ManyManyThroughList)) { throw new BadMethodCallException('Cannot have extra fields on this list type'); } // Allow string IDs for compatability with ManyManyList if (!is_numeric($itemID)) { throw new InvalidArgumentException('$itemID must be an integer or numeric string'); } $itemID = (int)$itemID; $rows = $this->getFinalisedRows(); // Skip if no extrafields or record not in this list if (empty($this->extraFields) || !array_key_exists($itemID, $rows)) { return []; } $result = []; foreach ($this->extraFields as $fieldName => $spec) { $row = $rows[$itemID]; if (array_key_exists($fieldName, $row)) { $result[$fieldName] = $row[$fieldName]; } else { $result[$fieldName] = null; } } return $result; } /** * Gets the extra fields included in the relationship. * * @return array a map of field names to types * @throws BadMethodCallException if the relation type for this list is not many_many */ public function getExtraFields(): array { if (!($this->dataList instanceof ManyManyList) && !($this->dataList instanceof ManyManyThroughList)) { throw new BadMethodCallException('Cannot have extra fields on this list type'); } return $this->extraFields; } private function setDataObjectEagerLoadedData(int $id, DataObject $item): void { if (array_key_exists($id, $this->eagerLoadedData)) { foreach ($this->eagerLoadedData[$id] as $relation => $data) { $item->setEagerLoadedData($relation, $data); } } } /** * Gets the final rows for this list after applying all transformations. * Currently only limit is applied lazily, but others could be done this was as well. */ private function getFinalisedRows(): array { return $this->doLimit($this->doSort($this->rows)); } private function doLimit(array $rows): array { list($length, $offset) = $this->limitOffset; // If the limit is 0, return an empty list. if ($length === 0) { return []; } return array_slice($rows, $offset, $length, true); } private function doSort(array $rows): array { // Do nothing if there's no defined sort order. if (empty($this->sort)) { return $rows; } if ($this->sort === 'shuffle') { ArrayLib::shuffleAssociative($rows); return $rows; } uasort($rows, function (array $row, array $other): int { $compared = 0; foreach ($this->sort as $column => $direction) { $rowValue = $this->extractValue($row, $column); $otherValue = $this->extractValue($other, $column); // We need to treat numbers differently than numeric strings to match database behaviour if ($this->isNumericNotString($rowValue) && $this->isNumericNotString($otherValue)) { $compared = $rowValue <=> $otherValue; } else { $compared = strcasecmp($rowValue ?? '', $otherValue ?? ''); } if ($compared !== 0) { // Reverse the direction for desc; i.e. -1 becomes 1 and 1 becomes -1 if (strtolower($direction) === 'desc') { $compared *= -1; } // If the comparison clearly marks an order, we don't need to check the remaining columns. break; } } return $compared; }); return $rows; } private function isNumericNotString(mixed $value): bool { return is_numeric($value) && !is_string($value); } }