Skip to content
41 changes: 41 additions & 0 deletions changelogs/DP-36234.yml
Original file line number Diff line number Diff line change
@@ -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
63 changes: 63 additions & 0 deletions conf/drupal/config/views.view.mass_dashboard_trash.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -23,29 +22,102 @@ 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')]
#[CLI\Option(name: 'max', description: 'Maximum number of entities to delete.')]
#[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();
}
}
Expand Down
10 changes: 0 additions & 10 deletions docroot/modules/custom/trashbin/trashbin.module
Original file line number Diff line number Diff line change
Expand Up @@ -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', '=');
}