diff --git a/wire/core/Page.php b/wire/core/Page.php index 49175f40..b5e10745 100644 --- a/wire/core/Page.php +++ b/wire/core/Page.php @@ -1169,6 +1169,57 @@ class Page extends WireData implements \Countable, WireMatchable { return $this->values()->getDotValue($this, $key); } + /** + * Preload multiple fields together as a group (experimental) + * + * This is an optimization that enables you to load the values for multiple fields into + * a page at once, and often in a single query. For fields where it is supported, and + * for cases where you have a lot of fields to load at once, it can be up to 50% faster + * than the default of lazy-loading fields. + * + * To use, call `$page->preload([ 'field1', 'field2', 'etc.' ])` before accessing + * `$page->field1`, `$page->field2`, etc. + * + * The more fields you give this method, the more performance improvement it can offer. + * As a result, don't bother if with only a few fields, as it's less likely to make + * a difference at small scale. You will also see a more measurable benefit if preloading + * fields for lots of pages at once. + * + * Preload works with some Fieldtypes and not others. For details on what it is doing, + * specify `true` for the `debug` option which will make it return array of what it + * loaded and what it didn't. Have a look at this array with TracyDebugger or output + * a print_r() call on it, and the result is self explanatory. + * + * NOTE: This function is currently experimental, recommended for testing only. + * + * ~~~~~ + * // Example usage + * $page->preload([ 'headline', 'body', 'sidebar', 'intro', 'summary' ]); + * echo " + *

$page->headline

"; + *
$page->intro
+ *
$page->body
+ * + * $page->summary + * "; + * ~~~~~ + * + * @param array $fieldNames Names of fields to preload or omit (or blank array) + * to preload all supported fields. + * @param array $options Options to modify default behavior: + * `debug` (bool): Specify true to return array of debug info (default=false). + * @return int|array Number of fields preloaded, or array of details (if debug) + * @since 3.0.243 + * + */ + public function preload(array $fieldNames = array(), $options = array()) { + if(empty($fieldNames)) { + return $this->wire()->pages->loader()->preloadAllFields($this, $options); + } else { + return $this->wire()->pages->loader()->preloadFields($this, $fieldNames, $options); + } + } + /** * Hookable method called when a request to a field was made that didn't match anything * diff --git a/wire/core/PagesLoader.php b/wire/core/PagesLoader.php index 6bd1457f..e452276b 100644 --- a/wire/core/PagesLoader.php +++ b/wire/core/PagesLoader.php @@ -698,7 +698,8 @@ class PagesLoader extends Wire { foreach($row as $key => $value) { if(strpos($key, '__')) { if($value === null) { - $row[$key] = 'null'; // ensure detected by later isset in foreach($joinFields) + // $row[$key] = 'null'; // ensure detected by later isset in foreach($joinFields) + $row[$key] = new NullField(); } else { $page->setFieldValue($key, $value, false); } @@ -712,7 +713,10 @@ class PagesLoader extends Wire { if(!$template->fieldgroup->hasField($joinField)) continue; $field = $page->getField($joinField); if(!$field || !$field->type) continue; - if(isset($row["{$joinField}__data"])) { + $v = isset($row["{$joinField}__data"]) ? $row["{$joinField}__data"] : null; + if($v instanceof NullField) $v = null; + // if(isset($row["{$joinField}__data"])) { + if($v !== null) { if(!$field->hasFlag(Field::flagAutojoin)) { $field->addFlag(Field::flagAutojoin); $tmpAutojoinFields[$field->id] = $field; @@ -2028,6 +2032,286 @@ class PagesLoader extends Wire { return $this->pages->find($selector, $options)->getTotal(); } + + /** + * Preload/Prefetch fields for page together as a group (experimental) + * + * This is an optimization that enables you to load the values for multiple fields into + * a page at once, and often in a single query. This is similar to the `joinFields` option + * when loading a page, or the `autojoin` option configured with a field, except that it + * can be used after a page is already loaded. It provides a performance improvement + * relative lazy-loading of fields individually as they are accessed. + * + * Preload works only with Fieldtypes that do not override the core’s loading methods. + * Preload also does not work with FieldtypeMulti types at present, except for the Page + * Fieldtype when configured to load a single page. Though it can be enabled for testing + * purposes using the `useFieldtypeMulti` $options argument. + * + * NOTE: This function is currently experimental, recommended for testing only. + * + * @param Page $page Page to preload fields for + * @param array $fieldNames Names of fields to preload + * @param array $options + * - `debug` (bool): Specify true to return array of debug info (default=false). + * - `useFieldtypeMulti` (bool): Enable FieldtypeMulti for testing purposes (default=false). + * @return int|array Number of fields preloaded, or array of details (if debug) + * @since 3.0.243 + * + */ + public function preloadFields(Page $page, array $fieldNames, $options = array()) { + + $defaults = [ + 'debug' => is_bool($options) ? $options : false, + 'useFieldtypeMulti' => false, + ]; + + static $level = 0; + + $options = is_array($options) ? array_merge($defaults, $options) : $defaults; + $debug = $options['debug']; + $database = $page->wire()->database; + $fieldNames = array_unique($fieldNames); + $fields = $page->wire()->fields; + $loadFields = []; + $loadedFields = []; + $selects = []; + $joins = []; + $numJoins = 0; + $maxJoins = 60; + + $log = [ + 'loaded' => [], + 'skipped' => [], + 'blank' => [], + 'queries' => 1, + 'timer' => 0.0, + ]; + + if(!$page->id || !$page->template) return $debug ? $log : 0; + + foreach($fieldNames as $fieldKey => $fieldName) { + + // identify which fields to load and which to skip + $field = $fields->get($fieldName); + $fieldName = $field ? $field->name : ''; + $fieldNames[$fieldKey] = $fieldName; + $error = $field ? $this->skipPreloadField($page, $field, $options) : 'Field not found'; + + if($error) { + unset($fieldNames[$fieldKey]); + if($debug) $log['skipped'][$fieldName] = $error; + continue; + } + + $fieldtype = $field->type; + $schema = $fieldtype->trimDatabaseSchema($fieldtype->getDatabaseSchema($field)); + $numJoins += count($schema); + + if($numJoins >= $maxJoins) break; + + $loadFields[$fieldName] = $field; + $table = $field->getTable(); + + // build selects and joins + foreach(array_keys($schema) as $colName) { + if($options['useFieldtypeMulti'] && $fieldtype instanceof FieldtypeMulti) { + $sep = FieldtypeMulti::multiValueSeparator; + $orderBy = "ORDER BY $table.sort"; + $selects[] = "GROUP_CONCAT($table.$colName $orderBy SEPARATOR '$sep') AS `{$table}__$colName`"; + } else { + $selects[] = "$table.$colName AS {$table}__$colName"; + } + $joins[$table] = "LEFT JOIN $table ON $table.pages_id=pages.id"; + } + + unset($fieldNames[$fieldKey]); + } + + if(!count($selects)) return $debug ? $log : 0; + + $level++; + $timer = $debug ? Debug::timer() : false; + + // build and execute the query + $sql = + 'SELECT ' . implode(",\n", $selects) . ' ' . + "\nFROM pages " . + "\n" . implode(" \n", $joins) . ' ' . + "\nWHERE pages.id=:pid"; + + $query = $database->prepare($sql); + $query->bindValue(':pid', $page->id, \PDO::PARAM_INT); + $query->execute(); + + $data = []; + $row = $query->fetch(\PDO::FETCH_ASSOC); + $query->closeCursor(); + + // combine data from DB into column groups by field name + if($row) { + foreach($row as $key => $value) { + list($table, $colName) = explode('__', $key, 2); + list(, $fieldName) = explode('_', $table, 2); + if(!isset($data[$fieldName])) $data[$fieldName] = []; + $data[$fieldName][$colName] = $value; + } + } + + // wake up loaded values and populate to $page + foreach($data as $fieldName => $sleepValue) { + if(!isset($loadFields[$fieldName])) continue; + $field = $loadFields[$fieldName]; + $fieldtype = $field->type; + $cols = array_keys($sleepValue); + if(count($cols) === 1 && array_key_exists('data', $sleepValue)) { + $sleepValue = $sleepValue['data']; + } + if($sleepValue === null) continue; // force to getBlankValue in loop below this + if($options['useFieldtypeMulti'] && $fieldtype instanceof FieldtypeMulti) { + if(strrpos($sleepValue, FieldtypeMulti::multiValueSeparator)) { + $sleepValue = explode(FieldtypeMulti::multiValueSeparator, $sleepValue); + } + } + $value = $fieldtype->wakeupValue($page, $field, $sleepValue); + $page->_parentSet($field->name, $value); + $loadedFields[$field->name] = $fieldName; + unset($loadFields[$field->name]); + if($debug) { + $log['loaded'][$fieldName] = "$fieldtype->shortName: " . implode(',', $cols); + } + } + + // any remaining loadFields not present in DB should get blank value + foreach($loadFields as $field) { + $value = $field->type->getBlankValue($page, $field); + $page->_parentSet($field->name, $value); + if($debug) $log['blank'][$field->name] = $field->type->shortName; + } + + $numLoaded = count($loadedFields); + + // go recursive for any remaining fields + if(count($fieldNames)) { + $result = $this->preloadFields($page, $fieldNames, $debug); + if($debug) { + foreach($log as $key => $value) { + if(is_array($value)) { + $log[$key] = array_merge($value, $result[$key]); + } else if(is_int($value)) { + $log[$key] += $result[$key]; + } + } + } else { + $numLoaded += $result; + } + } + + $level--; + if($debug && $timer && !$level) $log['timer'] = Debug::timer($timer); + + return $debug ? $log : $numLoaded; + } + + /** + * Preload all supported fields for given page (experimental) + * + * NOTE: This function is currently experimental, recommended for testing only. + * + * @param Page $page Page to preload fields for + * @param array $options + * - `debug` (bool): Specify true to return array of debug info (default=false). + * - `skipFieldNames` (array): Optional names of fields to skip over (default=[]). + * @return int|array Number of fields preloaded, or array of details (if debug) + * @since 3.0.243 + * + */ + public function preloadAllFields(Page $page, $options = array()) { + $fieldNames = []; + $skipFieldNames = isset($options['skipFieldNames']) ? $options['skipFieldNames'] : false; + foreach($page->template->fieldgroup as $field) { + if($skipFieldNames && in_array($field->name, $skipFieldNames)) continue; + $fieldNames[] = $field->name; + } + return $this->preloadFields($page, $fieldNames, $options); + } + + /** + * Skip preloading of this field or fieldtype? + * + * Returns populated string with reason if yes, or blank string if no. + * + * @param Page $page + * @param Field $field + * @param array $options + * @return string + * + */ + protected function skipPreloadField(Page $page, Field $field, array $options) { + + static $fieldtypeErrors = []; + + $useFieldtypeMulti = isset($options['useFieldtypeMulti']) ? $options['useFieldtypeMulti'] : false; + $error = ''; + + if($page->_parentGet($field->name) !== null) { + $error = 'Already loaded'; + } else if(!$page->template->fieldgroup->hasField($field)) { + $error = "Template '$page->template' does not have field"; + } else if(!$field->getTable()) { + $error = 'Field has no table'; + } + + if($error) return $error; + + $fieldtype = $field->type; + $shortName = $fieldtype->shortName; + + if(isset($fieldtypeErrors[$shortName])) return $fieldtypeErrors[$shortName]; + + // fieldtype status not yet known + $schema = $fieldtype->getDatabaseSchema($field); + $xtra = isset($schema['xtra']) ? $schema['xtra'] : []; + + if($fieldtype instanceof FieldtypeMulti) { + if($useFieldtypeMulti) { + // allow group_concat for FieldtypeMulti + } else if($fieldtype instanceof FieldtypePage && $field->get('derefAsPage') > 0) { + // allow single-page matches + } else { + $error = "$shortName: Unsupported"; + } + } else if($fieldtype instanceof FieldtypeFieldsetOpen) { + $error = 'Fieldset: Unsupported'; + } + + if(!$error && isset($xtra['all']) && $xtra['all'] === false) { + if($shortName !== 'Repeater' && $shortName !== 'RepeaterMatrix') { + $error = "$shortName: External storage"; + } + } + + if(!$error) { + $ref = new \ReflectionClass($fieldtype); + // identify parent class that implements loadPageField method + $info = $ref->getMethod('___loadPageField'); + $class = wireClassName($info->class); + // whitelist of classes with custom loadPageField methods we support + $rootClasses = [ + 'Fieldtype', + 'FieldtypeMulti', + 'FieldtypeTextarea', + 'FieldtypeTextareaLanguage' + ]; + if(!in_array($class, $rootClasses)) { + $error = "$shortName: Has custom loader"; + } + } + + $fieldtypeErrors[$shortName] = $error; + + return $error; + } + /** * Remove pages from already-loaded PageArray aren't visible or accessible * diff --git a/wire/modules/Fieldtype/FieldtypePage.module b/wire/modules/Fieldtype/FieldtypePage.module index 9c62645a..04610629 100644 --- a/wire/modules/Fieldtype/FieldtypePage.module +++ b/wire/modules/Fieldtype/FieldtypePage.module @@ -257,6 +257,7 @@ class FieldtypePage extends FieldtypeMulti implements Module, ConfigurableModule * */ protected function wakeupValueToArray($value) { + $value = (string) $value; if(strpos($value, '|') !== false) { $value = explode('|', $value); } else if(strpos($value, ',') !== false) {