*/ private array $tokens_by_roles_cache = array(); /** * Memoized count_tokens() result for this request. The step log puts it on * every notification's recipients line, and one request can carry thousands * of notifications. * * @var int|null */ private ?int $token_count = null; /** * Memoized has_tokens() result. Null until the first lookup, and reset by create() so a stale false cannot drop a notification. * * @var bool|null */ private ?bool $has_tokens = null; /** * Buffered last-send stamps awaiting a write, as token post ID => GMT * datetime. Holds the most recent time recorded for each token this * request. Flushed by {@see self::flush_last_sent_at()} on shutdown. * * @var array */ private array $pending_last_sent_at = array(); /** * Whether the shutdown flush for `$pending_last_sent_at` has been registered. * * @var bool */ private bool $last_sent_at_flush_registered = false; /** * How many tokens to write per statement when flushing last-send stamps. */ const LAST_SENT_AT_CHUNK_SIZE = 100; /** * Meta key holding the GMT datetime of the last successful send to WPCOM. * * Deliberately absent from `build_meta_array_from_token()`: it is written * only by `record_last_sent_at()`, so an unrelated token update (e.g. the app * re-registering with a new locale) cannot clobber it. */ const LAST_SENT_AT_META_KEY = 'last_sent_at_gmt'; const SUPPORTED_META = array( 'origin', 'device_uuid', 'token', 'platform', 'device_locale', 'metadata', self::LAST_SENT_AT_META_KEY, ); /** * Creates a post representing the push token. * * @since 10.5.0 * @param array $data Token data with keys: user_id, token, platform, device_uuid (optional), origin. * @throws PushTokenInvalidDataException If the token data is invalid. * @throws WC_Data_Exception If the token creation fails. * @return PushToken The created push token with ID set. */ public function create( array $data ): PushToken { $push_token = new PushToken( $data ); if ( ! $push_token->can_be_created() ) { throw new PushTokenInvalidDataException( 'Can\'t create push token because the push token data provided is invalid.' ); } $id = wp_insert_post( array( 'post_author' => (int) $push_token->get_user_id(), 'post_type' => PushToken::POST_TYPE, 'post_status' => 'private', 'meta_input' => $this->build_meta_array_from_token( $push_token ), ), true ); if ( is_wp_error( $id ) ) { // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped throw new WC_Data_Exception( (string) $id->get_error_code(), $id->get_error_message(), WP_Http::INTERNAL_SERVER_ERROR ); // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped } $push_token->set_id( $id ); $this->has_tokens = null; $this->token_count = null; return $push_token; } /** * Gets post representing a push token. * * @since 10.5.0 * @param int $id The push token ID. * @throws PushTokenInvalidDataException If the ID is invalid. * @throws PushTokenNotFoundException If the token can't be found. * @return PushToken The populated push token. */ public function read( int $id ): PushToken { $push_token = new PushToken( array( 'id' => $id ) ); $post = get_post( $push_token->get_id() ); if ( ! $post || PushToken::POST_TYPE !== $post->post_type ) { throw new PushTokenNotFoundException(); } $meta = $this->build_meta_array_from_database( (int) $push_token->get_id() ); if ( empty( $meta['token'] ) || empty( $meta['platform'] ) || empty( $meta['origin'] ) || ( empty( $meta['device_uuid'] ) && PushToken::PLATFORM_BROWSER !== $meta['platform'] ) ) { throw new PushTokenInvalidDataException( 'Can\'t read push token because the push token record is malformed.' ); } $push_token->set_user_id( (int) $post->post_author ); $push_token->set_token( $meta['token'] ); $push_token->set_device_uuid( $meta['device_uuid'] ?? null ); $push_token->set_platform( $meta['platform'] ); $push_token->set_origin( $meta['origin'] ); /** * These meta items were added after the ability to store tokens, so may * not be available for older tokens. Use sensible defaults. */ $push_token->set_device_locale( $meta['device_locale'] ?? PushToken::DEFAULT_DEVICE_LOCALE ); $push_token->set_metadata( $meta['metadata'] ?? array() ); $push_token->set_last_sent_at_gmt( $meta[ self::LAST_SENT_AT_META_KEY ] ?? null ); /** * Both timestamps come from the post record rather than meta, because * WordPress already maintains them. See {@see PushToken::$last_confirmed_at_gmt} * for what `post_modified_gmt` means for a push token. */ $push_token->set_created_at_gmt( $post->post_date_gmt ); $push_token->set_last_confirmed_at_gmt( $post->post_modified_gmt ); return $push_token; } /** * Updates a post representing the push token. * * @since 10.5.0 * @param PushToken $push_token The push token to update. * @throws PushTokenInvalidDataException If the token can't be updated. * @throws WC_Data_Exception If the token update fails. * @return bool True on success. */ public function update( PushToken $push_token ): bool { if ( ! $push_token->can_be_updated() ) { throw new PushTokenInvalidDataException( 'Can\'t update push token because the push token data provided is invalid.' ); } $result = wp_update_post( array( 'ID' => (int) $push_token->get_id(), 'post_author' => (int) $push_token->get_user_id(), 'post_type' => PushToken::POST_TYPE, 'post_status' => 'private', 'meta_input' => $this->build_meta_array_from_token( $push_token ), ), true ); if ( is_wp_error( $result ) ) { // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped throw new WC_Data_Exception( (string) $result->get_error_code(), $result->get_error_message(), WP_Http::INTERNAL_SERVER_ERROR ); // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped } if ( null === $push_token->get_device_uuid() ) { delete_post_meta( (int) $push_token->get_id(), 'device_uuid' ); } return true; } /** * Deletes a push token. * * @since 10.5.0 * @param int $id The push token ID. * @throws PushTokenNotFoundException If the token can't be found. * @return bool True on success. */ public function delete( int $id ): bool { $post = get_post( $id ); if ( ! $post || PushToken::POST_TYPE !== $post->post_type ) { throw new PushTokenNotFoundException(); } return (bool) wp_delete_post( (int) $id, true ); } /** * Deletes every push token belonging to a user. * * A non-positive ID is refused rather than queried, so a caller that loses * the user ID cannot match the rows of every author-less token at once. * * @since 11.2.0 * @param int $user_id The user whose tokens should be deleted. * @return int The number of tokens deleted. */ public function delete_for_user( int $user_id ): int { if ( $user_id < 1 ) { return 0; } global $wpdb; // Direct query so pre_get_posts filters cannot hide a token, and any status is deleted. $post_ids = $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s AND post_author = %d", PushToken::POST_TYPE, $user_id ) ); if ( empty( $post_ids ) ) { return 0; } $deleted = 0; foreach ( $post_ids as $post_id ) { if ( wp_delete_post( (int) $post_id, true ) ) { ++$deleted; } } // Anything read earlier in this request now includes deleted tokens. $this->tokens_by_roles_cache = array(); $this->token_count = null; return $deleted; } /** * Find tokens for this user and platform that match either the token * or device UUID. We check the token value to avoid creating a duplicate. * We check the device UUID value because only one token should be issued * per device, therefore if we already have one then we can update it to * avoid creating a duplicate. * * @since 10.5.0 * @param array $data Token data with keys: user_id, platform, origin, token (optional), device_uuid (optional). * @return null|PushToken * @throws PushTokenInvalidDataException If push token is missing data. */ public function get_by_token_or_device_id( array $data ): ?PushToken { $user_id = $data['user_id'] ?? null; $platform = $data['platform'] ?? null; $origin = $data['origin'] ?? null; $token = $data['token'] ?? null; $device_uuid = $data['device_uuid'] ?? null; if ( ! $user_id || ! $platform || ! $origin || ( /** * Platforms iOS and Android require token OR device UUID. */ PushToken::PLATFORM_BROWSER !== $platform && ! $token && ! $device_uuid ) || ( /** * Browsers don't have device UUIDs, so require token. */ PushToken::PLATFORM_BROWSER === $platform && ! $token ) ) { throw new PushTokenInvalidDataException( 'Can\'t retrieve push token because the push token data provided is invalid.' ); } $query = new WP_Query( array( 'post_type' => PushToken::POST_TYPE, 'post_status' => 'private', 'author' => $user_id, 'posts_per_page' => -1, 'orderby' => 'ID', 'order' => 'DESC', 'fields' => 'ids', ) ); /** * Typehint for PHPStan, specifies these are IDs and not instances of * WP_Post. * * @var int[] $post_ids */ $post_ids = $query->posts; if ( empty( $post_ids ) ) { return null; } update_meta_cache( 'post', $post_ids ); foreach ( $post_ids as $post_id ) { try { $meta = $this->build_meta_array_from_database( $post_id ); } catch ( Exception $e ) { wc_get_logger()->warning( 'Failed to load meta for push token.', array( 'source' => PushNotifications::FEATURE_NAME, 'token_id' => $post_id, 'error' => $e->getMessage(), ) ); continue; } if ( $meta['platform'] === $platform && $meta['origin'] === $origin && ( ( $token && $token === $meta['token'] ) || ( $device_uuid && $device_uuid === $meta['device_uuid'] ) ) ) { return new PushToken( array( 'id' => $post_id, 'user_id' => $user_id, 'token' => $meta['token'], 'device_uuid' => $meta['device_uuid'] ?? null, 'platform' => $meta['platform'], 'origin' => $meta['origin'], /** * These meta items were added after the ability to store * tokens, so may not be available for older tokens. Use * sensible defaults. */ 'device_locale' => $meta['device_locale'] ?? PushToken::DEFAULT_DEVICE_LOCALE, 'metadata' => $meta['metadata'] ?? array(), 'last_sent_at_gmt' => $meta[ self::LAST_SENT_AT_META_KEY ] ?? null, ) ); } } return null; } /** * Determines whether any push token exists, ignoring roles and preferences so callers can bail out before the cost of get_tokens_for_roles(). * * @since 11.2.0 * @return bool True if at least one push token exists. */ public function has_tokens(): bool { if ( null !== $this->has_tokens ) { return $this->has_tokens; } global $wpdb; // Exactly this SQL to leverage the wp_posts type_status_author index. $this->has_tokens = (bool) $wpdb->get_var( $wpdb->prepare( "SELECT 1 FROM {$wpdb->posts} WHERE post_type = %s AND post_status = 'private' LIMIT 1", PushToken::POST_TYPE ) ); return $this->has_tokens; } /** * Counts every push token on the store, whatever the owner's role. * * Registration requires one of the roles that receive push notifications, * so no token starts out ineligible. A user can lose the role afterwards * though, and their token stays, which is the difference between this count * and what get_tokens_for_roles() returns. * * @since 11.3.0 * @return int */ public function count_tokens(): int { if ( null !== $this->token_count ) { return $this->token_count; } global $wpdb; $this->token_count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = %s AND post_status = 'private'", PushToken::POST_TYPE ) ); return $this->token_count; } /** * Returns the IDs of the push tokens a user owns, most recently registered * first. * * Capped, because the step log read tests every line against every ID * returned. Re-registering a device updates its post, so ordering by * modification time keeps the devices still in use and drops the ones * abandoned longest ago. * * @since 11.3.0 * @param int $user_id The user. * @param int $limit The most IDs to return. * @return int[] */ public function get_token_ids_for_user( int $user_id, int $limit = 100 ): array { global $wpdb; return array_map( 'intval', $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s AND post_status = 'private' AND post_author = %d ORDER BY post_modified DESC LIMIT %d", PushToken::POST_TYPE, $user_id, $limit ) ) ); } /** * Returns push tokens belonging to users with the given roles. * * When called without pagination parameters, returns all tokens as a * flat array (cached per-request), most recently registered first. When * $page and $per_page are provided, returns a paginated result with total * counts, ordered by ID so a re-registration cannot move a token between * pages. * * The eligible-user lookup is restricted to users that actually own * push tokens, so the role check runs against a handful of IDs instead * of scanning every user's capabilities meta, which does not scale on * sites with very large user tables. * * @param string[] $roles The roles to query tokens for. * @param int|null $page Optional page number (1-based). * @param int|null $per_page Optional number of tokens per page. * @param array $filters Optional exact-match filters: `user_id` (int) and `device_uuid` (string). * @phpstan-param array{user_id?: int|null, device_uuid?: string|null} $filters * @return PushToken[]|array{tokens: PushToken[], total: int, total_pages: int} * * @since 10.7.0 */ public function get_tokens_for_roles( array $roles, ?int $page = null, ?int $per_page = null, array $filters = array() ) { $paginate = null !== $page && null !== $per_page; $user_id = empty( $filters['user_id'] ) ? null : (int) $filters['user_id']; $device_uuid = empty( $filters['device_uuid'] ) ? null : (string) $filters['device_uuid']; $cache_key = implode( ',', $roles ) . ":$page:$per_page:$user_id:$device_uuid"; $empty_result = $paginate ? array( 'tokens' => array(), 'total' => 0, 'total_pages' => 0, ) : array(); if ( empty( $roles ) ) { return $empty_result; } if ( isset( $this->tokens_by_roles_cache[ $cache_key ] ) ) { return $this->tokens_by_roles_cache[ $cache_key ]; } global $wpdb; // Exactly this SQL to leverage the wp_posts type_status_author index; low token cardinality keeps it fast at any store size. $sql = "SELECT DISTINCT post_author FROM {$wpdb->posts} WHERE post_type = %s AND post_status = 'private'"; $args = array( PushToken::POST_TYPE ); if ( null !== $user_id ) { $sql .= ' AND post_author = %d'; $args[] = $user_id; } // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $sql is built from literals above and every value goes through a placeholder. $users_with_tokens = $wpdb->get_col( $wpdb->prepare( $sql, ...$args ) ); // An empty include must short-circuit: WP_User_Query would ignore it and scan all users by role. $user_ids = empty( $users_with_tokens ) ? array() : get_users( array( 'role__in' => $roles, 'fields' => 'ID', 'include' => $users_with_tokens, ) ); if ( empty( $user_ids ) ) { $this->tokens_by_roles_cache[ $cache_key ] = $empty_result; return $this->tokens_by_roles_cache[ $cache_key ]; } $query_args = array( 'post_type' => PushToken::POST_TYPE, 'post_status' => 'private', 'author__in' => $user_ids, 'posts_per_page' => $paginate ? $per_page : -1, 'fields' => 'ids', ); if ( $paginate ) { $query_args['paged'] = $page; $query_args['orderby'] = 'ID'; $query_args['order'] = 'ASC'; } if ( null !== $device_uuid ) { // Bounded by author__in, so the meta join only sees this store's own tokens. // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query $query_args['meta_query'] = array( array( 'key' => 'device_uuid', 'value' => $device_uuid, ), ); } $query = new WP_Query( $query_args ); /** * Typehint for PHPStan, specifies these are IDs and not instances of * WP_Post. * * @var int[] $post_ids */ $post_ids = $query->posts; if ( empty( $post_ids ) ) { $result = $empty_result; // WP_Query skips counting when a page comes back empty, so a page past the end would otherwise report no matches. if ( $paginate && $page > 1 ) { $count_query = new WP_Query( array_merge( $query_args, array( 'paged' => 1, 'posts_per_page' => 1, ) ) ); $result['total'] = (int) $count_query->found_posts; $result['total_pages'] = (int) ceil( $result['total'] / max( 1, $per_page ) ); } $this->tokens_by_roles_cache[ $cache_key ] = $result; return $result; } _prime_post_caches( $post_ids, false, true ); $tokens = array(); foreach ( $post_ids as $post_id ) { try { $tokens[] = $this->read( (int) $post_id ); } catch ( WC_Data_Exception $e ) { wc_get_logger()->warning( 'Skipping malformed push token during role-based query.', array( 'source' => PushNotifications::FEATURE_NAME, 'token_id' => $post_id, 'error' => $e->getMessage(), ) ); } } if ( ! $paginate ) { /** * Sorted on the GMT date because WP_Query can only order by the local * `post_modified`, which runs backwards across a daylight saving change. */ usort( $tokens, fn ( PushToken $a, PushToken $b ) => array( $b->get_last_confirmed_at_gmt(), $b->get_id() ) <=> array( $a->get_last_confirmed_at_gmt(), $a->get_id() ) ); } $result = $paginate ? array( 'tokens' => $tokens, 'total' => (int) $query->found_posts, 'total_pages' => (int) $query->max_num_pages, ) : $tokens; $this->tokens_by_roles_cache[ $cache_key ] = $result; return $result; } /** * Records that the given tokens were successfully sent to WPCOM. * * Buffers the stamps and writes them once on shutdown rather than per call. * A single request often processes several notifications — the loopback * receives every notification a store event produced, and a bulk order * update can produce dozens — and each one would otherwise repeat the same * write against the same handful of tokens. Buffering makes the cost a * function of the request rather than of the notification count, while each * token still keeps the exact time of its own most recent send. * * @since 11.2.0 * * @param PushToken[] $push_tokens The tokens WPCOM accepted. * @return void */ public function record_last_sent_at( array $push_tokens ): void { $timestamp = gmdate( 'Y-m-d H:i:s' ); foreach ( $push_tokens as $push_token ) { $id = $push_token->get_id(); if ( $id ) { $this->pending_last_sent_at[ $id ] = $timestamp; } } if ( empty( $this->pending_last_sent_at ) || $this->last_sent_at_flush_registered ) { return; } add_action( 'shutdown', array( $this, 'flush_last_sent_at' ) ); // The safety net and retry jobs run under an Action Scheduler queue // runner, which is routinely killed on a time limit. Shutdown functions // do not run on a kill, so flush after each action as well. add_action( 'action_scheduler_after_execute', array( $this, 'flush_last_sent_at' ) ); $this->last_sent_at_flush_registered = true; } /** * Writes the buffered last-send stamps. * * Runs on shutdown and after each Action Scheduler action, and is safe to * call directly to force the write early. * * Failure is swallowed: not knowing when a token was last used is a * diagnostic gap, and must never turn a delivered notification into a * failed one. * * @since 11.2.0 * * @return void */ public function flush_last_sent_at(): void { // Lets a later `record_last_sent_at()` re-assert the Action Scheduler // hook, which fires once per action. It cannot re-arm `shutdown`: // WP_Hook iterates a copy of its callbacks, so a stamp recorded during // shutdown after this ran is dropped. No path does that today. $this->last_sent_at_flush_registered = false; if ( empty( $this->pending_last_sent_at ) ) { return; } $pending = $this->pending_last_sent_at; $this->pending_last_sent_at = array(); // Chunked so neither the `IN` list nor the number of placeholders handed // to `$wpdb->prepare()` grows with the number of registered devices. A // large `IN` list can also push the optimizer off the `post_id` index. foreach ( array_chunk( $pending, self::LAST_SENT_AT_CHUNK_SIZE, true ) as $chunk ) { try { $this->write_last_sent_at_chunk( $chunk ); } catch ( Throwable $e ) { // Throwable, not Exception. `action_scheduler_after_execute` // fires between the action running and `mark_complete()`, inside // the runner's own Throwable catch, so an Error escaping here // would record a delivered notification's action as failed. $this->warn_last_sent_at_not_recorded( array_keys( $chunk ), $e->getMessage() ); } } } /** * Writes one chunk of buffered last-send stamps. * * Existing rows are updated in place rather than deleted and reinserted. * This is the busiest write path in the feature, and a delete/insert cycle * on the same rows consumes `meta_id` values permanently and fragments the * primary key, for no benefit — `wp_postmeta` has no unique key on * `(post_id, meta_key)`, so the rows to update have to be identified first * either way. * * @param array $chunk Map of token post ID to GMT datetime. * @return void */ private function write_last_sent_at_chunk( array $chunk ): void { global $wpdb; $post_ids = array_keys( $chunk ); /** * The statements below interpolate a placeholder list whose length * depends on the number of tokens. The interpolated strings are built * from literals only — never from token data — and every value still * travels through `$wpdb->prepare()`, which is why the sniffs are * suppressed rather than the queries being restructured. */ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $select = $wpdb->prepare( sprintf( "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = %%s AND post_id IN ( %s )", implode( ', ', array_fill( 0, count( $post_ids ), '%d' ) ) ), array_merge( array( self::LAST_SENT_AT_META_KEY ), $post_ids ) ); // Checked before the read, not after: a null here would leave // `last_result` holding whatever ran previously. if ( ! is_string( $select ) || '' === $select ) { $this->warn_last_sent_at_not_recorded( $post_ids, 'Could not build the query to read existing stamps, skipping chunk.' ); return; } // Run the statement rather than using `get_col()`, whose empty array // means both "nothing matched" and "the read failed". Taking a failed // read as "no rows exist" would insert duplicates that `wp_postmeta` // has no unique key to prevent. `last_error` alone cannot be trusted // either: `wpdb::query()` returns false before it clears the previous // error when the `query` filter empties the statement. $rows = $wpdb->query( $select ); if ( false === $rows ) { $this->warn_last_sent_at_not_recorded( $post_ids, sprintf( 'Could not read existing stamps, skipping chunk. %s', '' !== $wpdb->last_error ? $wpdb->last_error : 'The query did not run.' ) ); return; } $existing = array_map( 'intval', wp_list_pluck( (array) $wpdb->last_result, 'post_id' ) ); // Tokens sent at the same moment share an UPDATE, so this is usually // one query, without giving a token another's send time when a request // spans a second boundary. $by_timestamp = array(); foreach ( $chunk as $post_id => $timestamp ) { $by_timestamp[ $timestamp ][] = $post_id; } foreach ( $by_timestamp as $timestamp => $ids ) { $update_ids = array_values( array_intersect( $ids, $existing ) ); $insert_ids = array_values( array_diff( $ids, $existing ) ); if ( ! empty( $update_ids ) ) { // Advance-only. Each request captures its timestamp when it // dispatches and writes it on shutdown, so a slow request can // reach this after a later one has already written a newer // value. `Y-m-d H:i:s` compares lexicographically in date // order, and a zero-row match returns 0 rather than false. $update = $wpdb->prepare( sprintf( "UPDATE {$wpdb->postmeta} SET meta_value = %%s WHERE meta_key = %%s AND meta_value < %%s AND post_id IN ( %s )", implode( ', ', array_fill( 0, count( $update_ids ), '%d' ) ) ), array_merge( array( $timestamp, self::LAST_SENT_AT_META_KEY, $timestamp ), $update_ids ) ); if ( ! is_string( $update ) || '' === $update ) { $this->warn_last_sent_at_not_recorded( $update_ids, 'Could not build the update statement.' ); } elseif ( false === $wpdb->query( $update ) ) { $this->warn_last_sent_at_not_recorded( $update_ids, $wpdb->last_error ); } } if ( ! empty( $insert_ids ) ) { $insert_args = array(); foreach ( $insert_ids as $post_id ) { $insert_args[] = $post_id; $insert_args[] = self::LAST_SENT_AT_META_KEY; $insert_args[] = $timestamp; } $insert = $wpdb->prepare( sprintf( "INSERT INTO {$wpdb->postmeta} ( post_id, meta_key, meta_value ) VALUES %s", implode( ', ', array_fill( 0, count( $insert_ids ), '( %d, %s, %s )' ) ) ), $insert_args ); if ( ! is_string( $insert ) || '' === $insert ) { $this->warn_last_sent_at_not_recorded( $insert_ids, 'Could not build the insert statement.' ); } elseif ( false === $wpdb->query( $insert ) ) { $this->warn_last_sent_at_not_recorded( $insert_ids, $wpdb->last_error ); } } } // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching // The rows were written behind the meta API's back, so the cached meta // for these posts is now stale and must be dropped. wp_cache_delete_multiple( $post_ids, 'post_meta' ); } /** * Logs that last-send stamps could not be recorded. * * @param int[] $post_ids The token post IDs affected. * @param string $error What went wrong, and what was skipped as a result. * @return void */ private function warn_last_sent_at_not_recorded( array $post_ids, string $error ): void { wc_get_logger()->warning( 'Could not record last sent time for push tokens.', array( 'source' => PushNotifications::FEATURE_NAME, 'token_count' => count( $post_ids ), 'error' => $error, ) ); } /** * Returns an associative array of post meta as key => value pairs for the * keys defined in SUPPORTED_META; missing keys return null. Use * `update_meta_cache` with `get_post_meta` to allow reading the meta as * single values which automatically unserialize when requires, * rather than nested arrays that don't. * * @since 10.5.0 * @param int $id The push token ID. * @return array */ private function build_meta_array_from_database( int $id ): array { $meta_by_key = array_fill_keys( static::SUPPORTED_META, null ); foreach ( static::SUPPORTED_META as $key ) { $meta = get_post_meta( $id, $key, true ); if ( '' !== $meta ) { $meta_by_key[ $key ] = $meta; } } return $meta_by_key; } /** * Returns an associative array of post meta as key => value pairs, built * using push token properties. * * @since 10.5.0 * @param PushToken $push_token An instance of PushToken. * @return array */ private function build_meta_array_from_token( PushToken $push_token ) { return array_filter( array( 'platform' => $push_token->get_platform(), 'token' => $push_token->get_token(), 'device_uuid' => $push_token->get_device_uuid(), 'origin' => $push_token->get_origin(), 'device_locale' => $push_token->get_device_locale(), 'metadata' => $push_token->get_metadata(), ), fn ( $value ) => null !== $value && '' !== $value ); } }