diff options
Diffstat (limited to 'core')
151 files changed, 3580 insertions, 927 deletions
diff --git a/core/.phpstan-baseline.php b/core/.phpstan-baseline.php index e52a99b406fc..07367cd8a2d0 100644 --- a/core/.phpstan-baseline.php +++ b/core/.phpstan-baseline.php @@ -48957,18 +48957,6 @@ $ignoreErrors[] = [ 'path' => __DIR__ . '/tests/Drupal/TestSite/Commands/TestSiteInstallCommand.php', ]; $ignoreErrors[] = [ - 'message' => '#^Method Drupal\\\\TestSite\\\\Commands\\\\TestSiteInstallCommand\\:\\:changeDatabasePrefix\\(\\) has no return type specified\\.$#', - 'identifier' => 'missingType.return', - 'count' => 1, - 'path' => __DIR__ . '/tests/Drupal/TestSite/Commands/TestSiteInstallCommand.php', -]; -$ignoreErrors[] = [ - 'message' => '#^Method Drupal\\\\TestSite\\\\Commands\\\\TestSiteInstallCommand\\:\\:changeDatabasePrefixTrait\\(\\) has no return type specified\\.$#', - 'identifier' => 'missingType.return', - 'count' => 1, - 'path' => __DIR__ . '/tests/Drupal/TestSite/Commands/TestSiteInstallCommand.php', -]; -$ignoreErrors[] = [ 'message' => '#^Method Drupal\\\\TestSite\\\\Commands\\\\TestSiteInstallCommand\\:\\:doInstall\\(\\) has no return type specified\\.$#', 'identifier' => 'missingType.return', 'count' => 1, @@ -49041,12 +49029,6 @@ $ignoreErrors[] = [ 'path' => __DIR__ . '/tests/Drupal/TestSite/Commands/TestSiteInstallCommand.php', ]; $ignoreErrors[] = [ - 'message' => '#^Method Drupal\\\\TestSite\\\\Commands\\\\TestSiteInstallCommand\\:\\:prepareDatabasePrefix\\(\\) has no return type specified\\.$#', - 'identifier' => 'missingType.return', - 'count' => 1, - 'path' => __DIR__ . '/tests/Drupal/TestSite/Commands/TestSiteInstallCommand.php', -]; -$ignoreErrors[] = [ 'message' => '#^Method Drupal\\\\TestSite\\\\Commands\\\\TestSiteInstallCommand\\:\\:prepareEnvironment\\(\\) has no return type specified\\.$#', 'identifier' => 'missingType.return', 'count' => 1, @@ -49162,12 +49144,6 @@ $ignoreErrors[] = [ 'path' => __DIR__ . '/tests/Drupal/Tests/BrowserTestBase.php', ]; $ignoreErrors[] = [ - 'message' => '#^Method Drupal\\\\Tests\\\\BrowserTestBase\\:\\:changeDatabasePrefix\\(\\) has no return type specified\\.$#', - 'identifier' => 'missingType.return', - 'count' => 1, - 'path' => __DIR__ . '/tests/Drupal/Tests/BrowserTestBase.php', -]; -$ignoreErrors[] = [ 'message' => '#^Method Drupal\\\\Tests\\\\BrowserTestBase\\:\\:cleanupEnvironment\\(\\) has no return type specified\\.$#', 'identifier' => 'missingType.return', 'count' => 1, @@ -49288,12 +49264,6 @@ $ignoreErrors[] = [ 'path' => __DIR__ . '/tests/Drupal/Tests/BrowserTestBase.php', ]; $ignoreErrors[] = [ - 'message' => '#^Method Drupal\\\\Tests\\\\BrowserTestBase\\:\\:prepareDatabasePrefix\\(\\) has no return type specified\\.$#', - 'identifier' => 'missingType.return', - 'count' => 1, - 'path' => __DIR__ . '/tests/Drupal/Tests/BrowserTestBase.php', -]; -$ignoreErrors[] = [ 'message' => '#^Method Drupal\\\\Tests\\\\BrowserTestBase\\:\\:prepareEnvironment\\(\\) has no return type specified\\.$#', 'identifier' => 'missingType.return', 'count' => 1, @@ -52116,18 +52086,6 @@ $ignoreErrors[] = [ 'path' => __DIR__ . '/tests/Drupal/Tests/Core/Test/TestDiscoveryTest.php', ]; $ignoreErrors[] = [ - 'message' => '#^Method class@anonymous/core/tests/Drupal/Tests/Core/Test/TestSetupTraitTest\\.php\\:39\\:\\:changeDatabasePrefix\\(\\) has no return type specified\\.$#', - 'identifier' => 'missingType.return', - 'count' => 1, - 'path' => __DIR__ . '/tests/Drupal/Tests/Core/Test/TestSetupTraitTest.php', -]; -$ignoreErrors[] = [ - 'message' => '#^Method class@anonymous/core/tests/Drupal/Tests/Core/Test/TestSetupTraitTest\\.php\\:39\\:\\:prepareDatabasePrefix\\(\\) has no return type specified\\.$#', - 'identifier' => 'missingType.return', - 'count' => 1, - 'path' => __DIR__ . '/tests/Drupal/Tests/Core/Test/TestSetupTraitTest.php', -]; -$ignoreErrors[] = [ 'message' => '#^Method Drupal\\\\Tests\\\\Core\\\\Theme\\\\AjaxBasePageNegotiatorTest\\:\\:providerTestApplies\\(\\) has no return type specified\\.$#', 'identifier' => 'missingType.return', 'count' => 1, diff --git a/core/core.libraries.yml b/core/core.libraries.yml index fa763c94eb17..4803c6599b8a 100644 --- a/core/core.libraries.yml +++ b/core/core.libraries.yml @@ -632,6 +632,21 @@ drupal.htmx: theme_token: null ajaxTrustedUrl: {} +drupal.item-list: + version: VERSION + css: + component: + misc/components/item-list.module.css: { weight: -10 } + moved_files: + system/base: + deprecation_version: 11.3.0 + removed_version: 12.0.0 + deprecation_link: https://www.drupal.org/node/3530832 + css: + component: + css/components/item-list.module.css: + base: misc/components/item-list.module.css + drupal.machine-name: version: VERSION js: diff --git a/core/core.services.yml b/core/core.services.yml index 31b7a5b6aa52..f1df7c0200ea 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -771,6 +771,9 @@ services: class: Drupal\Core\Field\FieldDefinitionListener arguments: ['@entity_type.manager', '@entity_field.manager', '@keyvalue', '@cache.discovery'] Drupal\Core\Field\FieldDefinitionListenerInterface: '@field_definition.listener' + Drupal\Core\Field\FieldPreprocess: + class: Drupal\Core\Field\FieldPreprocess + autowire: true entity.form_builder: class: Drupal\Core\Entity\EntityFormBuilder arguments: ['@entity_type.manager', '@form_builder'] diff --git a/core/includes/theme.inc b/core/includes/theme.inc index 9e271ceaf8f0..5a53d94962b5 100644 --- a/core/includes/theme.inc +++ b/core/includes/theme.inc @@ -9,6 +9,7 @@ */ use Drupal\Core\Datetime\DatePreprocess; +use Drupal\Core\Field\FieldPreprocess; use Drupal\Core\Theme\ThemePreprocess; use Drupal\Core\Url; use Drupal\Component\Utility\Html; @@ -803,6 +804,7 @@ function template_preprocess_table(&$variables): void { */ function template_preprocess_item_list(&$variables): void { $variables['wrapper_attributes'] = new Attribute($variables['wrapper_attributes']); + $variables['#attached']['library'][] = 'core/drupal.item-list'; foreach ($variables['items'] as &$item) { $attributes = []; // If the item value is an array, then it is a render array. @@ -1141,55 +1143,15 @@ function template_preprocess_region(&$variables): void { * - element: A render element representing the field. * - attributes: A string containing the attributes for the wrapping div. * - title_attributes: A string containing the attributes for the title. + * + * @deprecated in drupal:11.3.0 and is removed from drupal:12.0.0. Initial + * template_preprocess functions are registered directly in hook_theme(). + * + * @see https://www.drupal.org/node/3504125 */ function template_preprocess_field(&$variables, $hook): void { - $element = $variables['element']; - - // Creating variables for the template. - $variables['entity_type'] = $element['#entity_type']; - $variables['field_name'] = $element['#field_name']; - $variables['field_type'] = $element['#field_type']; - $variables['label_display'] = $element['#label_display']; - - $variables['label_hidden'] = ($element['#label_display'] == 'hidden'); - // Always set the field label - allow themes to decide whether to display it. - // In addition the label should be rendered but hidden to support screen - // readers. - $variables['label'] = $element['#title']; - - $variables['multiple'] = $element['#is_multiple']; - - static $default_attributes; - if (!isset($default_attributes)) { - $default_attributes = new Attribute(); - } - - // Merge attributes when a single-value field has a hidden label. - if ($element['#label_display'] == 'hidden' && !$variables['multiple'] && !empty($element['#items'][0]->_attributes)) { - $variables['attributes'] = AttributeHelper::mergeCollections($variables['attributes'], (array) $element['#items'][0]->_attributes); - } - - // We want other preprocess functions and the theme implementation to have - // fast access to the field item render arrays. The item render array keys - // (deltas) should always be numerically indexed starting from 0, and looping - // on those keys is faster than calling Element::children() or looping on all - // keys within $element, since that requires traversal of all element - // properties. - $variables['items'] = []; - $delta = 0; - while (!empty($element[$delta])) { - $variables['items'][$delta]['content'] = $element[$delta]; - - // Modules can add field item attributes (to - // $item->_attributes) within hook_entity_prepare_view(). Some field - // formatters move those attributes into some nested formatter-specific - // element in order have them rendered on the desired HTML element (e.g., on - // the <a> element of a field item being rendered as a link). Other field - // formatters leave them within $element['#items'][$delta]['_attributes'] to - // be rendered on the item wrappers provided by field.html.twig. - $variables['items'][$delta]['attributes'] = !empty($element['#items'][$delta]->_attributes) ? new Attribute($element['#items'][$delta]->_attributes) : clone($default_attributes); - $delta++; - } + @trigger_error(__FUNCTION__ . '() is deprecated in drupal:11.3.0 and is removed from drupal:12.0.0. Initial template_preprocess functions are registered directly in hook_theme(). See https://www.drupal.org/node/3504125', E_USER_DEPRECATED); + \Drupal::service(FieldPreprocess::class)->preprocessField($variables); } /** @@ -1202,112 +1164,15 @@ function template_preprocess_field(&$variables, $hook): void { * @param array $variables * An associative array containing: * - element: A render element representing the form element. + * + * @deprecated in drupal:11.3.0 and is removed from drupal:12.0.0. Initial + * template_preprocess functions are registered directly in hook_theme(). + * + * @see https://www.drupal.org/node/3504125 */ function template_preprocess_field_multiple_value_form(&$variables): void { - $element = $variables['element']; - $variables['multiple'] = $element['#cardinality_multiple']; - $variables['attributes'] = $element['#attributes']; - - if ($variables['multiple']) { - $table_id = Html::getUniqueId($element['#field_name'] . '_values'); - // Using table id allows handing nested content with the same field names. - $order_class = $table_id . '-delta-order'; - $header_attributes = new Attribute(['class' => ['label']]); - if (!empty($element['#required'])) { - $header_attributes['class'][] = 'js-form-required'; - $header_attributes['class'][] = 'form-required'; - } - $header = [ - [ - 'data' => [ - '#type' => 'html_tag', - '#tag' => 'h4', - '#value' => $element['#title'], - '#attributes' => $header_attributes, - ], - 'colspan' => 2, - 'class' => ['field-label'], - ], - [], - t('Order', [], ['context' => 'Sort order']), - ]; - $rows = []; - - // Sort items according to '_weight' (needed when the form comes back after - // preview or failed validation). - $items = []; - $variables['button'] = []; - foreach (Element::children($element) as $key) { - if ($key === 'add_more') { - $variables['button'] = &$element[$key]; - } - else { - $items[] = &$element[$key]; - } - } - usort($items, '_field_multiple_value_form_sort_helper'); - - // Add the items as table rows. - foreach ($items as $item) { - $item['_weight']['#attributes']['class'] = [$order_class]; - - // Remove weight form element from item render array so it can be rendered - // in a separate table column. - $delta_element = $item['_weight']; - unset($item['_weight']); - - // Render actions in a separate column. - $actions = []; - if (isset($item['_actions'])) { - $actions = $item['_actions']; - unset($item['_actions']); - } - - $cells = [ - ['data' => '', 'class' => ['field-multiple-drag']], - ['data' => $item], - ['data' => $actions], - ['data' => $delta_element, 'class' => ['delta-order']], - ]; - $rows[] = [ - 'data' => $cells, - 'class' => ['draggable'], - ]; - } - - $variables['table'] = [ - '#type' => 'table', - '#header' => $header, - '#rows' => $rows, - '#attributes' => [ - 'id' => $table_id, - 'class' => ['field-multiple-table'], - ], - '#tabledrag' => [ - [ - 'action' => 'order', - 'relationship' => 'sibling', - 'group' => $order_class, - ], - ], - ]; - - if (!empty($element['#description'])) { - $description_id = $element['#attributes']['aria-describedby']; - $description_attributes['id'] = $description_id; - $variables['description']['attributes'] = new Attribute($description_attributes); - $variables['description']['content'] = $element['#description']; - - // Add the description's id to the table aria attributes. - $variables['table']['#attributes']['aria-describedby'] = $element['#attributes']['aria-describedby']; - } - } - else { - $variables['elements'] = []; - foreach (Element::children($element) as $key) { - $variables['elements'][] = $element[$key]; - } - } + @trigger_error(__FUNCTION__ . '() is deprecated in drupal:11.3.0 and is removed from drupal:12.0.0. Initial template_preprocess functions are registered directly in hook_theme(). See https://www.drupal.org/node/3504125', E_USER_DEPRECATED); + \Drupal::service(FieldPreprocess::class)->preprocessFieldMultipleValueForm($variables); } /** @@ -1542,11 +1407,17 @@ function template_preprocess_menu_local_action(&$variables): void { } /** - * Callback for usort() within template_preprocess_field_multiple_value_form(). + * Callback for usort() of field item form elements. * * Sorts using ['_weight']['#value'] + * + * @deprecated in drupal:11.3.0 and is removed from drupal:12.0.0. There is no + * replacement. + * + * @see https://www.drupal.org/node/3504125 */ function _field_multiple_value_form_sort_helper($a, $b) { + @trigger_error(__FUNCTION__ . '() is deprecated in drupal:11.3.0 and is removed from drupal:12.0.0. Initial template_preprocess functions are registered directly in hook_theme(). See https://www.drupal.org/node/3504125', E_USER_DEPRECATED); $a_weight = (is_array($a) && isset($a['_weight']['#value']) ? $a['_weight']['#value'] : 0); $b_weight = (is_array($b) && isset($b['_weight']['#value']) ? $b['_weight']['#value'] : 0); return $a_weight - $b_weight; diff --git a/core/lib/Drupal/Core/Ajax/AjaxResponse.php b/core/lib/Drupal/Core/Ajax/AjaxResponse.php index 87fb3588cb4d..e56385da2628 100644 --- a/core/lib/Drupal/Core/Ajax/AjaxResponse.php +++ b/core/lib/Drupal/Core/Ajax/AjaxResponse.php @@ -56,6 +56,23 @@ class AjaxResponse extends JsonResponse implements AttachmentsInterface { } /** + * Merges other ajax response with this one. + * + * Adds commands and merges attachments from the other ajax response. + * + * @param \Drupal\Core\Ajax\AjaxResponse $other + * An AJAX response to merge. + * + * @return $this + * Returns this after merging. + */ + public function mergeWith(AjaxResponse $other): AjaxResponse { + $this->commands = array_merge($this->getCommands(), $other->getCommands()); + $this->attachments = BubbleableMetadata::mergeAttachments($this->getAttachments(), $other->getAttachments()); + return $this; + } + + /** * Gets all AJAX commands. * * @return array diff --git a/core/lib/Drupal/Core/Asset/CssCollectionOptimizerLazy.php b/core/lib/Drupal/Core/Asset/CssCollectionOptimizerLazy.php index 8d3fd93eceeb..f099aaf719b9 100644 --- a/core/lib/Drupal/Core/Asset/CssCollectionOptimizerLazy.php +++ b/core/lib/Drupal/Core/Asset/CssCollectionOptimizerLazy.php @@ -148,7 +148,14 @@ class CssCollectionOptimizerLazy implements AssetCollectionGroupOptimizerInterfa $data .= "/* @license " . $css_asset['license']['name'] . " " . $css_asset['license']['url'] . " */\n"; } $current_license = $css_asset['license']; - $data .= $this->optimizer->optimize($css_asset); + + // Append this file if already minified; otherwise optimize it. + if (isset($css_asset['minified']) && $css_asset['minified']) { + $data .= file_get_contents($css_asset['data']); + } + else { + $data .= $this->optimizer->optimize($css_asset); + } } // Per the W3C specification at // https://www.w3.org/TR/REC-CSS2/cascade.html#at-import, @import rules must diff --git a/core/lib/Drupal/Core/Command/DbCommandBase.php b/core/lib/Drupal/Core/Command/DbCommandBase.php index c333c8a00c9b..53ad859383cb 100644 --- a/core/lib/Drupal/Core/Command/DbCommandBase.php +++ b/core/lib/Drupal/Core/Command/DbCommandBase.php @@ -38,7 +38,7 @@ class DbCommandBase extends Command { if (Database::getConnectionInfo('db-tools')) { throw new \RuntimeException('Database "db-tools" is already defined. Cannot define database provided.'); } - $info = Database::convertDbUrlToConnectionInfo($input->getOption('database-url'), \Drupal::root()); + $info = Database::convertDbUrlToConnectionInfo($input->getOption('database-url')); Database::addConnectionInfo('db-tools', 'default', $info); $key = 'db-tools'; } diff --git a/core/lib/Drupal/Core/Database/Database.php b/core/lib/Drupal/Core/Database/Database.php index e76bc2d6991f..23b5b79c7b65 100644 --- a/core/lib/Drupal/Core/Database/Database.php +++ b/core/lib/Drupal/Core/Database/Database.php @@ -13,6 +13,8 @@ use Drupal\Core\Cache\NullBackend; * This class is un-extendable. It acts to encapsulate all control and * shepherding of database connections into a single location without the use of * globals. + * + * @final */ abstract class Database { @@ -495,8 +497,8 @@ abstract class Database { * * @param string $url * The URL. - * @param string $root - * The root directory of the Drupal installation. + * @param string|bool|null $root + * (deprecated) The root directory of the Drupal installation. * @param bool|null $include_test_drivers * (optional) Whether to include test extensions. If FALSE, all 'tests' * directories are excluded in the search. When NULL will be determined by @@ -511,7 +513,16 @@ abstract class Database { * @throws \RuntimeException * Exception thrown when a module provided database driver does not exist. */ - public static function convertDbUrlToConnectionInfo($url, $root, ?bool $include_test_drivers = NULL) { + public static function convertDbUrlToConnectionInfo(string $url, $root = NULL, ?bool $include_test_drivers = NULL): array { + if ($root !== NULL) { + if (is_bool($root)) { + $include_test_drivers = $root; + } + else { + @trigger_error("Passing a string \$root value to " . __METHOD__ . "() is deprecated in drupal:11.3.0 and will be removed in drupal:12.0.0. There is no replacement. See https://www.drupal.org/node/3511287", E_USER_DEPRECATED); + } + } + // Check that the URL is well formed, starting with 'scheme://', where // 'scheme' is a database driver name. if (preg_match('/^(.*):\/\//', $url, $matches) !== 1) { diff --git a/core/lib/Drupal/Core/Database/Exception/SchemaPrimaryKeyMustBeDroppedException.php b/core/lib/Drupal/Core/Database/Exception/SchemaPrimaryKeyMustBeDroppedException.php new file mode 100644 index 000000000000..799e9ca9560f --- /dev/null +++ b/core/lib/Drupal/Core/Database/Exception/SchemaPrimaryKeyMustBeDroppedException.php @@ -0,0 +1,12 @@ +<?php + +namespace Drupal\Core\Database\Exception; + +use Drupal\Core\Database\DatabaseException; +use Drupal\Core\Database\SchemaException; + +/** + * Exception thrown if the Primary Key must be dropped before an operation. + */ +class SchemaPrimaryKeyMustBeDroppedException extends SchemaException implements DatabaseException { +} diff --git a/core/lib/Drupal/Core/Database/Statement/PdoResult.php b/core/lib/Drupal/Core/Database/Statement/PdoResult.php index 1353ea8e8ad7..f046001076af 100644 --- a/core/lib/Drupal/Core/Database/Statement/PdoResult.php +++ b/core/lib/Drupal/Core/Database/Statement/PdoResult.php @@ -31,6 +31,18 @@ class PdoResult extends ResultBase { } /** + * Returns the client-level database PDO statement object. + * + * This method should normally be used only within database driver code. + * + * @return \PDOStatement + * The client-level database PDO statement. + */ + public function getClientStatement(): \PDOStatement { + return $this->clientStatement; + } + + /** * {@inheritdoc} */ public function rowCount(): ?int { diff --git a/core/lib/Drupal/Core/Database/Statement/PdoTrait.php b/core/lib/Drupal/Core/Database/Statement/PdoTrait.php index 46b913727696..d8bf62b4fdff 100644 --- a/core/lib/Drupal/Core/Database/Statement/PdoTrait.php +++ b/core/lib/Drupal/Core/Database/Statement/PdoTrait.php @@ -49,23 +49,17 @@ trait PdoTrait { } /** - * Returns the client-level database PDO statement object. + * Returns the client-level database statement object. * * This method should normally be used only within database driver code. * - * @return \PDOStatement - * The client-level database PDO statement. + * @return object + * The client-level database statement. * * @throws \RuntimeException * If the client-level statement is not set. */ - public function getClientStatement(): \PDOStatement { - if (isset($this->clientStatement)) { - assert($this->clientStatement instanceof \PDOStatement); - return $this->clientStatement; - } - throw new \LogicException('\\PDOStatement not initialized'); - } + abstract public function getClientStatement(): object; /** * Sets the default fetch mode for the PDO statement. diff --git a/core/lib/Drupal/Core/Database/Statement/StatementBase.php b/core/lib/Drupal/Core/Database/Statement/StatementBase.php index 98fa378d58f4..e266616975f9 100644 --- a/core/lib/Drupal/Core/Database/Statement/StatementBase.php +++ b/core/lib/Drupal/Core/Database/Statement/StatementBase.php @@ -86,6 +86,36 @@ abstract class StatementBase implements \Iterator, StatementInterface { } /** + * Determines if the client-level database statement object exists. + * + * This method should normally be used only within database driver code. + * + * @return bool + * TRUE if the client statement exists, FALSE otherwise. + */ + public function hasClientStatement(): bool { + return isset($this->clientStatement); + } + + /** + * Returns the client-level database statement object. + * + * This method should normally be used only within database driver code. + * + * @return object + * The client-level database statement. + * + * @throws \RuntimeException + * If the client-level statement is not set. + */ + public function getClientStatement(): object { + if ($this->hasClientStatement()) { + return $this->clientStatement; + } + throw new \LogicException('Client statement not initialized'); + } + + /** * {@inheritdoc} */ public function getConnectionTarget(): string { diff --git a/core/lib/Drupal/Core/Database/StatementPrefetchIterator.php b/core/lib/Drupal/Core/Database/StatementPrefetchIterator.php index 8a2a73f1bf7c..dc1d3c98eb39 100644 --- a/core/lib/Drupal/Core/Database/StatementPrefetchIterator.php +++ b/core/lib/Drupal/Core/Database/StatementPrefetchIterator.php @@ -97,6 +97,25 @@ class StatementPrefetchIterator extends StatementBase { } /** + * Returns the client-level database PDO statement object. + * + * This method should normally be used only within database driver code. + * + * @return \PDOStatement + * The client-level database PDO statement. + * + * @throws \RuntimeException + * If the client-level statement is not set. + */ + public function getClientStatement(): \PDOStatement { + if (isset($this->clientStatement)) { + assert($this->clientStatement instanceof \PDOStatement); + return $this->clientStatement; + } + throw new \LogicException('\\PDOStatement not initialized'); + } + + /** * {@inheritdoc} */ public function execute($args = [], $options = []) { diff --git a/core/lib/Drupal/Core/Database/StatementWrapperIterator.php b/core/lib/Drupal/Core/Database/StatementWrapperIterator.php index f580d645cad4..27293131a906 100644 --- a/core/lib/Drupal/Core/Database/StatementWrapperIterator.php +++ b/core/lib/Drupal/Core/Database/StatementWrapperIterator.php @@ -48,6 +48,25 @@ class StatementWrapperIterator extends StatementBase { } /** + * Returns the client-level database PDO statement object. + * + * This method should normally be used only within database driver code. + * + * @return \PDOStatement + * The client-level database PDO statement. + * + * @throws \RuntimeException + * If the client-level statement is not set. + */ + public function getClientStatement(): \PDOStatement { + if (isset($this->clientStatement)) { + assert($this->clientStatement instanceof \PDOStatement); + return $this->clientStatement; + } + throw new \LogicException('\\PDOStatement not initialized'); + } + + /** * {@inheritdoc} */ public function execute($args = [], $options = []) { @@ -71,7 +90,7 @@ class StatementWrapperIterator extends StatementBase { $this->result = new PdoResult( $this->fetchMode, $this->fetchOptions, - $this->clientStatement, + $this->getClientStatement(), ); $this->markResultsetIterable($return); } diff --git a/core/lib/Drupal/Core/EventSubscriber/ConfigImportSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/ConfigImportSubscriber.php index a00d087e8347..d4185e82669b 100644 --- a/core/lib/Drupal/Core/EventSubscriber/ConfigImportSubscriber.php +++ b/core/lib/Drupal/Core/EventSubscriber/ConfigImportSubscriber.php @@ -7,6 +7,7 @@ use Drupal\Core\Config\ConfigImporter; use Drupal\Core\Config\ConfigImporterEvent; use Drupal\Core\Config\ConfigImportValidateEventSubscriberBase; use Drupal\Core\Config\ConfigNameException; +use Drupal\Core\Database\Connection; use Drupal\Core\Extension\ConfigImportModuleUninstallValidatorInterface; use Drupal\Core\Extension\ModuleExtensionList; use Drupal\Core\Extension\ThemeExtensionList; @@ -48,12 +49,15 @@ class ConfigImportSubscriber extends ConfigImportValidateEventSubscriberBase { * The module extension list. * @param \Traversable $uninstallValidators * The uninstall validator services. + * @param \Drupal\Core\Database\Connection $connection + * The database connection. */ public function __construct( ThemeExtensionList $theme_extension_list, ModuleExtensionList $extension_list_module, #[AutowireIterator(tag: 'module_install.uninstall_validator')] protected \Traversable $uninstallValidators, + protected readonly Connection $connection, ) { $this->themeList = $theme_extension_list; $this->moduleExtensionList = $extension_list_module; @@ -103,6 +107,7 @@ class ConfigImportSubscriber extends ConfigImportValidateEventSubscriberBase { $current_core_extension = $config_importer->getStorageComparer()->getTargetStorage()->read('core.extension'); $install_profile = $current_core_extension['profile'] ?? NULL; $new_install_profile = $core_extension['profile'] ?? NULL; + $database_driver_module = $this->connection->getProvider(); // Ensure the profile is not changing. if ($install_profile !== $new_install_profile) { @@ -159,7 +164,10 @@ class ConfigImportSubscriber extends ConfigImportValidateEventSubscriberBase { $uninstalls = $config_importer->getExtensionChangelist('module', 'uninstall'); foreach ($uninstalls as $module) { foreach (array_keys($module_data[$module]->required_by) as $dependent_module) { - if ($module_data[$dependent_module]->status && !in_array($dependent_module, $uninstalls, TRUE) && $dependent_module !== $install_profile) { + if ($module_data[$dependent_module]->status && + !in_array($dependent_module, $uninstalls, TRUE) && + !in_array($dependent_module, [$install_profile, $database_driver_module], TRUE) + ) { $module_name = $module_data[$module]->info['name']; $dependent_module_name = $module_data[$dependent_module]->info['name']; $config_importer->logError($this->t('Unable to uninstall the %module module since the %dependent_module module is installed.', [ diff --git a/core/lib/Drupal/Core/Field/FieldPreprocess.php b/core/lib/Drupal/Core/Field/FieldPreprocess.php new file mode 100644 index 000000000000..4f06ce2403fd --- /dev/null +++ b/core/lib/Drupal/Core/Field/FieldPreprocess.php @@ -0,0 +1,205 @@ +<?php + +namespace Drupal\Core\Field; + +use Drupal\Component\Utility\Html; +use Drupal\Core\Render\Element; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\Core\Template\Attribute; +use Drupal\Core\Template\AttributeHelper; + +/** + * Field theme preprocess. + * + * @internal + */ +class FieldPreprocess { + + use StringTranslationTrait; + + /** + * Prepares variables for field templates. + * + * Default template: field.html.twig. + * + * @param array $variables + * An associative array containing: + * - element: A render element representing the field. + * - attributes: A string containing the attributes for the wrapping div. + * - title_attributes: A string containing the attributes for the title. + */ + public function preprocessField(array &$variables): void { + $element = $variables['element']; + + // Creating variables for the template. + $variables['entity_type'] = $element['#entity_type']; + $variables['field_name'] = $element['#field_name']; + $variables['field_type'] = $element['#field_type']; + $variables['label_display'] = $element['#label_display']; + + $variables['label_hidden'] = ($element['#label_display'] == 'hidden'); + // Always set the field label - allow themes to decide whether to display + // it. In addition the label should be rendered but hidden to support screen + // readers. + $variables['label'] = $element['#title']; + + $variables['multiple'] = $element['#is_multiple']; + + static $default_attributes; + if (!isset($default_attributes)) { + $default_attributes = new Attribute(); + } + + // Merge attributes when a single-value field has a hidden label. + if ($element['#label_display'] == 'hidden' && !$variables['multiple'] && !empty($element['#items'][0]->_attributes)) { + $variables['attributes'] = AttributeHelper::mergeCollections($variables['attributes'], (array) $element['#items'][0]->_attributes); + } + + // We want other preprocess functions and the theme implementation to have + // fast access to the field item render arrays. The item render array keys + // (deltas) should always be numerically indexed starting from 0, and + // looping on those keys is faster than calling Element::children() or + // looping on all keys within $element, since that requires traversal of all + // element properties. + $variables['items'] = []; + $delta = 0; + while (!empty($element[$delta])) { + $variables['items'][$delta]['content'] = $element[$delta]; + + // Modules can add field item attributes (to + // $item->_attributes) within hook_entity_prepare_view(). Some field + // formatters move those attributes into some nested formatter-specific + // element in order have them rendered on the desired HTML element (e.g., + // on the <a> element of a field item being rendered as a link). Other + // field formatters leave them within + // $element['#items'][$delta]['_attributes'] to be rendered on the item + // wrappers provided by field.html.twig. + $variables['items'][$delta]['attributes'] = !empty($element['#items'][$delta]->_attributes) ? new Attribute($element['#items'][$delta]->_attributes) : clone($default_attributes); + $delta++; + } + } + + /** + * Prepares variables for individual form element templates. + * + * Default template: field-multiple-value-form.html.twig. + * + * Combines multiple values into a table with drag-n-drop reordering. + * + * @param array $variables + * An associative array containing: + * - element: A render element representing the form element. + */ + public function preprocessFieldMultipleValueForm(array &$variables): void { + $element = $variables['element']; + $variables['multiple'] = $element['#cardinality_multiple']; + $variables['attributes'] = $element['#attributes']; + + if ($variables['multiple']) { + $table_id = Html::getUniqueId($element['#field_name'] . '_values'); + // Using table id allows handing nested content with the same field names. + $order_class = $table_id . '-delta-order'; + $header_attributes = new Attribute(['class' => ['label']]); + if (!empty($element['#required'])) { + $header_attributes['class'][] = 'js-form-required'; + $header_attributes['class'][] = 'form-required'; + } + $header = [ + [ + 'data' => [ + '#type' => 'html_tag', + '#tag' => 'h4', + '#value' => $element['#title'], + '#attributes' => $header_attributes, + ], + 'colspan' => 2, + 'class' => ['field-label'], + ], + [], + $this->t('Order', [], ['context' => 'Sort order']), + ]; + $rows = []; + + // Sort items according to '_weight' (needed when the form comes back + // after preview or failed validation). + $items = []; + $variables['button'] = []; + foreach (Element::children($element) as $key) { + if ($key === 'add_more') { + $variables['button'] = &$element[$key]; + } + else { + $items[] = &$element[$key]; + } + } + usort($items, function ($a, $b) { + // Sorts using ['_weight']['#value']. + $a_weight = (is_array($a) && isset($a['_weight']['#value']) ? $a['_weight']['#value'] : 0); + $b_weight = (is_array($b) && isset($b['_weight']['#value']) ? $b['_weight']['#value'] : 0); + return $a_weight - $b_weight; + }); + + // Add the items as table rows. + foreach ($items as $item) { + $item['_weight']['#attributes']['class'] = [$order_class]; + + // Remove weight form element from item render array so it can be + // rendered in a separate table column. + $delta_element = $item['_weight']; + unset($item['_weight']); + + // Render actions in a separate column. + $actions = []; + if (isset($item['_actions'])) { + $actions = $item['_actions']; + unset($item['_actions']); + } + + $cells = [ + ['data' => '', 'class' => ['field-multiple-drag']], + ['data' => $item], + ['data' => $actions], + ['data' => $delta_element, 'class' => ['delta-order']], + ]; + $rows[] = [ + 'data' => $cells, + 'class' => ['draggable'], + ]; + } + + $variables['table'] = [ + '#type' => 'table', + '#header' => $header, + '#rows' => $rows, + '#attributes' => [ + 'id' => $table_id, + 'class' => ['field-multiple-table'], + ], + '#tabledrag' => [ + [ + 'action' => 'order', + 'relationship' => 'sibling', + 'group' => $order_class, + ], + ], + ]; + + if (!empty($element['#description'])) { + $description_id = $element['#attributes']['aria-describedby']; + $description_attributes['id'] = $description_id; + $variables['description']['attributes'] = new Attribute($description_attributes); + $variables['description']['content'] = $element['#description']; + + // Add the description's id to the table aria attributes. + $variables['table']['#attributes']['aria-describedby'] = $element['#attributes']['aria-describedby']; + } + } + else { + $variables['elements'] = []; + foreach (Element::children($element) as $key) { + $variables['elements'][] = $element[$key]; + } + } + } + +} diff --git a/core/lib/Drupal/Core/Form/FormBuilder.php b/core/lib/Drupal/Core/Form/FormBuilder.php index ee3e4893feca..ece07555fac2 100644 --- a/core/lib/Drupal/Core/Form/FormBuilder.php +++ b/core/lib/Drupal/Core/Form/FormBuilder.php @@ -602,16 +602,16 @@ class FormBuilder implements FormBuilderInterface, FormValidatorInterface, FormS return; } - // If $form_state->isRebuilding() has been set and input has been + // If $form_state->setRebuild(TRUE) was called and input has been // processed without validation errors, we are in a multi-step workflow - // that is not yet complete. A new $form needs to be constructed based on - // the changes made to $form_state during this request. Normally, a submit - // handler sets $form_state->isRebuilding() if a fully executed form - // requires another step. However, for forms that have not been fully - // executed (e.g., Ajax submissions triggered by non-buttons), there is no - // submit handler to set $form_state->isRebuilding(). It would not make - // sense to redisplay the identical form without an error for the user to - // correct, so we also rebuild error-free non-executed forms, regardless + // that is not yet complete. A new $form needs to be constructed based + // on the changes made to $form_state during this request. + // + // Typically, a submit handler calls $form_state->setRebuild(TRUE) when + // a fully executed form requires another step. However, for forms that + // have not been fully executed (e.g., AJAX submissions triggered by + // non-buttons), there is no submit handler to call setRebuild(). In + // that case, we also rebuild error-free, non-executed forms, regardless // of $form_state->isRebuilding(). // @todo Simplify this logic; considering Ajax and non-HTML front-ends, // along with element-level #submit properties, it makes no sense to diff --git a/core/lib/Drupal/Core/Test/TestSetupTrait.php b/core/lib/Drupal/Core/Test/TestSetupTrait.php index e68c9dba532b..b25af6f622b3 100644 --- a/core/lib/Drupal/Core/Test/TestSetupTrait.php +++ b/core/lib/Drupal/Core/Test/TestSetupTrait.php @@ -134,7 +134,7 @@ trait TestSetupTrait { * @see \Drupal\Tests\BrowserTestBase::prepareEnvironment() * @see drupal_valid_test_ua() */ - protected function prepareDatabasePrefix() { + protected function prepareDatabasePrefix(): void { $test_db = new TestDatabase(); $this->siteDirectory = $test_db->getTestSitePath(); $this->databasePrefix = $test_db->getDatabasePrefix(); @@ -143,7 +143,7 @@ trait TestSetupTrait { /** * Changes the database connection to the prefixed one. */ - protected function changeDatabasePrefix() { + protected function changeDatabasePrefix(): void { if (empty($this->databasePrefix)) { $this->prepareDatabasePrefix(); } @@ -154,7 +154,7 @@ trait TestSetupTrait { // Ensure no existing database gets in the way. If a default database // exists already it must be removed. Database::removeConnection('default'); - $database = Database::convertDbUrlToConnectionInfo($db_url, $this->root ?? DRUPAL_ROOT, TRUE); + $database = Database::convertDbUrlToConnectionInfo($db_url, TRUE); Database::addConnectionInfo('default', 'default', $database); } diff --git a/core/lib/Drupal/Core/Theme/Registry.php b/core/lib/Drupal/Core/Theme/Registry.php index 383776b65be8..3568c8b37d7d 100644 --- a/core/lib/Drupal/Core/Theme/Registry.php +++ b/core/lib/Drupal/Core/Theme/Registry.php @@ -40,6 +40,11 @@ class Registry implements DestructableInterface { private const string PREPROCESS_INVOKES = 'preprocess invokes'; /** + * A common key for storing preprocess callbacks used by every theme hook. + */ + private const string GLOBAL_PREPROCESS = 'global preprocess'; + + /** * The theme object representing the active theme for this registry. * * @var \Drupal\Core\Theme\ActiveTheme @@ -410,10 +415,6 @@ class Registry implements DestructableInterface { * @see hook_theme_registry_alter() */ protected function build() { - $cache = [ - static::PREPROCESS_INVOKES => [], - ]; - $fixed_preprocess_functions = $this->collectModulePreprocess($cache, 'preprocess'); // First, preprocess the theme hooks advertised by modules. This will // serve as the basic registry. Since the list of enabled modules is the // same regardless of the theme used, this is cached in its own entry to @@ -422,6 +423,10 @@ class Registry implements DestructableInterface { $cache = $cached->data; } else { + $cache = [ + static::PREPROCESS_INVOKES => [], + ]; + $cache[static::GLOBAL_PREPROCESS] = $this->collectModulePreprocess($cache, 'preprocess'); if (defined('MAINTENANCE_MODE') && constant('MAINTENANCE_MODE') === 'install') { // System is still set here so preprocess can be updated in install. $this->processExtension($cache, 'system', 'install', 'system', $this->moduleList->getPath('system')); @@ -431,7 +436,6 @@ class Registry implements DestructableInterface { $this->processExtension($cache, $module, 'module', $module, $this->moduleList->getPath($module)); }); } - $this->addFixedPreprocessFunctions($cache, $fixed_preprocess_functions); // Only cache this registry if all modules are loaded. if ($this->moduleHandler->isLoaded()) { @@ -439,8 +443,6 @@ class Registry implements DestructableInterface { } } - $old_cache = $cache; - // Process each base theme. // Ensure that we start with the root of the parents, so that both CSS files // and preprocess functions comes first. @@ -461,10 +463,6 @@ class Registry implements DestructableInterface { // Hooks provided by the theme itself. $this->processExtension($cache, $this->theme->getName(), 'theme', $this->theme->getName(), $this->theme->getPath()); - // Add the fixed preprocess functions to hooks defined by themes. They - // were already added to hooks defined by modules and potentially cached. - $this->addFixedPreprocessFunctions($cache, $fixed_preprocess_functions, $old_cache); - // Discover and add all preprocess functions for theme hook suggestions. $this->postProcessExtension($cache, $this->theme); @@ -941,35 +939,6 @@ class Registry implements DestructableInterface { } /** - * Adds $prefix_preprocess functions to every hook. - * - * @param array $cache - * The theme registry, as documented in - * \Drupal\Core\Theme\Registry::processExtension(). - * @param array $fixed_preprocess_functions - * A list of preprocess functions. - * @param array $old_cache - * An already processed theme registry. - */ - protected function addFixedPreprocessFunctions(array &$cache, array $fixed_preprocess_functions, array $old_cache = []): void { - foreach (array_keys(array_diff_key($cache, $old_cache)) as $hook) { - if ($hook == static::PREPROCESS_INVOKES) { - continue; - } - if (!isset($cache[$hook]['preprocess functions'])) { - $cache[$hook]['preprocess functions'] = $fixed_preprocess_functions; - } - else { - $offset = 0; - while (isset($cache[$hook]['preprocess functions'][$offset]) && is_string($cache[$hook]['preprocess functions'][$offset]) && str_starts_with($cache[$hook]['preprocess functions'][$offset], 'template_')) { - $offset++; - } - array_splice($cache[$hook]['preprocess functions'], $offset, 0, $fixed_preprocess_functions); - } - } - } - - /** * Collect module implementations of a single hook. * * @param array $cache diff --git a/core/lib/Drupal/Core/Theme/ThemeCommonElements.php b/core/lib/Drupal/Core/Theme/ThemeCommonElements.php index 5ddf58fd6f22..90994e47dca5 100644 --- a/core/lib/Drupal/Core/Theme/ThemeCommonElements.php +++ b/core/lib/Drupal/Core/Theme/ThemeCommonElements.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Drupal\Core\Theme; use Drupal\Core\Datetime\DatePreprocess; +use Drupal\Core\Field\FieldPreprocess; /** * Provide common theme render elements. @@ -241,9 +242,11 @@ class ThemeCommonElements { // From field system. 'field' => [ 'render element' => 'element', + 'initial preprocess' => FieldPreprocess::class . ':preprocessField', ], 'field_multiple_value_form' => [ 'render element' => 'element', + 'initial preprocess' => FieldPreprocess::class . ':preprocessFieldMultipleValueForm', ], 'off_canvas_page_wrapper' => [ 'variables' => [ diff --git a/core/lib/Drupal/Core/Theme/ThemeManager.php b/core/lib/Drupal/Core/Theme/ThemeManager.php index 3724ea6e1563..c7c5f2090705 100644 --- a/core/lib/Drupal/Core/Theme/ThemeManager.php +++ b/core/lib/Drupal/Core/Theme/ThemeManager.php @@ -288,27 +288,52 @@ class ThemeManager implements ThemeManagerInterface { } } + $invoke_preprocess_callback = function (mixed $preprocessor_function) use ($invoke_map, &$variables, $hook, $info): mixed { + // Preprocess hooks are stored as strings resembling functions. + // This is for backwards compatibility and may represent OOP + // implementations as well. + if (is_string($preprocessor_function) && isset($invoke_map[$preprocessor_function])) { + // Invoke module preprocess functions. + $this->moduleHandler->invoke(... $invoke_map[$preprocessor_function], args: [&$variables, $hook, $info]); + } + // Invoke preprocess callbacks that are not in the invoke map, such as + // those from themes or an alter hook. + elseif (is_callable($preprocessor_function)) { + call_user_func_array($preprocessor_function, [&$variables, $hook, $info]); + } + return $variables; + }; + + // Global preprocess functions are always called, after initial and + // template preprocess and before regular module and theme preprocess + // callbacks. template preprocess callbacks are deprecated but still + // supported, so they need to be called before the first non-template + // preprocess callback, and if that doesn't happen, after the loop. + $global_preprocess = $theme_registry->getGlobalPreprocess(); + $global_preprocess_called = FALSE; + // Invoke preprocess hooks. - // By default $info['preprocess functions'] should always be set, but it's - // good to check it if default Registry service implementation is - // overridden. See \Drupal\Core\Theme\Registry. if (isset($info['preprocess functions'])) { foreach ($info['preprocess functions'] as $preprocessor_function) { - // Preprocess hooks are stored as strings resembling functions. - // This is for backwards compatibility and may represent OOP - // implementations as well. - if (is_string($preprocessor_function) && isset($invoke_map[$preprocessor_function])) { - // While themes are not modules, ModuleHandlerInterface::invoke calls - // a legacy invoke which can can call any extension, not just - // modules. - $this->moduleHandler->invoke(... $invoke_map[$preprocessor_function], args: [&$variables, $hook, $info]); - } - // Check if hook_theme_registry_alter added a manual callback. - elseif (is_callable($preprocessor_function)) { - call_user_func_array($preprocessor_function, [&$variables, $hook, $info]); + // If global preprocess functions have not been called yet and this is + // not a template preprocess function, invoke them now. + if (!$global_preprocess_called && is_string($preprocessor_function) && !str_starts_with($preprocessor_function, 'template_')) { + $global_preprocess_called = TRUE; + foreach ($global_preprocess as $global_preprocess_callback) { + $invoke_preprocess_callback($global_preprocess_callback); + } } + $invoke_preprocess_callback($preprocessor_function); } } + + // If global process hasn't been invoked yet, do that now. + if (!$global_preprocess_called) { + foreach ($global_preprocess as $global_preprocess_callback) { + $invoke_preprocess_callback($global_preprocess_callback); + } + } + // Allow theme preprocess functions to set $variables['#attached'] and // $variables['#cache'] and use them like the corresponding element // properties on render arrays. This is the officially supported diff --git a/core/lib/Drupal/Core/Utility/ThemeRegistry.php b/core/lib/Drupal/Core/Utility/ThemeRegistry.php index a6846ba263c3..bdc8188b5e43 100644 --- a/core/lib/Drupal/Core/Utility/ThemeRegistry.php +++ b/core/lib/Drupal/Core/Utility/ThemeRegistry.php @@ -176,8 +176,21 @@ class ThemeRegistry extends CacheCollector implements DestructableInterface { * * @internal */ - public function getPreprocessInvokes() { + public function getPreprocessInvokes(): array { return $this->get('preprocess invokes'); } + /** + * Gets global preprocess callbacks. + * + * @return array + * An array of preprocess callbacks that should be called for every theme + * hook. + * + * @internal + */ + public function getGlobalPreprocess(): array { + return $this->get('global preprocess'); + } + } diff --git a/core/modules/system/css/components/item-list.module.css b/core/misc/components/item-list.module.css index 2d23ee5bd335..2d23ee5bd335 100644 --- a/core/modules/system/css/components/item-list.module.css +++ b/core/misc/components/item-list.module.css diff --git a/core/modules/comment/templates/field--comment.html.twig b/core/modules/comment/templates/field--comment.html.twig index 879f4d57ae4f..1ea746db0296 100644 --- a/core/modules/comment/templates/field--comment.html.twig +++ b/core/modules/comment/templates/field--comment.html.twig @@ -22,7 +22,7 @@ * - field_type: The type of the field. * - label_display: The display settings for the label. * - * @see template_preprocess_field() + * @see \Drupal\Core\Field\FieldPreprocess::preprocessField() * @see comment_preprocess_field() */ #} diff --git a/core/modules/config/tests/src/Functional/ConfigImportAllTest.php b/core/modules/config/tests/src/Functional/ConfigImportAllTest.php index 501953547012..2a40a48f47ee 100644 --- a/core/modules/config/tests/src/Functional/ConfigImportAllTest.php +++ b/core/modules/config/tests/src/Functional/ConfigImportAllTest.php @@ -7,6 +7,7 @@ namespace Drupal\Tests\config\Functional; use Drupal\Core\Config\StorageComparer; use Drupal\Core\Entity\ContentEntityTypeInterface; use Drupal\Core\Extension\ExtensionLifecycle; +use Drupal\Core\Extension\ModuleExtensionList; use Drupal\Tests\SchemaCheckTestTrait; use Drupal\Tests\system\Functional\Module\ModuleTestBase; @@ -109,6 +110,9 @@ class ConfigImportAllTest extends ModuleTestBase { $all_modules = \Drupal::service('extension.list.module')->getList(); $database_module = \Drupal::service('database')->getProvider(); $expected_modules = ['path_alias', 'system', 'user', $database_module]; + // If the database module has dependencies, they are expected too. + $database_module_extension = \Drupal::service(ModuleExtensionList::class)->get($database_module); + $database_module_dependencies = $database_module_extension->requires ? array_keys($database_module_extension->requires) : []; // Ensure that only core required modules and the install profile can not be // uninstalled. @@ -127,8 +131,11 @@ class ConfigImportAllTest extends ModuleTestBase { // Can not uninstall config and use admin/config/development/configuration! unset($modules_to_uninstall['config']); - // Can not uninstall the database module. + // Can not uninstall the database module and its dependencies. unset($modules_to_uninstall[$database_module]); + foreach ($database_module_dependencies as $dependency) { + unset($modules_to_uninstall[$dependency]); + } $this->assertTrue(isset($modules_to_uninstall['comment']), 'The comment module will be disabled'); $this->assertTrue(isset($modules_to_uninstall['file']), 'The File module will be disabled'); diff --git a/core/modules/field_ui/src/Form/EntityFormDisplayEditForm.php b/core/modules/field_ui/src/Form/EntityFormDisplayEditForm.php index bd18b66a07c5..9259f341c95a 100644 --- a/core/modules/field_ui/src/Form/EntityFormDisplayEditForm.php +++ b/core/modules/field_ui/src/Form/EntityFormDisplayEditForm.php @@ -127,13 +127,13 @@ class EntityFormDisplayEditForm extends EntityDisplayFormBase { $this->moduleHandler->invokeAllWith( 'field_widget_third_party_settings_form', function (callable $hook, string $module) use (&$settings_form, $plugin, $field_definition, &$form, $form_state) { - $settings_form[$module] = ($settings_form[$module] ?? []) + $hook( + $settings_form[$module] = ($settings_form[$module] ?? []) + ($hook( $plugin, $field_definition, $this->entity->getMode(), $form, $form_state - ); + ) ?? []); } ); return $settings_form; diff --git a/core/modules/field_ui/src/Form/EntityViewDisplayEditForm.php b/core/modules/field_ui/src/Form/EntityViewDisplayEditForm.php index 305f8039d70a..a188af5d92d6 100644 --- a/core/modules/field_ui/src/Form/EntityViewDisplayEditForm.php +++ b/core/modules/field_ui/src/Form/EntityViewDisplayEditForm.php @@ -162,13 +162,13 @@ class EntityViewDisplayEditForm extends EntityDisplayFormBase { $this->moduleHandler->invokeAllWith( 'field_formatter_third_party_settings_form', function (callable $hook, string $module) use (&$settings_form, &$plugin, &$field_definition, &$form, &$form_state) { - $settings_form[$module] = ($settings_form[$module] ?? []) + $hook( + $settings_form[$module] = ($settings_form[$module] ?? []) + ($hook( $plugin, $field_definition, $this->entity->getMode(), $form, $form_state, - ); + )) ?? []; } ); return $settings_form; diff --git a/core/modules/file/file.module b/core/modules/file/file.module index 5dd741f0c432..295c35998e44 100644 --- a/core/modules/file/file.module +++ b/core/modules/file/file.module @@ -499,7 +499,12 @@ function template_preprocess_file_widget_multiple(&$variables): void { foreach (Element::children($element) as $key) { $widgets[] = &$element[$key]; } - usort($widgets, '_field_multiple_value_form_sort_helper'); + usort($widgets, function ($a, $b) { + // Sorts using ['_weight']['#value']. + $a_weight = (is_array($a) && isset($a['_weight']['#value']) ? $a['_weight']['#value'] : 0); + $b_weight = (is_array($b) && isset($b['_weight']['#value']) ? $b['_weight']['#value'] : 0); + return $a_weight - $b_weight; + }); $rows = []; foreach ($widgets as &$widget) { diff --git a/core/modules/layout_builder/tests/modules/layout_builder_test/layout_builder_test.module b/core/modules/layout_builder/tests/modules/layout_builder_test/layout_builder_test.module deleted file mode 100644 index d7dda399be39..000000000000 --- a/core/modules/layout_builder/tests/modules/layout_builder_test/layout_builder_test.module +++ /dev/null @@ -1,51 +0,0 @@ -<?php - -/** - * @file - * Provides hook implementations for Layout Builder tests. - */ - -declare(strict_types=1); - -use Drupal\Core\Entity\Display\EntityViewDisplayInterface; -use Drupal\Core\Entity\EntityInterface; - -/** - * Implements hook_ENTITY_TYPE_view(). - */ -function layout_builder_test_node_view(array &$build, EntityInterface $entity, EntityViewDisplayInterface $display, $view_mode): void { - if ($display->getComponent('layout_builder_test')) { - $build['layout_builder_test'] = [ - '#markup' => 'Extra, Extra read all about it.', - ]; - } - if ($display->getComponent('layout_builder_test_2')) { - $build['layout_builder_test_2'] = [ - '#markup' => 'Extra Field 2 is hidden by default.', - ]; - } -} - -/** - * Implements hook_preprocess_HOOK() for one-column layout template. - */ -function layout_builder_test_preprocess_layout__onecol(&$vars): void { - if (!empty($vars['content']['#entity'])) { - $vars['content']['content'][\Drupal::service('uuid')->generate()] = [ - '#type' => 'markup', - '#markup' => sprintf('Yes, I can access the %s', $vars['content']['#entity']->label()), - ]; - } -} - -/** - * Implements hook_preprocess_HOOK() for two-column layout template. - */ -function layout_builder_test_preprocess_layout__twocol_section(&$vars): void { - if (!empty($vars['content']['#entity'])) { - $vars['content']['first'][\Drupal::service('uuid')->generate()] = [ - '#type' => 'markup', - '#markup' => sprintf('Yes, I can access the entity %s in two column', $vars['content']['#entity']->label()), - ]; - } -} diff --git a/core/modules/layout_builder/tests/modules/layout_builder_test/src/Hook/LayoutBuilderTestEntityHooks.php b/core/modules/layout_builder/tests/modules/layout_builder_test/src/Hook/LayoutBuilderTestEntityHooks.php new file mode 100644 index 000000000000..820630e2a4d5 --- /dev/null +++ b/core/modules/layout_builder/tests/modules/layout_builder_test/src/Hook/LayoutBuilderTestEntityHooks.php @@ -0,0 +1,63 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\layout_builder_test\Hook; + +use Drupal\Core\Entity\Display\EntityFormDisplayInterface; +use Drupal\Core\Entity\Display\EntityViewDisplayInterface; +use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Hook\Attribute\Hook; + +/** + * Entity hook implementations for layout_builder_test. + */ +class LayoutBuilderTestEntityHooks { + + /** + * Implements hook_ENTITY_TYPE_view(). + */ + #[Hook('node_view')] + public function nodeView(array &$build, EntityInterface $entity, EntityViewDisplayInterface $display, $view_mode): void { + if ($display->getComponent('layout_builder_test')) { + $build['layout_builder_test'] = [ + '#markup' => 'Extra, Extra read all about it.', + ]; + } + if ($display->getComponent('layout_builder_test_2')) { + $build['layout_builder_test_2'] = [ + '#markup' => 'Extra Field 2 is hidden by default.', + ]; + } + } + + /** + * Implements hook_entity_extra_field_info(). + */ + #[Hook('entity_extra_field_info')] + public function entityExtraFieldInfo(): array { + $extra['node']['bundle_with_section_field']['display']['layout_builder_test'] = [ + 'label' => 'Extra label', + 'description' => 'Extra description', + 'weight' => 0, + ]; + $extra['node']['bundle_with_section_field']['display']['layout_builder_test_2'] = [ + 'label' => 'Extra Field 2', + 'description' => 'Extra Field 2 description', + 'weight' => 0, + 'visible' => FALSE, + ]; + return $extra; + } + + /** + * Implements hook_entity_form_display_alter(). + */ + #[Hook('entity_form_display_alter', module: 'layout_builder')] + public function layoutBuilderEntityFormDisplayAlter(EntityFormDisplayInterface $form_display, array $context): void { + if ($context['form_mode'] === 'layout_builder') { + $form_display->setComponent('status', ['type' => 'boolean_checkbox', 'settings' => ['display_label' => TRUE]]); + } + } + +} diff --git a/core/modules/layout_builder/tests/modules/layout_builder_test/src/Hook/LayoutBuilderTestFormHooks.php b/core/modules/layout_builder/tests/modules/layout_builder_test/src/Hook/LayoutBuilderTestFormHooks.php new file mode 100644 index 000000000000..8298a97d515a --- /dev/null +++ b/core/modules/layout_builder/tests/modules/layout_builder_test/src/Hook/LayoutBuilderTestFormHooks.php @@ -0,0 +1,57 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\layout_builder_test\Hook; + +use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Hook\Attribute\Hook; + +/** + * Form hook implementations for layout_builder_test. + */ +class LayoutBuilderTestFormHooks { + + /** + * Implements hook_form_BASE_FORM_ID_alter() for layout_builder_configure_block. + */ + #[Hook('form_layout_builder_configure_block_alter')] + public function formLayoutBuilderConfigureBlockAlter(&$form, FormStateInterface $form_state, $form_id) : void { + /** @var \Drupal\layout_builder\Form\ConfigureBlockFormBase $form_object */ + $form_object = $form_state->getFormObject(); + $form['layout_builder_test']['storage'] = [ + '#type' => 'item', + '#title' => 'Layout Builder Storage: ' . $form_object->getSectionStorage()->getStorageId(), + ]; + $form['layout_builder_test']['section'] = [ + '#type' => 'item', + '#title' => 'Layout Builder Section: ' . $form_object->getCurrentSection()->getLayoutId(), + ]; + $form['layout_builder_test']['component'] = [ + '#type' => 'item', + '#title' => 'Layout Builder Component: ' . $form_object->getCurrentComponent()->getPluginId(), + ]; + } + + /** + * Implements hook_form_BASE_FORM_ID_alter() for layout_builder_configure_section. + */ + #[Hook('form_layout_builder_configure_section_alter')] + public function formLayoutBuilderConfigureSectionAlter(&$form, FormStateInterface $form_state, $form_id) : void { + /** @var \Drupal\layout_builder\Form\ConfigureSectionForm $form_object */ + $form_object = $form_state->getFormObject(); + $form['layout_builder_test']['storage'] = [ + '#type' => 'item', + '#title' => 'Layout Builder Storage: ' . $form_object->getSectionStorage()->getStorageId(), + ]; + $form['layout_builder_test']['section'] = [ + '#type' => 'item', + '#title' => 'Layout Builder Section: ' . $form_object->getCurrentSection()->getLayoutId(), + ]; + $form['layout_builder_test']['layout'] = [ + '#type' => 'item', + '#title' => 'Layout Builder Layout: ' . $form_object->getCurrentLayout()->getPluginId(), + ]; + } + +} diff --git a/core/modules/layout_builder/tests/modules/layout_builder_test/src/Hook/LayoutBuilderTestHooks.php b/core/modules/layout_builder/tests/modules/layout_builder_test/src/Hook/LayoutBuilderTestHooks.php deleted file mode 100644 index 397eedc8dad7..000000000000 --- a/core/modules/layout_builder/tests/modules/layout_builder_test/src/Hook/LayoutBuilderTestHooks.php +++ /dev/null @@ -1,137 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace Drupal\layout_builder_test\Hook; - -use Drupal\Core\Url; -use Drupal\Core\Link; -use Drupal\Core\Routing\RouteMatchInterface; -use Drupal\Core\Breadcrumb\Breadcrumb; -use Drupal\Core\Entity\Display\EntityFormDisplayInterface; -use Drupal\Core\Form\FormStateInterface; -use Drupal\Core\Hook\Attribute\Hook; -use Drupal\Core\Hook\Order\OrderBefore; - -/** - * Hook implementations for layout_builder_test. - */ -class LayoutBuilderTestHooks { - - /** - * Implements hook_plugin_filter_TYPE__CONSUMER_alter(). - */ - #[Hook('plugin_filter_block__layout_builder_alter')] - public function pluginFilterBlockLayoutBuilderAlter(array &$definitions, array $extra): void { - // Explicitly remove the "Help" blocks from the list. - unset($definitions['help_block']); - // Explicitly remove the "Sticky at top of lists field_block". - $disallowed_fields = ['sticky']; - // Remove "Changed" field if this is the first section. - if ($extra['delta'] === 0) { - $disallowed_fields[] = 'changed'; - } - foreach ($definitions as $plugin_id => $definition) { - // Field block IDs are in the form 'field_block:{entity}:{bundle}:{name}', - // for example 'field_block:node:article:revision_timestamp'. - preg_match('/field_block:.*:.*:(.*)/', $plugin_id, $parts); - if (isset($parts[1]) && in_array($parts[1], $disallowed_fields, TRUE)) { - // Unset any field blocks that match our predefined list. - unset($definitions[$plugin_id]); - } - } - } - - /** - * Implements hook_entity_extra_field_info(). - */ - #[Hook('entity_extra_field_info')] - public function entityExtraFieldInfo(): array { - $extra['node']['bundle_with_section_field']['display']['layout_builder_test'] = [ - 'label' => 'Extra label', - 'description' => 'Extra description', - 'weight' => 0, - ]; - $extra['node']['bundle_with_section_field']['display']['layout_builder_test_2'] = [ - 'label' => 'Extra Field 2', - 'description' => 'Extra Field 2 description', - 'weight' => 0, - 'visible' => FALSE, - ]; - return $extra; - } - - /** - * Implements hook_form_BASE_FORM_ID_alter() for layout_builder_configure_block. - */ - #[Hook('form_layout_builder_configure_block_alter')] - public function formLayoutBuilderConfigureBlockAlter(&$form, FormStateInterface $form_state, $form_id) : void { - /** @var \Drupal\layout_builder\Form\ConfigureBlockFormBase $form_object */ - $form_object = $form_state->getFormObject(); - $form['layout_builder_test']['storage'] = [ - '#type' => 'item', - '#title' => 'Layout Builder Storage: ' . $form_object->getSectionStorage()->getStorageId(), - ]; - $form['layout_builder_test']['section'] = [ - '#type' => 'item', - '#title' => 'Layout Builder Section: ' . $form_object->getCurrentSection()->getLayoutId(), - ]; - $form['layout_builder_test']['component'] = [ - '#type' => 'item', - '#title' => 'Layout Builder Component: ' . $form_object->getCurrentComponent()->getPluginId(), - ]; - } - - /** - * Implements hook_form_BASE_FORM_ID_alter() for layout_builder_configure_section. - */ - #[Hook('form_layout_builder_configure_section_alter')] - public function formLayoutBuilderConfigureSectionAlter(&$form, FormStateInterface $form_state, $form_id) : void { - /** @var \Drupal\layout_builder\Form\ConfigureSectionForm $form_object */ - $form_object = $form_state->getFormObject(); - $form['layout_builder_test']['storage'] = [ - '#type' => 'item', - '#title' => 'Layout Builder Storage: ' . $form_object->getSectionStorage()->getStorageId(), - ]; - $form['layout_builder_test']['section'] = [ - '#type' => 'item', - '#title' => 'Layout Builder Section: ' . $form_object->getCurrentSection()->getLayoutId(), - ]; - $form['layout_builder_test']['layout'] = [ - '#type' => 'item', - '#title' => 'Layout Builder Layout: ' . $form_object->getCurrentLayout()->getPluginId(), - ]; - } - - /** - * Implements hook_entity_form_display_alter(). - */ - #[Hook('entity_form_display_alter', module: 'layout_builder')] - public function layoutBuilderEntityFormDisplayAlter(EntityFormDisplayInterface $form_display, array $context): void { - if ($context['form_mode'] === 'layout_builder') { - $form_display->setComponent('status', ['type' => 'boolean_checkbox', 'settings' => ['display_label' => TRUE]]); - } - } - - /** - * Implements hook_system_breadcrumb_alter(). - */ - #[Hook( - 'system_breadcrumb_alter', - order: new OrderBefore( - modules: ['layout_builder'] - ) - )] - public function systemBreadcrumbAlter(Breadcrumb &$breadcrumb, RouteMatchInterface $route_match, array $context): void { - $breadcrumb->addLink(Link::fromTextAndUrl('External link', Url::fromUri('http://www.example.com'))); - } - - /** - * Implements hook_theme(). - */ - #[Hook('theme')] - public function theme() : array { - return ['block__preview_aware_block' => ['base hook' => 'block']]; - } - -} diff --git a/core/modules/layout_builder/tests/modules/layout_builder_test/src/Hook/LayoutBuilderTestMenuHooks.php b/core/modules/layout_builder/tests/modules/layout_builder_test/src/Hook/LayoutBuilderTestMenuHooks.php new file mode 100644 index 000000000000..9304876a9089 --- /dev/null +++ b/core/modules/layout_builder/tests/modules/layout_builder_test/src/Hook/LayoutBuilderTestMenuHooks.php @@ -0,0 +1,32 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\layout_builder_test\Hook; + +use Drupal\Core\Url; +use Drupal\Core\Link; +use Drupal\Core\Routing\RouteMatchInterface; +use Drupal\Core\Breadcrumb\Breadcrumb; +use Drupal\Core\Hook\Attribute\Hook; +use Drupal\Core\Hook\Order\OrderBefore; + +/** + * Menu hook implementations for layout_builder_test. + */ +class LayoutBuilderTestMenuHooks { + + /** + * Implements hook_system_breadcrumb_alter(). + */ + #[Hook( + 'system_breadcrumb_alter', + order: new OrderBefore( + modules: ['layout_builder'] + ) + )] + public function systemBreadcrumbAlter(Breadcrumb &$breadcrumb, RouteMatchInterface $route_match, array $context): void { + $breadcrumb->addLink(Link::fromTextAndUrl('External link', Url::fromUri('http://www.example.com'))); + } + +} diff --git a/core/modules/layout_builder/tests/modules/layout_builder_test/src/Hook/LayoutBuilderTestPluginHooks.php b/core/modules/layout_builder/tests/modules/layout_builder_test/src/Hook/LayoutBuilderTestPluginHooks.php new file mode 100644 index 000000000000..1464d4193332 --- /dev/null +++ b/core/modules/layout_builder/tests/modules/layout_builder_test/src/Hook/LayoutBuilderTestPluginHooks.php @@ -0,0 +1,38 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\layout_builder_test\Hook; + +use Drupal\Core\Hook\Attribute\Hook; + +/** + * Plugin hook implementations for layout_builder_test. + */ +class LayoutBuilderTestPluginHooks { + + /** + * Implements hook_plugin_filter_TYPE__CONSUMER_alter(). + */ + #[Hook('plugin_filter_block__layout_builder_alter')] + public function pluginFilterBlockLayoutBuilderAlter(array &$definitions, array $extra): void { + // Explicitly remove the "Help" blocks from the list. + unset($definitions['help_block']); + // Explicitly remove the "Sticky at top of lists field_block". + $disallowed_fields = ['sticky']; + // Remove "Changed" field if this is the first section. + if ($extra['delta'] === 0) { + $disallowed_fields[] = 'changed'; + } + foreach ($definitions as $plugin_id => $definition) { + // Field block IDs are in the form 'field_block:{entity}:{bundle}:{name}', + // for example 'field_block:node:article:revision_timestamp'. + preg_match('/field_block:.*:.*:(.*)/', $plugin_id, $parts); + if (isset($parts[1]) && in_array($parts[1], $disallowed_fields, TRUE)) { + // Unset any field blocks that match our predefined list. + unset($definitions[$plugin_id]); + } + } + } + +} diff --git a/core/modules/layout_builder/tests/modules/layout_builder_test/src/Hook/LayoutBuilderTestThemeHooks.php b/core/modules/layout_builder/tests/modules/layout_builder_test/src/Hook/LayoutBuilderTestThemeHooks.php new file mode 100644 index 000000000000..e67249102b59 --- /dev/null +++ b/core/modules/layout_builder/tests/modules/layout_builder_test/src/Hook/LayoutBuilderTestThemeHooks.php @@ -0,0 +1,57 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\layout_builder_test\Hook; + +use Drupal\Core\Hook\Attribute\Hook; +use Drupal\Component\Uuid\UuidInterface; + +/** + * Theme hook implementations for layout_builder_test. + */ +class LayoutBuilderTestThemeHooks { + + public function __construct( + protected readonly UuidInterface $uuid, + ) {} + + /** + * Implements hook_theme(). + */ + #[Hook('theme')] + public function theme() : array { + return [ + 'block__preview_aware_block' => [ + 'base hook' => 'block', + ], + ]; + } + + /** + * Implements hook_preprocess_HOOK() for one-column layout template. + */ + #[Hook('preprocess_layout__onecol')] + public function layoutOneCol(&$vars): void { + if (!empty($vars['content']['#entity'])) { + $vars['content']['content'][$this->uuid->generate()] = [ + '#type' => 'markup', + '#markup' => sprintf('Yes, I can access the %s', $vars['content']['#entity']->label()), + ]; + } + } + + /** + * Implements hook_preprocess_HOOK() for two-column layout template. + */ + #[Hook('preprocess_layout__twocol_section')] + public function layoutTwocolSection(&$vars): void { + if (!empty($vars['content']['#entity'])) { + $vars['content']['first'][$this->uuid->generate()] = [ + '#type' => 'markup', + '#markup' => sprintf('Yes, I can access the entity %s in two column', $vars['content']['#entity']->label()), + ]; + } + } + +} diff --git a/core/modules/layout_builder/tests/modules/layout_builder_theme_suggestions_test/layout_builder_theme_suggestions_test.module b/core/modules/layout_builder/tests/modules/layout_builder_theme_suggestions_test/layout_builder_theme_suggestions_test.module deleted file mode 100644 index 5632c3fb8a9e..000000000000 --- a/core/modules/layout_builder/tests/modules/layout_builder_theme_suggestions_test/layout_builder_theme_suggestions_test.module +++ /dev/null @@ -1,19 +0,0 @@ -<?php - -/** - * @file - * For testing theme suggestions. - */ - -declare(strict_types=1); - -/** - * Implements hook_preprocess_HOOK() for the list of layouts. - */ -function layout_builder_theme_suggestions_test_preprocess_item_list__layouts(&$variables): void { - foreach (array_keys($variables['items']) as $layout_id) { - if (isset($variables['items'][$layout_id]['value']['#title']['icon'])) { - $variables['items'][$layout_id]['value']['#title']['icon'] = ['#markup' => __FUNCTION__]; - } - } -} diff --git a/core/modules/layout_builder/tests/modules/layout_builder_theme_suggestions_test/src/Hook/LayoutBuilderThemeSuggestionsTestHooks.php b/core/modules/layout_builder/tests/modules/layout_builder_theme_suggestions_test/src/Hook/LayoutBuilderThemeSuggestionsTestHooks.php deleted file mode 100644 index 6f90fe628703..000000000000 --- a/core/modules/layout_builder/tests/modules/layout_builder_theme_suggestions_test/src/Hook/LayoutBuilderThemeSuggestionsTestHooks.php +++ /dev/null @@ -1,28 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace Drupal\layout_builder_theme_suggestions_test\Hook; - -use Drupal\Core\Hook\Attribute\Hook; - -/** - * Hook implementations for layout_builder_theme_suggestions_test. - */ -class LayoutBuilderThemeSuggestionsTestHooks { - - /** - * Implements hook_theme(). - */ - #[Hook('theme')] - public function theme() : array { - // It is necessary to explicitly register the template via hook_theme() - // because it is added via a module, not a theme. - return [ - 'field__node__body__bundle_with_section_field__default' => [ - 'base hook' => 'field', - ], - ]; - } - -} diff --git a/core/modules/layout_builder/tests/modules/layout_builder_theme_suggestions_test/src/Hook/LayoutBuilderThemeSuggestionsTestThemeHooks.php b/core/modules/layout_builder/tests/modules/layout_builder_theme_suggestions_test/src/Hook/LayoutBuilderThemeSuggestionsTestThemeHooks.php new file mode 100644 index 000000000000..3e087944e944 --- /dev/null +++ b/core/modules/layout_builder/tests/modules/layout_builder_theme_suggestions_test/src/Hook/LayoutBuilderThemeSuggestionsTestThemeHooks.php @@ -0,0 +1,40 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\layout_builder_theme_suggestions_test\Hook; + +use Drupal\Core\Hook\Attribute\Hook; + +/** + * Theme hook implementations for layout_builder_theme_suggestions_test. + */ +class LayoutBuilderThemeSuggestionsTestThemeHooks { + + /** + * Implements hook_theme(). + */ + #[Hook('theme')] + public function theme() : array { + // It is necessary to explicitly register the template via hook_theme() + // because it is added via a module, not a theme. + return [ + 'field__node__body__bundle_with_section_field__default' => [ + 'base hook' => 'field', + ], + ]; + } + + /** + * Implements hook_preprocess_HOOK() for the list of layouts. + */ + #[Hook('preprocess_item_list__layouts')] + public function itemListLayouts(&$variables): void { + foreach (array_keys($variables['items']) as $layout_id) { + if (isset($variables['items'][$layout_id]['value']['#title']['icon'])) { + $variables['items'][$layout_id]['value']['#title']['icon'] = ['#markup' => __METHOD__]; + } + } + } + +} diff --git a/core/modules/layout_builder/tests/src/Functional/LayoutBuilderThemeSuggestionsTest.php b/core/modules/layout_builder/tests/src/Functional/LayoutBuilderThemeSuggestionsTest.php index b107ec4f9b41..d9423cd23e81 100644 --- a/core/modules/layout_builder/tests/src/Functional/LayoutBuilderThemeSuggestionsTest.php +++ b/core/modules/layout_builder/tests/src/Functional/LayoutBuilderThemeSuggestionsTest.php @@ -66,7 +66,7 @@ class LayoutBuilderThemeSuggestionsTest extends BrowserTestBase { $this->drupalGet('node/1/layout'); $page->clickLink('Add section'); - $assert_session->pageTextContains('layout_builder_theme_suggestions_test_preprocess_item_list__layouts'); + $assert_session->pageTextContains('itemListLayouts'); } /** diff --git a/core/modules/locale/locale.batch.inc b/core/modules/locale/locale.batch.inc index 0f204b6af2df..5de40ee764ca 100644 --- a/core/modules/locale/locale.batch.inc +++ b/core/modules/locale/locale.batch.inc @@ -243,6 +243,15 @@ function locale_translation_batch_fetch_import($project, $langcode, $options, &$ } } } + elseif ($source->type == LOCALE_TRANSLATION_CURRENT) { + /* + * This can happen if the locale_translation_batch_fetch_import + * batch was interrupted + * and the translation was imported by another batch. + */ + $context['message'] = t('Ignoring already imported translation for %project.', ['%project' => $source->project]); + $context['finished'] = 1; + } } } } diff --git a/core/modules/locale/tests/src/Kernel/LocaleBatchTest.php b/core/modules/locale/tests/src/Kernel/LocaleBatchTest.php new file mode 100644 index 000000000000..47249930c6e1 --- /dev/null +++ b/core/modules/locale/tests/src/Kernel/LocaleBatchTest.php @@ -0,0 +1,49 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\locale\Kernel; + +use Drupal\KernelTests\KernelTestBase; + +/** + * Tests locale batches. + * + * @group locale + */ +class LocaleBatchTest extends KernelTestBase { + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'locale', + 'system', + 'language', + ]; + + /** + * Checks that the import batch finishes if the translation has already been imported. + */ + public function testBuildProjects(): void { + $this->installConfig(['locale']); + $this->installSchema('locale', ['locale_file']); + $this->container->get('module_handler')->loadInclude('locale', 'batch.inc'); + + \Drupal::database()->insert('locale_file') + ->fields([ + 'project' => 'drupal', + 'langcode' => 'en', + 'filename' => 'drupal.po', + 'version' => \Drupal::VERSION, + 'timestamp' => time(), + ]) + ->execute(); + + $context = []; + locale_translation_batch_fetch_import('drupal', 'en', [], $context); + $this->assertEquals(1, $context['finished']); + $this->assertEquals('Ignoring already imported translation for drupal.', $context['message']); + } + +} diff --git a/core/modules/media/src/Hook/MediaHooks.php b/core/modules/media/src/Hook/MediaHooks.php index 9a80bfec1583..37f24658ce3b 100644 --- a/core/modules/media/src/Hook/MediaHooks.php +++ b/core/modules/media/src/Hook/MediaHooks.php @@ -195,10 +195,10 @@ class MediaHooks { $elements['#media_help']['#media_add_help'] = $this->t('Create your media on the <a href=":add_page" target="_blank">media add page</a> (opens a new window), then add it by name to the field below.', [':add_page' => $add_url]); } $elements['#theme'] = 'media_reference_help'; - // @todo template_preprocess_field_multiple_value_form() assumes this key - // exists, but it does not exist in the case of a single widget that - // accepts multiple values. This is for some reason necessary to use - // our template for the entity_autocomplete_tags widget. + // @todo \Drupal\Core\Field\FieldPreprocess::preprocessFieldMultipleValueForm() + // assumes this key exists, but it does not exist in the case of a single + // widget that accepts multiple values. This is for some reason necessary + // to use our template for the entity_autocomplete_tags widget. // Research and resolve this in https://www.drupal.org/node/2943020. if (empty($elements['#cardinality_multiple'])) { $elements['#cardinality_multiple'] = NULL; diff --git a/core/modules/media/templates/media-reference-help.html.twig b/core/modules/media/templates/media-reference-help.html.twig index 910dc4e94bea..4adc22db002e 100644 --- a/core/modules/media/templates/media-reference-help.html.twig +++ b/core/modules/media/templates/media-reference-help.html.twig @@ -3,7 +3,7 @@ * @file * Theme override for media reference fields. * - * @see template_preprocess_field_multiple_value_form() + * @see \Drupal\Core\Field\FieldPreprocess::preprocessFieldMultipleValueForm() */ #} {% diff --git a/core/modules/media/tests/modules/media_test_embed/media_test_embed.module b/core/modules/media/tests/modules/media_test_embed/media_test_embed.module deleted file mode 100644 index abb19d895090..000000000000 --- a/core/modules/media/tests/modules/media_test_embed/media_test_embed.module +++ /dev/null @@ -1,15 +0,0 @@ -<?php - -/** - * @file - * Helper module for the Media Embed text editor plugin tests. - */ - -declare(strict_types=1); - -/** - * Implements hook_preprocess_HOOK(). - */ -function media_test_embed_preprocess_media_embed_error(&$variables): void { - $variables['attributes']['class'][] = 'this-error-message-is-themeable'; -} diff --git a/core/modules/media/tests/modules/media_test_embed/src/Hook/MediaTestEmbedThemeHooks.php b/core/modules/media/tests/modules/media_test_embed/src/Hook/MediaTestEmbedThemeHooks.php new file mode 100644 index 000000000000..ede5f6df253c --- /dev/null +++ b/core/modules/media/tests/modules/media_test_embed/src/Hook/MediaTestEmbedThemeHooks.php @@ -0,0 +1,22 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\media_test_embed\Hook; + +use Drupal\Core\Hook\Attribute\Hook; + +/** + * Theme hook implementations for media_test_embed. + */ +class MediaTestEmbedThemeHooks { + + /** + * Implements hook_preprocess_HOOK(). + */ + #[Hook('preprocess_media_embed_error')] + public function preprocessMediaEmbedError(&$variables): void { + $variables['attributes']['class'][] = 'this-error-message-is-themeable'; + } + +} diff --git a/core/modules/media/tests/modules/media_test_oembed/media_test_oembed.module b/core/modules/media/tests/modules/media_test_oembed/media_test_oembed.module deleted file mode 100644 index 910318dd8681..000000000000 --- a/core/modules/media/tests/modules/media_test_oembed/media_test_oembed.module +++ /dev/null @@ -1,20 +0,0 @@ -<?php - -/** - * @file - * Helper module for the Media oEmbed tests. - */ - -declare(strict_types=1); - -/** - * Implements hook_preprocess_media_oembed_iframe(). - */ -function media_test_oembed_preprocess_media_oembed_iframe(array &$variables): void { - if ($variables['resource']->getProvider()->getName() === 'YouTube') { - $variables['media'] = str_replace('?feature=oembed', '?feature=oembed&pasta=rigatoni', (string) $variables['media']); - } - // @see \Drupal\Tests\media\Kernel\OEmbedIframeControllerTest - $variables['#attached']['library'][] = 'media_test_oembed/frame'; - $variables['#cache']['tags'][] = 'yo_there'; -} diff --git a/core/modules/media/tests/modules/media_test_oembed/src/Hook/MediaTestOembedThemeHooks.php b/core/modules/media/tests/modules/media_test_oembed/src/Hook/MediaTestOembedThemeHooks.php new file mode 100644 index 000000000000..626d68f9812c --- /dev/null +++ b/core/modules/media/tests/modules/media_test_oembed/src/Hook/MediaTestOembedThemeHooks.php @@ -0,0 +1,27 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\media_test_oembed\Hook; + +use Drupal\Core\Hook\Attribute\Hook; + +/** + * Theme hook implementations for media_test_oembed. + */ +class MediaTestOembedThemeHooks { + + /** + * Implements hook_preprocess_media_oembed_iframe(). + */ + #[Hook('preprocess_media_oembed_iframe')] + public function preprocessMediaOembedIframe(array &$variables): void { + if ($variables['resource']->getProvider()->getName() === 'YouTube') { + $variables['media'] = str_replace('?feature=oembed', '?feature=oembed&pasta=rigatoni', (string) $variables['media']); + } + // @see \Drupal\Tests\media\Kernel\OEmbedIframeControllerTest + $variables['#attached']['library'][] = 'media_test_oembed/frame'; + $variables['#cache']['tags'][] = 'yo_there'; + } + +} diff --git a/core/modules/migrate_drupal_ui/migrate_drupal_ui.routing.yml b/core/modules/migrate_drupal_ui/migrate_drupal_ui.routing.yml index 61f77faa44ca..1348f7dc04b5 100644 --- a/core/modules/migrate_drupal_ui/migrate_drupal_ui.routing.yml +++ b/core/modules/migrate_drupal_ui/migrate_drupal_ui.routing.yml @@ -50,6 +50,6 @@ migrate_drupal_ui.log: defaults: _controller: '\Drupal\migrate_drupal_ui\Controller\MigrateController::showLog' requirements: - _custom_access: '\Drupal\migrate_drupal_ui\MigrateAccessCheck::checkAccess' + _permission: 'access site reports' options: _admin_route: TRUE diff --git a/core/modules/migrate_drupal_ui/tests/src/Functional/MigrateControllerTest.php b/core/modules/migrate_drupal_ui/tests/src/Functional/MigrateControllerTest.php index 68071daba559..56d9e10a91f4 100644 --- a/core/modules/migrate_drupal_ui/tests/src/Functional/MigrateControllerTest.php +++ b/core/modules/migrate_drupal_ui/tests/src/Functional/MigrateControllerTest.php @@ -25,14 +25,6 @@ class MigrateControllerTest extends BrowserTestBase { /** * {@inheritdoc} - * - * @todo Remove and fix test to not rely on super user. - * @see https://www.drupal.org/project/drupal/issues/3437620 - */ - protected bool $usesSuperUserAccessPolicy = TRUE; - - /** - * {@inheritdoc} */ protected $defaultTheme = 'stark'; @@ -42,8 +34,9 @@ class MigrateControllerTest extends BrowserTestBase { protected function setUp(): void { parent::setUp(); - // Log in as user 1. Migrations in the UI can only be performed as user 1. - $this->drupalLogin($this->rootUser); + // Log in as a user with access to view the migration report. + $account = $this->drupalCreateUser(['access site reports', 'administer views']); + $this->drupalLogin($account); // Create a migrate message for testing purposes. \Drupal::logger('migrate_drupal_ui')->notice('A test message'); diff --git a/core/modules/mysql/src/Driver/Database/mysql/ExceptionHandler.php b/core/modules/mysql/src/Driver/Database/mysql/ExceptionHandler.php index 7926175e0dcf..9f2a89ca6ea9 100644 --- a/core/modules/mysql/src/Driver/Database/mysql/ExceptionHandler.php +++ b/core/modules/mysql/src/Driver/Database/mysql/ExceptionHandler.php @@ -5,6 +5,7 @@ namespace Drupal\mysql\Driver\Database\mysql; use Drupal\Component\Utility\Unicode; use Drupal\Core\Database\DatabaseExceptionWrapper; use Drupal\Core\Database\ExceptionHandler as BaseExceptionHandler; +use Drupal\Core\Database\Exception\SchemaPrimaryKeyMustBeDroppedException; use Drupal\Core\Database\Exception\SchemaTableColumnSizeTooLargeException; use Drupal\Core\Database\Exception\SchemaTableKeyTooLargeException; use Drupal\Core\Database\IntegrityConstraintViolationException; @@ -19,44 +20,81 @@ class ExceptionHandler extends BaseExceptionHandler { * {@inheritdoc} */ public function handleExecutionException(\Exception $exception, StatementInterface $statement, array $arguments = [], array $options = []): void { - if ($exception instanceof \PDOException) { - // Wrap the exception in another exception, because PHP does not allow - // overriding Exception::getMessage(). Its message is the extra database - // debug information. - $code = is_int($exception->getCode()) ? $exception->getCode() : 0; - - // If a max_allowed_packet error occurs the message length is truncated. - // This should prevent the error from recurring if the exception is logged - // to the database using dblog or the like. - if (($exception->errorInfo[1] ?? NULL) === 1153) { - $message = Unicode::truncateBytes($exception->getMessage(), Connection::MIN_MAX_ALLOWED_PACKET); - throw new DatabaseExceptionWrapper($message, $code, $exception); - } - - $message = $exception->getMessage() . ": " . $statement->getQueryString() . "; " . print_r($arguments, TRUE); - - // SQLSTATE 23xxx errors indicate an integrity constraint violation. Also, - // in case of attempted INSERT of a record with an undefined column and no - // default value indicated in schema, MySql returns a 1364 error code. - if ( - substr($exception->getCode(), -6, -3) == '23' || - ($exception->errorInfo[1] ?? NULL) === 1364 - ) { - throw new IntegrityConstraintViolationException($message, $code, $exception); - } - - if ($exception->getCode() === '42000') { - match ($exception->errorInfo[1]) { - 1071 => throw new SchemaTableKeyTooLargeException($message, $code, $exception), - 1074 => throw new SchemaTableColumnSizeTooLargeException($message, $code, $exception), - default => throw new DatabaseExceptionWrapper($message, 0, $exception), - }; - } - - throw new DatabaseExceptionWrapper($message, 0, $exception); + if (!$exception instanceof \PDOException) { + throw $exception; + } + $this->rethrowNormalizedException($exception, $exception->getCode(), $exception->errorInfo[1] ?? NULL, $statement->getQueryString(), $arguments); + } + + /** + * Rethrows exceptions thrown during execution of statement objects. + * + * Wrap the exception in another exception, because PHP does not allow + * overriding Exception::getMessage(). Its message is the extra database + * debug information. + * + * @param \Exception $exception + * The exception to be handled. + * @param int|string $sqlState + * MySql SQLState error condition. + * @param int|null $errorCode + * MySql error code. + * @param string $queryString + * The SQL statement string. + * @param array $arguments + * An array of arguments for the prepared statement. + * + * @throws \Drupal\Core\Database\DatabaseExceptionWrapper + * @throws \Drupal\Core\Database\IntegrityConstraintViolationException + * @throws \Drupal\Core\Database\Exception\SchemaPrimaryKeyMustBeDroppedException + * @throws \Drupal\Core\Database\Exception\SchemaTableColumnSizeTooLargeException + * @throws \Drupal\Core\Database\Exception\SchemaTableKeyTooLargeException + */ + protected function rethrowNormalizedException( + \Exception $exception, + int|string $sqlState, + ?int $errorCode, + string $queryString, + array $arguments, + ): void { + + // SQLState could be 'HY000' which cannot be used as a $code argument for + // exceptions. PDOException is contravariant in this case, but since we are + // re-throwing an exception that inherits from \Exception, we need to + // convert the code to an integer. + // @see https://www.php.net/manual/en/class.exception.php + // @see https://www.php.net/manual/en/class.pdoexception.php + $code = (int) $sqlState; + + // If a max_allowed_packet error occurs the message length is truncated. + // This should prevent the error from recurring if the exception is logged + // to the database using dblog or the like. + if ($errorCode === 1153) { + $message = Unicode::truncateBytes($exception->getMessage(), Connection::MIN_MAX_ALLOWED_PACKET); + throw new DatabaseExceptionWrapper($message, $code, $exception); + } + + $message = $exception->getMessage() . ": " . $queryString . "; " . print_r($arguments, TRUE); + + // SQLSTATE 23xxx errors indicate an integrity constraint violation. Also, + // in case of attempted INSERT of a record with an undefined column and no + // default value indicated in schema, MySql returns a 1364 error code. + if (substr($sqlState, -6, -3) == '23' || $errorCode === 1364) { + throw new IntegrityConstraintViolationException($message, $code, $exception); } - throw $exception; + match ($sqlState) { + 'HY000' => match ($errorCode) { + 4111 => throw new SchemaPrimaryKeyMustBeDroppedException($message, 0, $exception), + default => throw new DatabaseExceptionWrapper($message, 0, $exception), + }, + '42000' => match ($errorCode) { + 1071 => throw new SchemaTableKeyTooLargeException($message, $code, $exception), + 1074 => throw new SchemaTableColumnSizeTooLargeException($message, $code, $exception), + default => throw new DatabaseExceptionWrapper($message, 0, $exception), + }, + default => throw new DatabaseExceptionWrapper($message, 0, $exception), + }; } } diff --git a/core/modules/mysql/src/Driver/Database/mysql/Schema.php b/core/modules/mysql/src/Driver/Database/mysql/Schema.php index a8b9c07564e8..c3eb28584334 100644 --- a/core/modules/mysql/src/Driver/Database/mysql/Schema.php +++ b/core/modules/mysql/src/Driver/Database/mysql/Schema.php @@ -2,7 +2,7 @@ namespace Drupal\mysql\Driver\Database\mysql; -use Drupal\Core\Database\DatabaseExceptionWrapper; +use Drupal\Core\Database\Exception\SchemaPrimaryKeyMustBeDroppedException; use Drupal\Core\Database\SchemaException; use Drupal\Core\Database\SchemaObjectExistsException; use Drupal\Core\Database\SchemaObjectDoesNotExistException; @@ -438,11 +438,11 @@ class Schema extends DatabaseSchema { try { $this->executeDdlStatement($query); } - catch (DatabaseExceptionWrapper $e) { + catch (SchemaPrimaryKeyMustBeDroppedException $e) { // MySQL error number 4111 (ER_DROP_PK_COLUMN_TO_DROP_GIPK) indicates that // when dropping and adding a primary key, the generated invisible primary // key (GIPK) column must also be dropped. - if (isset($e->getPrevious()->errorInfo[1]) && $e->getPrevious()->errorInfo[1] === 4111 && isset($keys_new['primary key']) && $this->indexExists($table, 'PRIMARY') && $this->findPrimaryKeyColumns($table) === ['my_row_id']) { + if (isset($keys_new['primary key']) && $this->indexExists($table, 'PRIMARY') && $this->findPrimaryKeyColumns($table) === ['my_row_id']) { $this->executeDdlStatement($query . ', DROP COLUMN [my_row_id]'); } else { diff --git a/core/modules/mysql/tests/src/Functional/RequirementsTest.php b/core/modules/mysql/tests/src/Functional/RequirementsTest.php index 5d054334b696..38617714bc8c 100644 --- a/core/modules/mysql/tests/src/Functional/RequirementsTest.php +++ b/core/modules/mysql/tests/src/Functional/RequirementsTest.php @@ -32,7 +32,7 @@ class RequirementsTest extends BrowserTestBase { // The isolation_level option is only available for MySQL. $connection = Database::getConnection(); - if ($connection->driver() !== 'mysql') { + if (!in_array($connection->driver(), ['mysql', 'mysqli'])) { $this->markTestSkipped("This test does not support the {$connection->driver()} database driver."); } } diff --git a/core/modules/mysqli/mysqli.info.yml b/core/modules/mysqli/mysqli.info.yml new file mode 100644 index 000000000000..38a9239f3e95 --- /dev/null +++ b/core/modules/mysqli/mysqli.info.yml @@ -0,0 +1,9 @@ +name: MySQLi +type: module +description: 'Database driver for MySQLi.' +version: VERSION +package: Core (Experimental) +lifecycle: experimental +hidden: true +dependencies: + - drupal:mysql diff --git a/core/modules/mysqli/mysqli.install b/core/modules/mysqli/mysqli.install new file mode 100644 index 000000000000..7f1147d63adb --- /dev/null +++ b/core/modules/mysqli/mysqli.install @@ -0,0 +1,78 @@ +<?php + +/** + * @file + * Install, update and uninstall functions for the mysqli module. + */ + +use Drupal\Core\Database\Database; +use Drupal\Core\Extension\Requirement\RequirementSeverity; +use Drupal\Core\Render\Markup; + +/** + * Implements hook_requirements(). + */ +function mysqli_requirements($phase): array { + $requirements = []; + + if ($phase === 'runtime') { + // Test with MySql databases. + if (Database::isActiveConnection()) { + $connection = Database::getConnection(); + // Only show requirements when MySQLi is the default database connection. + if (!($connection->driver() === 'mysqli' && $connection->getProvider() === 'mysqli')) { + return []; + } + + $query = $connection->isMariaDb() ? 'SELECT @@SESSION.tx_isolation' : 'SELECT @@SESSION.transaction_isolation'; + + $isolation_level = $connection->query($query)->fetchField(); + + $tables_missing_primary_key = []; + $tables = $connection->schema()->findTables('%'); + foreach ($tables as $table) { + $primary_key_column = Database::getConnection()->query("SHOW KEYS FROM {" . $table . "} WHERE Key_name = 'PRIMARY'")->fetchAllAssoc('Column_name'); + if (empty($primary_key_column)) { + $tables_missing_primary_key[] = $table; + } + } + + $description = []; + if ($isolation_level == 'READ-COMMITTED') { + if (empty($tables_missing_primary_key)) { + $severity_level = RequirementSeverity::OK; + } + else { + $severity_level = RequirementSeverity::Error; + } + } + else { + if ($isolation_level == 'REPEATABLE-READ') { + $severity_level = RequirementSeverity::Warning; + } + else { + $severity_level = RequirementSeverity::Error; + $description[] = t('This is not supported by Drupal.'); + } + $description[] = t('The recommended level for Drupal is "READ COMMITTED".'); + } + + if (!empty($tables_missing_primary_key)) { + $description[] = t('For this to work correctly, all tables must have a primary key. The following table(s) do not have a primary key: @tables.', ['@tables' => implode(', ', $tables_missing_primary_key)]); + } + + $description[] = t('See the <a href=":performance_doc">setting MySQL transaction isolation level</a> page for more information.', [ + ':performance_doc' => 'https://www.drupal.org/docs/system-requirements/setting-the-mysql-transaction-isolation-level', + ]); + + $requirements['mysql_transaction_level'] = [ + 'title' => t('Transaction isolation level'), + 'severity' => $severity_level, + 'value' => $isolation_level, + 'description' => Markup::create(implode(' ', $description)), + ]; + } + } + + return $requirements; +} diff --git a/core/modules/mysqli/mysqli.services.yml b/core/modules/mysqli/mysqli.services.yml new file mode 100644 index 000000000000..82a476ceb9e8 --- /dev/null +++ b/core/modules/mysqli/mysqli.services.yml @@ -0,0 +1,4 @@ +services: + mysqli.views.cast_sql: + class: Drupal\mysqli\Plugin\views\query\MysqliCastSql + public: false diff --git a/core/modules/mysqli/src/Driver/Database/mysqli/Connection.php b/core/modules/mysqli/src/Driver/Database/mysqli/Connection.php new file mode 100644 index 000000000000..e41df23075a3 --- /dev/null +++ b/core/modules/mysqli/src/Driver/Database/mysqli/Connection.php @@ -0,0 +1,191 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\mysqli\Driver\Database\mysqli; + +use Drupal\Core\Database\Connection as BaseConnection; +use Drupal\Core\Database\ConnectionNotDefinedException; +use Drupal\Core\Database\Database; +use Drupal\Core\Database\DatabaseAccessDeniedException; +use Drupal\Core\Database\DatabaseNotFoundException; +use Drupal\Core\Database\Transaction\TransactionManagerInterface; +use Drupal\mysql\Driver\Database\mysql\Connection as BaseMySqlConnection; + +/** + * MySQLi implementation of \Drupal\Core\Database\Connection. + */ +class Connection extends BaseMySqlConnection { + + /** + * {@inheritdoc} + */ + protected $statementWrapperClass = Statement::class; + + public function __construct( + \mysqli $connection, + array $connectionOptions = [], + ) { + // If the SQL mode doesn't include 'ANSI_QUOTES' (explicitly or via a + // combination mode), then MySQL doesn't interpret a double quote as an + // identifier quote, in which case use the non-ANSI-standard backtick. + // + // @see https://dev.mysql.com/doc/refman/8.0/en/sql-mode.html#sqlmode_ansi_quotes + $ansiQuotesModes = ['ANSI_QUOTES', 'ANSI']; + $isAnsiQuotesMode = FALSE; + if (isset($connectionOptions['init_commands']['sql_mode'])) { + foreach ($ansiQuotesModes as $mode) { + // None of the modes in $ansiQuotesModes are substrings of other modes + // that are not in $ansiQuotesModes, so a simple stripos() does not + // return false positives. + if (stripos($connectionOptions['init_commands']['sql_mode'], $mode) !== FALSE) { + $isAnsiQuotesMode = TRUE; + break; + } + } + } + + if ($this->identifierQuotes === ['"', '"'] && !$isAnsiQuotesMode) { + $this->identifierQuotes = ['`', '`']; + } + + BaseConnection::__construct($connection, $connectionOptions); + } + + /** + * {@inheritdoc} + */ + public static function open(array &$connection_options = []) { + // Sets mysqli error reporting mode to report errors from mysqli function + // calls and to throw mysqli_sql_exception for errors. + // @see https://www.php.net/manual/en/mysqli-driver.report-mode.php + mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); + + // Allow PDO options to be overridden. + $connection_options += [ + 'pdo' => [], + ]; + + try { + $mysqli = @new \mysqli( + $connection_options['host'], + $connection_options['username'], + $connection_options['password'], + $connection_options['database'] ?? '', + !empty($connection_options['port']) ? (int) $connection_options['port'] : 3306, + $connection_options['unix_socket'] ?? '' + ); + if (!$mysqli->set_charset('utf8mb4')) { + throw new InvalidCharsetException('Invalid charset utf8mb4'); + } + } + catch (\mysqli_sql_exception $e) { + if ($e->getCode() === static::DATABASE_NOT_FOUND) { + throw new DatabaseNotFoundException($e->getMessage(), $e->getCode(), $e); + } + elseif ($e->getCode() === static::ACCESS_DENIED) { + throw new DatabaseAccessDeniedException($e->getMessage(), $e->getCode(), $e); + } + + throw new ConnectionNotDefinedException('Invalid database connection: ' . $e->getMessage(), $e->getCode(), $e); + } + + // Force MySQL to use the UTF-8 character set. Also set the collation, if a + // certain one has been set; otherwise, MySQL defaults to + // 'utf8mb4_0900_ai_ci' for the 'utf8mb4' character set. + if (!empty($connection_options['collation'])) { + $mysqli->query('SET NAMES utf8mb4 COLLATE ' . $connection_options['collation']); + } + else { + $mysqli->query('SET NAMES utf8mb4'); + } + + // Set MySQL init_commands if not already defined. Default Drupal's MySQL + // behavior to conform more closely to SQL standards. This allows Drupal + // to run almost seamlessly on many different kinds of database systems. + // These settings force MySQL to behave the same as postgresql, or sqlite + // in regard to syntax interpretation and invalid data handling. See + // https://www.drupal.org/node/344575 for further discussion. Also, as MySQL + // 5.5 changed the meaning of TRADITIONAL we need to spell out the modes one + // by one. + $connection_options += [ + 'init_commands' => [], + ]; + + $connection_options['init_commands'] += [ + 'sql_mode' => "SET sql_mode = 'ANSI,TRADITIONAL'", + ]; + if (!empty($connection_options['isolation_level'])) { + $connection_options['init_commands'] += [ + 'isolation_level' => 'SET SESSION TRANSACTION ISOLATION LEVEL ' . strtoupper($connection_options['isolation_level']), + ]; + } + + // Execute initial commands. + foreach ($connection_options['init_commands'] as $sql) { + $mysqli->query($sql); + } + + return $mysqli; + } + + /** + * {@inheritdoc} + */ + public function driver() { + return 'mysqli'; + } + + /** + * {@inheritdoc} + */ + public function clientVersion() { + return \mysqli_get_client_info(); + } + + /** + * {@inheritdoc} + */ + public function createDatabase($database): void { + // Escape the database name. + $database = Database::getConnection()->escapeDatabase($database); + + try { + // Create the database and set it as active. + $this->connection->query("CREATE DATABASE $database"); + $this->connection->query("USE $database"); + } + catch (\Exception $e) { + throw new DatabaseNotFoundException($e->getMessage()); + } + } + + /** + * {@inheritdoc} + */ + public function quote($string, $parameter_type = \PDO::PARAM_STR) { + return "'" . $this->connection->escape_string((string) $string) . "'"; + } + + /** + * {@inheritdoc} + */ + public function lastInsertId(?string $name = NULL): string { + return (string) $this->connection->insert_id; + } + + /** + * {@inheritdoc} + */ + public function exceptionHandler() { + return new ExceptionHandler(); + } + + /** + * {@inheritdoc} + */ + protected function driverTransactionManager(): TransactionManagerInterface { + return new TransactionManager($this); + } + +} diff --git a/core/modules/mysqli/src/Driver/Database/mysqli/ExceptionHandler.php b/core/modules/mysqli/src/Driver/Database/mysqli/ExceptionHandler.php new file mode 100644 index 000000000000..78e7a331f121 --- /dev/null +++ b/core/modules/mysqli/src/Driver/Database/mysqli/ExceptionHandler.php @@ -0,0 +1,30 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\mysqli\Driver\Database\mysqli; + +use Drupal\Core\Database\StatementInterface; +use Drupal\mysql\Driver\Database\mysql\ExceptionHandler as BaseMySqlExceptionHandler; + +/** + * MySQLi database exception handler class. + */ +class ExceptionHandler extends BaseMySqlExceptionHandler { + + /** + * {@inheritdoc} + */ + public function handleExecutionException(\Exception $exception, StatementInterface $statement, array $arguments = [], array $options = []): void { + // Close the client statement to release handles. + if ($statement->hasClientStatement()) { + $statement->getClientStatement()->close(); + } + + if (!($exception instanceof \mysqli_sql_exception)) { + throw $exception; + } + $this->rethrowNormalizedException($exception, $exception->getSqlState(), $exception->getCode(), $statement->getQueryString(), $arguments); + } + +} diff --git a/core/modules/mysqli/src/Driver/Database/mysqli/Install/Tasks.php b/core/modules/mysqli/src/Driver/Database/mysqli/Install/Tasks.php new file mode 100644 index 000000000000..f27a083541e6 --- /dev/null +++ b/core/modules/mysqli/src/Driver/Database/mysqli/Install/Tasks.php @@ -0,0 +1,28 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\mysqli\Driver\Database\mysqli\Install; + +use Drupal\mysql\Driver\Database\mysql\Install\Tasks as BaseInstallTasks; + +/** + * Specifies installation tasks for MySQLi. + */ +class Tasks extends BaseInstallTasks { + + /** + * {@inheritdoc} + */ + public function installable() { + return extension_loaded('mysqli'); + } + + /** + * {@inheritdoc} + */ + public function name() { + return $this->t('@parent via mysqli (Experimental)', ['@parent' => parent::name()]); + } + +} diff --git a/core/modules/mysqli/src/Driver/Database/mysqli/InvalidCharsetException.php b/core/modules/mysqli/src/Driver/Database/mysqli/InvalidCharsetException.php new file mode 100644 index 000000000000..e6f2c86148c5 --- /dev/null +++ b/core/modules/mysqli/src/Driver/Database/mysqli/InvalidCharsetException.php @@ -0,0 +1,13 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\mysqli\Driver\Database\mysqli; + +use Drupal\Core\Database\DatabaseExceptionWrapper; + +/** + * This exception class signals an invalid charset is being used. + */ +class InvalidCharsetException extends DatabaseExceptionWrapper { +} diff --git a/core/modules/mysqli/src/Driver/Database/mysqli/NamedPlaceholderConverter.php b/core/modules/mysqli/src/Driver/Database/mysqli/NamedPlaceholderConverter.php new file mode 100644 index 000000000000..31386bc907bc --- /dev/null +++ b/core/modules/mysqli/src/Driver/Database/mysqli/NamedPlaceholderConverter.php @@ -0,0 +1,250 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\mysqli\Driver\Database\mysqli; + +// cspell:ignore DBAL MULTICHAR + +/** + * A class to convert a SQL statement with named placeholders to positional. + * + * The parsing logic and the implementation is inspired by the PHP PDO parser, + * and a simplified copy of the parser implementation done by the Doctrine DBAL + * project. + * + * This class is a near-copy of Doctrine\DBAL\SQL\Parser, which is part of the + * Doctrine project: <http://www.doctrine-project.org>. It was copied from + * version 4.0.0. + * + * Original copyright: + * + * Copyright (c) 2006-2018 Doctrine Project + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * @see https://github.com/doctrine/dbal/blob/4.0.0/src/SQL/Parser.php + * + * @internal + */ +final class NamedPlaceholderConverter { + /** + * A list of regex patterns for parsing. + */ + private const string SPECIAL_CHARS = ':\?\'"`\\[\\-\\/'; + private const string BACKTICK_IDENTIFIER = '`[^`]*`'; + private const string BRACKET_IDENTIFIER = '(?<!\b(?i:ARRAY))\[(?:[^\]])*\]'; + private const string MULTICHAR = ':{2,}'; + private const string NAMED_PARAMETER = ':[a-zA-Z0-9_]+'; + private const string POSITIONAL_PARAMETER = '(?<!\\?)\\?(?!\\?)'; + private const string ONE_LINE_COMMENT = '--[^\r\n]*'; + private const string MULTI_LINE_COMMENT = '/\*([^*]+|\*+[^/*])*\**\*/'; + private const string SPECIAL = '[' . self::SPECIAL_CHARS . ']'; + private const string OTHER = '[^' . self::SPECIAL_CHARS . ']+'; + + /** + * The combined regex pattern for parsing. + */ + private string $sqlPattern; + + /** + * The list of original named arguments. + * + * The initial placeholder colon is removed. + * + * @var array<string|int, mixed> + */ + private array $originalParameters = []; + + /** + * The maximum positional placeholder parsed. + * + * Normally Drupal does not produce SQL with positional placeholders, but + * this is to manage the edge case. + */ + private int $originalParameterIndex = 0; + + /** + * The converted SQL statement in its parts. + * + * @var list<string> + */ + private array $convertedSQL = []; + + /** + * The list of converted arguments. + * + * @var list<mixed> + */ + private array $convertedParameters = []; + + public function __construct() { + // Builds the combined regex pattern for parsing. + $this->sqlPattern = sprintf('(%s)', implode('|', [ + $this->getAnsiSQLStringLiteralPattern("'"), + $this->getAnsiSQLStringLiteralPattern('"'), + self::BACKTICK_IDENTIFIER, + self::BRACKET_IDENTIFIER, + self::MULTICHAR, + self::ONE_LINE_COMMENT, + self::MULTI_LINE_COMMENT, + self::OTHER, + ])); + } + + /** + * Parses an SQL statement with named placeholders. + * + * This method explodes the SQL statement in parts that can be reassembled + * into a string with positional placeholders. + * + * @param string $sql + * The SQL statement with named placeholders. + * @param array<string|int, mixed> $args + * The statement arguments. + */ + public function parse(string $sql, array $args): void { + // Reset the object state. + $this->originalParameters = []; + $this->originalParameterIndex = 0; + $this->convertedSQL = []; + $this->convertedParameters = []; + + foreach ($args as $key => $value) { + if (is_int($key)) { + // Positional placeholder; edge case. + $this->originalParameters[$key] = $value; + } + else { + // Named placeholder like ':placeholder'; remove the initial colon. + $parameter = $key[0] === ':' ? substr($key, 1) : $key; + $this->originalParameters[$parameter] = $value; + } + } + + /** @var array<string,callable> $patterns */ + $patterns = [ + self::NAMED_PARAMETER => function (string $sql): void { + $this->addNamedParameter($sql); + }, + self::POSITIONAL_PARAMETER => function (string $sql): void { + $this->addPositionalParameter($sql); + }, + $this->sqlPattern => function (string $sql): void { + $this->addOther($sql); + }, + self::SPECIAL => function (string $sql): void { + $this->addOther($sql); + }, + ]; + + $offset = 0; + + while (($handler = current($patterns)) !== FALSE) { + if (preg_match('~\G' . key($patterns) . '~s', $sql, $matches, 0, $offset) === 1) { + $handler($matches[0]); + reset($patterns); + $offset += strlen($matches[0]); + } + elseif (preg_last_error() !== PREG_NO_ERROR) { + throw new \RuntimeException('Regular expression error'); + } + else { + next($patterns); + } + } + + assert($offset === strlen($sql)); + } + + /** + * Helper to return a regex pattern from a delimiter character. + * + * @param string $delimiter + * A delimiter character. + * + * @return string + * The regex pattern. + */ + private function getAnsiSQLStringLiteralPattern(string $delimiter): string { + return $delimiter . '[^' . $delimiter . ']*' . $delimiter; + } + + /** + * Adds a positional placeholder to the converted parts. + * + * Normally Drupal does not produce SQL with positional placeholders, but + * this is to manage the edge case. + * + * @param string $sql + * The SQL part. + */ + private function addPositionalParameter(string $sql): void { + $index = $this->originalParameterIndex; + + if (!array_key_exists($index, $this->originalParameters)) { + throw new \RuntimeException('Missing Positional Parameter ' . $index); + } + + $this->convertedSQL[] = '?'; + $this->convertedParameters[] = $this->originalParameters[$index]; + + $this->originalParameterIndex++; + } + + /** + * Adds a named placeholder to the converted parts. + * + * @param string $sql + * The SQL part. + */ + private function addNamedParameter(string $sql): void { + $name = substr($sql, 1); + + if (!array_key_exists($name, $this->originalParameters)) { + throw new \RuntimeException('Missing Named Parameter ' . $name); + } + + $this->convertedSQL[] = '?'; + $this->convertedParameters[] = $this->originalParameters[$name]; + } + + /** + * Adds a generic SQL string fragment to the converted parts. + * + * @param string $sql + * The SQL part. + */ + private function addOther(string $sql): void { + $this->convertedSQL[] = $sql; + } + + /** + * Returns the converted SQL statement with positional placeholders. + * + * @return string + * The converted SQL statement with positional placeholders. + */ + public function getConvertedSQL(): string { + return implode('', $this->convertedSQL); + } + + /** + * Returns the array of arguments for use with positional placeholders. + * + * @return list<mixed> + * The array of arguments for use with positional placeholders. + */ + public function getConvertedParameters(): array { + return $this->convertedParameters; + } + +} diff --git a/core/modules/mysqli/src/Driver/Database/mysqli/Result.php b/core/modules/mysqli/src/Driver/Database/mysqli/Result.php new file mode 100644 index 000000000000..2c5e57c3aa82 --- /dev/null +++ b/core/modules/mysqli/src/Driver/Database/mysqli/Result.php @@ -0,0 +1,95 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\mysqli\Driver\Database\mysqli; + +use Drupal\Core\Database\DatabaseExceptionWrapper; +use Drupal\Core\Database\FetchModeTrait; +use Drupal\Core\Database\Statement\FetchAs; +use Drupal\Core\Database\Statement\ResultBase; + +/** + * Class for mysqli-provided results of a data query language (DQL) statement. + */ +class Result extends ResultBase { + + use FetchModeTrait; + + /** + * Constructor. + * + * @param \Drupal\Core\Database\Statement\FetchAs $fetchMode + * The fetch mode. + * @param array{class: class-string, constructor_args: list<mixed>, column: int, cursor_orientation?: int, cursor_offset?: int} $fetchOptions + * The fetch options. + * @param \mysqli_result|false $mysqliResult + * The MySQLi result object. + * @param \mysqli $mysqliConnection + * Client database connection object. + */ + public function __construct( + FetchAs $fetchMode, + array $fetchOptions, + protected readonly \mysqli_result|false $mysqliResult, + protected readonly \mysqli $mysqliConnection, + ) { + parent::__construct($fetchMode, $fetchOptions); + } + + /** + * {@inheritdoc} + */ + public function rowCount(): ?int { + // The most accurate value to return for Drupal here is the first + // occurrence of an integer in the string stored by the connection's + // $info property. + // This is something like 'Rows matched: 1 Changed: 1 Warnings: 0' for + // UPDATE or DELETE operations, 'Records: 2 Duplicates: 1 Warnings: 0' + // for INSERT ones. + // This however requires a regex parsing of the string which is expensive; + // $affected_rows would be less accurate but much faster. We would need + // Drupal to be less strict in testing, and never rely on this value in + // runtime (which would be healthy anyway). + if ($this->mysqliConnection->info !== NULL) { + $matches = []; + if (preg_match('/\s(\d+)\s/', $this->mysqliConnection->info, $matches) === 1) { + return (int) $matches[0]; + } + else { + throw new DatabaseExceptionWrapper('Invalid data in the $info property of the mysqli connection - ' . $this->mysqliConnection->info); + } + } + elseif ($this->mysqliConnection->affected_rows !== NULL) { + return $this->mysqliConnection->affected_rows; + } + throw new DatabaseExceptionWrapper('Unable to retrieve affected rows data'); + } + + /** + * {@inheritdoc} + */ + public function setFetchMode(FetchAs $mode, array $fetchOptions): bool { + // There are no methods to set fetch mode in \mysqli_result. + return TRUE; + } + + /** + * {@inheritdoc} + */ + public function fetch(FetchAs $mode, array $fetchOptions): array|object|int|float|string|bool|NULL { + assert($this->mysqliResult instanceof \mysqli_result); + + $mysqli_row = $this->mysqliResult->fetch_assoc(); + + if (!$mysqli_row) { + return FALSE; + } + + // Stringify all non-NULL column values. + $row = array_map(fn ($value) => $value === NULL ? NULL : (string) $value, $mysqli_row); + + return $this->assocToFetchMode($row, $mode, $fetchOptions); + } + +} diff --git a/core/modules/mysqli/src/Driver/Database/mysqli/Statement.php b/core/modules/mysqli/src/Driver/Database/mysqli/Statement.php new file mode 100644 index 000000000000..f3b4346992df --- /dev/null +++ b/core/modules/mysqli/src/Driver/Database/mysqli/Statement.php @@ -0,0 +1,126 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\mysqli\Driver\Database\mysqli; + +use Drupal\Core\Database\Connection; +use Drupal\Core\Database\Statement\FetchAs; +use Drupal\Core\Database\Statement\StatementBase; + +/** + * MySQLi implementation of \Drupal\Core\Database\Query\StatementInterface. + */ +class Statement extends StatementBase { + + /** + * Holds the index position of named parameters. + * + * The mysqli driver only allows positional placeholders '?', whereas in + * Drupal the SQL is generated with named placeholders ':name'. In order to + * execute the SQL, the string containing the named placeholders is converted + * to using positional ones, and the position (index) of each named + * placeholder in the string is stored here. + */ + protected array $paramsPositions; + + /** + * Constructs a Statement object. + * + * @param \Drupal\Core\Database\Connection $connection + * Drupal database connection object. + * @param \mysqli $clientConnection + * Client database connection object. + * @param string $queryString + * The SQL query string. + * @param array $driverOpts + * (optional) Array of query options. + * @param bool $rowCountEnabled + * (optional) Enables counting the rows affected. Defaults to FALSE. + */ + public function __construct( + Connection $connection, + \mysqli $clientConnection, + string $queryString, + protected array $driverOpts = [], + bool $rowCountEnabled = FALSE, + ) { + parent::__construct($connection, $clientConnection, $queryString, $rowCountEnabled); + $this->setFetchMode(FetchAs::Object); + } + + /** + * Returns the client-level database statement object. + * + * This method should normally be used only within database driver code. + * + * @return \mysqli_stmt + * The client-level database statement. + */ + public function getClientStatement(): \mysqli_stmt { + if ($this->hasClientStatement()) { + assert($this->clientStatement instanceof \mysqli_stmt); + return $this->clientStatement; + } + throw new \LogicException('\\mysqli_stmt not initialized'); + } + + /** + * {@inheritdoc} + */ + public function execute($args = [], $options = []) { + if (isset($options['fetch'])) { + if (is_string($options['fetch'])) { + $this->setFetchMode(FetchAs::ClassObject, $options['fetch']); + } + else { + if (is_int($options['fetch'])) { + @trigger_error("Passing the 'fetch' key as an integer to \$options in execute() is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. Use a case of \Drupal\Core\Database\Statement\FetchAs enum instead. See https://www.drupal.org/node/3488338", E_USER_DEPRECATED); + } + $this->setFetchMode($options['fetch']); + } + } + + $startEvent = $this->dispatchStatementExecutionStartEvent($args ?? []); + + try { + // Prepare the lower-level statement if it's not been prepared already. + if (!$this->hasClientStatement()) { + // Replace named placeholders with positional ones if needed. + $this->paramsPositions = array_flip(array_keys($args)); + $converter = new NamedPlaceholderConverter(); + $converter->parse($this->queryString, $args); + [$convertedQueryString, $args] = [$converter->getConvertedSQL(), $converter->getConvertedParameters()]; + $this->clientStatement = $this->clientConnection->prepare($convertedQueryString); + } + else { + // Transform the $args to positional. + $tmp = []; + foreach ($this->paramsPositions as $param => $pos) { + $tmp[$pos] = $args[$param]; + } + $args = $tmp; + } + + // In mysqli, the results of the statement execution are returned in a + // different object than the statement itself. + $return = $this->getClientStatement()->execute($args); + $this->result = new Result( + $this->fetchMode, + $this->fetchOptions, + $this->getClientStatement()->get_result(), + $this->clientConnection, + ); + $this->markResultsetIterable($return); + } + catch (\Exception $e) { + $this->dispatchStatementExecutionFailureEvent($startEvent, $e); + throw $e; + } + + $this->dispatchStatementExecutionEndEvent($startEvent); + + return $return; + } + +} diff --git a/core/modules/mysqli/src/Driver/Database/mysqli/TransactionManager.php b/core/modules/mysqli/src/Driver/Database/mysqli/TransactionManager.php new file mode 100644 index 000000000000..90237fd6a43c --- /dev/null +++ b/core/modules/mysqli/src/Driver/Database/mysqli/TransactionManager.php @@ -0,0 +1,82 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\mysqli\Driver\Database\mysqli; + +use Drupal\Core\Database\Transaction\ClientConnectionTransactionState; +use Drupal\Core\Database\Transaction\TransactionManagerBase; + +/** + * MySqli implementation of TransactionManagerInterface. + */ +class TransactionManager extends TransactionManagerBase { + + /** + * {@inheritdoc} + */ + protected function beginClientTransaction(): bool { + return $this->connection->getClientConnection()->begin_transaction(); + } + + /** + * {@inheritdoc} + */ + protected function addClientSavepoint(string $name): bool { + return $this->connection->getClientConnection()->savepoint($name); + } + + /** + * {@inheritdoc} + */ + protected function rollbackClientSavepoint(string $name): bool { + // Mysqli does not have a rollback_to_savepoint method, and it does not + // allow a prepared statement for 'ROLLBACK TO SAVEPOINT', so we need to + // fallback to querying on the client connection directly. + try { + return (bool) $this->connection->getClientConnection()->query('ROLLBACK TO SAVEPOINT ' . $name); + } + catch (\mysqli_sql_exception) { + // If the rollback failed, most likely the savepoint was not there + // because the transaction is no longer active. In this case we void the + // transaction stack. + $this->voidClientTransaction(); + return TRUE; + } + } + + /** + * {@inheritdoc} + */ + protected function releaseClientSavepoint(string $name): bool { + return $this->connection->getClientConnection()->release_savepoint($name); + } + + /** + * {@inheritdoc} + */ + protected function rollbackClientTransaction(): bool { + // Note: mysqli::rollback() returns TRUE if there's no active transaction. + // This is diverging from PDO MySql. A PHP bug report exists. + // @see https://bugs.php.net/bug.php?id=81533. + $clientRollback = $this->connection->getClientConnection()->rollBack(); + $this->setConnectionTransactionState($clientRollback ? + ClientConnectionTransactionState::RolledBack : + ClientConnectionTransactionState::RollbackFailed + ); + return $clientRollback; + } + + /** + * {@inheritdoc} + */ + protected function commitClientTransaction(): bool { + $clientCommit = $this->connection->getClientConnection()->commit(); + $this->setConnectionTransactionState($clientCommit ? + ClientConnectionTransactionState::Committed : + ClientConnectionTransactionState::CommitFailed + ); + return $clientCommit; + } + +} diff --git a/core/modules/mysqli/src/Hook/MysqliHooks.php b/core/modules/mysqli/src/Hook/MysqliHooks.php new file mode 100644 index 000000000000..5fae187d16c7 --- /dev/null +++ b/core/modules/mysqli/src/Hook/MysqliHooks.php @@ -0,0 +1,32 @@ +<?php + +namespace Drupal\mysqli\Hook; + +use Drupal\Core\Hook\Attribute\Hook; +use Drupal\Core\Routing\RouteMatchInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; + +/** + * Hook implementations for mysqli. + */ +class MysqliHooks { + + use StringTranslationTrait; + + /** + * Implements hook_help(). + */ + #[Hook('help')] + public function help($route_name, RouteMatchInterface $route_match): ?string { + switch ($route_name) { + case 'help.page.mysqli': + $output = ''; + $output .= '<h3>' . $this->t('About') . '</h3>'; + $output .= '<p>' . $this->t('The MySQLi module provides the connection between Drupal and a MySQL, MariaDB or equivalent database using the mysqli PHP extension. For more information, see the <a href=":mysqli">online documentation for the MySQLi module</a>.', [':mysqli' => 'https://www.drupal.org/documentation/modules/mysqli']) . '</p>'; + return $output; + + } + return NULL; + } + +} diff --git a/core/modules/mysqli/src/Plugin/views/query/MysqliCastSql.php b/core/modules/mysqli/src/Plugin/views/query/MysqliCastSql.php new file mode 100644 index 000000000000..d1f1ca55f8f5 --- /dev/null +++ b/core/modules/mysqli/src/Plugin/views/query/MysqliCastSql.php @@ -0,0 +1,11 @@ +<?php + +namespace Drupal\mysqli\Plugin\views\query; + +use Drupal\mysql\Plugin\views\query\MysqlCastSql; + +/** + * MySQLi specific cast handling. + */ +class MysqliCastSql extends MysqlCastSql { +} diff --git a/core/modules/mysqli/tests/src/Functional/GenericTest.php b/core/modules/mysqli/tests/src/Functional/GenericTest.php new file mode 100644 index 000000000000..736381069606 --- /dev/null +++ b/core/modules/mysqli/tests/src/Functional/GenericTest.php @@ -0,0 +1,28 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\mysqli\Functional; + +use Drupal\Core\Extension\ExtensionLifecycle; +use Drupal\Tests\system\Functional\Module\GenericModuleTestBase; +use PHPUnit\Framework\Attributes\Group; + +/** + * Generic module test for mysqli. + */ +#[Group('mysqli')] +class GenericTest extends GenericModuleTestBase { + + /** + * Checks visibility of the module. + */ + public function testMysqliModule(): void { + $module = $this->getModule(); + \Drupal::service('module_installer')->install([$module]); + $info = \Drupal::service('extension.list.module')->getExtensionInfo($module); + $this->assertTrue($info['hidden']); + $this->assertSame(ExtensionLifecycle::EXPERIMENTAL, $info['lifecycle']); + } + +} diff --git a/core/modules/mysqli/tests/src/Kernel/mysqli/ConnectionTest.php b/core/modules/mysqli/tests/src/Kernel/mysqli/ConnectionTest.php new file mode 100644 index 000000000000..c940eb919d33 --- /dev/null +++ b/core/modules/mysqli/tests/src/Kernel/mysqli/ConnectionTest.php @@ -0,0 +1,15 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\mysqli\Kernel\mysqli; + +use Drupal\Tests\mysql\Kernel\mysql\ConnectionTest as BaseMySqlTest; +use PHPUnit\Framework\Attributes\Group; + +/** + * MySQL-specific connection tests. + */ +#[Group('Database')] +class ConnectionTest extends BaseMySqlTest { +} diff --git a/core/modules/mysqli/tests/src/Kernel/mysqli/ConnectionUnitTest.php b/core/modules/mysqli/tests/src/Kernel/mysqli/ConnectionUnitTest.php new file mode 100644 index 000000000000..42fa5d733dfd --- /dev/null +++ b/core/modules/mysqli/tests/src/Kernel/mysqli/ConnectionUnitTest.php @@ -0,0 +1,23 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\mysqli\Kernel\mysqli; + +use Drupal\Tests\mysql\Kernel\mysql\ConnectionUnitTest as BaseMySqlTest; +use PHPUnit\Framework\Attributes\Group; + +/** + * MySQL-specific connection unit tests. + */ +#[Group('Database')] +class ConnectionUnitTest extends BaseMySqlTest { + + /** + * Tests pdo options override. + */ + public function testConnectionOpen(): void { + $this->markTestSkipped('mysqli is not a pdo driver.'); + } + +} diff --git a/core/modules/mysqli/tests/src/Kernel/mysqli/DatabaseExceptionWrapperTest.php b/core/modules/mysqli/tests/src/Kernel/mysqli/DatabaseExceptionWrapperTest.php new file mode 100644 index 000000000000..2e27fff09f5a --- /dev/null +++ b/core/modules/mysqli/tests/src/Kernel/mysqli/DatabaseExceptionWrapperTest.php @@ -0,0 +1,38 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\mysqli\Kernel\mysqli; + +use Drupal\Tests\mysql\Kernel\mysql\DatabaseExceptionWrapperTest as BaseMySqlTest; +use PHPUnit\Framework\Attributes\Group; + +/** + * Tests exceptions thrown by queries. + */ +#[Group('Database')] +class DatabaseExceptionWrapperTest extends BaseMySqlTest { + + /** + * Tests Connection::prepareStatement exceptions on preparation. + * + * Core database drivers use PDO emulated statements or the StatementPrefetch + * class, which defer the statement check to the moment of the execution. In + * order to test a failure at preparation time, we have to force the + * connection not to emulate statement preparation. Still, this is only valid + * for the MySql driver. + */ + public function testPrepareStatementFailOnPreparation(): void { + $this->markTestSkipped('mysqli is not a pdo driver.'); + } + + /** + * Tests Connection::prepareStatement exception on execution. + */ + public function testPrepareStatementFailOnExecution(): void { + $this->expectException(\mysqli_sql_exception::class); + $stmt = $this->connection->prepareStatement('bananas', []); + $stmt->execute(); + } + +} diff --git a/core/modules/mysqli/tests/src/Kernel/mysqli/LargeQueryTest.php b/core/modules/mysqli/tests/src/Kernel/mysqli/LargeQueryTest.php new file mode 100644 index 000000000000..ead54a27c012 --- /dev/null +++ b/core/modules/mysqli/tests/src/Kernel/mysqli/LargeQueryTest.php @@ -0,0 +1,52 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\mysqli\Kernel\mysqli; + +use Drupal\Component\Utility\Environment; +use Drupal\Core\Database\Database; +use Drupal\Core\Database\DatabaseExceptionWrapper; +use Drupal\Tests\mysql\Kernel\mysql\LargeQueryTest as BaseMySqlTest; +use PHPUnit\Framework\Attributes\Group; + +/** + * Tests handling of large queries. + */ +#[Group('Database')] +class LargeQueryTest extends BaseMySqlTest { + + /** + * Tests truncation of messages when max_allowed_packet exception occurs. + */ + public function testMaxAllowedPacketQueryTruncating(): void { + $connectionInfo = Database::getConnectionInfo(); + Database::addConnectionInfo('default', 'testMaxAllowedPacketQueryTruncating', $connectionInfo['default']); + $testConnection = Database::getConnection('testMaxAllowedPacketQueryTruncating'); + + // The max_allowed_packet value is configured per database instance. + // Retrieve the max_allowed_packet value from the current instance and + // check if PHP is configured with sufficient allowed memory to be able + // to generate a query larger than max_allowed_packet. + $max_allowed_packet = $testConnection->query('SELECT @@global.max_allowed_packet')->fetchField(); + if (!Environment::checkMemoryLimit($max_allowed_packet + (16 * 1024 * 1024))) { + $this->markTestSkipped('The configured max_allowed_packet exceeds the php memory limit. Therefore the test is skipped.'); + } + + $long_name = str_repeat('a', $max_allowed_packet + 1); + try { + $testConnection->query('SELECT [name] FROM {test} WHERE [name] = :name', [':name' => $long_name]); + $this->fail("An exception should be thrown for queries larger than 'max_allowed_packet'"); + } + catch (\Throwable $e) { + Database::closeConnection('testMaxAllowedPacketQueryTruncating'); + // Got a packet bigger than 'max_allowed_packet' bytes exception thrown. + $this->assertInstanceOf(DatabaseExceptionWrapper::class, $e); + $this->assertEquals(1153, $e->getPrevious()->getCode()); + // 'max_allowed_packet' exception message truncated. + // Use strlen() to count the bytes exactly, not the Unicode chars. + $this->assertLessThanOrEqual($max_allowed_packet, strlen($e->getMessage())); + } + } + +} diff --git a/core/modules/mysqli/tests/src/Kernel/mysqli/PrefixInfoTest.php b/core/modules/mysqli/tests/src/Kernel/mysqli/PrefixInfoTest.php new file mode 100644 index 000000000000..894245826cb3 --- /dev/null +++ b/core/modules/mysqli/tests/src/Kernel/mysqli/PrefixInfoTest.php @@ -0,0 +1,15 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\mysqli\Kernel\mysqli; + +use Drupal\Tests\mysql\Kernel\mysql\PrefixInfoTest as BaseMySqlTest; +use PHPUnit\Framework\Attributes\Group; + +/** + * Tests that the prefix info for a database schema is correct. + */ +#[Group('Database')] +class PrefixInfoTest extends BaseMySqlTest { +} diff --git a/core/modules/mysqli/tests/src/Kernel/mysqli/SchemaTest.php b/core/modules/mysqli/tests/src/Kernel/mysqli/SchemaTest.php new file mode 100644 index 000000000000..23fa565156fb --- /dev/null +++ b/core/modules/mysqli/tests/src/Kernel/mysqli/SchemaTest.php @@ -0,0 +1,15 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\mysqli\Kernel\mysqli; + +use Drupal\Tests\mysql\Kernel\mysql\SchemaTest as BaseMySqlTest; +use PHPUnit\Framework\Attributes\Group; + +/** + * Tests schema API for the MySQL driver. + */ +#[Group('Database')] +class SchemaTest extends BaseMySqlTest { +} diff --git a/core/modules/mysqli/tests/src/Kernel/mysqli/SqlModeTest.php b/core/modules/mysqli/tests/src/Kernel/mysqli/SqlModeTest.php new file mode 100644 index 000000000000..7bbf1b85b391 --- /dev/null +++ b/core/modules/mysqli/tests/src/Kernel/mysqli/SqlModeTest.php @@ -0,0 +1,43 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\mysqli\Kernel\mysqli; + +use Drupal\KernelTests\Core\Database\DriverSpecificDatabaseTestBase; +use PHPUnit\Framework\Attributes\Group; + +/** + * Tests compatibility of the MySQL driver with various sql_mode options. + */ +#[Group('Database')] +class SqlModeTest extends DriverSpecificDatabaseTestBase { + + /** + * Tests quoting identifiers in queries. + */ + public function testQuotingIdentifiers(): void { + // Use SQL-reserved words for both the table and column names. + $query = $this->connection->query('SELECT [update] FROM {select}'); + $this->assertEquals('Update value 1', $query->fetchObject()->update); + $this->assertStringContainsString('SELECT `update` FROM `', $query->getQueryString()); + } + + /** + * {@inheritdoc} + */ + protected function getDatabaseConnectionInfo() { + $info = parent::getDatabaseConnectionInfo(); + + // This runs during setUp(), so is not yet skipped for non MySQL databases. + // We defer skipping the test to later in setUp(), so that that can be + // based on databaseType() rather than 'driver', but here all we have to go + // on is 'driver'. + if ($info['default']['driver'] === 'mysqli') { + $info['default']['init_commands']['sql_mode'] = "SET sql_mode = ''"; + } + + return $info; + } + +} diff --git a/core/modules/mysqli/tests/src/Kernel/mysqli/SyntaxTest.php b/core/modules/mysqli/tests/src/Kernel/mysqli/SyntaxTest.php new file mode 100644 index 000000000000..7ccdcf1022f9 --- /dev/null +++ b/core/modules/mysqli/tests/src/Kernel/mysqli/SyntaxTest.php @@ -0,0 +1,28 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\mysqli\Kernel\mysqli; + +use Drupal\KernelTests\Core\Database\DriverSpecificSyntaxTestBase; +use PHPUnit\Framework\Attributes\Group; + +/** + * Tests MySql syntax interpretation. + */ +#[Group('Database')] +class SyntaxTest extends DriverSpecificSyntaxTestBase { + + /** + * Tests string concatenation with separator, with field values. + */ + public function testConcatWsFields(): void { + $result = $this->connection->query("SELECT CONCAT_WS('-', CONVERT(:a1 USING utf8mb4), [name], CONVERT(:a2 USING utf8mb4), [age]) FROM {test} WHERE [age] = :age", [ + ':a1' => 'name', + ':a2' => 'age', + ':age' => 25, + ]); + $this->assertSame('name-John-age-25', $result->fetchField()); + } + +} diff --git a/core/modules/mysqli/tests/src/Kernel/mysqli/TemporaryQueryTest.php b/core/modules/mysqli/tests/src/Kernel/mysqli/TemporaryQueryTest.php new file mode 100644 index 000000000000..19539fa65877 --- /dev/null +++ b/core/modules/mysqli/tests/src/Kernel/mysqli/TemporaryQueryTest.php @@ -0,0 +1,15 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\mysqli\Kernel\mysqli; + +use Drupal\Tests\mysql\Kernel\mysql\TemporaryQueryTest as BaseMySqlTest; +use PHPUnit\Framework\Attributes\Group; + +/** + * Tests the temporary query functionality. + */ +#[Group('Database')] +class TemporaryQueryTest extends BaseMySqlTest { +} diff --git a/core/modules/mysqli/tests/src/Kernel/mysqli/TransactionTest.php b/core/modules/mysqli/tests/src/Kernel/mysqli/TransactionTest.php new file mode 100644 index 000000000000..60f6c27540dc --- /dev/null +++ b/core/modules/mysqli/tests/src/Kernel/mysqli/TransactionTest.php @@ -0,0 +1,54 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\mysqli\Kernel\mysqli; + +use Drupal\KernelTests\Core\Database\DriverSpecificTransactionTestBase; +use PHPUnit\Framework\Attributes\Group; + +/** + * Tests transaction for the MySQLi driver. + */ +#[Group('Database')] +class TransactionTest extends DriverSpecificTransactionTestBase { + + /** + * Tests starting a transaction when there's one active on the client. + * + * MySQLi does not fail if multiple commits are made on the client, so this + * test is failing. Let's change this if/when MySQLi will provide a way to + * check if a client transaction is active. + * + * This is mitigated by the fact that transaction should not be initiated from + * code outside the TransactionManager, that keeps track of the stack of + * transaction-related operations in its stack. + */ + public function testStartTransactionWhenActive(): void { + $this->markTestSkipped('Skipping this while MySQLi cannot detect if a client transaction is active.'); + $this->connection->getClientConnection()->begin_transaction(); + $this->connection->startTransaction(); + $this->assertFalse($this->connection->inTransaction()); + } + + /** + * Tests committing a transaction when there's none active on the client. + * + * MySQLi does not fail if multiple commits are made on the client, so this + * test is failing. Let's change this if/when MySQLi will provide a way to + * check if a client transaction is active. + * + * This is mitigated by the fact that transaction should not be initiated from + * code outside the TransactionManager, that keeps track of the stack of + * transaction-related operations in its stack. + */ + public function testCommitTransactionWhenInactive(): void { + $this->markTestSkipped('Skipping this while MySQLi cannot detect if a client transaction is active.'); + $transaction = $this->connection->startTransaction(); + $this->assertTrue($this->connection->inTransaction()); + $this->connection->getClientConnection()->commit(); + $this->assertFalse($this->connection->inTransaction()); + unset($transaction); + } + +} diff --git a/core/modules/mysqli/tests/src/Unit/NamedPlaceholderConverterTest.php b/core/modules/mysqli/tests/src/Unit/NamedPlaceholderConverterTest.php new file mode 100644 index 000000000000..a000a132e203 --- /dev/null +++ b/core/modules/mysqli/tests/src/Unit/NamedPlaceholderConverterTest.php @@ -0,0 +1,400 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\mysqli\Unit; + +use Drupal\mysqli\Driver\Database\mysqli\NamedPlaceholderConverter; +use Drupal\Tests\UnitTestCase; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; + +/** + * Tests \Drupal\mysqli\Driver\Database\mysqli\NamedPlaceholderConverter. + */ +#[CoversClass(NamedPlaceholderConverter::class)] +#[Group('Database')] +class NamedPlaceholderConverterTest extends UnitTestCase { + + /** + * Tests ::parse(). + * + * @legacy-covers ::parse + * @legacy-covers ::getConvertedSQL + * @legacy-covers ::getConvertedParameters + */ + #[DataProvider('statementsWithParametersProvider')] + public function testParse(string $sql, array $parameters, string $expectedSql, array $expectedParameters): void { + $converter = new NamedPlaceholderConverter(); + $converter->parse($sql, $parameters); + $this->assertSame($expectedSql, $converter->getConvertedSQL()); + $this->assertSame($expectedParameters, $converter->getConvertedParameters()); + } + + /** + * Data for testParse. + */ + public static function statementsWithParametersProvider(): iterable { + yield [ + 'SELECT ?', + ['foo'], + 'SELECT ?', + ['foo'], + ]; + + yield [ + 'SELECT * FROM Foo WHERE bar IN (?, ?, ?)', + ['baz', 'qux', 'fred'], + 'SELECT * FROM Foo WHERE bar IN (?, ?, ?)', + ['baz', 'qux', 'fred'], + ]; + + yield [ + 'SELECT ? FROM ?', + ['baz', 'qux'], + 'SELECT ? FROM ?', + ['baz', 'qux'], + ]; + + yield [ + 'SELECT "?" FROM foo WHERE bar = ?', + ['baz'], + 'SELECT "?" FROM foo WHERE bar = ?', + ['baz'], + ]; + + yield [ + "SELECT '?' FROM foo WHERE bar = ?", + ['baz'], + "SELECT '?' FROM foo WHERE bar = ?", + ['baz'], + ]; + + yield [ + 'SELECT `?` FROM foo WHERE bar = ?', + ['baz'], + 'SELECT `?` FROM foo WHERE bar = ?', + ['baz'], + ]; + + yield [ + 'SELECT [?] FROM foo WHERE bar = ?', + ['baz'], + 'SELECT [?] FROM foo WHERE bar = ?', + ['baz'], + ]; + + yield [ + 'SELECT * FROM foo WHERE jsonb_exists_any(foo.bar, ARRAY[?])', + ['baz'], + 'SELECT * FROM foo WHERE jsonb_exists_any(foo.bar, ARRAY[?])', + ['baz'], + ]; + + yield [ + "SELECT 'foo-bar?' FROM foo WHERE bar = ?", + ['baz'], + "SELECT 'foo-bar?' FROM foo WHERE bar = ?", + ['baz'], + ]; + + yield [ + 'SELECT "foo-bar?" FROM foo WHERE bar = ?', + ['baz'], + 'SELECT "foo-bar?" FROM foo WHERE bar = ?', + ['baz'], + ]; + + yield [ + 'SELECT `foo-bar?` FROM foo WHERE bar = ?', + ['baz'], + 'SELECT `foo-bar?` FROM foo WHERE bar = ?', + ['baz'], + ]; + + yield [ + 'SELECT [foo-bar?] FROM foo WHERE bar = ?', + ['baz'], + 'SELECT [foo-bar?] FROM foo WHERE bar = ?', + ['baz'], + ]; + + yield [ + 'SELECT :foo FROM :bar', + [':foo' => 'baz', ':bar' => 'qux'], + 'SELECT ? FROM ?', + ['baz', 'qux'], + ]; + + yield [ + 'SELECT * FROM Foo WHERE bar IN (:name1, :name2)', + [':name1' => 'baz', ':name2' => 'qux'], + 'SELECT * FROM Foo WHERE bar IN (?, ?)', + ['baz', 'qux'], + ]; + + yield [ + 'SELECT ":foo" FROM Foo WHERE bar IN (:name1, :name2)', + [':name1' => 'baz', ':name2' => 'qux'], + 'SELECT ":foo" FROM Foo WHERE bar IN (?, ?)', + ['baz', 'qux'], + ]; + + yield [ + "SELECT ':foo' FROM Foo WHERE bar IN (:name1, :name2)", + [':name1' => 'baz', ':name2' => 'qux'], + "SELECT ':foo' FROM Foo WHERE bar IN (?, ?)", + ['baz', 'qux'], + ]; + + yield [ + 'SELECT :foo_id', + [':foo_id' => 'bar'], + 'SELECT ?', + ['bar'], + ]; + + yield [ + 'SELECT @rank := 1 AS rank, :foo AS foo FROM :bar', + [':foo' => 'baz', ':bar' => 'qux'], + 'SELECT @rank := 1 AS rank, ? AS foo FROM ?', + ['baz', 'qux'], + ]; + + yield [ + 'SELECT * FROM Foo WHERE bar > :start_date AND baz > :start_date', + [':start_date' => 'qux'], + 'SELECT * FROM Foo WHERE bar > ? AND baz > ?', + ['qux', 'qux'], + ]; + + yield [ + 'SELECT foo::date as date FROM Foo WHERE bar > :start_date AND baz > :start_date', + [':start_date' => 'qux'], + 'SELECT foo::date as date FROM Foo WHERE bar > ? AND baz > ?', + ['qux', 'qux'], + ]; + + yield [ + 'SELECT `d.ns:col_name` FROM my_table d WHERE `d.date` >= :param1', + [':param1' => 'qux'], + 'SELECT `d.ns:col_name` FROM my_table d WHERE `d.date` >= ?', + ['qux'], + ]; + + yield [ + 'SELECT [d.ns:col_name] FROM my_table d WHERE [d.date] >= :param1', + [':param1' => 'qux'], + 'SELECT [d.ns:col_name] FROM my_table d WHERE [d.date] >= ?', + ['qux'], + ]; + + yield [ + 'SELECT * FROM foo WHERE jsonb_exists_any(foo.bar, ARRAY[:foo])', + [':foo' => 'qux'], + 'SELECT * FROM foo WHERE jsonb_exists_any(foo.bar, ARRAY[?])', + ['qux'], + ]; + + yield [ + 'SELECT * FROM foo WHERE jsonb_exists_any(foo.bar, array[:foo])', + [':foo' => 'qux'], + 'SELECT * FROM foo WHERE jsonb_exists_any(foo.bar, array[?])', + ['qux'], + ]; + + yield [ + "SELECT table.column1, ARRAY['3'] FROM schema.table table WHERE table.f1 = :foo AND ARRAY['3']", + [':foo' => 'qux'], + "SELECT table.column1, ARRAY['3'] FROM schema.table table WHERE table.f1 = ? AND ARRAY['3']", + ['qux'], + ]; + + yield [ + "SELECT table.column1, ARRAY['3']::integer[] FROM schema.table table WHERE table.f1 = :foo AND ARRAY['3']::integer[]", + [':foo' => 'qux'], + "SELECT table.column1, ARRAY['3']::integer[] FROM schema.table table WHERE table.f1 = ? AND ARRAY['3']::integer[]", + ['qux'], + ]; + + yield [ + "SELECT table.column1, ARRAY[:foo] FROM schema.table table WHERE table.f1 = :bar AND ARRAY['3']", + [':foo' => 'qux', ':bar' => 'git'], + "SELECT table.column1, ARRAY[?] FROM schema.table table WHERE table.f1 = ? AND ARRAY['3']", + ['qux', 'git'], + ]; + + yield [ + 'SELECT table.column1, ARRAY[:foo]::integer[] FROM schema.table table' . " WHERE table.f1 = :bar AND ARRAY['3']::integer[]", + [':foo' => 'qux', ':bar' => 'git'], + 'SELECT table.column1, ARRAY[?]::integer[] FROM schema.table table' . " WHERE table.f1 = ? AND ARRAY['3']::integer[]", + ['qux', 'git'], + ]; + + yield 'Parameter array with placeholder keys missing starting colon' => [ + 'SELECT table.column1, ARRAY[:foo]::integer[] FROM schema.table table' . " WHERE table.f1 = :bar AND ARRAY['3']::integer[]", + ['foo' => 'qux', 'bar' => 'git'], + 'SELECT table.column1, ARRAY[?]::integer[] FROM schema.table table' . " WHERE table.f1 = ? AND ARRAY['3']::integer[]", + ['qux', 'git'], + ]; + + yield 'Quotes inside literals escaped by doubling' => [ + <<<'SQL' +SELECT * FROM foo +WHERE bar = ':not_a_param1 ''":not_a_param2"''' + OR bar=:a_param1 + OR bar=:a_param2||':not_a_param3' + OR bar=':not_a_param4 '':not_a_param5'' :not_a_param6' + OR bar='' + OR bar=:a_param3 +SQL, + [':a_param1' => 'qux', ':a_param2' => 'git', ':a_param3' => 'foo'], + <<<'SQL' +SELECT * FROM foo +WHERE bar = ':not_a_param1 ''":not_a_param2"''' + OR bar=? + OR bar=?||':not_a_param3' + OR bar=':not_a_param4 '':not_a_param5'' :not_a_param6' + OR bar='' + OR bar=? +SQL, + ['qux', 'git', 'foo'], + ]; + + yield [ + 'SELECT data.age AS age, data.id AS id, data.name AS name, data.id AS id FROM test_data data WHERE (data.description LIKE :condition_0 ESCAPE \'\\\\\') AND (data.description LIKE :condition_1 ESCAPE \'\\\\\') ORDER BY id ASC', + [':condition_0' => 'qux', ':condition_1' => 'git'], + 'SELECT data.age AS age, data.id AS id, data.name AS name, data.id AS id FROM test_data data WHERE (data.description LIKE ? ESCAPE \'\\\\\') AND (data.description LIKE ? ESCAPE \'\\\\\') ORDER BY id ASC', + ['qux', 'git'], + ]; + + yield [ + 'SELECT data.age AS age, data.id AS id, data.name AS name, data.id AS id FROM test_data data WHERE (data.description LIKE :condition_0 ESCAPE "\\\\") AND (data.description LIKE :condition_1 ESCAPE "\\\\") ORDER BY id ASC', + [':condition_0' => 'qux', ':condition_1' => 'git'], + 'SELECT data.age AS age, data.id AS id, data.name AS name, data.id AS id FROM test_data data WHERE (data.description LIKE ? ESCAPE "\\\\") AND (data.description LIKE ? ESCAPE "\\\\") ORDER BY id ASC', + ['qux', 'git'], + ]; + + yield 'Combined single and double quotes' => [ + <<<'SQL' +SELECT data.age AS age, data.id AS id, data.name AS name, data.id AS id + FROM test_data data + WHERE (data.description LIKE :condition_0 ESCAPE "\\") + AND (data.description LIKE :condition_1 ESCAPE '\\') ORDER BY id ASC +SQL, + [':condition_0' => 'qux', ':condition_1' => 'git'], + <<<'SQL' +SELECT data.age AS age, data.id AS id, data.name AS name, data.id AS id + FROM test_data data + WHERE (data.description LIKE ? ESCAPE "\\") + AND (data.description LIKE ? ESCAPE '\\') ORDER BY id ASC +SQL, + ['qux', 'git'], + ]; + + yield [ + 'SELECT data.age AS age, data.id AS id, data.name AS name, data.id AS id FROM test_data data WHERE (data.description LIKE :condition_0 ESCAPE `\\\\`) AND (data.description LIKE :condition_1 ESCAPE `\\\\`) ORDER BY id ASC', + [':condition_0' => 'qux', ':condition_1' => 'git'], + 'SELECT data.age AS age, data.id AS id, data.name AS name, data.id AS id FROM test_data data WHERE (data.description LIKE ? ESCAPE `\\\\`) AND (data.description LIKE ? ESCAPE `\\\\`) ORDER BY id ASC', + ['qux', 'git'], + ]; + + yield 'Combined single quotes and backticks' => [ + <<<'SQL' +SELECT data.age AS age, data.id AS id, data.name AS name, data.id AS id + FROM test_data data + WHERE (data.description LIKE :condition_0 ESCAPE '\\') + AND (data.description LIKE :condition_1 ESCAPE `\\`) ORDER BY id ASC +SQL, + [':condition_0' => 'qux', ':condition_1' => 'git'], + <<<'SQL' +SELECT data.age AS age, data.id AS id, data.name AS name, data.id AS id + FROM test_data data + WHERE (data.description LIKE ? ESCAPE '\\') + AND (data.description LIKE ? ESCAPE `\\`) ORDER BY id ASC +SQL, + ['qux', 'git'], + ]; + + yield '? placeholders inside comments' => [ + <<<'SQL' +/* + * test placeholder ? + */ +SELECT dummy as "dummy?" + FROM DUAL + WHERE '?' = '?' +-- AND dummy <> ? + AND dummy = ? +SQL, + ['baz'], + <<<'SQL' +/* + * test placeholder ? + */ +SELECT dummy as "dummy?" + FROM DUAL + WHERE '?' = '?' +-- AND dummy <> ? + AND dummy = ? +SQL, + ['baz'], + ]; + + yield 'Named placeholders inside comments' => [ + <<<'SQL' +/* + * test :placeholder + */ +SELECT dummy as "dummy?" + FROM DUAL + WHERE '?' = '?' +-- AND dummy <> :dummy + AND dummy = :key +SQL, + [':key' => 'baz'], + <<<'SQL' +/* + * test :placeholder + */ +SELECT dummy as "dummy?" + FROM DUAL + WHERE '?' = '?' +-- AND dummy <> :dummy + AND dummy = ? +SQL, + ['baz'], + ]; + + yield 'Escaped question' => [ + <<<'SQL' +SELECT '{"a":null}'::jsonb ?? :key +SQL, + [':key' => 'qux'], + <<<'SQL' +SELECT '{"a":null}'::jsonb ?? ? +SQL, + ['qux'], + ]; + } + + /** + * Tests reusing the parser object. + * + * @legacy-covers ::parse + * @legacy-covers ::getConvertedSQL + * @legacy-covers ::getConvertedParameters + */ + public function testParseReuseObject(): void { + $converter = new NamedPlaceholderConverter(); + $converter->parse('SELECT ?', ['foo']); + $this->assertSame('SELECT ?', $converter->getConvertedSQL()); + $this->assertSame(['foo'], $converter->getConvertedParameters()); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Missing Positional Parameter 0'); + $converter->parse('SELECT ?', []); + } + +} diff --git a/core/modules/navigation/tests/navigation_test/navigation_test.module b/core/modules/navigation/tests/navigation_test/navigation_test.module deleted file mode 100644 index 3c7eb2fade87..000000000000 --- a/core/modules/navigation/tests/navigation_test/navigation_test.module +++ /dev/null @@ -1,19 +0,0 @@ -<?php - -/** - * @file - * Contains main module functions. - */ - -declare(strict_types=1); - -use Drupal\Component\Utility\Html; - -/** - * Implements hook_preprocess_HOOK(). - */ -function navigation_test_preprocess_block__navigation(&$variables): void { - // Add some additional classes so we can target the correct contextual link - // in tests. - $variables['attributes']['class'][] = Html::cleanCssIdentifier('block-' . $variables['elements']['#plugin_id']); -} diff --git a/core/modules/navigation/tests/navigation_test/src/Hook/NavigationTestThemeHooks.php b/core/modules/navigation/tests/navigation_test/src/Hook/NavigationTestThemeHooks.php new file mode 100644 index 000000000000..9020deed81d9 --- /dev/null +++ b/core/modules/navigation/tests/navigation_test/src/Hook/NavigationTestThemeHooks.php @@ -0,0 +1,25 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\navigation_test\Hook; + +use Drupal\Component\Utility\Html; +use Drupal\Core\Hook\Attribute\Hook; + +/** + * Theme hook implementations for navigation_test module. + */ +class NavigationTestThemeHooks { + + /** + * Implements hook_preprocess_HOOK(). + */ + #[Hook('preprocess_block__navigation')] + public function preprocessBlockNavigation(&$variables): void { + // Add some additional classes so we can target the correct contextual link + // in tests. + $variables['attributes']['class'][] = Html::cleanCssIdentifier('block-' . $variables['elements']['#plugin_id']); + } + +} diff --git a/core/modules/node/src/Plugin/views/filter/Access.php b/core/modules/node/src/Plugin/views/filter/Access.php index 4934a2f2e635..4d579f687ce4 100644 --- a/core/modules/node/src/Plugin/views/filter/Access.php +++ b/core/modules/node/src/Plugin/views/filter/Access.php @@ -36,7 +36,7 @@ class Access extends FilterPluginBase { */ public function query() { $account = $this->view->getUser(); - if (!$account->hasPermission('bypass node access')) { + if (!$account->hasPermission('bypass node access') && $this->moduleHandler->hasImplementations('node_grants')) { $table = $this->ensureMyTable(); $grants = $this->query->getConnection()->condition('OR'); foreach (node_access_grants('view', $account) as $realm => $gids) { diff --git a/core/modules/search/tests/modules/search_embedded_form/search_embedded_form.module b/core/modules/search/tests/modules/search_embedded_form/search_embedded_form.module deleted file mode 100644 index d21af735ecad..000000000000 --- a/core/modules/search/tests/modules/search_embedded_form/search_embedded_form.module +++ /dev/null @@ -1,20 +0,0 @@ -<?php - -/** - * @file - * Test module implementing a form that can be embedded in search results. - * - * A sample use of an embedded form is an e-commerce site where each search - * result may include an embedded form with buttons like "Add to cart" for each - * individual product (node) listed in the search results. - */ - -declare(strict_types=1); - -/** - * Adds the test form to search results. - */ -function search_embedded_form_preprocess_search_result(&$variables): void { - $form = \Drupal::formBuilder()->getForm('Drupal\search_embedded_form\Form\SearchEmbeddedForm'); - $variables['snippet'] = array_merge($variables['snippet'], $form); -} diff --git a/core/modules/search/tests/modules/search_embedded_form/src/Hook/SearchEmbeddedFormThemeHooks.php b/core/modules/search/tests/modules/search_embedded_form/src/Hook/SearchEmbeddedFormThemeHooks.php new file mode 100644 index 000000000000..89036be010bb --- /dev/null +++ b/core/modules/search/tests/modules/search_embedded_form/src/Hook/SearchEmbeddedFormThemeHooks.php @@ -0,0 +1,32 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\search_embedded_form\Hook; + +use Drupal\Core\Form\FormBuilderInterface; +use Drupal\Core\Hook\Attribute\Hook; + +/** + * Theme hook implementations for search_embedded_form module. + * + * A sample use of an embedded form is an e-commerce site where each search + * result may include an embedded form with buttons like "Add to cart" for each + * individual product (node) listed in the search results. + */ +class SearchEmbeddedFormThemeHooks { + + public function __construct( + protected FormBuilderInterface $formBuilder, + ) {} + + /** + * Implements hook_preprocess_HOOK(). + */ + #[Hook('preprocess_search_result')] + public function preprocessSearchResult(&$variables): void { + $form = $this->formBuilder->getForm('Drupal\search_embedded_form\Form\SearchEmbeddedForm'); + $variables['snippet'] = array_merge($variables['snippet'], $form); + } + +} diff --git a/core/modules/system/src/EventSubscriber/SecurityFileUploadEventSubscriber.php b/core/modules/system/src/EventSubscriber/SecurityFileUploadEventSubscriber.php index 737b9a7a53e8..c9722a5e4e12 100644 --- a/core/modules/system/src/EventSubscriber/SecurityFileUploadEventSubscriber.php +++ b/core/modules/system/src/EventSubscriber/SecurityFileUploadEventSubscriber.php @@ -52,22 +52,17 @@ class SecurityFileUploadEventSubscriber implements EventSubscriberInterface { // http://php.net/manual/security.filesystem.nullbytes.php $filename = str_replace(chr(0), '', $filename); + if ($filename !== $event->getFilename()) { + $event->setFilename($filename)->setSecurityRename(); + } + // Split up the filename by periods. The first part becomes the basename, // the last part the final extension. $filename_parts = explode('.', $filename); // Remove file basename. $filename = array_shift($filename_parts); - // Remove final extension. + // Remove final extension. In the case of dot filenames this will be empty. $final_extension = (string) array_pop($filename_parts); - // Check if we're dealing with a dot file that is also an insecure extension - // e.g. .htaccess. In this scenario there is only one 'part' and the - // extension becomes the filename. We use the original filename from the - // event rather than the trimmed version above. - $insecure_uploads = $this->configFactory->get('system.file')->get('allow_insecure_uploads'); - if (!$insecure_uploads && $final_extension === '' && str_contains($event->getFilename(), '.') && in_array(strtolower($filename), FileSystemInterface::INSECURE_EXTENSIONS, TRUE)) { - $final_extension = $filename; - $filename = ''; - } $extensions = $event->getAllowedExtensions(); if (!empty($extensions) && !in_array(strtolower($final_extension), $extensions, TRUE)) { @@ -81,7 +76,7 @@ class SecurityFileUploadEventSubscriber implements EventSubscriberInterface { return; } - if (!$insecure_uploads && in_array(strtolower($final_extension), FileSystemInterface::INSECURE_EXTENSIONS, TRUE)) { + if (!$this->configFactory->get('system.file')->get('allow_insecure_uploads') && in_array(strtolower($final_extension), FileSystemInterface::INSECURE_EXTENSIONS, TRUE)) { if (empty($extensions) || in_array('txt', $extensions, TRUE)) { // Add .txt to potentially executable files prior to munging to help // prevent exploits. This results in a filenames like filename.php being diff --git a/core/modules/system/system.libraries.yml b/core/modules/system/system.libraries.yml index af0eeea05d20..acb02dd1f4b7 100644 --- a/core/modules/system/system.libraries.yml +++ b/core/modules/system/system.libraries.yml @@ -7,7 +7,6 @@ base: css/components/container-inline.module.css: { weight: -10 } css/components/clearfix.module.css: { weight: -10 } css/components/hidden.module.css: { weight: -10 } - css/components/item-list.module.css: { weight: -10 } css/components/js.module.css: { weight: -10 } css/components/reset-appearance.module.css: { weight: -10 } diff --git a/core/modules/system/templates/field-multiple-value-form.html.twig b/core/modules/system/templates/field-multiple-value-form.html.twig index 832b9f61794a..ecd268690b46 100644 --- a/core/modules/system/templates/field-multiple-value-form.html.twig +++ b/core/modules/system/templates/field-multiple-value-form.html.twig @@ -16,7 +16,7 @@ * - attributes: HTML attributes to apply to the description container. * - button: "Add another item" button. * - * @see template_preprocess_field_multiple_value_form() + * @see \Drupal\Core\Field\FieldPreprocess::preprocessFieldMultipleValueForm() * * @ingroup themeable */ diff --git a/core/modules/system/templates/field.html.twig b/core/modules/system/templates/field.html.twig index 1497678b50ad..2bef0a02e6f0 100644 --- a/core/modules/system/templates/field.html.twig +++ b/core/modules/system/templates/field.html.twig @@ -33,7 +33,7 @@ * - field_type: The type of the field. * - label_display: The display settings for the label. * - * @see template_preprocess_field() + * @see \Drupal\Core\Field\FieldPreprocess::preprocessField() * * @ingroup themeable */ diff --git a/core/modules/system/tests/modules/common_test/common_test.module b/core/modules/system/tests/modules/common_test/common_test.module index 5d7fdc3dc6b8..5a17e7a6fcdb 100644 --- a/core/modules/system/tests/modules/common_test/common_test.module +++ b/core/modules/system/tests/modules/common_test/common_test.module @@ -10,8 +10,9 @@ declare(strict_types=1); /** * Implements hook_TYPE_alter(). * - * Same as common_test_drupal_alter_alter(), but here, we verify that themes - * can also alter and come last. + * Same as CommonTestHooks::drupalAlterAlter(), but here, we verify that themes + * can also alter and come last. This file gets included by + * CommonTestHooks::includeThemeFunction(). */ function olivero_drupal_alter_alter(&$data, &$arg2 = NULL, &$arg3 = NULL): void { // Alter first argument. @@ -40,27 +41,3 @@ function olivero_drupal_alter_alter(&$data, &$arg2 = NULL, &$arg3 = NULL): void } } } - -/** - * Implements MODULE_preprocess(). - * - * @see RenderTest::testDrupalRenderThemePreprocessAttached() - */ -function common_test_preprocess(&$variables, $hook): void { - if (!\Drupal::state()->get('theme_preprocess_attached_test', FALSE)) { - return; - } - $variables['#attached']['library'][] = 'test/generic_preprocess'; -} - -/** - * Implements MODULE_preprocess_HOOK(). - * - * @see RenderTest::testDrupalRenderThemePreprocessAttached() - */ -function common_test_preprocess_common_test_render_element(&$variables): void { - if (!\Drupal::state()->get('theme_preprocess_attached_test', FALSE)) { - return; - } - $variables['#attached']['library'][] = 'test/specific_preprocess'; -} diff --git a/core/modules/system/tests/modules/common_test/src/Hook/CommonTestHooks.php b/core/modules/system/tests/modules/common_test/src/Hook/CommonTestHooks.php index a3e65453b04e..aa93bfb5083c 100644 --- a/core/modules/system/tests/modules/common_test/src/Hook/CommonTestHooks.php +++ b/core/modules/system/tests/modules/common_test/src/Hook/CommonTestHooks.php @@ -4,8 +4,6 @@ declare(strict_types=1); namespace Drupal\common_test\Hook; -use Drupal\Core\Language\LanguageInterface; -use Drupal\Core\Asset\AttachedAssetsInterface; use Drupal\Core\Hook\Attribute\Hook; /** @@ -59,53 +57,6 @@ class CommonTestHooks { } /** - * Implements hook_theme(). - */ - #[Hook('theme')] - public function theme() : array { - return [ - 'common_test_foo' => [ - 'variables' => [ - 'foo' => 'foo', - 'bar' => 'bar', - ], - ], - 'common_test_render_element' => [ - 'render element' => 'foo', - ], - ]; - } - - /** - * Implements hook_library_info_build(). - */ - #[Hook('library_info_build')] - public function libraryInfoBuild(): array { - $libraries = []; - if (\Drupal::state()->get('common_test.library_info_build_test')) { - $libraries['dynamic_library'] = ['version' => '1.0', 'css' => ['base' => ['common_test.css' => []]]]; - } - return $libraries; - } - - /** - * Implements hook_library_info_alter(). - */ - #[Hook('library_info_alter')] - public function libraryInfoAlter(&$libraries, $module): void { - if ($module === 'core' && isset($libraries['loadjs'])) { - // Change the version of loadjs to 0.0. - $libraries['loadjs']['version'] = '0.0'; - // Make loadjs depend on jQuery Form to test library dependencies. - $libraries['loadjs']['dependencies'][] = 'core/internal.jquery.form'; - } - // Alter the dynamically registered library definition. - if ($module === 'common_test' && isset($libraries['dynamic_library'])) { - $libraries['dynamic_library']['dependencies'] = ['core/jquery']; - } - } - - /** * Implements hook_cron(). * * System module should handle if a module does not catch an exception and @@ -118,80 +69,4 @@ class CommonTestHooks { throw new \Exception('Uncaught exception'); } - /** - * Implements hook_page_attachments(). - * - * @see \Drupal\system\Tests\Common\PageRenderTest::assertPageRenderHookExceptions() - */ - #[Hook('page_attachments')] - public function pageAttachments(array &$page): void { - $page['#attached']['library'][] = 'core/foo'; - $page['#attached']['library'][] = 'core/bar'; - $page['#cache']['tags'] = ['example']; - $page['#cache']['contexts'] = ['user.permissions']; - if (\Drupal::state()->get('common_test.hook_page_attachments.descendant_attached', FALSE)) { - $page['content']['#attached']['library'][] = 'core/jquery'; - } - if (\Drupal::state()->get('common_test.hook_page_attachments.render_array', FALSE)) { - $page['something'] = ['#markup' => 'test']; - } - if (\Drupal::state()->get('common_test.hook_page_attachments.early_rendering', FALSE)) { - // Do some early rendering. - $element = ['#markup' => '123']; - \Drupal::service('renderer')->render($element); - } - } - - /** - * Implements hook_page_attachments_alter(). - * - * @see \Drupal\system\Tests\Common\PageRenderTest::assertPageRenderHookExceptions() - */ - #[Hook('page_attachments_alter')] - public function pageAttachmentsAlter(array &$page): void { - // Remove a library that was added in common_test_page_attachments(), to - // test that this hook can do what it claims to do. - if (isset($page['#attached']['library']) && ($index = array_search('core/bar', $page['#attached']['library'])) && $index !== FALSE) { - unset($page['#attached']['library'][$index]); - } - $page['#attached']['library'][] = 'core/baz'; - $page['#cache']['tags'] = ['example']; - $page['#cache']['contexts'] = ['user.permissions']; - if (\Drupal::state()->get('common_test.hook_page_attachments_alter.descendant_attached', FALSE)) { - $page['content']['#attached']['library'][] = 'core/jquery'; - } - if (\Drupal::state()->get('common_test.hook_page_attachments_alter.render_array', FALSE)) { - $page['something'] = ['#markup' => 'test']; - } - } - - /** - * Implements hook_js_alter(). - * - * @see \Drupal\KernelTests\Core\Asset\AttachedAssetsTest::testAlter() - */ - #[Hook('js_alter')] - public function jsAlter(&$javascript, AttachedAssetsInterface $assets, LanguageInterface $language): void { - // Attach alter.js above tableselect.js. - $alter_js = \Drupal::service('extension.list.module')->getPath('common_test') . '/alter.js'; - if (array_key_exists($alter_js, $javascript) && array_key_exists('core/misc/tableselect.js', $javascript)) { - $javascript[$alter_js]['weight'] = $javascript['core/misc/tableselect.js']['weight'] - 1; - } - } - - /** - * Implements hook_js_settings_alter(). - * - * @see \Drupal\system\Tests\Common\JavaScriptTest::testHeaderSetting() - */ - #[Hook('js_settings_alter')] - public function jsSettingsAlter(&$settings, AttachedAssetsInterface $assets): void { - // Modify an existing setting. - if (array_key_exists('pluralDelimiter', $settings)) { - $settings['pluralDelimiter'] = '☃'; - } - // Add a setting. - $settings['foo'] = 'bar'; - } - } diff --git a/core/modules/system/tests/modules/common_test/src/Hook/CommonTestThemeHooks.php b/core/modules/system/tests/modules/common_test/src/Hook/CommonTestThemeHooks.php new file mode 100644 index 000000000000..f47116e8920d --- /dev/null +++ b/core/modules/system/tests/modules/common_test/src/Hook/CommonTestThemeHooks.php @@ -0,0 +1,165 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\common_test\Hook; + +use Drupal\Core\Language\LanguageInterface; +use Drupal\Core\Asset\AttachedAssetsInterface; +use Drupal\Core\Hook\Attribute\Hook; + +/** + * Hook implementations for common_test. + */ +class CommonTestThemeHooks { + + /** + * Implements hook_theme(). + */ + #[Hook('theme')] + public function theme() : array { + return [ + 'common_test_foo' => [ + 'variables' => [ + 'foo' => 'foo', + 'bar' => 'bar', + ], + ], + 'common_test_render_element' => [ + 'render element' => 'foo', + ], + ]; + } + + /** + * Implements hook_library_info_build(). + */ + #[Hook('library_info_build')] + public function libraryInfoBuild(): array { + $libraries = []; + if (\Drupal::state()->get('common_test.library_info_build_test')) { + $libraries['dynamic_library'] = ['version' => '1.0', 'css' => ['base' => ['common_test.css' => []]]]; + } + return $libraries; + } + + /** + * Implements hook_library_info_alter(). + */ + #[Hook('library_info_alter')] + public function libraryInfoAlter(&$libraries, $module): void { + if ($module === 'core' && isset($libraries['loadjs'])) { + // Change the version of loadjs to 0.0. + $libraries['loadjs']['version'] = '0.0'; + // Make loadjs depend on jQuery Form to test library dependencies. + $libraries['loadjs']['dependencies'][] = 'core/internal.jquery.form'; + } + // Alter the dynamically registered library definition. + if ($module === 'common_test' && isset($libraries['dynamic_library'])) { + $libraries['dynamic_library']['dependencies'] = ['core/jquery']; + } + } + + /** + * Implements hook_page_attachments(). + * + * @see \Drupal\system\Tests\Common\PageRenderTest::assertPageRenderHookExceptions() + */ + #[Hook('page_attachments')] + public function pageAttachments(array &$page): void { + $page['#attached']['library'][] = 'core/foo'; + $page['#attached']['library'][] = 'core/bar'; + $page['#cache']['tags'] = ['example']; + $page['#cache']['contexts'] = ['user.permissions']; + if (\Drupal::state()->get('common_test.hook_page_attachments.descendant_attached', FALSE)) { + $page['content']['#attached']['library'][] = 'core/jquery'; + } + if (\Drupal::state()->get('common_test.hook_page_attachments.render_array', FALSE)) { + $page['something'] = ['#markup' => 'test']; + } + if (\Drupal::state()->get('common_test.hook_page_attachments.early_rendering', FALSE)) { + // Do some early rendering. + $element = ['#markup' => '123']; + \Drupal::service('renderer')->render($element); + } + } + + /** + * Implements hook_page_attachments_alter(). + * + * @see \Drupal\system\Tests\Common\PageRenderTest::assertPageRenderHookExceptions() + */ + #[Hook('page_attachments_alter')] + public function pageAttachmentsAlter(array &$page): void { + // Remove a library that was added in common_test_page_attachments(), to + // test that this hook can do what it claims to do. + if (isset($page['#attached']['library']) && ($index = array_search('core/bar', $page['#attached']['library'])) && $index !== FALSE) { + unset($page['#attached']['library'][$index]); + } + $page['#attached']['library'][] = 'core/baz'; + $page['#cache']['tags'] = ['example']; + $page['#cache']['contexts'] = ['user.permissions']; + if (\Drupal::state()->get('common_test.hook_page_attachments_alter.descendant_attached', FALSE)) { + $page['content']['#attached']['library'][] = 'core/jquery'; + } + if (\Drupal::state()->get('common_test.hook_page_attachments_alter.render_array', FALSE)) { + $page['something'] = ['#markup' => 'test']; + } + } + + /** + * Implements hook_js_alter(). + * + * @see \Drupal\KernelTests\Core\Asset\AttachedAssetsTest::testAlter() + */ + #[Hook('js_alter')] + public function jsAlter(&$javascript, AttachedAssetsInterface $assets, LanguageInterface $language): void { + // Attach alter.js above tableselect.js. + $alter_js = \Drupal::service('extension.list.module')->getPath('common_test') . '/alter.js'; + if (array_key_exists($alter_js, $javascript) && array_key_exists('core/misc/tableselect.js', $javascript)) { + $javascript[$alter_js]['weight'] = $javascript['core/misc/tableselect.js']['weight'] - 1; + } + } + + /** + * Implements hook_js_settings_alter(). + * + * @see \Drupal\system\Tests\Common\JavaScriptTest::testHeaderSetting() + */ + #[Hook('js_settings_alter')] + public function jsSettingsAlter(&$settings, AttachedAssetsInterface $assets): void { + // Modify an existing setting. + if (array_key_exists('pluralDelimiter', $settings)) { + $settings['pluralDelimiter'] = '☃'; + } + // Add a setting. + $settings['foo'] = 'bar'; + } + + /** + * Implements hook_preprocess(). + * + * @see RenderTest::testDrupalRenderThemePreprocessAttached() + */ + #[Hook('preprocess')] + public function preprocess(&$variables, $hook): void { + if (!\Drupal::state()->get('theme_preprocess_attached_test', FALSE)) { + return; + } + $variables['#attached']['library'][] = 'test/generic_preprocess'; + } + + /** + * Implements hook_preprocess_HOOK(). + * + * @see RenderTest::testDrupalRenderThemePreprocessAttached() + */ + #[Hook('preprocess_common_test_render_element')] + public function commonTestRenderElement(&$variables): void { + if (!\Drupal::state()->get('theme_preprocess_attached_test', FALSE)) { + return; + } + $variables['#attached']['library'][] = 'test/specific_preprocess'; + } + +} diff --git a/core/modules/system/tests/modules/js_displace/js_displace.module b/core/modules/system/tests/modules/js_displace/js_displace.module deleted file mode 100644 index 8b34072bd659..000000000000 --- a/core/modules/system/tests/modules/js_displace/js_displace.module +++ /dev/null @@ -1,15 +0,0 @@ -<?php - -/** - * @file - * Functions to support testing Drupal.displace() JavaScript API. - */ - -declare(strict_types=1); - -/** - * Implements hook_preprocess_html(). - */ -function js_displace_preprocess_html(&$variables): void { - $variables['#attached']['library'][] = 'core/drupal.displace'; -} diff --git a/core/modules/system/tests/modules/js_displace/src/Hook/JsDisplaceThemeHooks.php b/core/modules/system/tests/modules/js_displace/src/Hook/JsDisplaceThemeHooks.php new file mode 100644 index 000000000000..d9b37274d46c --- /dev/null +++ b/core/modules/system/tests/modules/js_displace/src/Hook/JsDisplaceThemeHooks.php @@ -0,0 +1,22 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\js_displace\Hook; + +use Drupal\Core\Hook\Attribute\Hook; + +/** + * Theme hook implementations for js_displace module. + */ +class JsDisplaceThemeHooks { + + /** + * Implements hook_preprocess_HOOK(). + */ + #[Hook('preprocess_html')] + public function preprocessHtml(&$variables): void { + $variables['#attached']['library'][] = 'core/drupal.displace'; + } + +} diff --git a/core/modules/system/tests/src/Functional/FileTransfer/FileTransferTest.php b/core/modules/system/tests/src/Functional/FileTransfer/FileTransferTest.php index 2a0886550191..ae157ab56624 100644 --- a/core/modules/system/tests/src/Functional/FileTransfer/FileTransferTest.php +++ b/core/modules/system/tests/src/Functional/FileTransfer/FileTransferTest.php @@ -57,10 +57,7 @@ class FileTransferTest extends BrowserTestBase { public function _buildFakeModule() { $location = 'temporary://fake'; if (is_dir($location)) { - $ret = 0; - $output = []; - exec('rm -Rf ' . escapeshellarg($location), $output, $ret); - if ($ret != 0) { + if (!\Drupal::service('file_system')->deleteRecursive($location)) { throw new \Exception('Error removing fake module directory.'); } } diff --git a/core/modules/system/tests/src/Functional/Module/GenericModuleTestBase.php b/core/modules/system/tests/src/Functional/Module/GenericModuleTestBase.php index 7b3754bc34cb..d22f433a4147 100644 --- a/core/modules/system/tests/src/Functional/Module/GenericModuleTestBase.php +++ b/core/modules/system/tests/src/Functional/Module/GenericModuleTestBase.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Drupal\Tests\system\Functional\Module; use Drupal\Core\Database\Database; +use Drupal\Core\Extension\ModuleExtensionList; use Drupal\Tests\BrowserTestBase; /** @@ -50,9 +51,12 @@ abstract class GenericModuleTestBase extends BrowserTestBase { if (empty($info['required'])) { $connection = Database::getConnection(); - // When the database driver is provided by a module, then that module - // cannot be uninstalled. - if ($module !== $connection->getProvider()) { + // The module that provides the database driver, or is a dependency of + // the database driver, cannot be uninstalled. + $database_module_extension = \Drupal::service(ModuleExtensionList::class)->get($connection->getProvider()); + $database_modules_required = $database_module_extension->requires ? array_keys($database_module_extension->requires) : []; + $database_modules_required[] = $connection->getProvider(); + if (!in_array($module, $database_modules_required)) { // Check that the module can be uninstalled and then re-installed again. $this->preUnInstallSteps(); $this->assertTrue(\Drupal::service('module_installer')->uninstall([$module]), "Failed to uninstall '$module' module"); diff --git a/core/modules/system/tests/src/Unit/Event/SecurityFileUploadEventSubscriberTest.php b/core/modules/system/tests/src/Unit/Event/SecurityFileUploadEventSubscriberTest.php index 79b9f52812e8..2cca7450089a 100644 --- a/core/modules/system/tests/src/Unit/Event/SecurityFileUploadEventSubscriberTest.php +++ b/core/modules/system/tests/src/Unit/Event/SecurityFileUploadEventSubscriberTest.php @@ -85,12 +85,17 @@ class SecurityFileUploadEventSubscriberTest extends UnitTestCase { 'no extension produces no errors' => ['foo', '', 'foo'], 'filename is munged' => ['foo.phar.png.php.jpg', 'jpg png', 'foo.phar_.png_.php_.jpg'], 'filename is munged regardless of case' => ['FOO.pHAR.PNG.PhP.jpg', 'jpg png', 'FOO.pHAR_.PNG_.PhP_.jpg'], - 'null bytes are removed' => ['foo' . chr(0) . '.txt' . chr(0), '', 'foo.txt'], + 'null bytes are removed even if some extensions are allowed' => [ + 'foo' . chr(0) . '.html' . chr(0), + 'txt', + 'foo.html', + ], 'dot files are renamed' => ['.git', '', 'git'], - 'htaccess files are renamed even if allowed' => ['.htaccess', 'htaccess txt', '.htaccess_.txt', '.htaccess'], + 'htaccess files are renamed even if allowed' => ['.htaccess', 'htaccess txt', 'htaccess'], '.phtml extension allowed with .phtml file' => ['foo.phtml', 'phtml', 'foo.phtml'], '.phtml, .txt extension allowed with .phtml file' => ['foo.phtml', 'phtml txt', 'foo.phtml_.txt', 'foo.phtml'], 'All extensions allowed with .phtml file' => ['foo.phtml', '', 'foo.phtml_.txt', 'foo.phtml'], + 'dot files are renamed even if allowed and not in security list' => ['.git', 'git', 'git'], ]; } @@ -147,18 +152,10 @@ class SecurityFileUploadEventSubscriberTest extends UnitTestCase { // The following filename would be rejected by 'FileExtension' constraint // and therefore remains unchanged. '.php is not munged when it would be rejected' => ['foo.php.php', 'jpg'], - '.php is not munged when it would be rejected and filename contains null byte character' => [ - 'foo.' . chr(0) . 'php.php', - 'jpg', - ], 'extension less files are not munged when they would be rejected' => [ 'foo', 'jpg', ], - 'dot files are not munged when they would be rejected' => [ - '.htaccess', - 'jpg png', - ], ]; } diff --git a/core/modules/user/config/schema/user.schema.yml b/core/modules/user/config/schema/user.schema.yml index ac54b6986d7d..d58cb3dc4ea2 100644 --- a/core/modules/user/config/schema/user.schema.yml +++ b/core/modules/user/config/schema/user.schema.yml @@ -105,6 +105,8 @@ user.mail: user.flood: type: config_object label: 'User flood settings' + constraints: + FullyValidatable: ~ mapping: uid_only: type: boolean @@ -112,15 +114,27 @@ user.flood: ip_limit: type: integer label: 'IP limit' + constraints: + Range: + min: 0 ip_window: type: integer label: 'IP window' + constraints: + Range: + min: 0 user_limit: type: integer label: 'User limit' + constraints: + Range: + min: 0 user_window: type: integer label: 'User window' + constraints: + Range: + min: 0 user.role.*: type: config_entity diff --git a/core/modules/views/tests/modules/views_test_data/test_views/views.view.test_content_access_filter.yml b/core/modules/views/tests/modules/views_test_data/test_views/views.view.test_content_access_filter.yml new file mode 100644 index 000000000000..8680489c2b6e --- /dev/null +++ b/core/modules/views/tests/modules/views_test_data/test_views/views.view.test_content_access_filter.yml @@ -0,0 +1,247 @@ +status: true +dependencies: + config: + - core.entity_view_mode.node.teaser + module: + - node + - user +id: test_content_access_filter +label: 'Test Content Access Filter' +module: views +description: '' +tag: '' +base_table: node_field_data +base_field: nid +display: + default: + display_plugin: default + id: default + display_title: Default + position: 0 + display_options: + access: + type: perm + options: + perm: 'access content' + cache: + type: tag + options: { } + query: + type: views_query + options: + disable_sql_rewrite: false + distinct: false + replica: false + query_comment: '' + query_tags: { } + exposed_form: + type: basic + options: + submit_button: Apply + reset_button: false + reset_button_label: Reset + exposed_sorts_label: 'Sort by' + expose_sort_order: true + sort_asc_label: Asc + sort_desc_label: Desc + pager: + type: mini + options: + items_per_page: 10 + offset: 0 + id: 0 + total_pages: null + expose: + items_per_page: false + items_per_page_label: 'Items per page' + items_per_page_options: '5, 10, 25, 50' + items_per_page_options_all: false + items_per_page_options_all_label: '- All -' + offset: false + offset_label: Offset + tags: + previous: ‹‹ + next: ›› + style: + type: default + row: + type: 'entity:node' + options: + view_mode: teaser + fields: + title: + id: title + table: node_field_data + field: title + entity_type: node + entity_field: title + label: '' + alter: + alter_text: false + make_link: false + absolute: false + trim: false + word_boundary: false + ellipsis: false + strip_tags: false + html: false + hide_empty: false + empty_zero: false + settings: + link_to_entity: true + plugin_id: field + relationship: none + group_type: group + admin_label: '' + exclude: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: true + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_alter_empty: true + click_sort_column: value + type: string + group_column: value + group_columns: { } + group_rows: true + delta_limit: 0 + delta_offset: 0 + delta_reversed: false + delta_first_last: false + multi_type: separator + separator: ', ' + field_api_classes: false + filters: + nid: + id: nid + table: node_access + field: nid + relationship: none + group_type: group + admin_label: '' + operator: '=' + value: '' + group: 1 + exposed: false + expose: + operator_id: '' + label: '' + description: '' + use_operator: false + operator: '' + operator_limit_selection: false + operator_list: { } + identifier: '' + required: false + remember: false + multiple: false + remember_roles: + authenticated: authenticated + is_grouped: false + group_info: + label: '' + description: '' + identifier: '' + optional: true + widget: select + multiple: false + remember: false + default_group: All + default_group_multiple: { } + group_items: { } + plugin_id: node_access + status: + id: status + table: node_field_data + field: status + relationship: none + group_type: group + admin_label: '' + operator: '=' + value: '1' + group: 1 + exposed: false + expose: + operator_id: '' + label: '' + description: '' + use_operator: false + operator: '' + operator_limit_selection: false + operator_list: { } + identifier: '' + required: false + remember: false + multiple: false + remember_roles: + authenticated: authenticated + is_grouped: false + group_info: + label: '' + description: '' + identifier: '' + optional: true + widget: select + multiple: false + remember: false + default_group: All + default_group_multiple: { } + group_items: { } + entity_type: node + entity_field: status + plugin_id: boolean + sorts: + created: + id: created + table: node_field_data + field: created + order: DESC + entity_type: node + entity_field: created + plugin_id: date + relationship: none + group_type: group + admin_label: '' + exposed: false + expose: + label: '' + field_identifier: '' + granularity: second + title: 'Test Content Access Filter' + header: { } + footer: { } + empty: { } + relationships: { } + arguments: { } + display_extenders: { } + cache_metadata: + max-age: -1 + contexts: + - 'languages:language_content' + - 'languages:language_interface' + - url.query_args + - 'user.node_grants:view' + - user.permissions + tags: { } + page_1: + display_plugin: page + id: page_1 + display_title: Page + position: 1 + display_options: + display_extenders: { } + path: test-content-access-filter + cache_metadata: + max-age: -1 + contexts: + - 'languages:language_content' + - 'languages:language_interface' + - url.query_args + - 'user.node_grants:view' + - user.permissions + tags: { } diff --git a/core/modules/views/tests/src/Functional/Plugin/AccessTest.php b/core/modules/views/tests/src/Functional/Plugin/AccessTest.php index df420cf879fa..92874dd8b417 100644 --- a/core/modules/views/tests/src/Functional/Plugin/AccessTest.php +++ b/core/modules/views/tests/src/Functional/Plugin/AccessTest.php @@ -22,7 +22,12 @@ class AccessTest extends ViewTestBase { * * @var array */ - public static $testViews = ['test_access_none', 'test_access_static', 'test_access_dynamic']; + public static $testViews = [ + 'test_access_none', + 'test_access_static', + 'test_access_dynamic', + 'test_content_access_filter', + ]; /** * {@inheritdoc} @@ -113,4 +118,32 @@ class AccessTest extends ViewTestBase { $this->assertSession()->statusCodeEquals(200); } + /** + * Tests that node_access table is joined when hook_node_grants() is implemented. + */ + public function testContentAccessFilter(): void { + $view = Views::getView('test_content_access_filter'); + $view->setDisplay('page_1'); + + $view->initQuery(); + $view->execute(); + /** @var \Drupal\Core\Database\Query\Select $main_query */ + $main_query = $view->build_info['query']; + $tables = array_keys($main_query->getTables()); + $this->assertNotContains('node_access', $tables); + + // Enable node access test module to ensure that table is present again. + \Drupal::service('module_installer')->install(['node_access_test']); + node_access_rebuild(); + + $view = Views::getView('test_content_access_filter'); + $view->setDisplay('page_1'); + $view->initQuery(); + $view->execute(); + /** @var \Drupal\Core\Database\Query\Select $main_query */ + $main_query = $view->build_info['query']; + $tables = array_keys($main_query->getTables()); + $this->assertContains('node_access', $tables); + } + } diff --git a/core/phpstan.neon.dist b/core/phpstan.neon.dist index 4cd4f9cfa591..40e1c821d029 100644 --- a/core/phpstan.neon.dist +++ b/core/phpstan.neon.dist @@ -36,6 +36,8 @@ parameters: # Skip Drupal 6 & 7 code. - scripts/dump-database-d?.sh - scripts/generate-d?-content.sh + # Skip update countries script for fake t() declaration. + - scripts/update-countries.sh # Skip data files. - lib/Drupal/Component/Transliteration/data/*.php # The following classes deliberately extend non-existent classes for testing. diff --git a/core/profiles/demo_umami/tests/src/FunctionalJavascript/AssetAggregationAcrossPagesTest.php b/core/profiles/demo_umami/tests/src/FunctionalJavascript/AssetAggregationAcrossPagesTest.php index ceb9e19d43d4..bab70e05e2ac 100644 --- a/core/profiles/demo_umami/tests/src/FunctionalJavascript/AssetAggregationAcrossPagesTest.php +++ b/core/profiles/demo_umami/tests/src/FunctionalJavascript/AssetAggregationAcrossPagesTest.php @@ -30,7 +30,7 @@ class AssetAggregationAcrossPagesTest extends PerformanceTestBase { 'ScriptCount' => 1, 'ScriptBytes' => 11700, 'StylesheetCount' => 6, - 'StylesheetBytes' => 119250, + 'StylesheetBytes' => 118700, ]; $this->assertMetrics($expected, $performance_data); } @@ -50,7 +50,7 @@ class AssetAggregationAcrossPagesTest extends PerformanceTestBase { 'ScriptCount' => 3, 'ScriptBytes' => 170500, 'StylesheetCount' => 5, - 'StylesheetBytes' => 85600, + 'StylesheetBytes' => 85000, ]; $this->assertMetrics($expected, $performance_data); } @@ -71,7 +71,7 @@ class AssetAggregationAcrossPagesTest extends PerformanceTestBase { 'ScriptCount' => 5, 'ScriptBytes' => 335637, 'StylesheetCount' => 5, - 'StylesheetBytes' => 205700, + 'StylesheetBytes' => 205100, ]; $this->assertMetrics($expected, $performance_data); } diff --git a/core/profiles/demo_umami/themes/umami/templates/classy/field/field--comment.html.twig b/core/profiles/demo_umami/themes/umami/templates/classy/field/field--comment.html.twig index 1ec3ee64b104..d59d328c741b 100644 --- a/core/profiles/demo_umami/themes/umami/templates/classy/field/field--comment.html.twig +++ b/core/profiles/demo_umami/themes/umami/templates/classy/field/field--comment.html.twig @@ -21,7 +21,7 @@ * - field_type: The type of the field. * - label_display: The display settings for the label. * - * @see template_preprocess_field() + * @see \Drupal\Core\Field\FieldPreprocess::preprocessField() * @see comment_preprocess_field() */ #} diff --git a/core/profiles/demo_umami/themes/umami/templates/classy/field/field.html.twig b/core/profiles/demo_umami/themes/umami/templates/classy/field/field.html.twig index 1cfbd651ce16..45e9aa74228c 100644 --- a/core/profiles/demo_umami/themes/umami/templates/classy/field/field.html.twig +++ b/core/profiles/demo_umami/themes/umami/templates/classy/field/field.html.twig @@ -34,7 +34,7 @@ * - label_display: The display settings for the label. * * - * @see template_preprocess_field() + * @see \Drupal\Core\Field\FieldPreprocess::preprocessField() */ #} {% diff --git a/core/profiles/demo_umami/themes/umami/templates/components/field/field--node--field-cooking-time--recipe--full.html.twig b/core/profiles/demo_umami/themes/umami/templates/components/field/field--node--field-cooking-time--recipe--full.html.twig index 6409f008fe56..2e3ab7eeaadb 100644 --- a/core/profiles/demo_umami/themes/umami/templates/components/field/field--node--field-cooking-time--recipe--full.html.twig +++ b/core/profiles/demo_umami/themes/umami/templates/components/field/field--node--field-cooking-time--recipe--full.html.twig @@ -34,7 +34,7 @@ * - label_display: The display settings for the label. * * - * @see template_preprocess_field() + * @see \Drupal\Core\Field\FieldPreprocess::preprocessField() */ #} {% diff --git a/core/profiles/demo_umami/themes/umami/templates/components/field/field--node--field-difficulty--recipe--full.html.twig b/core/profiles/demo_umami/themes/umami/templates/components/field/field--node--field-difficulty--recipe--full.html.twig index bccca7df8667..4a123126858c 100644 --- a/core/profiles/demo_umami/themes/umami/templates/components/field/field--node--field-difficulty--recipe--full.html.twig +++ b/core/profiles/demo_umami/themes/umami/templates/components/field/field--node--field-difficulty--recipe--full.html.twig @@ -34,7 +34,7 @@ * - label_display: The display settings for the label. * * - * @see template_preprocess_field() + * @see \Drupal\Core\Field\FieldPreprocess::preprocessField() */ #} {% diff --git a/core/profiles/demo_umami/themes/umami/templates/components/field/field--node--field-number-of-servings--recipe--full.html.twig b/core/profiles/demo_umami/themes/umami/templates/components/field/field--node--field-number-of-servings--recipe--full.html.twig index 8b40acd3b508..917afb25854d 100644 --- a/core/profiles/demo_umami/themes/umami/templates/components/field/field--node--field-number-of-servings--recipe--full.html.twig +++ b/core/profiles/demo_umami/themes/umami/templates/components/field/field--node--field-number-of-servings--recipe--full.html.twig @@ -34,7 +34,7 @@ * - label_display: The display settings for the label. * * - * @see template_preprocess_field() + * @see \Drupal\Core\Field\FieldPreprocess::preprocessField() */ #} {% diff --git a/core/profiles/demo_umami/themes/umami/templates/components/field/field--node--field-preparation-time--recipe--full.html.twig b/core/profiles/demo_umami/themes/umami/templates/components/field/field--node--field-preparation-time--recipe--full.html.twig index a9b622b5a54d..db3c50ec5a28 100644 --- a/core/profiles/demo_umami/themes/umami/templates/components/field/field--node--field-preparation-time--recipe--full.html.twig +++ b/core/profiles/demo_umami/themes/umami/templates/components/field/field--node--field-preparation-time--recipe--full.html.twig @@ -34,7 +34,7 @@ * - label_display: The display settings for the label. * * - * @see template_preprocess_field() + * @see \Drupal\Core\Field\FieldPreprocess::preprocessField() */ #} {% diff --git a/core/profiles/standard/tests/src/FunctionalJavascript/StandardPerformanceTest.php b/core/profiles/standard/tests/src/FunctionalJavascript/StandardPerformanceTest.php index f3a3196fab6e..304f0a50f966 100644 --- a/core/profiles/standard/tests/src/FunctionalJavascript/StandardPerformanceTest.php +++ b/core/profiles/standard/tests/src/FunctionalJavascript/StandardPerformanceTest.php @@ -200,7 +200,7 @@ class StandardPerformanceTest extends PerformanceTestBase { ['config:user.role.anonymous'], ], 'StylesheetCount' => 1, - 'StylesheetBytes' => 2100, + 'StylesheetBytes' => 1450, ]; $this->assertMetrics($expected, $performance_data); $expected_default_cache_cids = [ @@ -284,7 +284,7 @@ class StandardPerformanceTest extends PerformanceTestBase { ['config:user.role.anonymous'], ], 'StylesheetCount' => 1, - 'StylesheetBytes' => 1550, + 'StylesheetBytes' => 1500, ]; $this->assertMetrics($expected, $performance_data); @@ -321,7 +321,7 @@ class StandardPerformanceTest extends PerformanceTestBase { 'CacheTagInvalidationCount' => 0, 'CacheTagLookupQueryCount' => 12, 'StylesheetCount' => 1, - 'StylesheetBytes' => 1800, + 'StylesheetBytes' => 1150, ]; $this->assertMetrics($expected, $performance_data); } diff --git a/core/scripts/run-tests.sh b/core/scripts/run-tests.sh index 30e366f97b2e..a1a9d903af03 100755 --- a/core/scripts/run-tests.sh +++ b/core/scripts/run-tests.sh @@ -667,7 +667,7 @@ function simpletest_script_setup_database($new = FALSE): void { // Remove a possibly existing default connection (from settings.php). Database::removeConnection('default'); try { - $databases['default']['default'] = Database::convertDbUrlToConnectionInfo($args['dburl'], DRUPAL_ROOT, TRUE); + $databases['default']['default'] = Database::convertDbUrlToConnectionInfo($args['dburl'], TRUE); } catch (\InvalidArgumentException $e) { simpletest_script_print_error('Invalid --dburl. Reason: ' . $e->getMessage()); diff --git a/core/scripts/update-countries.sh b/core/scripts/update-countries.sh index 52676e9f30bb..59f04b196ee6 100755 --- a/core/scripts/update-countries.sh +++ b/core/scripts/update-countries.sh @@ -35,6 +35,16 @@ USAGE; exit('CLDR data file not found. (' . $uri . ")\n\n" . $usage . "\n"); } +// Fake the t() function used in CountryManager.php instead of attempting a full +// Drupal bootstrap of core/includes/bootstrap.inc (where t() is declared). +if (!function_exists('t')) { + + function t($string): string { + return $string; + } + +} + // Read in existing codes. // @todo Allow to remove previously existing country codes. // @see https://www.drupal.org/node/1436754 @@ -93,7 +103,7 @@ $out = ''; foreach ($countries as $code => $name) { // For .po translation file's sake, use double-quotes instead of escaped // single-quotes. - $name = str_contains($name, '\'' ? '"' . $name . '"' : "'" . $name . "'"); + $name = str_contains($name, '\'') ? '"' . $name . '"' : "'" . $name . "'"; $out .= ' ' . var_export($code, TRUE) . ' => t(' . $name . '),' . "\n"; } diff --git a/core/tests/Drupal/FunctionalTests/Bootstrap/UncaughtExceptionTest.php b/core/tests/Drupal/FunctionalTests/Bootstrap/UncaughtExceptionTest.php index 80fd287751c5..aac21dcff875 100644 --- a/core/tests/Drupal/FunctionalTests/Bootstrap/UncaughtExceptionTest.php +++ b/core/tests/Drupal/FunctionalTests/Bootstrap/UncaughtExceptionTest.php @@ -203,6 +203,7 @@ class UncaughtExceptionTest extends BrowserTestBase { switch ($this->container->get('database')->driver()) { case 'pgsql': case 'mysql': + case 'mysqli': $this->expectedExceptionMessage = $incorrect_username; break; diff --git a/core/tests/Drupal/FunctionalTests/Core/Recipe/StandardRecipeTest.php b/core/tests/Drupal/FunctionalTests/Core/Recipe/StandardRecipeTest.php index 4bf46e8d4911..68a793f8771f 100644 --- a/core/tests/Drupal/FunctionalTests/Core/Recipe/StandardRecipeTest.php +++ b/core/tests/Drupal/FunctionalTests/Core/Recipe/StandardRecipeTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Drupal\FunctionalTests\Core\Recipe; +use Drupal\Core\Extension\ModuleExtensionList; use Drupal\shortcut\Entity\Shortcut; use Drupal\Tests\standard\Functional\StandardTest; use Drupal\user\RoleInterface; @@ -35,7 +36,12 @@ class StandardRecipeTest extends StandardTest { $theme_installer->uninstall(['claro', 'olivero']); // Determine which modules to uninstall. - $uninstall = array_diff(array_keys(\Drupal::moduleHandler()->getModuleList()), ['user', 'system', 'path_alias', \Drupal::database()->getProvider()]); + // If the database module has dependencies, they are expected too. + $database_module_extension = \Drupal::service(ModuleExtensionList::class)->get(\Drupal::database()->getProvider()); + $database_modules = $database_module_extension->requires ? array_keys($database_module_extension->requires) : []; + $database_modules[] = \Drupal::database()->getProvider(); + $keep = array_merge(['user', 'system', 'path_alias'], $database_modules); + $uninstall = array_diff(array_keys(\Drupal::moduleHandler()->getModuleList()), $keep); foreach (['shortcut', 'field_config', 'filter_format', 'field_storage_config'] as $entity_type) { $storage = \Drupal::entityTypeManager()->getStorage($entity_type); $storage->delete($storage->loadMultiple()); diff --git a/core/tests/Drupal/FunctionalTests/ExistingDrupal8StyleDatabaseConnectionInSettingsPhpTest.php b/core/tests/Drupal/FunctionalTests/ExistingDrupal8StyleDatabaseConnectionInSettingsPhpTest.php index 01c694b4ef4b..fd7bfeecd38c 100644 --- a/core/tests/Drupal/FunctionalTests/ExistingDrupal8StyleDatabaseConnectionInSettingsPhpTest.php +++ b/core/tests/Drupal/FunctionalTests/ExistingDrupal8StyleDatabaseConnectionInSettingsPhpTest.php @@ -26,7 +26,7 @@ class ExistingDrupal8StyleDatabaseConnectionInSettingsPhpTest extends BrowserTes $driver = Database::getConnection()->driver(); if (!in_array($driver, ['mysql', 'pgsql', 'sqlite'])) { - $this->markTestSkipped("This test does not support the {$driver} database driver."); + $this->markTestSkipped("This test is only relevant for database drivers that were available in Drupal prior to database drivers becoming part of modules. The {$driver} database driver is not qualifying."); } $filename = $this->siteDirectory . '/settings.php'; diff --git a/core/tests/Drupal/FunctionalTests/Installer/InstallerExistingConfigTestBase.php b/core/tests/Drupal/FunctionalTests/Installer/InstallerExistingConfigTestBase.php index 2ac3ae778a4b..f7c949637547 100644 --- a/core/tests/Drupal/FunctionalTests/Installer/InstallerExistingConfigTestBase.php +++ b/core/tests/Drupal/FunctionalTests/Installer/InstallerExistingConfigTestBase.php @@ -7,6 +7,7 @@ namespace Drupal\FunctionalTests\Installer; use Drupal\Component\Serialization\Yaml; use Drupal\Core\Archiver\ArchiveTar; use Drupal\Core\Database\Database; +use Drupal\Core\Extension\ModuleExtensionList; use Drupal\Core\Installer\Form\SelectProfileForm; /** @@ -99,8 +100,11 @@ abstract class InstallerExistingConfigTestBase extends InstallerTestBase { // modules that can not be uninstalled in the core.extension configuration. if (file_exists($config_sync_directory . '/core.extension.yml')) { $core_extension = Yaml::decode(file_get_contents($config_sync_directory . '/core.extension.yml')); - $module = Database::getConnection()->getProvider(); - if ($module !== 'core') { + // If the database module has dependencies, they are expected too. + $database_module_extension = \Drupal::service(ModuleExtensionList::class)->get(Database::getConnection()->getProvider()); + $database_modules = $database_module_extension->requires ? array_keys($database_module_extension->requires) : []; + $database_modules[] = Database::getConnection()->getProvider(); + foreach ($database_modules as $module) { $core_extension['module'][$module] = 0; $core_extension['module'] = module_config_sort($core_extension['module']); } diff --git a/core/tests/Drupal/FunctionalTests/Update/UpdatePathTestBase.php b/core/tests/Drupal/FunctionalTests/Update/UpdatePathTestBase.php index 8b2c4b493e4b..3ee78e553654 100644 --- a/core/tests/Drupal/FunctionalTests/Update/UpdatePathTestBase.php +++ b/core/tests/Drupal/FunctionalTests/Update/UpdatePathTestBase.php @@ -137,6 +137,16 @@ abstract class UpdatePathTestBase extends BrowserTestBase { // Load the database(s). foreach ($this->databaseDumpFiles as $file) { + // Determine the version of the database dump if specified. + $matches = []; + $dumpVersion = preg_match('/drupal-(\d+\.\d+\.\d+)\./', $file, $matches) === 1 ? $matches[1] : NULL; + + // If the db driver is mysqli, we do not need to run the update tests for + // db dumps prior to 11.2 when the module was introduced. + if (Database::getConnection()->getProvider() === 'mysqli' && $dumpVersion && version_compare($dumpVersion, '11.2.0', '<')) { + $this->markTestSkipped("The mysqli driver was introduced in Drupal 11.2, skip update tests from database at version {$dumpVersion}"); + } + if (str_ends_with($file, '.gz')) { $file = "compress.zlib://$file"; } diff --git a/core/tests/Drupal/KernelTests/Core/Database/BasicSyntaxTest.php b/core/tests/Drupal/KernelTests/Core/Database/BasicSyntaxTest.php index 079009dc7a56..8725f647e8d1 100644 --- a/core/tests/Drupal/KernelTests/Core/Database/BasicSyntaxTest.php +++ b/core/tests/Drupal/KernelTests/Core/Database/BasicSyntaxTest.php @@ -63,18 +63,6 @@ class BasicSyntaxTest extends DatabaseTestBase { } /** - * Tests string concatenation with separator, with field values. - */ - public function testConcatWsFields(): void { - $result = $this->connection->query("SELECT CONCAT_WS('-', :a1, [name], :a2, [age]) FROM {test} WHERE [age] = :age", [ - ':a1' => 'name', - ':a2' => 'age', - ':age' => 25, - ]); - $this->assertSame('name-John-age-25', $result->fetchField()); - } - - /** * Tests escaping of LIKE wildcards. */ public function testLikeEscape(): void { diff --git a/core/tests/Drupal/KernelTests/Core/Database/DriverSpecificSyntaxTestBase.php b/core/tests/Drupal/KernelTests/Core/Database/DriverSpecificSyntaxTestBase.php index 7723d872cc12..e18336205398 100644 --- a/core/tests/Drupal/KernelTests/Core/Database/DriverSpecificSyntaxTestBase.php +++ b/core/tests/Drupal/KernelTests/Core/Database/DriverSpecificSyntaxTestBase.php @@ -43,4 +43,16 @@ abstract class DriverSpecificSyntaxTestBase extends DriverSpecificDatabaseTestBa $this->assertSame('[square]', $result->fetchField()); } + /** + * Tests string concatenation with separator, with field values. + */ + public function testConcatWsFields(): void { + $result = $this->connection->query("SELECT CONCAT_WS('-', :a1, [name], :a2, [age]) FROM {test} WHERE [age] = :age", [ + ':a1' => 'name', + ':a2' => 'age', + ':age' => 25, + ]); + $this->assertSame('name-John-age-25', $result->fetchField()); + } + } diff --git a/core/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php b/core/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php index 5e708fbbc2ff..a914752e9f17 100644 --- a/core/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php +++ b/core/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateMultipleTypesTest.php @@ -774,7 +774,7 @@ class EntityDefinitionUpdateMultipleTypesTest extends EntityKernelTestBase { $this->assertTrue($this->database->schema()->fieldExists('entity_test_update', 'new_base_field'), "New field 'new_base_field' has been created on the 'entity_test_update' table."); $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update_field__new_base_field'), "New index 'entity_test_update_field__new_base_field' has been created on the 'entity_test_update' table."); // Check index size in for MySQL. - if (Database::getConnection()->driver() == 'mysql') { + if (in_array(Database::getConnection()->driver(), ['mysql', 'mysqli'])) { $result = Database::getConnection()->query('SHOW INDEX FROM {entity_test_update} WHERE key_name = \'entity_test_update_field__new_base_field\' and column_name = \'new_base_field\'')->fetchObject(); $this->assertEquals(191, $result->Sub_part, 'The index length has been restricted to 191 characters for UTF8MB4 compatibility.'); } @@ -803,7 +803,7 @@ class EntityDefinitionUpdateMultipleTypesTest extends EntityKernelTestBase { $this->assertTrue($this->database->schema()->indexExists('entity_test_update', 'entity_test_update__type_index'), "New index 'entity_test_update__type_index' has been created on the 'entity_test_update' table."); // Check index size in for MySQL. - if (Database::getConnection()->driver() == 'mysql') { + if (in_array(Database::getConnection()->driver(), ['mysql', 'mysqli'])) { $result = Database::getConnection()->query('SHOW INDEX FROM {entity_test_update} WHERE key_name = \'entity_test_update__type_index\' and column_name = \'type\'')->fetchObject(); $this->assertEquals(191, $result->Sub_part, 'The index length has been restricted to 191 characters for UTF8MB4 compatibility.'); } diff --git a/core/tests/Drupal/KernelTests/KernelTestBase.php b/core/tests/Drupal/KernelTests/KernelTestBase.php index 34aca08f15a9..c45e937d371e 100644 --- a/core/tests/Drupal/KernelTests/KernelTestBase.php +++ b/core/tests/Drupal/KernelTests/KernelTestBase.php @@ -439,7 +439,7 @@ abstract class KernelTestBase extends TestCase implements ServiceProviderInterfa throw new \Exception('There is no database connection so no tests can be run. You must provide a SIMPLETEST_DB environment variable to run PHPUnit based functional tests outside of run-tests.sh. See https://www.drupal.org/node/2116263#skipped-tests for more information.'); } else { - $database = Database::convertDbUrlToConnectionInfo($db_url, $this->root, TRUE); + $database = Database::convertDbUrlToConnectionInfo($db_url, TRUE); Database::addConnectionInfo('default', 'default', $database); } diff --git a/core/tests/Drupal/KernelTests/KernelTestBaseDatabaseDriverModuleTest.php b/core/tests/Drupal/KernelTests/KernelTestBaseDatabaseDriverModuleTest.php index e8dfc3be6f45..828b4ab54ec0 100644 --- a/core/tests/Drupal/KernelTests/KernelTestBaseDatabaseDriverModuleTest.php +++ b/core/tests/Drupal/KernelTests/KernelTestBaseDatabaseDriverModuleTest.php @@ -25,7 +25,7 @@ class KernelTestBaseDatabaseDriverModuleTest extends KernelTestBase { throw new \Exception('There is no database connection so no tests can be run. You must provide a SIMPLETEST_DB environment variable to run PHPUnit based functional tests outside of run-tests.sh. See https://www.drupal.org/node/2116263#skipped-tests for more information.'); } else { - $database = Database::convertDbUrlToConnectionInfo($db_url, $this->root); + $database = Database::convertDbUrlToConnectionInfo($db_url); if (in_array($database['driver'], ['mysql', 'pgsql'])) { // Change the used database driver to the one provided by the module diff --git a/core/tests/Drupal/KernelTests/Scripts/TestSiteApplicationTest.php b/core/tests/Drupal/KernelTests/Scripts/TestSiteApplicationTest.php index 02521bdda2f8..2f8b7309b520 100644 --- a/core/tests/Drupal/KernelTests/Scripts/TestSiteApplicationTest.php +++ b/core/tests/Drupal/KernelTests/Scripts/TestSiteApplicationTest.php @@ -286,7 +286,7 @@ class TestSiteApplicationTest extends KernelTestBase { * The database key of the added connection. */ protected function addTestDatabase($db_prefix): string { - $database = Database::convertDbUrlToConnectionInfo(getenv('SIMPLETEST_DB'), $this->root); + $database = Database::convertDbUrlToConnectionInfo(getenv('SIMPLETEST_DB')); $database['prefix'] = $db_prefix; $target = __CLASS__ . $db_prefix; Database::addConnectionInfo($target, 'default', $database); diff --git a/core/tests/Drupal/TestSite/Commands/TestSiteInstallCommand.php b/core/tests/Drupal/TestSite/Commands/TestSiteInstallCommand.php index 1459f0cdfee3..470d3ef92e22 100644 --- a/core/tests/Drupal/TestSite/Commands/TestSiteInstallCommand.php +++ b/core/tests/Drupal/TestSite/Commands/TestSiteInstallCommand.php @@ -280,18 +280,18 @@ class TestSiteInstallCommand extends Command { } /** - * {@inheritdoc} + * Changes the database connection to the prefixed one. */ - protected function changeDatabasePrefix() { + protected function changeDatabasePrefix(): void { // Ensure that we use the database from SIMPLETEST_DB environment variable. Database::removeConnection('default'); $this->changeDatabasePrefixTrait(); } /** - * {@inheritdoc} + * Generates a database prefix for the site installation. */ - protected function prepareDatabasePrefix() { + protected function prepareDatabasePrefix(): void { // Override this method so that we can force a lock to be created. $test_db = new TestDatabase(NULL, TRUE); $this->siteDirectory = $test_db->getTestSitePath(); diff --git a/core/tests/Drupal/TestSite/Commands/TestSiteTearDownCommand.php b/core/tests/Drupal/TestSite/Commands/TestSiteTearDownCommand.php index 3d80f3a1c178..7e601981d4c6 100644 --- a/core/tests/Drupal/TestSite/Commands/TestSiteTearDownCommand.php +++ b/core/tests/Drupal/TestSite/Commands/TestSiteTearDownCommand.php @@ -84,7 +84,7 @@ class TestSiteTearDownCommand extends Command { protected function tearDown(TestDatabase $test_database, $db_url): void { // Connect to the test database. $root = dirname(__DIR__, 5); - $database = Database::convertDbUrlToConnectionInfo($db_url, $root); + $database = Database::convertDbUrlToConnectionInfo($db_url); $database['prefix'] = $test_database->getDatabasePrefix(); Database::addConnectionInfo(__CLASS__, 'default', $database); diff --git a/core/tests/Drupal/Tests/Core/Ajax/AjaxResponseTest.php b/core/tests/Drupal/Tests/Core/Ajax/AjaxResponseTest.php index ab5fce6d191c..9dd5727f2a4b 100644 --- a/core/tests/Drupal/Tests/Core/Ajax/AjaxResponseTest.php +++ b/core/tests/Drupal/Tests/Core/Ajax/AjaxResponseTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Drupal\Tests\Core\Ajax; use Drupal\Core\Ajax\AjaxResponse; +use Drupal\Core\Ajax\CommandInterface; use Drupal\Core\EventSubscriber\AjaxResponseSubscriber; use Drupal\Tests\UnitTestCase; use Symfony\Component\HttpFoundation\Request; @@ -98,4 +99,95 @@ class AjaxResponseTest extends UnitTestCase { $this->assertEquals('<textarea>[]</textarea>', $response->getContent()); } + /** + * Tests the mergeWith() method. + * + * @see \Drupal\Core\Ajax\AjaxResponse::mergeWith() + * + * @throws \PHPUnit\Framework\MockObject\Exception + */ + public function testMergeWithOtherAjaxResponse(): void { + $response = new AjaxResponse([]); + + $command_one = $this->createCommandMock('one'); + + $command_two = $this->createCommandMockWithSettingsAndLibrariesAttachments( + 'Drupal\Core\Ajax\HtmlCommand', [ + 'setting1' => 'value1', + 'setting2' => 'value2', + ], ['jquery', 'drupal'], 'two'); + $command_three = $this->createCommandMockWithSettingsAndLibrariesAttachments( + 'Drupal\Core\Ajax\InsertCommand', [ + 'setting1' => 'overridden', + 'setting3' => 'value3', + ], ['jquery', 'ajax'], 'three'); + + $response->addCommand($command_one); + $response->addCommand($command_two); + + $response2 = new AjaxResponse([]); + $response2->addCommand($command_three); + + $response->mergeWith($response2); + self::assertEquals([ + 'library' => ['jquery', 'drupal', 'jquery', 'ajax'], + 'drupalSettings' => [ + 'setting1' => 'overridden', + 'setting2' => 'value2', + 'setting3' => 'value3', + ], + ], $response->getAttachments()); + self::assertEquals([['command' => 'one'], ['command' => 'two'], ['command' => 'three']], $response->getCommands()); + } + + /** + * Creates a mock of a provided subclass of CommandInterface. + * + * Adds given settings and libraries to assets mock + * that is attached to the command mock. + * + * @param string $command_class_name + * The command class name to create the mock for. + * @param array|null $settings + * The settings to attach. + * @param array|null $libraries + * The libraries to attach. + * @param string $command_name + * The command name to pass to the mock. + */ + private function createCommandMockWithSettingsAndLibrariesAttachments( + string $command_class_name, + array|null $settings, + array|null $libraries, + string $command_name, + ): CommandInterface { + $command = $this->createMock($command_class_name); + $command->expects($this->once()) + ->method('render') + ->willReturn(['command' => $command_name]); + + $assets = $this->createMock('Drupal\Core\Asset\AttachedAssetsInterface'); + $assets->expects($this->once())->method('getLibraries')->willReturn($libraries); + $assets->expects($this->once())->method('getSettings')->willReturn($settings); + + $command->expects($this->once())->method('getAttachedAssets')->willReturn($assets); + + return $command; + } + + /** + * Creates a mock of the Drupal\Core\Ajax\CommandInterface. + * + * @param string $command_name + * The command name to pass to the mock. + */ + private function createCommandMock(string $command_name): CommandInterface { + $command = $this->createMock('Drupal\Core\Ajax\CommandInterface'); + $command->expects($this->once()) + ->method('render') + ->willReturn(['command' => $command_name]); + + return $command; + } + } diff --git a/core/tests/Drupal/Tests/Core/Asset/CssCollectionOptimizerLazyUnitTest.php b/core/tests/Drupal/Tests/Core/Asset/CssCollectionOptimizerLazyUnitTest.php index 798735a2c8a1..9dc1a0d113ee 100644 --- a/core/tests/Drupal/Tests/Core/Asset/CssCollectionOptimizerLazyUnitTest.php +++ b/core/tests/Drupal/Tests/Core/Asset/CssCollectionOptimizerLazyUnitTest.php @@ -148,4 +148,40 @@ class CssCollectionOptimizerLazyUnitTest extends UnitTestCase { self::assertStringEqualsFile(__DIR__ . '/css_test_files/css_license.css.optimized.aggregated.css', $aggregate); } + /** + * Test that external minified CSS assets do not trigger optimization. + * + * This ensures that fully external asset groups do not result in a + * CssOptimizer exception and are safely ignored. + */ + public function testExternalMinifiedCssAssetOptimizationIsSkipped(): void { + $mock_grouper = $this->createMock(AssetCollectionGrouperInterface::class); + $mock_optimizer = $this->createMock(AssetOptimizerInterface::class); + $mock_optimizer->expects($this->never())->method('optimize'); + + $optimizer = new CssCollectionOptimizerLazy( + $mock_grouper, + $mock_optimizer, + $this->createMock(ThemeManagerInterface::class), + $this->createMock(LibraryDependencyResolverInterface::class), + new RequestStack(), + $this->createMock(FileSystemInterface::class), + $this->createMock(ConfigFactoryInterface::class), + $this->createMock(FileUrlGeneratorInterface::class), + $this->createMock(TimeInterface::class), + $this->createMock(LanguageManagerInterface::class) + ); + $optimizer->optimizeGroup([ + 'items' => [ + [ + 'type' => 'external', + 'data' => __DIR__ . '/css_test_files/css_external.optimized.aggregated.css', + 'license' => FALSE, + 'preprocess' => TRUE, + 'minified' => TRUE, + ], + ], + ]); + } + } diff --git a/core/tests/Drupal/Tests/Core/Asset/css_test_files/css_external.optimized.aggregated.css b/core/tests/Drupal/Tests/Core/Asset/css_test_files/css_external.optimized.aggregated.css new file mode 100644 index 000000000000..dac82b6b80f6 --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Asset/css_test_files/css_external.optimized.aggregated.css @@ -0,0 +1 @@ +/* Placeholder external CSS file. */ diff --git a/core/tests/Drupal/Tests/Core/Database/UrlConversionTest.php b/core/tests/Drupal/Tests/Core/Database/UrlConversionTest.php index e36767d2f5a4..c74090fb599b 100644 --- a/core/tests/Drupal/Tests/Core/Database/UrlConversionTest.php +++ b/core/tests/Drupal/Tests/Core/Database/UrlConversionTest.php @@ -7,6 +7,7 @@ namespace Drupal\Tests\Core\Database; use Drupal\Core\Database\Database; use Drupal\Core\Extension\Exception\UnknownExtensionException; use Drupal\Tests\UnitTestCase; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; // cspell:ignore dummydb @@ -31,7 +32,7 @@ class UrlConversionTest extends UnitTestCase { * @dataProvider providerConvertDbUrlToConnectionInfo */ public function testDbUrlToConnectionConversion($url, $database_array, $include_test_drivers): void { - $result = Database::convertDbUrlToConnectionInfo($url, $this->root, $include_test_drivers); + $result = Database::convertDbUrlToConnectionInfo($url, $include_test_drivers); $this->assertEquals($database_array, $result); } @@ -279,10 +280,10 @@ class UrlConversionTest extends UnitTestCase { * * @dataProvider providerInvalidArgumentsUrlConversion */ - public function testGetInvalidArgumentExceptionInUrlConversion($url, $root, $expected_exception_message): void { + public function testGetInvalidArgumentExceptionInUrlConversion($url, $expected_exception_message): void { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage($expected_exception_message); - Database::convertDbUrlToConnectionInfo($url, $root); + Database::convertDbUrlToConnectionInfo($url); } /** @@ -291,14 +292,12 @@ class UrlConversionTest extends UnitTestCase { * @return array * Array of arrays with the following elements: * - An invalid URL string. - * - Drupal root string. * - The expected exception message. */ public static function providerInvalidArgumentsUrlConversion() { return [ - ['foo', '', "Missing scheme in URL 'foo'"], - ['foo', 'bar', "Missing scheme in URL 'foo'"], - ['foo/bar/baz', 'bar2', "Missing scheme in URL 'foo/bar/baz'"], + ['foo', "Missing scheme in URL 'foo'"], + ['foo/bar/baz', "Missing scheme in URL 'foo/bar/baz'"], ]; } @@ -307,7 +306,7 @@ class UrlConversionTest extends UnitTestCase { */ public function testNoModuleSpecifiedDefaultsToDriverName(): void { $url = 'dummydb://test_user:test_pass@test_host/test_database'; - $connection_info = Database::convertDbUrlToConnectionInfo($url, $this->root, TRUE); + $connection_info = Database::convertDbUrlToConnectionInfo($url, TRUE); $expected = [ 'driver' => 'dummydb', 'username' => 'test_user', @@ -518,7 +517,7 @@ class UrlConversionTest extends UnitTestCase { $url = 'foo_bar_mysql://test_user:test_pass@test_host:3306/test_database?module=foo_bar'; $this->expectException(UnknownExtensionException::class); $this->expectExceptionMessage("The database_driver Drupal\\foo_bar\\Driver\\Database\\foo_bar_mysql does not exist."); - Database::convertDbUrlToConnectionInfo($url, $this->root, TRUE); + Database::convertDbUrlToConnectionInfo($url, TRUE); } /** @@ -528,7 +527,16 @@ class UrlConversionTest extends UnitTestCase { $url = 'driver_test_mysql://test_user:test_pass@test_host:3306/test_database?module=driver_test'; $this->expectException(UnknownExtensionException::class); $this->expectExceptionMessage("The database_driver Drupal\\driver_test\\Driver\\Database\\driver_test_mysql does not exist."); - Database::convertDbUrlToConnectionInfo($url, $this->root, TRUE); + Database::convertDbUrlToConnectionInfo($url, TRUE); + } + + /** + * @covers ::convertDbUrlToConnectionInfo + */ + #[IgnoreDeprecations] + public function testDeprecationOfRootParameter(): void { + $this->expectDeprecation('Passing a string $root value to Drupal\\Core\\Database\\Database::convertDbUrlToConnectionInfo() is deprecated in drupal:11.3.0 and will be removed in drupal:12.0.0. There is no replacement. See https://www.drupal.org/node/3511287'); + Database::convertDbUrlToConnectionInfo('sqlite://localhost/test_database', $this->root, TRUE); } } diff --git a/core/tests/Drupal/Tests/Core/Entity/EntityViewBuilderTest.php b/core/tests/Drupal/Tests/Core/Entity/EntityViewBuilderTest.php new file mode 100644 index 000000000000..29d3d17dba0f --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Entity/EntityViewBuilderTest.php @@ -0,0 +1,80 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\Core\Entity; + +use Drupal\Core\Entity\Display\EntityViewDisplayInterface; +use Drupal\Core\Entity\EntityViewBuilder; +use Drupal\Core\Entity\FieldableEntityInterface; +use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\Core\Field\FieldItemListInterface; +use Drupal\Tests\UnitTestCase; + +/** + * @coversDefaultClass \Drupal\Core\Entity\EntityViewBuilder + * @group Entity + */ +class EntityViewBuilderTest extends UnitTestCase { + + const string ENTITY_TYPE_ID = 'test_entity_type'; + + /** + * The entity view builder under test. + * + * @var \Drupal\Core\Entity\EntityViewBuilder + */ + protected EntityViewBuilder $viewBuilder; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->viewBuilder = new class() extends EntityViewBuilder { + + public function __construct() { + $this->entityTypeId = EntityViewBuilderTest::ENTITY_TYPE_ID; + } + + }; + } + + /** + * Tests build components using a mocked Iterator. + */ + public function testBuildComponents(): void { + $field_name = $this->randomMachineName(); + $bundle = $this->randomMachineName(); + $entity_id = mt_rand(20, 30); + $field_item_list = $this->createStub(FieldItemListInterface::class); + $item = new \stdClass(); + $this->setupMockIterator($field_item_list, [$item]); + $entity = $this->createConfiguredStub(FieldableEntityInterface::class, [ + 'bundle' => $bundle, + 'hasField' => TRUE, + 'get' => $field_item_list, + ]); + $formatter_result = [ + $entity_id => ['#' . $this->randomMachineName() => $this->randomString()], + ]; + $display = $this->createConfiguredStub(EntityViewDisplayInterface::class, [ + 'getComponents' => [$field_name => []], + 'buildMultiple' => $formatter_result, + ]); + $entities = [$entity_id => $entity]; + $displays = [$bundle => $display]; + $build = [$entity_id => []]; + $view_mode = $this->randomMachineName(); + // Assert the hook is invoked. + $module_handler = $this->createMock(ModuleHandlerInterface::class); + $module_handler->expects($this->once()) + ->method('invokeAll') + ->with('entity_prepare_view', [self::ENTITY_TYPE_ID, $entities, $displays, $view_mode]); + $this->viewBuilder->setModuleHandler($module_handler); + $this->viewBuilder->buildComponents($build, $entities, $displays, $view_mode); + $this->assertSame([], $item->_attributes); + $this->assertSame($formatter_result, $build); + } + +} diff --git a/core/tests/Drupal/Tests/Core/Test/TestSetupTraitTest.php b/core/tests/Drupal/Tests/Core/Test/TestSetupTraitTest.php index ba1d28a50c6d..5627d068aa77 100644 --- a/core/tests/Drupal/Tests/Core/Test/TestSetupTraitTest.php +++ b/core/tests/Drupal/Tests/Core/Test/TestSetupTraitTest.php @@ -29,7 +29,7 @@ class TestSetupTraitTest extends UnitTestCase { public function testChangeDatabasePrefix(): void { $root = dirname(__FILE__, 7); putenv('SIMPLETEST_DB=pgsql://user:pass@127.0.0.1/db'); - $connection_info = Database::convertDbUrlToConnectionInfo('mysql://user:pass@localhost/db', $root); + $connection_info = Database::convertDbUrlToConnectionInfo('mysql://user:pass@localhost/db'); Database::addConnectionInfo('default', 'default', $connection_info); $this->assertEquals('mysql', Database::getConnectionInfo()['default']['driver']); $this->assertEquals('localhost', Database::getConnectionInfo()['default']['host']); diff --git a/core/tests/Drupal/Tests/UnitTestCase.php b/core/tests/Drupal/Tests/UnitTestCase.php index 2257148d0698..b279bd1ed250 100644 --- a/core/tests/Drupal/Tests/UnitTestCase.php +++ b/core/tests/Drupal/Tests/UnitTestCase.php @@ -13,6 +13,7 @@ use Drupal\Core\StringTranslation\PluralTranslatableMarkup; use Drupal\TestTools\Extension\DeprecationBridge\ExpectDeprecationTrait; use Drupal\TestTools\Extension\Dump\DebugDump; use PHPUnit\Framework\Attributes\BeforeClass; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; use Symfony\Component\VarDumper\VarDumper; @@ -210,4 +211,32 @@ abstract class UnitTestCase extends TestCase { return $class_resolver; } + /** + * Set up a traversable class mock to return specific items when iterated. + * + * Test doubles for types extending \Traversable are required to implement + * \Iterator which requires setting up five methods. Instead, this helper + * can be used. + * + * @param \PHPUnit\Framework\MockObject\MockObject&\Iterator $mock + * A mock object mocking a traversable class. + * @param array $items + * The items to return when this mock is iterated. + * + * @return \PHPUnit\Framework\MockObject\MockObject&\Iterator + * The same mock object ready to be iterated. + * + * @template T of \PHPUnit\Framework\MockObject\MockObject&\Iterator + * @phpstan-param T $mock + * @phpstan-return T + * @see https://github.com/sebastianbergmann/phpunit-mock-objects/issues/103 + */ + protected function setupMockIterator(MockObject&\Iterator $mock, array $items): MockObject&\Iterator { + $iterator = new \ArrayIterator($items); + foreach (get_class_methods(\Iterator::class) as $method) { + $mock->method($method)->willReturnCallback([$iterator, $method]); + } + return $mock; + } + } diff --git a/core/themes/claro/claro.info.yml b/core/themes/claro/claro.info.yml index 87156826550e..597f0f78ba3f 100644 --- a/core/themes/claro/claro.info.yml +++ b/core/themes/claro/claro.info.yml @@ -113,6 +113,8 @@ libraries-extend: - claro/dropbutton core/drupal.checkbox: - claro/checkbox + core/drupal.item-list: + - claro/item-list core/drupal.message: - claro/messages core/drupal.progress: diff --git a/core/themes/claro/claro.libraries.yml b/core/themes/claro/claro.libraries.yml index b10eb233ba42..83ad3aebc1e6 100644 --- a/core/themes/claro/claro.libraries.yml +++ b/core/themes/claro/claro.libraries.yml @@ -12,7 +12,6 @@ global-styling: css/classy/components/field.css: {} css/classy/components/icons.css: {} css/classy/components/inline-form.css: {} - css/classy/components/item-list.css: {} css/classy/components/link.css: {} css/classy/components/links.css: {} css/classy/components/menu.css: {} @@ -211,6 +210,11 @@ ajax: js: js/ajax.js: {} +item-list: + css: + component: + css/classy/components/item-list.css: {} + form.password-confirm: css: component: diff --git a/core/themes/claro/claro.theme b/core/themes/claro/claro.theme index a031dc860c35..81255d798a3e 100644 --- a/core/themes/claro/claro.theme +++ b/core/themes/claro/claro.theme @@ -1086,7 +1086,7 @@ function claro_preprocess_field_multiple_value_form(&$variables): void { if ($variables['multiple']) { // Add an additional CSS class to the field label table cell. The table // header cell should always exist unless removed by contrib. - // @see template_preprocess_field_multiple_value_form(). + // @see \Drupal\Core\Field\FieldPreprocess::preprocessFieldMultipleValueForm(). if (isset($variables['table']['#header'][0]['data']['#attributes'])) { $variables['table']['#header'][0]['data']['#attributes']->removeClass('label'); $variables['table']['#header'][0]['data']['#attributes']->addClass('form-item__label', 'form-item__label--multiple-value-form'); diff --git a/core/themes/claro/templates/classy/field/field--comment.html.twig b/core/themes/claro/templates/classy/field/field--comment.html.twig index 1ec3ee64b104..d59d328c741b 100644 --- a/core/themes/claro/templates/classy/field/field--comment.html.twig +++ b/core/themes/claro/templates/classy/field/field--comment.html.twig @@ -21,7 +21,7 @@ * - field_type: The type of the field. * - label_display: The display settings for the label. * - * @see template_preprocess_field() + * @see \Drupal\Core\Field\FieldPreprocess::preprocessField() * @see comment_preprocess_field() */ #} diff --git a/core/themes/claro/templates/classy/field/field.html.twig b/core/themes/claro/templates/classy/field/field.html.twig index 1cfbd651ce16..45e9aa74228c 100644 --- a/core/themes/claro/templates/classy/field/field.html.twig +++ b/core/themes/claro/templates/classy/field/field.html.twig @@ -34,7 +34,7 @@ * - label_display: The display settings for the label. * * - * @see template_preprocess_field() + * @see \Drupal\Core\Field\FieldPreprocess::preprocessField() */ #} {% diff --git a/core/themes/claro/templates/form/field-multiple-value-form.html.twig b/core/themes/claro/templates/form/field-multiple-value-form.html.twig index 60610275aa94..89a08a252805 100644 --- a/core/themes/claro/templates/form/field-multiple-value-form.html.twig +++ b/core/themes/claro/templates/form/field-multiple-value-form.html.twig @@ -17,7 +17,7 @@ * - attributes: HTML attributes to apply to the description container. * - button: "Add another item" button. * - * @see template_preprocess_field_multiple_value_form() + * @see \Drupal\Core\Field\FieldPreprocess::preprocessFieldMultipleValueForm() * @see claro_preprocess_field_multiple_value_form() */ #} diff --git a/core/themes/olivero/olivero.theme b/core/themes/olivero/olivero.theme index 21cfbe1208b1..88aa7531379c 100644 --- a/core/themes/olivero/olivero.theme +++ b/core/themes/olivero/olivero.theme @@ -381,13 +381,12 @@ function olivero_preprocess_field_multiple_value_form(&$variables): void { if (!empty($variables['multiple'])) { // Add an additional CSS class for the field label table cell. - // This repeats the logic of template_preprocess_field_multiple_value_form() + // This repeats the logic of + // \Drupal\Core\Field\FieldPreprocess::preprocessFieldMultipleValueForm() // without using '#prefix' and '#suffix' for the wrapper element. // // If the field is multiple, we don't have to check the existence of the // table header cell. - // - // @see template_preprocess_field_multiple_value_form(). $header_attributes = ['class' => ['form-item__label', 'form-item__label--multiple-value-form']]; if (!empty($variables['element']['#required'])) { $header_attributes['class'][] = 'js-form-required'; diff --git a/core/themes/olivero/templates/field/field--comment-body.html.twig b/core/themes/olivero/templates/field/field--comment-body.html.twig index f36894176f12..db2de1e50db6 100644 --- a/core/themes/olivero/templates/field/field--comment-body.html.twig +++ b/core/themes/olivero/templates/field/field--comment-body.html.twig @@ -17,7 +17,7 @@ * - field_type: The type of the field. * - label_display: The display settings for the label. * - * @see template_preprocess_field() + * @see \Drupal\Core\Field\FieldPreprocess::preprocessField() */ #} {% diff --git a/core/themes/olivero/templates/field/field--comment.html.twig b/core/themes/olivero/templates/field/field--comment.html.twig index c69553cea34e..bd1d3a622a32 100644 --- a/core/themes/olivero/templates/field/field--comment.html.twig +++ b/core/themes/olivero/templates/field/field--comment.html.twig @@ -23,7 +23,7 @@ * - field_type: The type of the field. * - label_display: The display settings for the label. * - * @see template_preprocess_field() + * @see \Drupal\Core\Field\FieldPreprocess::preprocessField() * @see comment_preprocess_field() */ #} diff --git a/core/themes/olivero/templates/field/field--node--field-tags.html.twig b/core/themes/olivero/templates/field/field--node--field-tags.html.twig index bf867a3dfbde..3af70f51f44c 100644 --- a/core/themes/olivero/templates/field/field--node--field-tags.html.twig +++ b/core/themes/olivero/templates/field/field--node--field-tags.html.twig @@ -17,7 +17,7 @@ * - field_type: The type of the field. * - label_display: The display settings for the label. * - * @see template_preprocess_field() + * @see \Drupal\Core\Field\FieldPreprocess::preprocessField() */ #} {% diff --git a/core/themes/olivero/templates/field/field.html.twig b/core/themes/olivero/templates/field/field.html.twig index 1cfbd651ce16..45e9aa74228c 100644 --- a/core/themes/olivero/templates/field/field.html.twig +++ b/core/themes/olivero/templates/field/field.html.twig @@ -34,7 +34,7 @@ * - label_display: The display settings for the label. * * - * @see template_preprocess_field() + * @see \Drupal\Core\Field\FieldPreprocess::preprocessField() */ #} {% diff --git a/core/themes/olivero/templates/form/field-multiple-value-form.html.twig b/core/themes/olivero/templates/form/field-multiple-value-form.html.twig index 6e6b5f1d8341..f187e37fe59d 100644 --- a/core/themes/olivero/templates/form/field-multiple-value-form.html.twig +++ b/core/themes/olivero/templates/form/field-multiple-value-form.html.twig @@ -16,7 +16,7 @@ * - attributes: HTML attributes to apply to the description container. * - button: "Add another item" button. * - * @see template_preprocess_field_multiple_value_form() + * @see \Drupal\Core\Field\FieldPreprocess::preprocessFieldMultipleValueForm() * * @ingroup themeable */ diff --git a/core/themes/stable9/css/system/components/item-list.module.css b/core/themes/stable9/css/core/components/item-list.module.css index 2d23ee5bd335..2d23ee5bd335 100644 --- a/core/themes/stable9/css/system/components/item-list.module.css +++ b/core/themes/stable9/css/core/components/item-list.module.css diff --git a/core/themes/stable9/stable9.info.yml b/core/themes/stable9/stable9.info.yml index c7c239764445..c079c2b9b47e 100644 --- a/core/themes/stable9/stable9.info.yml +++ b/core/themes/stable9/stable9.info.yml @@ -73,6 +73,11 @@ libraries-override: component: misc/components/fieldgroup.module.css: css/core/components/fieldgroup.module.css + core/drupal.item-list: + css: + component: + misc/components/item-list.module.css: css/core/components/item-list.module.css + core/drupal.progress: css: component: @@ -238,7 +243,6 @@ libraries-override: css/components/container-inline.module.css: css/system/components/container-inline.module.css css/components/clearfix.module.css: css/system/components/clearfix.module.css css/components/hidden.module.css: css/system/components/hidden.module.css - css/components/item-list.module.css: css/system/components/item-list.module.css css/components/js.module.css: css/system/components/js.module.css css/components/reset-appearance.module.css: css/system/components/reset-appearance.module.css system/admin: diff --git a/core/themes/stable9/templates/content/media-reference-help.html.twig b/core/themes/stable9/templates/content/media-reference-help.html.twig index 910dc4e94bea..4adc22db002e 100644 --- a/core/themes/stable9/templates/content/media-reference-help.html.twig +++ b/core/themes/stable9/templates/content/media-reference-help.html.twig @@ -3,7 +3,7 @@ * @file * Theme override for media reference fields. * - * @see template_preprocess_field_multiple_value_form() + * @see \Drupal\Core\Field\FieldPreprocess::preprocessFieldMultipleValueForm() */ #} {% diff --git a/core/themes/stable9/templates/field/field--comment.html.twig b/core/themes/stable9/templates/field/field--comment.html.twig index 33a60ae0bdcb..62633fed4473 100644 --- a/core/themes/stable9/templates/field/field--comment.html.twig +++ b/core/themes/stable9/templates/field/field--comment.html.twig @@ -22,7 +22,7 @@ * - field_type: The type of the field. * - label_display: The display settings for the label. * - * @see template_preprocess_field() + * @see \Drupal\Core\Field\FieldPreprocess::preprocessField() * @see comment_preprocess_field() */ #} diff --git a/core/themes/stable9/templates/field/field.html.twig b/core/themes/stable9/templates/field/field.html.twig index a10384dd6e4a..00aafb06a253 100644 --- a/core/themes/stable9/templates/field/field.html.twig +++ b/core/themes/stable9/templates/field/field.html.twig @@ -33,7 +33,7 @@ * - field_type: The type of the field. * - label_display: The display settings for the label. * - * @see template_preprocess_field() + * @see \Drupal\Core\Field\FieldPreprocess::preprocessField() */ #} {% diff --git a/core/themes/stable9/templates/form/field-multiple-value-form.html.twig b/core/themes/stable9/templates/form/field-multiple-value-form.html.twig index 246ac41bfdca..7e548afad6da 100644 --- a/core/themes/stable9/templates/form/field-multiple-value-form.html.twig +++ b/core/themes/stable9/templates/form/field-multiple-value-form.html.twig @@ -16,7 +16,7 @@ * - attributes: HTML attributes to apply to the description container. * - button: "Add another item" button. * - * @see template_preprocess_field_multiple_value_form() + * @see \Drupal\Core\Field\FieldPreprocess::preprocessFieldMultipleValueForm() */ #} {% if multiple %} diff --git a/core/themes/starterkit_theme/starterkit_theme.info.yml b/core/themes/starterkit_theme/starterkit_theme.info.yml index ca7e542349a3..49d6ad11e21c 100644 --- a/core/themes/starterkit_theme/starterkit_theme.info.yml +++ b/core/themes/starterkit_theme/starterkit_theme.info.yml @@ -16,6 +16,8 @@ libraries-extend: - starterkit_theme/dialog file/drupal.file: - starterkit_theme/file + core/drupal.item-list: + - starterkit_theme/item-list core/drupal.progress: - starterkit_theme/progress core/drupal.tablesort: diff --git a/core/themes/starterkit_theme/starterkit_theme.libraries.yml b/core/themes/starterkit_theme/starterkit_theme.libraries.yml index dfe9d8daa06b..7de83280179c 100644 --- a/core/themes/starterkit_theme/starterkit_theme.libraries.yml +++ b/core/themes/starterkit_theme/starterkit_theme.libraries.yml @@ -22,8 +22,6 @@ base: weight: -10 css/components/inline-form.css: weight: -10 - css/components/item-list.css: - weight: -10 css/components/link.css: weight: -10 css/components/links.css: @@ -90,14 +88,18 @@ progress: component: css/components/progress.css: weight: -10 - drupal.tablesort: version: VERSION css: component: css/components/tablesort.css: weight: -10 - +item-list: + version: VERSION + css: + component: + css/components/item-list.css: + weight: -10 search-results: version: VERSION css: diff --git a/core/themes/starterkit_theme/templates/field/field--comment.html.twig b/core/themes/starterkit_theme/templates/field/field--comment.html.twig index 1ec3ee64b104..d59d328c741b 100644 --- a/core/themes/starterkit_theme/templates/field/field--comment.html.twig +++ b/core/themes/starterkit_theme/templates/field/field--comment.html.twig @@ -21,7 +21,7 @@ * - field_type: The type of the field. * - label_display: The display settings for the label. * - * @see template_preprocess_field() + * @see \Drupal\Core\Field\FieldPreprocess::preprocessField() * @see comment_preprocess_field() */ #} diff --git a/core/themes/starterkit_theme/templates/field/field.html.twig b/core/themes/starterkit_theme/templates/field/field.html.twig index 1cfbd651ce16..45e9aa74228c 100644 --- a/core/themes/starterkit_theme/templates/field/field.html.twig +++ b/core/themes/starterkit_theme/templates/field/field.html.twig @@ -34,7 +34,7 @@ * - label_display: The display settings for the label. * * - * @see template_preprocess_field() + * @see \Drupal\Core\Field\FieldPreprocess::preprocessField() */ #} {% diff --git a/core/themes/starterkit_theme/templates/form/field-multiple-value-form.html.twig b/core/themes/starterkit_theme/templates/form/field-multiple-value-form.html.twig index 246ac41bfdca..7e548afad6da 100644 --- a/core/themes/starterkit_theme/templates/form/field-multiple-value-form.html.twig +++ b/core/themes/starterkit_theme/templates/form/field-multiple-value-form.html.twig @@ -16,7 +16,7 @@ * - attributes: HTML attributes to apply to the description container. * - button: "Add another item" button. * - * @see template_preprocess_field_multiple_value_form() + * @see \Drupal\Core\Field\FieldPreprocess::preprocessFieldMultipleValueForm() */ #} {% if multiple %} |