raw Software

Reading rows in a defined order is straightforward: store a position and use it in ORDER BY. The more interesting problem appears when a user rearranges an entire list in the browser and the server must persist the submitted sequence efficiently and safely.

MySQL 8 can turn a JSON array into rows with JSON_TABLE(). That makes it possible to validate and apply the complete order with one set-based UPDATE, without constructing SQL from comma-separated input or sending one statement per item.

Model the Stored Order

Each entry belongs to one user and carries a zero-based position:

CREATE TABLE entry (
    id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    user_id BIGINT UNSIGNED NOT NULL,
    title VARCHAR(200) NOT NULL,
    sort_position INT UNSIGNED NOT NULL,
    PRIMARY KEY (id),
    KEY idx_entry_user_order (user_id, sort_position, id)
);

The composite index supports both ownership filtering and ordered retrieval. Including id as the final key gives equal positions a deterministic order:

SELECT id, title
FROM entry
WHERE user_id = 5
ORDER BY sort_position, id;

Suppose that query currently yields these identifiers:

[315, 321, 318, 320, 300]

After dragging an item in the interface, the client submits the desired order as a JSON array:

[315, 318, 321, 320, 300]

Turn the Array into Rows

JSON_TABLE() produces one relational row per array element. FOR ORDINALITY numbers those rows from one, so subtracting one gives the stored zero-based position:

SET @new_order = JSON_ARRAY(315, 318, 321, 320, 300);

SELECT
    requested.ordinality - 1 AS sort_position,
    requested.entry_id
FROM JSON_TABLE(
    @new_order,
    '$[*]' COLUMNS (
        ordinality FOR ORDINALITY,
        entry_id BIGINT UNSIGNED PATH '$' ERROR ON ERROR
    )
) AS requested;

The result is a temporary two-column relation:

sort_position  entry_id
0              315
1              318
2              321
3              320
4              300

Apply a Validated Order

A reorder request must satisfy three conditions before it changes anything:

The following statement checks all three conditions and updates the list only when they hold:

SET @user_id = 5;
SET @new_order = JSON_ARRAY(315, 318, 321, 320, 300);

WITH
requested AS (
    SELECT
        item.ordinality - 1 AS sort_position,
        item.entry_id
    FROM JSON_TABLE(
        @new_order,
        '$[*]' COLUMNS (
            ordinality FOR ORDINALITY,
            entry_id BIGINT UNSIGNED PATH '$' ERROR ON ERROR
        )
    ) AS item
),
valid_request AS (
    SELECT 1 AS valid
    FROM requested AS r
    LEFT JOIN entry AS owned
        ON owned.user_id = @user_id
       AND owned.id = r.entry_id
    HAVING COUNT(*) = COUNT(DISTINCT r.entry_id)
       AND COUNT(*) = COUNT(owned.id)
         AND JSON_TYPE(@new_order) = 'ARRAY'
       AND COUNT(*) = (
           SELECT COUNT(*)
           FROM entry
           WHERE user_id = @user_id
       )
)
UPDATE entry AS e
JOIN requested AS r ON r.entry_id = e.id
JOIN valid_request AS validation ON validation.valid = 1
SET e.sort_position = r.sort_position
WHERE e.user_id = @user_id;

The common table expression does not trust the client to describe the current list correctly. A non-array value, a duplicate, an unknown identifier, an entry owned by another user, or an omitted entry makes valid_request empty, so the update touches no rows. The ownership predicate also remains on the target table as a final authorization boundary.

The statement is atomic: other sessions see either the old positions or the new positions, not a partly reordered list. If inserts, deletions, and reorder operations may run concurrently for the same list, serialize them by locking the owning list or user row in a transaction, or reject stale requests with an optimistic version number.

Bind the JSON Value

In production code, replace @new_order and @user_id with parameter markers. Serialize the application array as JSON and bind it as one value. Do not interpolate identifiers into the SQL string:

$order = json_decode($requestBody, true, flags: JSON_THROW_ON_ERROR);

if (!array_is_list($order)) {
    throw new InvalidArgumentException('Expected a JSON array.');
}

$statement = $pdo->prepare($reorderSql);
$statement->execute([
    'new_order' => json_encode($order, JSON_THROW_ON_ERROR),
    'owner_for_join' => $authenticatedUserId,
    'owner_for_count' => $authenticatedUserId,
    'owner_for_update' => $authenticatedUserId,
]);

Here, $reorderSql contains :new_order in place of the JSON variable and the three distinct owner placeholders in place of the corresponding @user_id occurrences. Distinct names remain compatible with native PDO prepares. Parsing each JSON element as BIGINT UNSIGNED rejects values that cannot be converted as requested, while bound parameters prevent SQL injection. The database checks ownership and completeness independently of the client-side shape check.

Why Not Build FIELD() from CSV?

A compact legacy pattern uses FIELD(id, ...) and inserts a comma-separated list directly into the statement. It has several problems:

FIELD() remains convenient for a small, trusted, one-off ordering expression. For persisted client input, a JSON value and a relational join preserve the boundary between data and SQL.

Statement Count Is Not Time Complexity

Replacing a client loop with one update removes $n$ database round trips, but it does not make the operation $O(1)$. The server still parses $n$ identifiers and updates $n$ rows, so the work remains $O(n)$. The improvement is one request, one atomic statement, and set-based execution.

For short user-managed lists, rewriting every position is usually the simplest correct design. Very large lists that change one item at a time may benefit from sparse or fractional position keys, followed by occasional normalization. That trades simpler writes for more involved key management and concurrency rules.

References