artabro/wire/core/Fields.php

1496 lines
44 KiB
PHP
Raw Permalink Normal View History

2024-08-27 11:35:37 +02:00
<?php namespace ProcessWire;
/**
* ProcessWire Fields
*
* Manages collection of ALL Field instances, not specific to any particular Fieldgroup
*
* ProcessWire 3.x, Copyright 2023 by Ryan Cramer
* https://processwire.com
*
* #pw-summary Manages all custom fields in ProcessWire, independently of any Fieldgroup.
* #pw-var $fields
* #pw-body =
* Each field returned is an object of type `Field`. The $fields API variable is iterable:
* ~~~~~
* foreach($fields as $field) {
* echo "<p>Name: $field->name, Type: $field->type, Label: $field->label</p>";
* }
* ~~~~~
* #pw-body
*
* @method Field|null get($key) Get a field by name or id
* @method bool changeFieldtype(Field $field1, $keepSettings = false)
* @method bool saveFieldgroupContext(Field $field, Fieldgroup $fieldgroup, $namespace = '')
* @method bool deleteFieldDataByTemplate(Field $field, Template $template) #pw-hooker
* @method void changedType(Saveable $item, Fieldtype $fromType, Fieldtype $toType) #pw-hooker
* @method void changeTypeReady(Saveable $item, Fieldtype $fromType, Fieldtype $toType) #pw-hooker
* @method bool|Field clone(Field $item, $name = '') Clone a field and return it or return false on fail.
* @method array getTags($getFieldNames = false) Get tags for all fields (3.0.179+) #pw-advanced
* @method bool applySetupName(Field $field, $setupName = '')
*
*/
class Fields extends WireSaveableItems {
/**
* Instance of FieldsArray
*
* @var FieldsArray
*
*/
protected $fieldsArray = null;
/**
* Field names that are native/permanent to the system and thus treated differently in several instances.
*
* For example, a Field can't be given one of these names.
*
*/
static protected $nativeNamesSystem = array(
'child',
'children',
'count',
'check_access',
'created_users_id',
'created',
'createdUser',
'createdUserID',
'createdUsersID',
'data',
'description',
'editUrl',
'end',
'fieldgroup',
'fields',
'find',
'flags',
'get',
'has_parent',
'hasParent',
'httpUrl',
'id',
'include',
'isNew',
'limit',
'modified_users_id',
'modified',
'modifiedUser',
'modifiedUserID',
'modifiedUsersID',
'name',
'num_children',
'numChildren',
'parent_id',
'parent',
'parents',
'path',
'published',
'rootParent',
'siblings',
'sort',
'sortfield',
'start',
'status',
'template',
'templatePrevious',
'templates_id',
'url',
'_custom',
);
/**
* Flag names in format [ flagInt => 'flagName' ]
*
* @var array
*
*/
protected $flagNames = array();
/**
* Field names that are native/permanent to this instance of ProcessWire (configurable at runtime)
*
* Array indexes are the names and values are all boolean true.
*
*/
protected $nativeNamesLocal = array();
/**
* Cache of all tags for all fields, populated to array when asked for the first time
*
* @var array|null
*
*/
protected $tagList = null;
/**
* @var FieldsTableTools|null
*
*/
protected $tableTools = null;
/**
* @var Fieldtypes|null
*
*/
protected $fieldtypes = null;
/**
* Construct
*
*/
public function __construct() {
parent::__construct();
$this->flagNames = array(
Field::flagAutojoin => 'autojoin',
Field::flagGlobal => 'global',
Field::flagSystem => 'system',
Field::flagPermanent => 'permanent',
Field::flagAccess => 'access',
Field::flagAccessAPI => 'access-api',
Field::flagAccessEditor => 'access-editor',
Field::flagFieldgroupContext => 'fieldgroup-context',
Field::flagSystemOverride => 'system-override',
);
// convert so that keys are names so that isset() can be used rather than in_array()
if(isset(self::$nativeNamesSystem[0])) {
self::$nativeNamesSystem = array_flip(self::$nativeNamesSystem);
}
}
public function getCacheItemName() {
return array('roles', 'permissions', 'title', 'process');
}
/**
* Construct and load the Fields
*
* #pw-internal
*
*/
public function init() {
$this->getWireArray();
}
/**
* Per WireSaveableItems interface, return a blank instance of a Field
*
* #pw-internal
*
* @return Field
*
*/
public function makeBlankItem() {
return $this->wire(new Field());
}
/**
* Make an item and populate with given data
*
* @param array $a Associative array of data to populate
* @return Saveable|Wire
* @throws WireException
* @since 3.0.146
*
*/
public function makeItem(array $a = array()) {
if(empty($a['type'])) return parent::makeItem($a);
if($this->fieldtypes === null) $this->fieldtypes = $this->wire()->fieldtypes;
if(!$this->fieldtypes) return parent::makeItem($a);
/** @var Fieldtype $fieldtype */
$fieldtype = $this->fieldtypes->get($a['type']);
if(!$fieldtype) {
if($this->useLazy) {
$this->error("Fieldtype module '$a[type]' for field '$a[name]' is missing");
$fieldtype = $this->fieldtypes->get('FieldtypeText');
} else {
return parent::makeItem($a);
}
}
$a['type'] = $fieldtype;
$a['id'] = (int) $a['id'];
$a['flags'] = (int) $a['flags'];
$class = $fieldtype->getFieldClass($a);
if(empty($class) || $class === 'Field') {
$class = '';
} else if(strpos($class, "\\") === false) {
$class = wireClassName($class, true);
if(!class_exists($class)) $class = '';
}
if(empty($class)) {
$field = new Field();
} else {
$field = new $class(); /** @var Field $field */
}
$this->wire($field);
$field->setTrackChanges(false);
foreach($a as $key => $value) {
$field->setRawSetting($key, $value);
}
$field->resetTrackChanges(true);
return $field;
}
/**
* Create a new Saveable item from a raw array ($row) and add it to $items
*
* @param array $row
* @param WireArray|null $items
* @return Saveable|WireData|Wire
* @since 3.0.194
*
*/
protected function initItem(array &$row, WireArray $items = null) {
/** @var Field $item */
$item = parent::initItem($row, $items);
$fieldtype = $item ? $item->type : null;
if($fieldtype) $fieldtype->initField($item);
return $item;
}
/**
* Per WireSaveableItems interface, return all available Field instances
*
* #pw-internal
*
* @return FieldsArray|WireArray
*
*/
public function getAll() {
if($this->useLazy()) $this->loadAllLazyItems();
return $this->getWireArray();
}
/**
* Get WireArray container that items are stored in
*
* @return WireArray
* @since 3.0.194
*
*/
public function getWireArray() {
if($this->fieldsArray === null) {
$this->fieldsArray = new FieldsArray();
$this->wire($this->fieldsArray);
$this->load($this->fieldsArray);
}
return $this->fieldsArray;
}
/**
* Per WireSaveableItems interface, return the table name used to save Fields
*
* #pw-internal
*
*/
public function getTable() {
return "fields";
}
/**
* Return the name that fields should be initially sorted by
*
* #pw-internal
*
*/
public function getSort() {
return $this->getTable() . ".name";
}
/**
* Save a Field to the database
*
* ~~~~~
* // Modify a field label and save it
* $field = $fields->get('title');
* $field->label = 'Title or Headline';
* $fields->save($field);
* ~~~~~
*
* @param Field|Saveable $item The field to save
* @return bool True on success, false on failure
* @throws WireException
*
*/
public function ___save(Saveable $item) {
if($item->flags & Field::flagFieldgroupContext) throw new WireException("Field $item is not saveable because it is in a specific context");
if(!strlen($item->name)) throw new WireException("Field name is required");
$database = $this->wire()->database;
$isNew = $item->id < 1;
$prevTable = $database->escapeTable($item->prevTable);
$table = $database->escapeTable($item->getTable());
if(!$isNew && $prevTable && $prevTable != $table) {
// note that we rename the table twice in order to force MySQL to perform the rename
// even if only the case has changed.
$schema = $item->type->getDatabaseSchema($item);
if(!empty($schema)) {
foreach(array($table, "tmp_$table") as $t) {
if(!$database->tableExists($t)) continue;
throw new WireException("Cannot rename to '$item->name' because table `$table` already exists");
}
$database->exec("RENAME TABLE `$prevTable` TO `tmp_$table`"); // QA
$database->exec("RENAME TABLE `tmp_$table` TO `$table`"); // QA
}
$item->prevTable = '';
}
if(!$isNew && $item->prevName && $item->prevName != $item->name) {
$item->type->renamedField($item, $item->prevName);
$item->prevName = '';
}
if($item->prevFieldtype && $item->prevFieldtype->name != $item->type->name) {
if(!$this->changeFieldtype($item)) {
$item->type = $item->prevFieldtype;
$this->error("Error changing fieldtype for '$item', reverted back to '{$item->type}'");
} else {
$item->prevFieldtype = null;
}
}
if(!$item->type) throw new WireException("Can't save a Field that doesn't have it's 'type' property set to a Fieldtype");
$item->type->saveFieldReady($item);
if(!parent::___save($item)) return false;
if($isNew) $item->type->createField($item);
$setupName = $item->setSetupName();
if($setupName || $isNew) {
if($this->applySetupName($item, $setupName)) {
$item->setSetupName('');
parent::___save($item);
}
}
if($item->flags & Field::flagGlobal) {
// make sure that all template fieldgroups contain this field and add to any that don't.
foreach($this->wire()->templates as $template) {
if($template->noGlobal) continue;
$fieldgroup = $template->fieldgroup;
if(!$fieldgroup->hasField($item)) {
$fieldgroup->add($item);
$fieldgroup->save();
$this->message("Added field '{$item->name}' to template/fieldgroup '$fieldgroup->name'", Notice::debug);
}
}
}
if($item->type) $item->type->savedField($item);
$this->getTags('reset');
return true;
}
/**
* Check that the given Field's table exists and create it if it doesn't
*
* @param Field $field
*
*/
protected function checkFieldTable(Field $field) {
$database = $this->wire()->database;
$table = $database->escapeTable($field->getTable());
if(empty($table)) return;
$exists = $database->query("SHOW TABLES LIKE '$table'")->rowCount() > 0;
if($exists) return;
try {
if($field->type && count($field->type->getDatabaseSchema($field))) {
if($field->type->createField($field)) $this->message("Created table '$table'");
}
} catch(\Exception $e) {
$this->trackException($e, false, $e->getMessage() . " (checkFieldTable)");
}
}
/**
* Check that all fields in the system have their tables installed
*
* This enables you to re-create field tables when migrating over entries from the Fields table manually (via SQL dumps or the like)
*
* #pw-internal
*
*/
public function checkFieldTables() {
foreach($this as $field) $this->checkFieldTable($field);
}
/**
* Delete a Field from the database
*
* This method will throw a WireException if you attempt to delete a field that is currently in use (i.e. assigned to one or more fieldgroups).
*
* @param Field|Saveable $item Field to delete
* @return bool True on success, false on failure
* @throws WireException
*
*/
public function ___delete(Saveable $item) {
if(!$this->getWireArray()->isValidItem($item)) {
throw new WireException("Fields::delete(item) only accepts items of type Field");
}
// if the field doesn't have an ID, so it's not one that came from the DB
if(!$item->id) {
$table = $item->getTable();
throw new WireException("Unable to delete from '$table' for field that doesn't exist in fields table");
}
// if it's in use by any fieldgroups, then we don't allow it to be deleted
if($item->numFieldgroups()) {
$names = $item->getFieldgroups()->implode("', '", (string) "name");
throw new WireException("Unable to delete field '$item->name' because it is in use by these fieldgroups: '$names'");
}
// if it's a system field, it may not be deleted
if($item->flags & Field::flagSystem) {
throw new WireException("Unable to delete field '$item->name' because it is a system field.");
}
// delete entries in fieldgroups_fields table. Not really necessary since the above exception prevents this, but here in case that changes.
$this->wire()->fieldgroups->deleteField($item);
// drop the field's table
if($item->type) $item->type->deleteField($item);
return parent::___delete($item);
}
/**
* Create and return a cloned copy of the given Field
*
* @param Field|Saveable $item Field to clone
* @param string $name Optionally specify name for new cloned item
* @return Field $item Returns the new clone on success, or false on failure
*
*/
public function ___clone(Saveable $item, $name = '') {
$item = $item->type->cloneField($item);
// don't clone system flags
if($item->flags & Field::flagSystem || $item->flags & Field::flagPermanent) {
$item->flags = $item->flags | Field::flagSystemOverride;
if($item->flags & Field::flagSystem) $item->flags = $item->flags & ~Field::flagSystem;
if($item->flags & Field::flagPermanent) $item->flags = $item->flags & ~Field::flagPermanent;
$item->flags = $item->flags & ~Field::flagSystemOverride;
}
// don't clone the 'global' flag
if($item->flags & Field::flagGlobal) $item->flags = $item->flags & ~Field::flagGlobal;
/** @var Field $item */
$item = parent::___clone($item, $name);
if($item) {
$item->prevTable = null;
$item->prevName = ''; // prevent renamed hook
}
return $item;
}
/**
* Save the context of the given field for the given fieldgroup
*
* #pw-advanced
*
* @param Field $field Field to save context for
* @param Fieldgroup $fieldgroup Context for when field is in this fieldgroup
* @param string $namespace An optional namespace for additional context
* @return bool True on success
* @throws WireException
*
*/
public function ___saveFieldgroupContext(Field $field, Fieldgroup $fieldgroup, $namespace = '') {
// get field without context
$fieldOriginal = $this->get($field->name);
$data = array();
// make sure given field and fieldgroup are valid
if(!($field->flags & Field::flagFieldgroupContext)) {
throw new WireException("Field must be in fieldgroup context before its context can be saved");
}
if(!$fieldgroup->has($fieldOriginal)) {
throw new WireException("Fieldgroup $fieldgroup does not contain field $field");
}
$field_id = (int) $field->id;
$fieldgroup_id = (int) $fieldgroup->id;
$database = $this->wire()->database;
$newValues = $field->getArray();
$oldValues = $fieldOriginal->getArray();
// 0 is the same as 100 for columnWidth, so we specifically set it just to prevent this from being saved when it doesn't need to be
if(!isset($oldValues['columnWidth'])) $oldValues['columnWidth'] = 100;
// add the built-in fields
foreach(array('label', 'description', 'notes', 'viewRoles', 'editRoles') as $key) {
$newValues[$key] = $field->$key;
$oldValues[$key] = $fieldOriginal->$key;
}
// account for flags that may be applied as part of context
$flags = $field->flags & ~Field::flagFieldgroupContext;
if($flags != $fieldOriginal->flags) {
$flagsAdd = 0;
$flagsDel = 0;
// flags that are allowed to be set via context
$contextFlags = array(
Field::flagAccess,
Field::flagAccessAPI,
Field::flagAccessEditor
);
foreach($contextFlags as $flag) {
if($fieldOriginal->hasFlag($flag) && !$field->hasFlag($flag)) $flagsDel = $flagsDel | $flag;
if(!$fieldOriginal->hasFlag($flag) && $field->hasFlag($flag)) $flagsAdd = $flagsAdd | $flag;
}
if($flagsAdd) $data['flagsAdd'] = $flagsAdd;
if($flagsDel) $data['flagsDel'] = $flagsDel;
}
// cycle through and determine which values should be saved
foreach($newValues as $key => $value) {
$oldValue = empty($oldValues[$key]) ? '' : $oldValues[$key];
// if both old and new are empty, then don't store a blank value in the context
if(empty($oldValue) && empty($value)) continue;
// if old and new value are the same, then don't duplicate the value in the context
if($value == $oldValue) continue;
// $value differs from $oldValue and should be saved
$data[$key] = $value;
}
// remove runtime properties (those that start with underscore)
foreach($data as $key => $value) {
if(strpos($key, '_') === 0) unset($data[$key]);
}
// keep all in the same order so that it's easier to compare (by eye) in the DB
ksort($data);
if($namespace) {
// get existing data and move everything here into a namespace within that data
$query = $database->prepare('SELECT data FROM fieldgroups_fields WHERE fields_id=:field_id AND fieldgroups_id=:fieldgroup_id');
$query->bindValue(':field_id', $field_id, \PDO::PARAM_INT);
$query->bindValue(':fieldgroup_id', $fieldgroup_id, \PDO::PARAM_INT);
$query->execute();
list($existingData) = $query->fetch(\PDO::FETCH_NUM);
$existingData = strlen($existingData) ? json_decode($existingData, true) : array();
if(!is_array($existingData)) $existingData = array();
foreach($data as $k => $v) {
// disallow namespace within namespace
if(strpos($k, Fieldgroup::contextNamespacePrefix) === 0) unset($data[$k]);
}
$existingData[Fieldgroup::contextNamespacePrefix . $namespace] = $data;
$data = $existingData;
}
// inject updated context back into model
$fieldgroup->setFieldContextArray($field->id, $data);
// if there is something in data, then JSON encode it. If it's empty then make it null.
$data = count($data) ? wireEncodeJSON($data, true) : null;
$query = $database->prepare('UPDATE fieldgroups_fields SET data=:data WHERE fields_id=:field_id AND fieldgroups_id=:fieldgroup_id');
if(empty($data)) {
$query->bindValue(':data', null, \PDO::PARAM_NULL);
} else {
$query->bindValue(':data', $data, \PDO::PARAM_STR);
}
$query->bindValue(':field_id', $field_id, \PDO::PARAM_INT);
$query->bindValue(':fieldgroup_id', $fieldgroup_id, \PDO::PARAM_INT);
$result = $query->execute();
return $result;
}
/**
* Change a field's type
*
* #pw-hooker
*
* @param Field $field1 Field with the new type already assigned
* @param bool $keepSettings Whether or not to keep custom $data settings (default=false)
* @throws WireException
* @return bool
*
*/
protected function ___changeFieldtype(Field $field1, $keepSettings = false) {
if(!$field1->prevFieldtype) {
throw new WireException("changeFieldType requires that the given field has had a type change");
}
if( ($field1->type instanceof FieldtypeMulti && !$field1->prevFieldtype instanceof FieldtypeMulti) ||
($field1->prevFieldtype instanceof FieldtypeMulti && !$field1->type instanceof FieldtypeMulti)) {
throw new WireException("Cannot convert between single and multiple value field types");
}
$fromType = $field1->prevFieldtype;
$toType = $field1->type;
$this->changeTypeReady($field1, $fromType, $toType);
$field2 = clone $field1;
$flags = $field2->flags;
if($flags & Field::flagSystem) {
$field2->flags = $flags | Field::flagSystemOverride;
$field2->flags = 0; // intentional overwrite after above line
}
$field2->name = $field2->name . "_PWTMP";
$field2->prevFieldtype = $field1->type;
$field2->type->createField($field2);
$field1->type = $field1->prevFieldtype;
$schema1 = array();
$schema2 = array();
$database = $this->wire()->database;
$table1 = $database->escapeTable($field1->table);
$table2 = $database->escapeTable($field2->table);
$query = $database->prepare("DESCRIBE `$table1`"); // QA
$query->execute();
/** @noinspection PhpAssignmentInConditionInspection */
while($row = $query->fetch(\PDO::FETCH_ASSOC)) $schema1[] = $row['Field'];
$query = $database->prepare("DESCRIBE `$table2`"); // QA
$query->execute();
/** @noinspection PhpAssignmentInConditionInspection */
while($row = $query->fetch(\PDO::FETCH_ASSOC)) $schema2[] = $row['Field'];
foreach($schema1 as $key => $value) {
if(!in_array($value, $schema2)) {
$this->message("changeFieldType loses table field '$value'", Notice::debug);
unset($schema1[$key]);
}
}
$sql = "INSERT INTO `$table2` (`" . implode('`,`', $schema1) . "`) " .
"SELECT `" . implode('`,`', $schema1) . "` FROM `$table1` ";
$error = '';
$exception = null;
try {
$result = $database->exec($sql);
if($result === false || $query->errorCode() > 0) {
$errorInfo = $query->errorInfo();
$error = !empty($errorInfo[2]) ? $errorInfo[2] : 'Unknown Error';
}
} catch(\Exception $e) {
$exception = $e;
$error = $e->getMessage();
}
if($exception) {
$this->error("Field type change failed. Database reports: $error");
$database->exec("DROP TABLE `$table2`"); // QA
$severe = $this->wire()->process != 'ProcessField';
$this->trackException($exception, $severe);
return false;
}
$database->exec("DROP TABLE `$table1`"); // QA
$database->exec("RENAME TABLE `$table2` TO `$table1`"); // QA
$field1->type = $field2->type;
if(!$keepSettings) {
// clear out the custom data, which contains settings specific to the Inputfield and Fieldtype
foreach($field1->getArray() as $key => $value) {
// skip fields that may be shared among any fieldtype
if(in_array($key, array('description', 'required', 'collapsed', 'notes'))) continue;
// skip over language labels/descriptions
if(preg_match('/^(description|label|notes)\d+/', $key)) continue;
// remove the custom field
$field1->remove($key);
}
}
$this->changedType($field1, $fromType, $toType);
return true;
}
/**
* Physically delete all field data (from the database) used by pages of a given template
*
* This is for internal API use only. This method is intended only to be called by
* Fieldtype::deleteTemplateField
*
* If you need to remove a field from a Fieldgroup, use Fieldgroup::remove(), and this
* method will be call automatically at the appropriate time when save the fieldgroup.
*
* #pw-hooker
*
* @param Field $field
* @param Template $template
* @return bool Whether or not it was successful
* @throws WireException when given a situation where deletion is not allowed
*
*/
public function ___deleteFieldDataByTemplate(Field $field, Template $template) {
// first we need to determine if the $field->type module has its own
// deletePageField method separate from base: Fieldtype/FieldtypeMulti
$reflector = new \ReflectionClass($field->type->className(true));
$hasDeletePageField = false;
foreach($reflector->getMethods() as $method) {
$methodName = $method->getName();
if(strpos($methodName, '___deletePageField') === false) continue;
try {
new \ReflectionMethod($reflector->getParentClass()->getName(), $methodName);
if(!in_array($method->getDeclaringClass()->getName(), array(
'Fieldtype',
'FieldtypeMulti',
__NAMESPACE__ . "\\Fieldtype",
__NAMESPACE__ . "\\FieldtypeMulti"))) {
$hasDeletePageField = true;
}
} catch(\Exception $e) {
// not there
}
break;
}
$numPages = $this->getNumPages($field, array('template' => $template));
$numRows = $this->getNumRows($field, array('template' => $template));
$success = true;
if($numPages <= 200 || $hasDeletePageField) {
$deleteType = $this->_('page-by-page');
// not many pages to operate on, OR fieldtype has a custom deletePageField method,
// so use verbose/slow method to delete the field from pages
$ids = $this->getNumPages($field, array('template' => $template, 'getPageIDs' => true));
$items = $this->wire()->pages->getById($ids, $template);
foreach($items as $page) {
try {
$field->type->deletePageField($page, $field);
// $this->message("Deleted '{$field->name}' from '{$page->path}'", Notice::debug);
} catch(\Exception $e) {
$this->trackException($e, false, true);
$success = false;
}
}
} else {
$deleteType = $this->_('single-query');
// large number of pages to operate on: use fast method
$database = $this->wire()->database;
$table = $database->escapeTable($field->getTable());
$sql = "DELETE $table FROM $table " .
"INNER JOIN pages ON pages.id=$table.pages_id " .
"WHERE pages.templates_id=:templates_id";
$query = $database->prepare($sql);
$query->bindValue(':templates_id', $template->id, \PDO::PARAM_INT);
try {
$query->execute();
} catch(\Exception $e) {
$this->error($e->getMessage(), Notice::log);
$this->trackException($e);
$success = false;
}
}
if($success) {
$this->message(
sprintf($this->_('Deleted field "%1$s" data in %2$d row(s) from %3$d page(s) using template "%4$s".'),
$field->name, $numRows, $numPages, $template->name) . " [$deleteType]",
Notice::log
);
} else {
$this->error(
sprintf($this->_('Error deleting field "%1$s" data, %2$d row(s), %3$d page(s) using template "%4$s".'),
$field->name, $numRows, $numPages, $template->name) . " [$deleteType]",
Notice::log
);
}
return $success;
}
/**
* Return a count of pages containing populated values for the given field
*
* @param Field $field
* @param array $options Optionally specify one of the following options:
* - `template` (template|int|string): Specify a Template object, ID or name to isolate returned rows specific to pages using that template.
* - `page` (Page|int|string): Specify a Page object, ID or path to isolate returned rows specific to that page.
* - `getPageIDs` (bool): Specify boolean true to make it return an array of matching Page IDs rather than a count.
* @return int|array Returns array only if getPageIDs option set, otherwise returns count of pages.
* @throws WireException If given option for page or template doesn't resolve to actual page/template.
*
*/
public function getNumPages(Field $field, array $options = array()) {
$options['countPages'] = true;
return $this->getNumRows($field, $options);
}
/**
* Return a count of database rows populated the given field
*
* @param Field $field
* @param array $options Optionally specify any of the following options:
* - `template` (Template|int|string): Specify a Template object, ID or name to isolate returned rows specific to pages using that template.
* - `page` (Page|int|string): Specify a Page object, ID or path to isolate returned rows specific to that page.
* - `countPages` (bool): Specify boolean true to make it return a page count rather than a row count (default=false).
* There will only be potential difference between rows and pages counts with multi-value fields.
* - `getPageIDs` (bool): Specify boolean true to make it return an array of matching Page IDs rather than a count (overrides countPages).
* @return int|array Returns array only if getPageIDs option set, otherwise returns a count of rows.
* @throws WireException If given option for page or template doesn't resolve to actual page/template.
*
*/
public function getNumRows(Field $field, array $options = array()) {
$defaults = array(
'template' => 0,
'page' => 0,
'countPages' => false,
'getPageIDs' => false,
);
if(!$field->type) return 0;
$options = array_merge($defaults, $options);
$database = $this->wire()->database;
$table = $database->escapeTable($field->getTable());
$useRowCount = false;
$schema = $field->type->getDatabaseSchema($field);
if(empty($schema)) {
// field has no schema or table (example: FieldtypeConcat)
if($options['getPageIDs']) return array();
return 0;
}
if($options['template']) {
// count by pages using specific template
if($options['template'] instanceof Template) {
$template = $options['template'];
} else {
$template = $this->wire()->templates->get($options['template']);
}
if(!$template) throw new WireException("Unknown template: $options[template]");
if($options['getPageIDs']) {
$sql = "SELECT DISTINCT $table.pages_id FROM $table ".
"JOIN pages ON pages.templates_id=:templateID AND pages.id=pages_id ";
} else if($options['countPages']) {
$sql = "SELECT COUNT(DISTINCT pages_id) FROM $table ".
"JOIN pages ON pages.templates_id=:templateID AND pages.id=pages_id ";
} else {
$sql = "SELECT COUNT(*) FROM pages " .
"JOIN $table ON $table.pages_id=pages.id " .
"WHERE pages.templates_id=:templateID ";
}
$query = $database->prepare($sql);
$query->bindValue(':templateID', $template->id, \PDO::PARAM_INT);
} else if($options['page']) {
// count by specific page
if(is_int($options['page'])) {
$pageID = $options['page'];
} else {
$page = $this->wire()->pages->get($options['page']);
$pageID = $page->id;
}
if(!$pageID) throw new WireException("Unknown page: $options[page]");
if($options['countPages']) {
// is there any the point to this?
$sql = "SELECT COUNT(DISTINCT pages_id) FROM $table WHERE pages_id=:pageID ";
} else {
$sql = "SELECT COUNT(*) FROM $table WHERE pages_id=:pageID ";
}
$query = $database->prepare($sql);
$query->bindValue(':pageID', $pageID, \PDO::PARAM_INT);
} else {
// overall total count
if($options['getPageIDs']) {
$sql = "SELECT DISTINCT $table.pages_id FROM $table";
} else if($options['countPages']) {
$sql = "SELECT COUNT(DISTINCT pages_id) FROM $table";
} else {
$sql = "SELECT COUNT(*) FROM $table";
}
$query = $database->prepare($sql);
}
$return = $options['getPageIDs'] ? array() : 0;
try {
$query->execute();
if($options['getPageIDs']) {
/** @noinspection PhpAssignmentInConditionInspection */
while($id = $query->fetchColumn()) {
$return[] = (int) $id;
}
} else if($useRowCount) {
$return = (int) $query->rowCount();
} else {
list($return) = $query->fetch(\PDO::FETCH_NUM);
$return = (int) $return;
}
} catch(\Exception $e) {
$this->error($e->getMessage() . " (getNumRows)");
$this->trackException($e, false);
}
return $return;
}
/**
* Is the given field name native/permanent to the database?
*
* This is deprecated, please us $fields->isNative($name) instead.
*
* #pw-internal
*
* @param string $name
* @return bool
* @deprecated
*
*/
public static function isNativeName($name) {
$fields = wire()->fields;
return $fields->isNative($name);
}
/**
* Is the given field name native/permanent to the database?
*
* Such fields are disallowed from being used for custom field names.
*
* @param string $name Field name you want to check
* @return bool True if field is native (and thus should not be used) or false if it's okay to use
*
*/
public function isNative($name) {
if(isset(self::$nativeNamesSystem[$name])) return true;
if(isset($this->nativeNamesLocal[$name])) return true;
return false;
}
/**
* Add a new name to be recognized as a native field name
*
* #pw-internal
*
* @param string $name
*
*/
public function setNative($name) {
$this->nativeNamesLocal[$name] = true;
}
/**
* Get list of all tags used by fields
*
* - By default it returns an array of tag names where both keys and values are the tag names.
* - If you specify true for the `$getFields` argument, it returns an array where the keys are
* tag names and the values are arrays of field names in the tag.
* - If you specify "reset" for the `$getFields` argument it returns a blank array and resets
* internal tags cache.
*
* #pw-advanced
*
* @param bool|string $getFieldNames Specify true to return associative array where keys are tags and values are field names
* …or specify the string "reset" to force getTags() to reset its cache, forcing it to reload on the next call.
* @return array Both keys and values are tags in return value
* @since 3.0.106 + made hookable in 3.0.179
*
*/
public function ___getTags($getFieldNames = false) {
if($getFieldNames === 'reset') {
$this->tagList = null;
return array();
}
if($this->tagList === null) {
$tagList = array();
foreach($this as $field) {
/** @var Field $field */
$fieldTags = $field->getTags();
foreach($fieldTags as $tag) {
if(!isset($tagList[$tag])) $tagList[$tag] = array();
$tagList[$tag][] = $field->name;
}
}
ksort($tagList);
$this->tagList = $tagList;
}
if($getFieldNames) return $this->tagList;
$tagList = array();
foreach($this->tagList as $tag => $fieldNames) {
$tagList[$tag] = $tag;
}
return $tagList;
}
/**
* Return all fields that have the given $tag
*
* Returns an associative array of `['field_name' => 'field_name']` if `$getFieldNames` argument is true,
* or `['field_name => Field instance]` if not (which is the default).
*
* @param string $tag Tag to find fields for
* @param bool $getFieldNames If true, returns array of field names rather than Field objects (default=false).
* @return array Array of Field objects, or array of field names if requested. Array keys are always field names.
* @since 3.0.106
*
*/
public function findByTag($tag, $getFieldNames = false) {
$tags = $this->getTags(true);
$items = array();
if(!isset($tags[$tag])) return $items;
foreach($tags[$tag] as $fieldName) {
$items[$fieldName] = ($getFieldNames ? $fieldName : $this->get($fieldName));
}
ksort($items);
return $items;
}
/**
* Find fields by type
*
* @param string|Fieldtype $type Fieldtype class name or object
* @param array $options
* - `inherit` (bool): Also find types that inherit from given type? (default=true)
* - `valueType` (string): Value type to return, one of 'field', 'id', or 'name' (default='field')
* - `indexType` (string): Index type to use, one of 'name', 'id', or '' blank for non-associative array (default='name')
* @return array|Field[]
* @since 3.0.194
*
*/
public function findByType($type, array $options = array()) {
$defaults = array(
'inherit' => true, // also find fields using type inherited from given type or interface?
'valueType' => 'field', // one of 'field', 'id', or 'name'
'indexType' => 'name', // one of 'name', 'id', or '' blank for non associative array
);
$options = array_merge($defaults, $options);
$valueType = $options['valueType'];
$indexType = $options['indexType'];
$inherit = $options['inherit'];
$matchTypes = array();
$matches = array();
if($inherit) {
$typeName = wireClassName($type, true);
foreach($this->wire()->fieldtypes as $fieldtype) {
if($fieldtype instanceof $typeName) $matchTypes[$fieldtype->className()] = true;
}
} else {
$typeName = wireClassName($type);
$matchTypes[$typeName] = true;
}
foreach($this->getWireArray() as $field) {
/** @var Field $field */
$fieldtype = $field->type;
if(!$fieldtype) continue;
if(!isset($matchTypes[$fieldtype->className()])) continue;
if($valueType === 'field') {
$value = $field;
} else if($valueType === 'name') {
$value = $field->name;
} else {
$value = $field->id;
}
if($indexType) {
$index = $field->get($options['indexType']);
$matches[$index] = $value;
} else {
$matches[] = $value;
}
}
if($this->useLazy()) {
foreach(array_keys($this->lazyItems) as $key) {
if(!isset($this->lazyItems[$key])) continue;
$row = $this->lazyItems[$key];
if(empty($row['type'])) continue;
$type = $row['type'];
if(!isset($matchTypes[$type])) continue;
if($valueType === 'field') {
$value = $this->getLazy((int) $row['id']);
} else if($valueType === 'name') {
$value = $row['name'];
} else {
$value = $row['id'];
}
if($indexType) {
$index = isset($data[$indexType]) ? $row[$indexType] : $row['id'];
$matches[$index] = $value;
} else {
$matches[] = $value;
}
}
}
return $matches;
}
/**
* Get all field names
*
* @param string $indexType One of 'name', 'id' or blank string for no index (default='')
* @return array
* @since 3.0.194
*
*/
public function getAllNames($indexType = '') {
return $this->getAllValues('name', $indexType);
}
/**
* Get all flag names or get all flag names for given flags or Field
*
* #pw-internal
*
* @param int|Field|null $flags Specify flags or Field or omit to get all flag names
* @param bool $getString Get a string of flag names rather than array? (default=false)
* @return array|string When array is returned, array is of strings indexed by flag value (int)
*
*/
public function getFlagNames($flags = null, $getString = false) {
if($flags === null) {
$a = $this->flagNames;
} else {
$a = array();
if($flags instanceof Field) $flags = $flags->flags;
foreach($this->flagNames as $flag => $name) {
if($flags & $flag) $a[$flag] = $name;
}
}
return $getString ? implode(' ', $a) : $a;
}
/**
* Overridden from WireSaveableItems to retain keys with 0 values and remove defaults we don't need saved
*
* #pw-internal
*
* @param array $value
* @return string of JSON
*
*/
protected function encodeData(array $value) {
if(isset($value['collapsed']) && $value['collapsed'] === 0) unset($value['collapsed']);
if(isset($value['columnWidth']) && (empty($value['columnWidth']) || $value['columnWidth'] == 100)) unset($value['columnWidth']);
return wireEncodeJSON($value, 0);
}
/**
* Does user have 'view' or 'edit' permission for this field? (internal use only)
*
* PLEASE NOTE: this does not check that the provided $page itself is viewable or editable.
* If you want that check, then use $page->viewable($field) or $page->editable($field) instead.
*
* This provides the back-end to the Field::viewable() and Field::editable() methods.
* This method is for internal use, please instead use the Field::viewable() or Field::editable() methods.
*
* #pw-internal
*
* @param Field|int|string Field to check
* @param string $permission Specify either 'view' or 'edit'
* @param Page|null $page Optionally specify a page for context
* @param User|null $user Optionally specify a user for context (default=current user)
* @return bool
* @throws WireException if given invalid arguments
*
*/
public function _hasPermission(Field $field, $permission, Page $page = null, User $user = null) {
if($permission != 'edit' && $permission != 'view') {
throw new WireException('Specify either "edit" or "view"');
}
if(is_null($user)) $user = $this->wire()->user;
if($user->isSuperuser()) return true;
if($page && $page->template && $page->template->fieldgroup->hasField($field)) {
// make sure we have a copy of $field that is in the context of $page
$_field = $page->template->fieldgroup->getFieldContext($field);
if($_field) $field = $_field;
}
if($field->useRoles) {
// field is access controlled
$has = false;
$roles = $permission == 'edit' ? $field->editRoles : $field->viewRoles;
if($permission == 'view' && in_array($this->wire()->config->guestUserRolePageID, $roles)) {
// if guest has view permission, then all have view permission
$has = true;
} else {
foreach($roles as $roleID) {
if($user->hasRole($roleID)) {
$has = true;
break;
}
}
}
} else {
// field is not access controlled
$has = $permission == 'view' ? true : $user->hasPermission("page-edit");
}
return $has;
}
/**
* Hook called when a field has changed type
*
* #pw-hooker
*
* @param Field|Saveable $item
* @param Fieldtype $fromType
* @param Fieldtype $toType
*
*/
public function ___changedType(Saveable $item, Fieldtype $fromType, Fieldtype $toType) { }
/**
* Hook called right before a field is about to change type
*
* #pw-hooker
*
* @param Field|Saveable $item
* @param Fieldtype $fromType
* @param Fieldtype $toType
*
*/
public function ___changeTypeReady(Saveable $item, Fieldtype $fromType, Fieldtype $toType) { }
/**
* Get Fieldtypes compatible (for type change) with given Field
*
* #pw-internal
*
* @param Field $field
* @return Fieldtypes
* @since 3.0.140
*
*/
public function getCompatibleFieldtypes(Field $field) {
$fieldtype = $field->type;
if($fieldtype) {
// ask fieldtype what is compatible
/** @var Fieldtypes $fieldtypes */
$fieldtypes = $fieldtype->getCompatibleFieldtypes($field);
if(!$fieldtypes instanceof WireArray) {
$fieldtypes = $this->wire(new Fieldtypes());
}
// ensure original is present
$fieldtypes->prepend($fieldtype);
} else {
// allow all
$fieldtypes = $this->wire()->fieldtypes;
}
return $fieldtypes;
}
/**
* Get FieldsIndexTools instance
*
* #pw-internal
*
* @return FieldsTableTools
* @since 3.0.150
*
*/
public function tableTools() {
if($this->tableTools === null) $this->tableTools = $this->wire(new FieldsTableTools());
return $this->tableTools;
}
/**
* Return the list of Fieldgroups using given field.
*
* #pw-internal
*
* @param Field|int|string Field to get fieldgroups for
* @param bool $getCount Get count rather than FieldgroupsArray? (default=false) 3.0.182+
* @return FieldgroupsArray|int WireArray of Fieldgroup objects or count if requested
*
*/
public function getFieldgroups($field, $getCount = false) {
$fieldId = $this->_fieldId($field);
$fieldgroups = $this->wire()->fieldgroups;
/** @var FieldgroupsArray $items */
$items = $getCount ? null : $this->wire(new FieldgroupsArray());
$ids = array();
$count = 0;
$sql = "SELECT fieldgroups_id FROM fieldgroups_fields WHERE fields_id=:fields_id";
$query = $this->wire()->database->prepare($sql);
$query->bindValue(':fields_id', $fieldId, \PDO::PARAM_INT);
$query->execute();
while($row = $query->fetch(\PDO::FETCH_NUM)) {
$id = (int) $row[0];
$ids[$id] = $id;
}
$query->closeCursor();
foreach($ids as $id) {
$fieldgroup = $fieldgroups->get($id);
if(!$fieldgroup) continue;
if($items) $items->add($fieldgroup);
$count++;
}
return $getCount ? $count : $items;
}
/**
* Return the list of of Templates using given field.
*
* #pw-internal
*
* @param Field|int|string Field to get templates for
* @param bool $getCount Get count rather than FieldgroupsArray? (default=false)
* @return TemplatesArray|int WireArray of Template objects or count when requested.
* @since 3.0.195
*
*/
public function getTemplates($field, $getCount = false) {
$fieldId = $this->_fieldId($field);
$templates = $this->wire()->templates;
$items = $getCount ? null : $this->wire(new TemplatesArray()); /** @var TemplatesArray $items */
$count = 0;
$ids = array();
if(!$fieldId) return $items;
$sql =
"SELECT fieldgroups_fields.fieldgroups_id, templates.id AS templates_id " .
"FROM fieldgroups_fields " .
"JOIN templates ON templates.fieldgroups_id=fieldgroups_fields.fieldgroups_id " .
"WHERE fieldgroups_fields.fields_id=:fields_id";
$query = $this->wire()->database->prepare($sql);
$query->bindValue(':fields_id', $fieldId, \PDO::PARAM_INT);
$query->execute();
while($row = $query->fetch(\PDO::FETCH_ASSOC)) {
$id = (int) $row['templates_id'];
$ids[$id] = $id;
}
$query->closeCursor();
foreach($ids as $id) {
$template = $templates->get($id);
if(!$template) continue;
if($items) $items->add($template);
$count++;
}
return $getCount ? $count : $items;
}
/**
* Setup a new field using predefined setup name(s) from the Fields fieldtype
*
* If no setupName is provided then this method doesnt do anything, but hooks to it might.
*
* @param Field $field Newly created field
* @param string $setupName Setup name to apply
* @return bool True if setup was appled, false if not
* @since 3.0.213
*
*/
protected function ___applySetupName(Field $field, $setupName = '') {
$setups = $field->type->getFieldSetups();
$setup = isset($setups[$setupName]) ? $setups[$setupName] : null;
if(!$setup) return false;
$title = isset($setup['title']) ? $setup['title'] : $setupName;
$func = isset($setup['setup']) ? $setup['setup'] : null;
foreach($setup as $property => $value) {
if($property === 'title' || $property === 'setup') continue;
$field->set($property, $value);
}
if($func && is_callable($func)) {
$func($field);
}
$this->message("Applied setup: $title", Notice::debug | Notice::noGroup);
return true;
}
/**
* Return field ID for given value (Field, field name, field ID) or 0 if none
*
* #pw-internal
*
* @param Field|string|int $field
* @return int
* @since 3.0.195
*
*/
public function _fieldId($field) {
if($field instanceof Field) {
$fieldId = $field->id;
} else if(ctype_digit("$field")) {
$fieldId = (int) $field;
} else {
$field = $this->get($field);
$fieldId = $field ? $field->id : 0;
}
return $fieldId;
}
}