diff --git a/changelogs/DP-36234.yml b/changelogs/DP-36234.yml new file mode 100644 index 0000000000..751e0cf56c --- /dev/null +++ b/changelogs/DP-36234.yml @@ -0,0 +1,41 @@ +# +# Write your changelog entry here. Every pull request must have a changelog yml file. +# +# Change types: +# ############################################################################# +# You can use one of the following types: +# - Added: For new features. +# - Changed: For changes to existing functionality. +# - Deprecated: For soon-to-be removed features. +# - Removed: For removed features. +# - Fixed: For any bug fixes. +# - Security: In case of vulnerabilities. +# +# Format +# ############################################################################# +# The format is crucial. Please follow the examples below. For reference, the requirements are: +# - All 3 parts are required and you must include "Type", "description" and "issue". +# - "Type" must be left aligned and followed by a colon. +# - "description" must be indented with 2 spaces followed by a colon +# - "issue" must be indented with 4 spaces followed by a colon. +# - "issue" is for the Jira ticket number only e.g. DP-1234 +# - No extra spaces, indents, or blank lines are allowed. +# +# Example: +# ############################################################################# +# Fixed: +# - description: Fixes scrolling on edit pages in Safari. +# issue: DP-13314 +# +# You may add more than 1 description & issue for each type using the following format: +# Changed: +# - description: Automating the release branch. +# issue: DP-10166 +# - description: Second change item that needs a description. +# issue: DP-19875 +# - description: Third change item that needs a description along with an issue. +# issue: DP-19843 +# +Fixed: + - description: Verify that Trash clear drush command is working correctly. + issue: DP-36234 diff --git a/conf/drupal/config/views.view.mass_dashboard_trash.yml b/conf/drupal/config/views.view.mass_dashboard_trash.yml index 8e451a0a00..e3d995aee5 100644 --- a/conf/drupal/config/views.view.mass_dashboard_trash.yml +++ b/conf/drupal/config/views.view.mass_dashboard_trash.yml @@ -613,6 +613,69 @@ display: multi_type: separator separator: ', ' field_api_classes: false + moderation_state: + id: moderation_state + table: node_field_data + field: moderation_state + relationship: vid + group_type: group + admin_label: '' + entity_type: node + plugin_id: moderation_state_field + label: 'Moderation state' + exclude: false + alter: + alter_text: false + text: '' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: 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_empty: false + empty_zero: false + hide_alter_empty: true + click_sort_column: value + type: content_moderation_state + settings: { } + 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 nos_per_1000_cleaned: id: nos_per_1000_cleaned table: mass_bigquery_data diff --git a/docroot/modules/custom/trashbin/src/Drush/Commands/TrashbinCommands.php b/docroot/modules/custom/trashbin/src/Drush/Commands/TrashbinCommands.php index b98e8e89d2..57765d1fbd 100644 --- a/docroot/modules/custom/trashbin/src/Drush/Commands/TrashbinCommands.php +++ b/docroot/modules/custom/trashbin/src/Drush/Commands/TrashbinCommands.php @@ -13,7 +13,6 @@ final class TrashbinCommands extends DrushCommands { use AutowireTrait; - const TRASHBIN_ONLY_TRASH = 'trashbin_only_trash'; const TRASHBIN_PURGE = 'trashbin:purge'; protected function __construct( @@ -23,7 +22,7 @@ protected function __construct( } /** - * Delete content entities that have been in the bin for more than n days. + * Delete content entities in Trash; when --days-ago=0, delete all trashed items; when >0, delete only those older than N days. */ #[CLI\Command(name: self::TRASHBIN_PURGE, aliases: [])] #[CLI\Argument(name: 'entity_type', description: 'Entity type to purge')] @@ -31,21 +30,94 @@ protected function __construct( #[CLI\Option(name: 'days-ago', description: 'Number of days that the item must be unchanged in the trashbin.')] #[CLI\Usage(name: 'drush --simulate trashbin:purge node', description: 'Get a report of what would be purged.')] public function purge($entity_type, $options = ['max' => 1000, 'days-ago' => 180]) { - $maximum = strtotime($options['days-ago'] . ' days ago', $this->time->getCurrentTime()); + // Capture command start time to avoid racing with edits during execution. + $startedAt = $this->time->getCurrentTime(); + $maximum = strtotime($options['days-ago'] . ' days ago', $startedAt); + + $cutoff = ((int) $options['days-ago'] > 0) ? $maximum : $startedAt; + + // Validate entity type and get storage/definition. $storage = $this->etm->getStorage($entity_type); - $query = $storage->getQuery(); - $query->addTag(self::TRASHBIN_ONLY_TRASH); - $query->condition('changed', $maximum, '<'); - $query->range(0, $options['max']); - $ids = $query->accessCheck(FALSE)->execute(); + $definition = $storage->getEntityType(); + + // Resolve table/keys from the entity type definition (nodes: node_field_data/node_revision). + $base_table = $definition->getDataTable() ?: $definition->getBaseTable(); + if (!$base_table) { + $this->logger()->error('Entity type {type} does not have a base/data table and cannot be purged.', ['type' => $entity_type]); + return; + } + + $id_key = $definition->getKey('id'); + $rev_key = $definition->getKey('revision'); + $changed_key = 'changed'; + + if (!$id_key || !$rev_key) { + $this->logger()->error('Entity type {type} must be revisionable to use trashbin purge (missing id/revision keys).', ['type' => $entity_type]); + return; + } + + // Build a DB query that joins to content_moderation_state_field_data on the + // current (latest) revision id and filters to moderation_state = trash. + $connection = \Drupal::database(); + $query = $connection->select($base_table, 'b') + ->fields('b', [$id_key]) + ->range(0, (int) $options['max']); + + // Note: innerJoin() returns the alias string, not the Select object, so it + // must not be chained. + $query->innerJoin( + 'content_moderation_state_field_data', + 'md', + 'md.content_entity_type_id = :etype AND md.content_entity_id = b.' . $id_key . ' AND md.content_entity_revision_id = b.' . $rev_key, + [':etype' => $entity_type] + ); + + $revision_table = $definition->getRevisionTable(); + $rt_timestamp = 'rt.revision_timestamp'; + if ($entity_type == 'media') { + $rt_timestamp = 'rt.revision_created'; + } + + if ($revision_table) { + $query->innerJoin( + $revision_table, + 'rt', + 'rt.' . $rev_key . ' = b.' . $rev_key . ' AND rt.' . $id_key . ' = b.' . $id_key + ); + } + + // Always: moderation_state = 'trash' + $query->condition('md.moderation_state', 'trash'); + + // Execution-stable cutoff: + // - days-ago > 0 → cutoff = now - N days + // - days-ago = 0 → cutoff = command start time (prevents deleting items created/edited during the run) + $query->where('GREATEST(b.' . $changed_key . ', ' . $rt_timestamp . ') < :cutoff', [':cutoff' => $cutoff]); + + $query->orderBy($rt_timestamp, 'DESC'); + + $ids = $query->execute()->fetchCol(); + $this->logger()->notice('Found {count} entities to delete.', ['count' => count($ids)]); + + if (!$ids) { + return; + } + $entities = $storage->loadMultiple($ids); foreach ($entities as $entity) { if (Drush::simulate()) { - $this->logger()->notice('Simulated delete of "{title}". ID={id}, {url}', ['title' => $entity->label(), 'id' => $entity->id(), 'url' => $entity->toUrl('canonical', ['absolute' => TRUE])->toString()]); + $this->logger()->notice('Simulated delete of "{title}". ID={id}, {url}', [ + 'title' => $entity->label(), + 'id' => $entity->id(), + 'url' => $entity->toUrl('canonical', ['absolute' => TRUE])->toString(), + ]); } else { - $this->logger()->notice('Start delete of "{title}". ID={id}', ['title' => $entity->label(), 'id' => $entity->id()]); + $this->logger()->notice('Start delete of "{title}". ID={id}', [ + 'title' => $entity->label(), + 'id' => $entity->id(), + ]); $entity->delete(); } } diff --git a/docroot/modules/custom/trashbin/trashbin.module b/docroot/modules/custom/trashbin/trashbin.module index 7633b4ab8e..45c13f43d2 100644 --- a/docroot/modules/custom/trashbin/trashbin.module +++ b/docroot/modules/custom/trashbin/trashbin.module @@ -57,13 +57,3 @@ function trashbin_query_entity_reference_alter(AlterableInterface $query) { $query->condition('cm.moderation_state', 'trash', '!='); } } - -/** - * Implements hook_query_TAG_alter(). - * - * Returns only trashed entities when tagged accordingly. - */ -function trashbin_query_trashbin_only_trash_alter(AlterableInterface $query): void { - $query->addJoin('LEFT', 'content_moderation_state_field_data', 'md', 'md.content_entity_revision_id = base_table.vid'); - $query->condition('md.moderation_state', 'trash', '='); -}