summaryrefslogtreecommitdiffstatshomepage
path: root/core/modules
diff options
context:
space:
mode:
Diffstat (limited to 'core/modules')
-rw-r--r--core/modules/comment/templates/field--comment.html.twig2
-rw-r--r--core/modules/config/tests/src/Functional/ConfigImportAllTest.php9
-rw-r--r--core/modules/field_ui/src/Form/EntityFormDisplayEditForm.php4
-rw-r--r--core/modules/field_ui/src/Form/EntityViewDisplayEditForm.php4
-rw-r--r--core/modules/file/file.module7
-rw-r--r--core/modules/layout_builder/tests/modules/layout_builder_test/layout_builder_test.module51
-rw-r--r--core/modules/layout_builder/tests/modules/layout_builder_test/src/Hook/LayoutBuilderTestEntityHooks.php63
-rw-r--r--core/modules/layout_builder/tests/modules/layout_builder_test/src/Hook/LayoutBuilderTestFormHooks.php57
-rw-r--r--core/modules/layout_builder/tests/modules/layout_builder_test/src/Hook/LayoutBuilderTestHooks.php137
-rw-r--r--core/modules/layout_builder/tests/modules/layout_builder_test/src/Hook/LayoutBuilderTestMenuHooks.php32
-rw-r--r--core/modules/layout_builder/tests/modules/layout_builder_test/src/Hook/LayoutBuilderTestPluginHooks.php38
-rw-r--r--core/modules/layout_builder/tests/modules/layout_builder_test/src/Hook/LayoutBuilderTestThemeHooks.php57
-rw-r--r--core/modules/layout_builder/tests/modules/layout_builder_theme_suggestions_test/layout_builder_theme_suggestions_test.module19
-rw-r--r--core/modules/layout_builder/tests/modules/layout_builder_theme_suggestions_test/src/Hook/LayoutBuilderThemeSuggestionsTestHooks.php28
-rw-r--r--core/modules/layout_builder/tests/modules/layout_builder_theme_suggestions_test/src/Hook/LayoutBuilderThemeSuggestionsTestThemeHooks.php40
-rw-r--r--core/modules/layout_builder/tests/src/Functional/LayoutBuilderThemeSuggestionsTest.php2
-rw-r--r--core/modules/locale/locale.batch.inc9
-rw-r--r--core/modules/locale/tests/src/Kernel/LocaleBatchTest.php49
-rw-r--r--core/modules/media/src/Hook/MediaHooks.php8
-rw-r--r--core/modules/media/templates/media-reference-help.html.twig2
-rw-r--r--core/modules/media/tests/modules/media_test_embed/media_test_embed.module15
-rw-r--r--core/modules/media/tests/modules/media_test_embed/src/Hook/MediaTestEmbedThemeHooks.php22
-rw-r--r--core/modules/media/tests/modules/media_test_oembed/media_test_oembed.module20
-rw-r--r--core/modules/media/tests/modules/media_test_oembed/src/Hook/MediaTestOembedThemeHooks.php27
-rw-r--r--core/modules/migrate_drupal_ui/migrate_drupal_ui.routing.yml2
-rw-r--r--core/modules/migrate_drupal_ui/tests/src/Functional/MigrateControllerTest.php13
-rw-r--r--core/modules/mysql/src/Driver/Database/mysql/ExceptionHandler.php110
-rw-r--r--core/modules/mysql/src/Driver/Database/mysql/Schema.php6
-rw-r--r--core/modules/mysql/tests/src/Functional/RequirementsTest.php2
-rw-r--r--core/modules/mysqli/mysqli.info.yml9
-rw-r--r--core/modules/mysqli/mysqli.install78
-rw-r--r--core/modules/mysqli/mysqli.services.yml4
-rw-r--r--core/modules/mysqli/src/Driver/Database/mysqli/Connection.php191
-rw-r--r--core/modules/mysqli/src/Driver/Database/mysqli/ExceptionHandler.php30
-rw-r--r--core/modules/mysqli/src/Driver/Database/mysqli/Install/Tasks.php28
-rw-r--r--core/modules/mysqli/src/Driver/Database/mysqli/InvalidCharsetException.php13
-rw-r--r--core/modules/mysqli/src/Driver/Database/mysqli/NamedPlaceholderConverter.php250
-rw-r--r--core/modules/mysqli/src/Driver/Database/mysqli/Result.php95
-rw-r--r--core/modules/mysqli/src/Driver/Database/mysqli/Statement.php126
-rw-r--r--core/modules/mysqli/src/Driver/Database/mysqli/TransactionManager.php82
-rw-r--r--core/modules/mysqli/src/Hook/MysqliHooks.php32
-rw-r--r--core/modules/mysqli/src/Plugin/views/query/MysqliCastSql.php11
-rw-r--r--core/modules/mysqli/tests/src/Functional/GenericTest.php28
-rw-r--r--core/modules/mysqli/tests/src/Kernel/mysqli/ConnectionTest.php15
-rw-r--r--core/modules/mysqli/tests/src/Kernel/mysqli/ConnectionUnitTest.php23
-rw-r--r--core/modules/mysqli/tests/src/Kernel/mysqli/DatabaseExceptionWrapperTest.php38
-rw-r--r--core/modules/mysqli/tests/src/Kernel/mysqli/LargeQueryTest.php52
-rw-r--r--core/modules/mysqli/tests/src/Kernel/mysqli/PrefixInfoTest.php15
-rw-r--r--core/modules/mysqli/tests/src/Kernel/mysqli/SchemaTest.php15
-rw-r--r--core/modules/mysqli/tests/src/Kernel/mysqli/SqlModeTest.php43
-rw-r--r--core/modules/mysqli/tests/src/Kernel/mysqli/SyntaxTest.php28
-rw-r--r--core/modules/mysqli/tests/src/Kernel/mysqli/TemporaryQueryTest.php15
-rw-r--r--core/modules/mysqli/tests/src/Kernel/mysqli/TransactionTest.php54
-rw-r--r--core/modules/mysqli/tests/src/Unit/NamedPlaceholderConverterTest.php400
-rw-r--r--core/modules/navigation/tests/navigation_test/navigation_test.module19
-rw-r--r--core/modules/navigation/tests/navigation_test/src/Hook/NavigationTestThemeHooks.php25
-rw-r--r--core/modules/node/src/Plugin/views/filter/Access.php2
-rw-r--r--core/modules/search/tests/modules/search_embedded_form/search_embedded_form.module20
-rw-r--r--core/modules/search/tests/modules/search_embedded_form/src/Hook/SearchEmbeddedFormThemeHooks.php32
-rw-r--r--core/modules/system/css/components/item-list.module.css19
-rw-r--r--core/modules/system/src/EventSubscriber/SecurityFileUploadEventSubscriber.php17
-rw-r--r--core/modules/system/system.libraries.yml1
-rw-r--r--core/modules/system/templates/field-multiple-value-form.html.twig2
-rw-r--r--core/modules/system/templates/field.html.twig2
-rw-r--r--core/modules/system/tests/modules/common_test/common_test.module29
-rw-r--r--core/modules/system/tests/modules/common_test/src/Hook/CommonTestHooks.php125
-rw-r--r--core/modules/system/tests/modules/common_test/src/Hook/CommonTestThemeHooks.php165
-rw-r--r--core/modules/system/tests/modules/js_displace/js_displace.module15
-rw-r--r--core/modules/system/tests/modules/js_displace/src/Hook/JsDisplaceThemeHooks.php22
-rw-r--r--core/modules/system/tests/src/Functional/FileTransfer/FileTransferTest.php5
-rw-r--r--core/modules/system/tests/src/Functional/Module/GenericModuleTestBase.php10
-rw-r--r--core/modules/system/tests/src/Unit/Event/SecurityFileUploadEventSubscriberTest.php17
-rw-r--r--core/modules/user/config/schema/user.schema.yml14
-rw-r--r--core/modules/views/tests/modules/views_test_data/test_views/views.view.test_content_access_filter.yml247
-rw-r--r--core/modules/views/tests/src/Functional/Plugin/AccessTest.php35
75 files changed, 2742 insertions, 591 deletions
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/css/components/item-list.module.css b/core/modules/system/css/components/item-list.module.css
deleted file mode 100644
index 2d23ee5bd335..000000000000
--- a/core/modules/system/css/components/item-list.module.css
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * @file
- * Styles for item list.
- */
-
-.item-list__comma-list,
-.item-list__comma-list li {
- display: inline;
-}
-.item-list__comma-list {
- margin: 0;
- padding: 0;
-}
-.item-list__comma-list li::after {
- content: ", ";
-}
-.item-list__comma-list li:last-child::after {
- content: "";
-}
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);
+ }
+
}