Skip to content
← Back to Snippets
Code

Safe Bulk Update Planner

Creates a reviewed bulk-update plan that requires approved columns, a WHERE filter, and a maximum-row guard before execution.

Purpose

Creates a reviewed bulk-update plan that requires approved columns, a WHERE filter, and a maximum-row guard before execution.

Snippet details

ContextDatabaseLevelPracticalCopy-and-paste statusMarked safe after review.

Categories

  • Security

Code

<?php

/*
 * Copyright (c) 2026 Jeffery L. Paris <jparis@phpog.com>.
 * Free for personal and internal use. Paid project use requires visible credit
 * to Jeffery L. Paris. Corporate use requires a paid license fee unless a
 * separate written license states otherwise.
 */

/**
 * Safe Bulk Update Planner.
 *
 * Purpose:
 * Plans a guarded bulk update without executing it.
 *
 * @param string $table_name Table planned for update.
 * @param array $allowed_columns Columns approved for changes and filters.
 * @param array $filter Required filter columns and values.
 * @param array $changes Columns and values to update.
 * @param int $maximum_rows Maximum rows allowed for the write step.
 * @return array Bulk update plan with count SQL, update SQL, values, and refusal reasons.
 */
function ogSnippetSafeBulkUpdatePlanner(string $table_name, array $allowed_columns, array $filter, array $changes, int $maximum_rows): array {
	$table_name = trim($table_name);
	$allowed_map = array();
	$set_parts = array();
	$where_parts = array();
	$values = array();
	$reasons = array();

	if (preg_match('/^[a-zA-Z0-9_]+$/', $table_name) !== 1) {
		$reasons[] = 'Table name is not an approved identifier.';
	}

	if ($maximum_rows < 1) {
		$reasons[] = 'Maximum row guard must be at least one.';
	}

	foreach ($allowed_columns as $column_name) {
		$column_name = trim((string) $column_name);

		if (preg_match('/^[a-zA-Z0-9_]+$/', $column_name) === 1) {
			$allowed_map[$column_name] = true;
		}
	}

	foreach ($changes as $column_name => $value) {
		$column_name = trim((string) $column_name);

		if (isset($allowed_map[$column_name]) === false) {
			$reasons[] = 'Change column not allowed: '.$column_name;
			continue;
		}

		$set_parts[] = '`'.$column_name.'` = ?';
		$values[] = $value;
	}

	foreach ($filter as $column_name => $value) {
		$column_name = trim((string) $column_name);

		if (isset($allowed_map[$column_name]) === false) {
			$reasons[] = 'Filter column not allowed: '.$column_name;
			continue;
		}

		$where_parts[] = '`'.$column_name.'` = ?';
		$values[] = $value;
	}

	if (count($set_parts) === 0) {
		$reasons[] = 'No approved update columns were provided.';
	}

	if (count($where_parts) === 0) {
		$reasons[] = 'Bulk update requires a WHERE filter.';
	}

	if (count($reasons) > 0) {
		return array(
			'approved' => false,
			'count_sql' => '',
			'update_sql' => '',
			'values' => array(),
			'maximum_rows' => $maximum_rows,
			'reasons' => $reasons
		);
	}

	$where_sql = implode(' AND ', $where_parts);
	$count_sql = 'SELECT COUNT(*) AS affected_preview FROM `'.$table_name.'` WHERE '.$where_sql;
	$update_sql = 'UPDATE `'.$table_name.'` SET '.implode(', ', $set_parts).' WHERE '.$where_sql.' LIMIT '.(int) $maximum_rows;

	return array(
		'approved' => true,
		'count_sql' => $count_sql,
		'update_sql' => $update_sql,
		'values' => $values,
		'maximum_rows' => $maximum_rows,
		'reasons' => array()
	);
}

$bulk_plan = ogSnippetSafeBulkUpdatePlanner(
	'items',
	array('status', 'category_slug'),
	array('category_slug' => 'expanse-tools'),
	array('status' => 'draft'),
	25
);

echo 'Bulk plan approved: '.(int) $bulk_plan['approved'];