'post_author', 'comments' => 'user_id', 'links' => 'link_owner', 'wc_orders' => 'customer_id', 'wc_customer_lookup' => 'user_id', ]; /** postmeta keys whose value is a user id. */ public const META_KEYS = ['_edit_last', '_customer_user']; private Db $db; private Scope $scope; private UserJournal $journal; public function __construct(Db $db, Scope $scope, UserJournal $journal) { $this->db = $db; $this->scope = $scope; $this->journal = $journal; } /** * Merge the users in $users / $usermeta (shadow tables) into the network. * * @return array{map: array, merged: array[], renamed: array[], created: int, removed: string[]} */ public function run(string $users, string $usermeta): array { $wpdb = $this->db->wpdb(); $out = ['map' => [], 'merged' => [], 'renamed' => [], 'created' => 0, 'removed' => []]; $super = array_values(array_filter((array) get_super_admins(), 'is_string')); foreach ($this->db->results('SELECT * FROM ' . Db::ident($users) . ' ORDER BY ID') as $u) { $old = (int) $u->ID; $email = trim((string) $u->user_email); $found = $email !== '' ? (int) $wpdb->get_var($wpdb->prepare("SELECT ID FROM {$wpdb->users} WHERE LOWER(user_email) = LOWER(%s) ORDER BY ID LIMIT 1", $email)) : 0; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- one-off work while a site joins a network; nothing to cache if ($found > 0) { $out['map'][$old] = $found; $out['merged'][] = ['login' => (string) $u->user_login, 'as' => (string) $wpdb->get_var($wpdb->prepare("SELECT user_login FROM {$wpdb->users} WHERE ID = %d", $found))]; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- one-off work while a site joins a network; nothing to cache continue; } $login = $this->free('user_login', (string) $u->user_login); if ($login !== (string) $u->user_login) { $out['renamed'][] = ['login' => (string) $u->user_login, 'as' => $login]; } $new = $this->db->insert($wpdb->users, [ 'user_login' => $login, 'user_pass' => (string) $u->user_pass, 'user_nicename' => $this->free('user_nicename', (string) ($u->user_nicename !== '' ? $u->user_nicename : sanitize_title($login))), 'user_email' => $email, 'user_url' => (string) $u->user_url, 'user_registered' => (string) $u->user_registered, 'user_activation_key' => '', 'user_status' => (int) $u->user_status, 'display_name' => (string) $u->display_name, ]); $this->journal->created_user($new); $out['map'][$old] = $new; $out['created']++; // Their own profile (names, settings) comes along; keys of sites and of the network do not. foreach ($this->db->results($wpdb->prepare('SELECT meta_key, meta_value FROM ' . Db::ident($usermeta) . ' WHERE user_id = %d', $old)) as $m) { // phpcs:ignore WordPress.DB.PreparedSQL -- table names quoted by Db::ident(), values prepared $key = (string) $m->meta_key; if (!$this->site_key($key) && strpos($key, $this->scope->base_prefix) !== 0 && !in_array($key, ['primary_blog', 'source_domain'], true)) { $this->db->insert($wpdb->usermeta, ['user_id' => $new, 'meta_key' => $key, 'meta_value' => (string) $m->meta_value]); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- one-off work while a site joins a network; nothing to cache } } $this->db->insert($wpdb->usermeta, ['user_id' => $new, 'meta_key' => 'primary_blog', 'meta_value' => (string) $this->scope->blog_id]); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- one-off work while a site joins a network; nothing to cache $this->db->insert($wpdb->usermeta, ['user_id' => $new, 'meta_key' => 'source_domain', 'meta_value' => (string) wp_parse_url($this->scope->home_url, PHP_URL_HOST)]); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- one-off work while a site joins a network; nothing to cache } // Replacing the subsite: people with a role on it who don't come along lose it (super admins keep theirs). $caps = $this->scope->prefix . 'capabilities'; $targets = array_values($out['map']); foreach ((array) $wpdb->get_col($wpdb->prepare("SELECT user_id FROM {$wpdb->usermeta} WHERE meta_key = %s", $caps)) as $user_id) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- one-off work while a site joins a network; nothing to cache $user_id = (int) $user_id; $login = (string) $wpdb->get_var($wpdb->prepare("SELECT user_login FROM {$wpdb->users} WHERE ID = %d", $user_id)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- one-off work while a site joins a network; nothing to cache if (in_array($user_id, $targets, true) || in_array($login, $super, true)) { continue; } foreach ($this->site_rows($user_id) as $row) { $this->journal->removed_meta((int) $row->umeta_id, $user_id, (string) $row->meta_key, (string) $row->meta_value); $this->db->query($wpdb->prepare("DELETE FROM {$wpdb->usermeta} WHERE umeta_id = %d", (int) $row->umeta_id)); } $out['removed'][] = $login; } // The incoming site's keys (roles, per-site settings) for each account. foreach ($out['map'] as $old => $new) { foreach ($this->db->results($wpdb->prepare('SELECT meta_key, meta_value FROM ' . Db::ident($usermeta) . ' WHERE user_id = %d', $old)) as $m) { // phpcs:ignore WordPress.DB.PreparedSQL -- table names quoted by Db::ident(), values prepared if ($this->site_key((string) $m->meta_key)) { $this->set_meta($new, (string) $m->meta_key, (string) $m->meta_value); } } clean_user_cache($new); } return $out; } /** * What run() would do with these incoming people ({login, email}), changing nothing (spec 2026-10-16 §5.5): who * is merged with which account, who is renamed, how many accounts are new and — replacing an existing subsite * ($scope; null for a new one) — who loses their role on it. Logins taken by earlier newcomers count as taken. * * @param array[] $incoming * @return array{merged: array[], renamed: array[], created: int, removed: string[]} */ public static function preview(Db $db, ?Scope $scope, array $incoming): array { $wpdb = $db->wpdb(); $out = ['merged' => [], 'renamed' => [], 'created' => 0, 'removed' => []]; $targets = []; $taken = []; foreach ($incoming as $u) { $login = is_string($u['login'] ?? null) ? $u['login'] : ''; $email = is_string($u['email'] ?? null) ? trim($u['email']) : ''; if ($login === '') { continue; } $found = $email !== '' ? $wpdb->get_row($wpdb->prepare("SELECT ID, user_login FROM {$wpdb->users} WHERE LOWER(user_email) = LOWER(%s) ORDER BY ID LIMIT 1", $email)) : null; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- one-off work while a site joins a network; nothing to cache if ($found !== null) { $targets[] = (int) $found->ID; $out['merged'][] = ['login' => $login, 'as' => (string) $found->user_login]; continue; } $free = $login; for ($i = 2; isset($taken[$free]) || $wpdb->get_var($wpdb->prepare("SELECT 1 FROM {$wpdb->users} WHERE user_login = %s LIMIT 1", $free)); $i++) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- one-off work while a site joins a network; nothing to cache $free = mb_substr($login, 0, 60 - strlen('-' . $i)) . '-' . $i; } $taken[$free] = true; if ($free !== $login) { $out['renamed'][] = ['login' => $login, 'as' => $free]; } $out['created']++; } if ($scope !== null) { $super = array_values(array_filter((array) get_super_admins(), 'is_string')); foreach ($db->results($wpdb->prepare("SELECT u.ID, u.user_login FROM {$wpdb->users} u JOIN {$wpdb->usermeta} m ON m.user_id = u.ID WHERE m.meta_key = %s ORDER BY u.user_login", $scope->prefix . 'capabilities')) as $r) { if (!in_array((int) $r->ID, $targets, true) && !in_array((string) $r->user_login, $super, true)) { $out['removed'][] = (string) $r->user_login; } } } return $out; } /** The (empty) table remap() fills with old → new ids; created before the merge's transaction, as DDL commits. */ public function map_table(string $tag): string { $m = Naming::shape($this->scope->base_prefix . 'usermap', $tag); $this->db->raw('DROP TABLE IF EXISTS ' . Db::ident($m)); $this->db->raw('CREATE TABLE ' . Db::ident($m) . ' (old_id bigint unsigned NOT NULL, new_id bigint unsigned NOT NULL, PRIMARY KEY (old_id))'); return $m; } /** * Change user ids in the incoming tables once, by joining a map of old → new ids: a row matched by one * old id gets its new id and is never matched again, so ids that swap (2 → 3, 3 → 2) don't collide. * * @param array $map * @param array $tables this site's table name (under the subsite prefix) => the table to change * @param string $m the table from map_table() */ public function remap(array $map, array $tables, string $m): void { $map = array_filter($map, static fn ($new, $old) => $new !== $old, ARRAY_FILTER_USE_BOTH); if ($map === []) { return; } $wpdb = $this->db->wpdb(); foreach (array_chunk($map, 500, true) as $chunk) { $values = []; foreach ($chunk as $old => $new) { $values[] = $wpdb->prepare('(%d, %d)', $old, $new); } $this->db->query('INSERT INTO ' . Db::ident($m) . ' VALUES ' . implode(',', $values)); } /** More columns that hold user ids: ['tables' => [suffix => column], 'meta_keys' => [postmeta keys]]. */ $extra = (array) apply_filters('relay_user_columns', ['tables' => [], 'meta_keys' => []]); $columns = array_merge(self::COLUMNS, (array) ($extra['tables'] ?? [])); $keys = array_values(array_unique(array_merge(self::META_KEYS, (array) ($extra['meta_keys'] ?? [])))); foreach ($tables as $name => $table) { $suffix = substr($name, strlen($this->scope->prefix)); if (isset($columns[$suffix])) { $col = Db::ident((string) $columns[$suffix]); $this->db->query('UPDATE ' . Db::ident($table) . ' t JOIN ' . Db::ident($m) . " m ON t.{$col} = m.old_id SET t.{$col} = m.new_id"); } if ($suffix === 'postmeta') { $in = implode(',', array_map(static fn ($k) => $wpdb->prepare('%s', $k), $keys)); $this->db->query('UPDATE ' . Db::ident($table) . ' t JOIN ' . Db::ident($m) . " m ON t.meta_value = CAST(m.old_id AS CHAR) SET t.meta_value = m.new_id WHERE t.meta_key IN ({$in})"); // _edit_lock is "time:user id" $this->db->query('UPDATE ' . Db::ident($table) . ' t JOIN ' . Db::ident($m) . " m ON SUBSTRING_INDEX(t.meta_value, ':', -1) = CAST(m.old_id AS CHAR) SET t.meta_value = CONCAT(SUBSTRING_INDEX(t.meta_value, ':', 1), ':', m.new_id) WHERE t.meta_key = '_edit_lock' AND t.meta_value LIKE '%:%'"); } } } /** A meta key that belongs to this subsite (its prefix; on the main site, the base prefix without a site number). */ public function site_key(string $key): bool { $s = $this->scope; if (strpos($key, $s->prefix) !== 0) { return false; } return !$s->is_main_site() || !preg_match('/^' . preg_quote($s->base_prefix, '/') . '[0-9]+_/', $key); } /** @return object[] the user's meta rows of this subsite */ private function site_rows(int $user_id): array { $wpdb = $this->db->wpdb(); $rows = $this->db->results($wpdb->prepare("SELECT umeta_id, meta_key, meta_value FROM {$wpdb->usermeta} WHERE user_id = %d AND meta_key LIKE %s", $user_id, $wpdb->esc_like($this->scope->prefix) . '%')); return array_values(array_filter($rows, fn ($r) => $this->site_key((string) $r->meta_key))); } private function set_meta(int $user_id, string $key, string $value): void { $wpdb = $this->db->wpdb(); $row = $wpdb->get_row($wpdb->prepare("SELECT umeta_id, meta_value FROM {$wpdb->usermeta} WHERE user_id = %d AND meta_key = %s ORDER BY umeta_id LIMIT 1", $user_id, $key)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- one-off work while a site joins a network; nothing to cache if ($row !== null) { if ((string) $row->meta_value !== $value) { $this->journal->changed_meta((int) $row->umeta_id, $user_id, $key, (string) $row->meta_value); $this->db->query($wpdb->prepare("UPDATE {$wpdb->usermeta} SET meta_value = %s WHERE umeta_id = %d", $value, (int) $row->umeta_id)); } return; } $this->journal->added_meta($this->db->insert($wpdb->usermeta, ['user_id' => $user_id, 'meta_key' => $key, 'meta_value' => $value]), $user_id); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- one-off work while a site joins a network; nothing to cache } /** $wanted, or the first of "$wanted-2", "$wanted-3", … no network account uses in $column. */ private function free(string $column, string $wanted): string { $wpdb = $this->db->wpdb(); $taken = static fn (string $v): bool => (bool) $wpdb->get_var($wpdb->prepare("SELECT 1 FROM {$wpdb->users} WHERE {$column} = %s LIMIT 1", $v)); // phpcs:ignore WordPress.DB.PreparedSQL, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- $column is user_login or user_nicename; one-off work while a site joins a network; nothing to cache if (!$taken($wanted)) { return $wanted; } for ($i = 2; ; $i++) { $candidate = mb_substr($wanted, 0, ($column === 'user_login' ? 60 : 50) - strlen('-' . $i)) . '-' . $i; if (!$taken($candidate)) { return $candidate; } } } }