diff options
Diffstat (limited to 'core/modules')
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); + } + } |