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) {