Commit b1ce2c8f8b for wordpress.org
commit b1ce2c8f8b3d093e0e367729006c632461178279
Author: jorgefilipecosta <jorgefilipecosta@git.wordpress.org>
Date: Thu Oct 29 09:58:50 2026 +0000
Abilities API: Return the `core/users-query` registration date as `registered.site` and `registered.gmt`.
Replace the `registered_date` field with a `registered` object holding the registration date in the site's timezone (`site`) and in GMT (`gmt`), like the `core/content-query` date fields. The `fields` input accepts `registered.site` and `registered.gmt` to select a single date.
Developed in https://github.com/WordPress/wordpress-develop/pull/15001.
Follow-up to [64346].
Props jorgefilipecosta, gziolo.
See #64657.
Built from https://develop.svn.wordpress.org/trunk@65002
git-svn-id: http://core.svn.wordpress.org/trunk@64152 1a063a9b-81f0-0310-95a4-ce76da25c4cd
diff --git a/wp-includes/abilities/class-wp-abilities-users.php b/wp-includes/abilities/class-wp-abilities-users.php
index 87b39ce5b5..a0f21de801 100644
--- a/wp-includes/abilities/class-wp-abilities-users.php
+++ b/wp-includes/abilities/class-wp-abilities-users.php
@@ -395,11 +395,12 @@ final class WP_Abilities_Users {
}
/**
- * Returns the requested fields, or a lean default set when none are given.
+ * Returns the requested field paths, or a lean default set when none are given.
*
* An empty or absent `fields` value selects a lean set of common read fields.
- * Otherwise the requested fields are returned. The input schema has already
- * validated the names against the supported set before the ability executes.
+ * Otherwise the requested paths are returned. The input schema has already
+ * validated them against the paths from {@see self::get_field_paths()} before the
+ * ability executes, and {@see self::format_user()} ignores any other path.
*
* The `id` field is always included, matching the REST users controller
* where `id` is present in every context. This also guarantees the result
@@ -408,7 +409,7 @@ final class WP_Abilities_Users {
* @since 7.2.0
*
* @param array<mixed> $input The ability input.
- * @return string[] List of requested field names.
+ * @return string[] List of requested field paths.
*/
private function normalize_fields( array $input ): array {
$fields = isset( $input['fields'] ) ? $this->normalize_string_list( $input['fields'] ) : array();
@@ -427,8 +428,9 @@ final class WP_Abilities_Users {
* Returns the user field definitions, keyed by field name in output order.
*
* This is the single source of truth for the ability's user fields: the output
- * schema uses the definitions directly, while the input schema and field
- * normalization use the keys. The field set is deliberately unconditional:
+ * schema uses the definitions directly, while the input schema `fields` enum lists
+ * each field, followed by the path of each of its sub-fields, such as
+ * `registered.gmt`. The field set is deliberately unconditional:
* the registered schemas are a registration-time snapshot, so conditional
* availability (such as `avatar_urls` honoring the `show_avatars` option) is
* enforced per call in {@see self::format_user()} instead of here, where the
@@ -440,19 +442,19 @@ final class WP_Abilities_Users {
*/
private function get_user_properties(): array {
return array(
- 'id' => array(
+ 'id' => array(
'type' => 'integer',
'description' => __( 'The user ID.' ),
),
- 'name' => array(
+ 'name' => array(
'type' => 'string',
'description' => __( 'The display name for the user.' ),
),
- 'description' => array(
+ 'description' => array(
'type' => 'string',
'description' => __( 'Description of the user. Present when the current user can view it.' ),
),
- 'url' => array(
+ 'url' => array(
/*
* Unlike the REST users controller, `url` declares no `uri` format. It is
* empty for users without a website, and clients that check formats,
@@ -462,16 +464,16 @@ final class WP_Abilities_Users {
'type' => 'string',
'description' => __( 'URL of the user. Present when the current user can view it.' ),
),
- 'link' => array(
+ 'link' => array(
'type' => 'string',
'format' => 'uri',
'description' => __( 'Author archive URL for the user.' ),
),
- 'slug' => array(
+ 'slug' => array(
'type' => 'string',
'description' => __( 'An alphanumeric identifier for the user.' ),
),
- 'avatar_urls' => array(
+ 'avatar_urls' => array(
'type' => 'object',
'description' => __( 'Avatar URLs for the user, keyed by image size in pixels. A size is null when no avatar URL can be resolved for it. Present when the show_avatars option is enabled.' ),
'additionalProperties' => array(
@@ -479,37 +481,49 @@ final class WP_Abilities_Users {
'format' => 'uri',
),
),
- 'username' => array(
+ 'username' => array(
'type' => 'string',
'description' => __( 'Login name for the user. Present when the current user can view it.' ),
),
- 'email' => array(
+ 'email' => array(
'type' => array( 'string', 'null' ),
'format' => 'email',
'description' => __( 'The email address for the user. Null when the user has no stored address, or when the stored address is not a valid email. Present when the current user can view it.' ),
),
- 'first_name' => array(
+ 'first_name' => array(
'type' => 'string',
'description' => __( 'First name for the user. Present when the current user can view it.' ),
),
- 'last_name' => array(
+ 'last_name' => array(
'type' => 'string',
'description' => __( 'Last name for the user. Present when the current user can view it.' ),
),
- 'nickname' => array(
+ 'nickname' => array(
'type' => 'string',
'description' => __( 'The nickname for the user. Present when the current user can view it.' ),
),
- 'locale' => array(
+ 'locale' => array(
'type' => 'string',
'description' => __( 'Locale for the user. Present when the current user can view it.' ),
),
- 'registered_date' => array(
- 'type' => array( 'string', 'null' ),
- 'format' => 'date-time',
- 'description' => __( 'Registration date for the user. Null when the stored date is not a valid date. Present when the current user can view it.' ),
+ 'registered' => array(
+ 'type' => 'object',
+ 'additionalProperties' => false,
+ 'properties' => array(
+ 'site' => array(
+ 'type' => array( 'string', 'null' ),
+ 'format' => 'date-time',
+ 'description' => __( "The registration date, in ISO 8601 format using the site's timezone. Null when the stored date is not a valid date." ),
+ ),
+ 'gmt' => array(
+ 'type' => array( 'string', 'null' ),
+ 'format' => 'date-time',
+ 'description' => __( 'The registration date, in ISO 8601 format as GMT. Null when the stored date is not a valid date.' ),
+ ),
+ ),
+ 'description' => __( 'The registration date for the user. Present when the current user can view it.' ),
),
- 'roles' => array(
+ 'roles' => array(
'type' => 'array',
'description' => __( 'Roles assigned to the user. Present when the current user can view them.' ),
/*
@@ -527,6 +541,47 @@ final class WP_Abilities_Users {
);
}
+ /**
+ * Returns the field paths the `fields` input accepts, in output order.
+ *
+ * Each user field is followed by the path of each sub-field its definition declares,
+ * such as `registered`, `registered.site` and `registered.gmt`. Requesting a field
+ * returns all of its sub-fields, while a sub-field path returns only that sub-field.
+ *
+ * @since 7.2.0
+ *
+ * @return string[] The field paths.
+ */
+ private function get_field_paths(): array {
+ $paths = array();
+ foreach ( $this->get_user_properties() as $name => $schema ) {
+ $paths[] = $name;
+
+ foreach ( array_keys( $schema['properties'] ?? array() ) as $sub_field ) {
+ $paths[] = "{$name}.{$sub_field}";
+ }
+ }
+
+ return $paths;
+ }
+
+ /**
+ * Checks whether a field path is requested.
+ *
+ * A field such as `registered` is requested when it or one of its paths, such as
+ * `registered.gmt`, is requested, and a path such as `registered.gmt` is requested
+ * when it or its whole field is.
+ *
+ * @since 7.2.0
+ *
+ * @param string $path The field path, such as `registered` or `registered.gmt`.
+ * @param string[] $fields The requested field paths.
+ * @return bool Whether the field path is requested.
+ */
+ private function is_field_requested( string $path, array $fields ): bool {
+ return rest_is_field_included( $path, $fields );
+ }
+
/**
* Normalizes a mixed value into a list of user IDs.
*
@@ -700,9 +755,9 @@ final class WP_Abilities_Users {
'uniqueItems' => true,
'items' => array(
'type' => 'string',
- 'enum' => array_keys( $this->get_user_properties() ),
+ 'enum' => $this->get_field_paths(),
),
- 'description' => __( 'Limit each returned user to these fields. If omitted or empty, a lean set of common read fields is returned. The `id` field is always included, and fields the current user cannot view are omitted rather than causing an error.' ),
+ 'description' => __( 'Limit each returned user to these fields. A field such as `registered` returns all of its sub-fields, while a path such as `registered.gmt` returns only that sub-field. If omitted or empty, a lean set of common read fields is returned. The `id` field is always included, and fields the current user cannot view are omitted rather than causing an error.' ),
),
'pagination' => array(
'type' => 'object',
@@ -779,47 +834,45 @@ final class WP_Abilities_Users {
/**
* Formats a user into the ability output shape.
*
- * Only the requested fields the current user can see are included, except
- * `id`, which {@see self::normalize_fields()} always requests.
+ * Fields are selected with {@see self::is_field_requested()}, so `registered` returns
+ * both of its sub-fields, while `registered.site` or `registered.gmt` returns only that
+ * one. Only the requested fields the current user can see are included, except `id`,
+ * which {@see self::normalize_fields()} always requests.
*
* @since 7.2.0
*
* @param WP_User $user The user object.
- * @param string[] $fields The requested field names.
+ * @param string[] $fields The requested field paths.
* @return array<string, mixed> The formatted user data.
*/
private function format_user( WP_User $user, array $fields ): array {
- $fields_requested = static function ( string $field ) use ( $fields ): bool {
- return in_array( $field, $fields, true );
- };
-
$can_view_sensitive = get_current_user_id() === $user->ID || current_user_can( 'edit_user', $user->ID );
$data = array();
- if ( $fields_requested( 'id' ) ) {
+ if ( $this->is_field_requested( 'id', $fields ) ) {
$data['id'] = $user->ID;
}
- if ( $fields_requested( 'name' ) ) {
+ if ( $this->is_field_requested( 'name', $fields ) ) {
$data['name'] = (string) $user->display_name;
}
- if ( $fields_requested( 'description' ) ) {
+ if ( $this->is_field_requested( 'description', $fields ) ) {
$data['description'] = (string) $user->description;
}
- if ( $fields_requested( 'url' ) ) {
+ if ( $this->is_field_requested( 'url', $fields ) ) {
$data['url'] = (string) $user->user_url;
}
- if ( $fields_requested( 'link' ) ) {
+ if ( $this->is_field_requested( 'link', $fields ) ) {
$data['link'] = (string) get_author_posts_url( $user->ID, $user->user_nicename );
}
- if ( $fields_requested( 'slug' ) ) {
+ if ( $this->is_field_requested( 'slug', $fields ) ) {
$data['slug'] = (string) $user->user_nicename;
}
/*
* The schemas always declare avatar_urls; availability is enforced here,
* since the option can change after the schemas are registered.
*/
- if ( $fields_requested( 'avatar_urls' ) && get_option( 'show_avatars' ) ) {
+ if ( $this->is_field_requested( 'avatar_urls', $fields ) && get_option( 'show_avatars' ) ) {
$data['avatar_urls'] = array_map(
static function ( $url ) {
return is_string( $url ) ? $url : null;
@@ -829,32 +882,34 @@ final class WP_Abilities_Users {
}
if ( $can_view_sensitive ) {
- if ( $fields_requested( 'username' ) ) {
+ if ( $this->is_field_requested( 'username', $fields ) ) {
$data['username'] = (string) $user->user_login;
}
- if ( $fields_requested( 'email' ) ) {
+ if ( $this->is_field_requested( 'email', $fields ) ) {
$data['email'] = is_email( $user->user_email ) ? (string) $user->user_email : null;
}
- if ( $fields_requested( 'first_name' ) ) {
+ if ( $this->is_field_requested( 'first_name', $fields ) ) {
$data['first_name'] = (string) $user->first_name;
}
- if ( $fields_requested( 'last_name' ) ) {
+ if ( $this->is_field_requested( 'last_name', $fields ) ) {
$data['last_name'] = (string) $user->last_name;
}
- if ( $fields_requested( 'nickname' ) ) {
+ if ( $this->is_field_requested( 'nickname', $fields ) ) {
$data['nickname'] = (string) $user->nickname;
}
- if ( $fields_requested( 'locale' ) ) {
+ if ( $this->is_field_requested( 'locale', $fields ) ) {
$data['locale'] = (string) get_user_locale( $user );
}
- if ( $fields_requested( 'registered_date' ) ) {
- /*
- * The zero date, the column default, formats with a negative year that the
- * `date-time` format rejects, so it is reported as null, like an unusable email.
- */
- $registered_timestamp = strtotime( $user->user_registered );
- $registered_date = false !== $registered_timestamp ? gmdate( 'c', $registered_timestamp ) : '';
- $data['registered_date'] = rest_parse_date( $registered_date ) ? $registered_date : null;
+
+ $registered = array();
+ if ( $this->is_field_requested( 'registered.site', $fields ) ) {
+ $registered['site'] = $this->format_registered_date( $user, wp_timezone() );
+ }
+ if ( $this->is_field_requested( 'registered.gmt', $fields ) ) {
+ $registered['gmt'] = $this->format_registered_date( $user, new DateTimeZone( 'UTC' ) );
+ }
+ if ( ! empty( $registered ) ) {
+ $data['registered'] = $registered;
}
}
@@ -865,13 +920,43 @@ final class WP_Abilities_Users {
* matching the REST users controller, where `roles` is an edit-context
* field and rows the caller cannot edit are dropped from collections.
*/
- if ( $fields_requested( 'roles' ) && $can_view_sensitive ) {
+ if ( $this->is_field_requested( 'roles', $fields ) && $can_view_sensitive ) {
$data['roles'] = array_values( $user->roles );
}
return $data;
}
+ /**
+ * Formats the user's registration date as an ISO 8601 string in a timezone.
+ *
+ * The `user_registered` column holds the date in GMT, so it is parsed as UTC
+ * whatever the PHP default timezone is, then converted to the given timezone.
+ *
+ * @since 7.2.0
+ *
+ * @param WP_User $user The user object.
+ * @param DateTimeZone $timezone The timezone to format the date in.
+ * @return string|null The ISO 8601 date, or null when the stored date is not a valid date.
+ */
+ private function format_registered_date( WP_User $user, DateTimeZone $timezone ): ?string {
+ $datetime = date_create_immutable_from_format( 'Y-m-d H:i:s', (string) $user->user_registered, new DateTimeZone( 'UTC' ) );
+ if ( ! $datetime ) {
+ return null;
+ }
+
+ /*
+ * A malformed stored date can still parse, since out-of-range parts roll over: the
+ * zero date, the column default, formats with a negative year, and converting a
+ * date at either end of the range can push its year outside four digits. The
+ * `date-time` format rejects these, which would fail output validation for the
+ * whole call, so they are reported as null too, like an unusable email.
+ */
+ $date = $datetime->setTimezone( $timezone )->format( 'c' );
+
+ return false !== rest_parse_date( $date ) ? $date : null;
+ }
+
/**
* Builds the ability output for a page of formatted users.
*
diff --git a/wp-includes/version.php b/wp-includes/version.php
index f576c45448..5742c9c52f 100644
--- a/wp-includes/version.php
+++ b/wp-includes/version.php
@@ -16,7 +16,7 @@
*
* @global string $wp_version
*/
-$wp_version = '7.2-beta3-65001';
+$wp_version = '7.2-beta3-65002';
/**
* Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema.