array( 'ID' => __( 'ID', 'ai-conduit' ), 'user_login' => __( 'Username', 'ai-conduit' ), 'user_email' => __( 'Email', 'ai-conduit' ), 'display_name' => __( 'Display Name', 'ai-conduit' ), 'user_registered' => __( 'Registration Date', 'ai-conduit' ), ), 'table' => $wpdb->users, 'meta_table' => $wpdb->usermeta, 'meta_fk_column' => 'user_id', 'id_column' => 'ID', ); } /** * Map field keys to users table columns. * * @since 1.0.0 * * @return array */ public function get_field_column_map(): array { return array( 'id' => 'ID', 'display_name' => 'display_name', 'email' => 'user_email', 'login' => 'user_login', 'registered_date' => 'user_registered', 'url' => 'user_url', ); } /** * Available native user fields. * * @since 1.0.0 * * @return array */ public function get_available_fields(): array { return array( 'id' => __( 'ID', 'ai-conduit' ), 'display_name' => __( 'Display Name', 'ai-conduit' ), 'email' => __( 'Email', 'ai-conduit' ), 'login' => __( 'Username', 'ai-conduit' ), 'roles' => __( 'Roles', 'ai-conduit' ), 'registered_date' => __( 'Registration Date', 'ai-conduit' ), 'url' => __( 'Website URL', 'ai-conduit' ), 'description' => __( 'Bio / Description', 'ai-conduit' ), ); } /** * Available standard filters. * * @since 1.0.0 * * @return array */ public function get_available_filters(): array { return array( 'id' => array( 'type' => 'integer', 'description' => __( 'Fetch a specific user by ID', 'ai-conduit' ), ), 'display_name' => array( 'type' => 'string', 'description' => __( 'Search by display name', 'ai-conduit' ), ), 'email' => array( 'type' => 'string', 'description' => __( 'Search by email address', 'ai-conduit' ), ), 'role' => array( 'type' => 'string', 'description' => __( 'Filter by user role', 'ai-conduit' ), ), 'registered_after' => array( 'type' => 'string', 'description' => __( 'Users registered after this date (YYYY-MM-DD)', 'ai-conduit' ), ), 'registered_before' => array( 'type' => 'string', 'description' => __( 'Users registered before this date (YYYY-MM-DD)', 'ai-conduit' ), ), 'per_page' => array( 'type' => 'integer', 'description' => __( 'Number of results per page', 'ai-conduit' ), 'default' => 10, ), 'page' => array( 'type' => 'integer', 'description' => __( 'Page number for pagination (starts at 1)', 'ai-conduit' ), 'default' => 1, ), 'orderby' => array( 'type' => 'string', 'description' => __( 'Sort by field', 'ai-conduit' ), 'enum' => array( 'ID', 'display_name', 'login', 'email', 'registered' ), ), 'order' => array( 'type' => 'string', 'description' => __( 'Sort direction', 'ai-conduit' ), 'enum' => array( 'ASC', 'DESC' ), ), ); } /** * Build inputSchema properties including meta filters. * * @since 1.0.0 * * @param array $config The block's saved configuration. * @return array JSON Schema properties. */ public function get_input_schema_properties( array $config ): array { $properties = parent::get_input_schema_properties( $config ); // Meta key filters — array-of-conditions per key. foreach ( $config['meta_keys'] ?? array() as $meta_key => $meta_config ) { if ( empty( $meta_config['as_filter'] ) ) { continue; } $meta_type = $meta_config['type'] ?? 'CHAR'; $compare = $meta_config['compare'] ?? array( '=' ); $schema_type = in_array( $meta_type, array( 'NUMERIC', 'DECIMAL', 'UNSIGNED' ), true ) ? 'number' : 'string'; $schema_format = array( 'NUMERIC' => 'integer (e.g. 29)', 'UNSIGNED' => 'positive integer (e.g. 100)', 'DECIMAL' => 'decimal (e.g. 29.99)', 'DATE' => 'date (YYYY-MM-DD)', 'DATETIME' => 'date-time (YYYY-MM-DDTHH:MM:SS)', 'TIME' => 'time (HH:MM:SS)', ); $has_array_ops = array_intersect( array( 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN' ), $compare ); $has_scalar_ops = array_diff( $compare, array( 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN', 'EXISTS', 'NOT EXISTS' ) ); $base_schema = array( 'type' => $schema_type ); if ( isset( $schema_format[ $meta_type ] ) ) { $base_schema['format'] = $schema_format[ $meta_type ]; } if ( ! empty( $has_array_ops ) && ! empty( $has_scalar_ops ) ) { $value_schema = array( 'oneOf' => array( $base_schema, array( 'type' => 'array', 'items' => $base_schema ), ), ); } elseif ( ! empty( $has_array_ops ) ) { $value_schema = array( 'type' => 'array', 'items' => $base_schema ); } else { $value_schema = $base_schema; } $has_exists_only = empty( $has_array_ops ) && empty( $has_scalar_ops ); $item_required = $has_exists_only ? array( 'compare' ) : array( 'value', 'compare' ); $item_properties = array( 'compare' => array( 'type' => 'string', 'enum' => array_values( $compare ), ), ); if ( ! $has_exists_only ) { $item_properties['value'] = $value_schema; } $properties[ $meta_key ] = array( 'type' => 'array', 'items' => array( 'type' => 'object', 'properties' => $item_properties, 'required' => $item_required, ), 'description' => sprintf( /* translators: %s: meta key name */ __( 'Filter by %s', 'ai-conduit' ), $meta_key ), ); } // Taxonomy filters. foreach ( $config['taxonomies'] ?? array() as $tax_slug => $tax_config ) { if ( empty( $tax_config['as_filter'] ) ) { continue; } $prop = array( 'type' => 'string', 'description' => sprintf( /* translators: %s: taxonomy slug */ __( 'Filter by %s', 'ai-conduit' ), $tax_slug ), ); if ( ! empty( $tax_config['terms'] ) ) { $prop['enum'] = $tax_config['terms']; } $properties[ $tax_slug ] = $prop; } return $properties; } /** * Execute the users query. * * @since 1.0.0 * * @param array $config The block's saved configuration. * @param array $arguments The LLM-provided input arguments. * @return array List of user data arrays. */ public function execute( array $config, array $arguments ): array { $query_args = $this->build_query_args( $config, $arguments ); $query_args['count_total'] = true; // Prevent duplicate rows when EXISTS meta_query JOINs the meta table. $needs_distinct = $this->meta_query_has_exists( $query_args ); if ( $needs_distinct ) { add_action( 'pre_user_query', array( $this, 'add_user_query_distinct' ) ); } $user_query = new WP_User_Query( $query_args ); if ( $needs_distinct ) { remove_action( 'pre_user_query', array( $this, 'add_user_query_distinct' ) ); } $users = $user_query->get_results(); $total = (int) $user_query->get_total(); $selected_fields = $config['fields'] ?? array_keys( $this->get_available_fields() ); $meta_keys = $config['meta_keys'] ?? array(); $taxonomies = $config['taxonomies'] ?? array(); // Collect user IDs and extract native fields + taxonomies. $user_ids = wp_list_pluck( $users, 'ID' ); $results = array(); foreach ( $users as $user ) { $item = $this->extract_fields( $user, $selected_fields ); $item = $this->attach_taxonomies( $user, $item, $taxonomies ); $results[ $user->ID ] = $item; } // Batch-fetch meta for all users in a single query. if ( ! empty( $user_ids ) && ! empty( $meta_keys ) ) { $meta_map = $this->batch_fetch_meta( $user_ids, $meta_keys ); foreach ( $meta_map as $user_id => $meta_values ) { if ( isset( $results[ $user_id ] ) ) { $results[ $user_id ] = array_merge( $results[ $user_id ], $meta_values ); } } } $per_page = $query_args['number']; return array( 'data' => array_values( $results ), '_pagination' => array( 'page' => max( 1, absint( $arguments['page'] ?? 1 ) ), 'per_page' => $per_page, 'total' => $total, 'total_pages' => $per_page > 0 ? (int) ceil( $total / $per_page ) : 1, ), ); } /** * Build WP_User_Query arguments from config and LLM arguments. * * @since 1.0.0 * * @param array $config The block's saved configuration. * @param array $arguments The LLM-provided input arguments. * @return array WP_User_Query compatible arguments. */ private function build_query_args( array $config, array $arguments ): array { $max_per_page = $config['filters']['per_page']['max'] ?? 50; $number = max( 1, min( absint( $arguments['per_page'] ?? 10 ), $max_per_page ) ); $page = max( 1, absint( $arguments['page'] ?? 1 ) ); $query_args = array( 'number' => $number, 'offset' => ( $page - 1 ) * $number, 'orderby' => sanitize_text_field( $arguments['orderby'] ?? 'display_name' ), 'order' => sanitize_text_field( $arguments['order'] ?? 'ASC' ), ); if ( ! empty( $arguments['id'] ) ) { $query_args['include'] = array( absint( $arguments['id'] ) ); } if ( ! empty( $arguments['display_name'] ) ) { $query_args['search'] = '*' . sanitize_text_field( $arguments['display_name'] ) . '*'; $query_args['search_columns'] = array( 'display_name' ); } if ( ! empty( $arguments['email'] ) ) { $query_args['search'] = sanitize_email( $arguments['email'] ); $query_args['search_columns'] = array( 'user_email' ); } if ( ! empty( $arguments['role'] ) ) { $query_args['role'] = sanitize_text_field( $arguments['role'] ); } // Registration date filters. $date_query = array(); if ( ! empty( $arguments['registered_after'] ) && $this->is_valid_date( $arguments['registered_after'] ) ) { $date_query['after'] = sanitize_text_field( $arguments['registered_after'] ); } if ( ! empty( $arguments['registered_before'] ) && $this->is_valid_date( $arguments['registered_before'] ) ) { $date_query['before'] = sanitize_text_field( $arguments['registered_before'] ); } if ( ! empty( $date_query ) ) { $query_args['date_query'] = array( $date_query ); } $query_args = $this->apply_meta_filters( $query_args, $config, $arguments ); $query_args = $this->apply_taxonomy_filters( $query_args, $config, $arguments ); return $query_args; } /** * Apply meta key filter arguments to the query. * * @since 1.0.0 * * @param array $query_args Current WP_User_Query arguments. * @param array $config The block's saved configuration. * @param array $arguments The LLM-provided input arguments. * @return array Modified WP_User_Query arguments. */ private function apply_meta_filters( array $query_args, array $config, array $arguments ): array { $allowed_operators = array( '=', '!=', '<', '>', '<=', '>=', 'LIKE', 'NOT LIKE', 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN', 'EXISTS', 'NOT EXISTS' ); foreach ( $config['meta_keys'] ?? array() as $meta_key => $meta_config ) { if ( empty( $meta_config['as_filter'] ) || false !== strpos( $meta_key, '%' ) ) { continue; } $meta_key_sanitized = sanitize_text_field( $meta_key ); $meta_type = $meta_config['type'] ?? 'CHAR'; $enabled_operators = $meta_config['compare'] ?? array( '=' ); if ( ! isset( $arguments[ $meta_key_sanitized ] ) || ! is_array( $arguments[ $meta_key_sanitized ] ) ) { continue; } foreach ( $arguments[ $meta_key_sanitized ] as $condition ) { $compare = $condition['compare'] ?? ''; // Validate against admin-allowed and WordPress-supported operators. if ( ! in_array( $compare, $enabled_operators, true ) || ! in_array( $compare, $allowed_operators, true ) ) { continue; } $clause = array( 'key' => $meta_key_sanitized, 'compare' => $compare, 'type' => $meta_type, ); // EXISTS / NOT EXISTS don't use a value. if ( in_array( $compare, array( 'EXISTS', 'NOT EXISTS' ), true ) ) { // No value needed. } elseif ( in_array( $compare, array( 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN' ), true ) ) { $raw = $condition['value'] ?? array(); if ( ! is_array( $raw ) ) { continue; } $clause['value'] = array_map( 'sanitize_text_field', $raw ); } else { $raw = $condition['value'] ?? ''; if ( is_array( $raw ) ) { continue; } $clause['value'] = sanitize_text_field( (string) $raw ); } $query_args['meta_query'][] = $clause; } } return $query_args; } /** * Extract selected native fields from a user. * * @since 1.0.0 * * @param WP_User $user The user object. * @param array $selected_fields List of field keys to include. * @return array Associative array of field_key => value. */ private function meta_query_has_exists( array $query_args ): bool { foreach ( $query_args['meta_query'] ?? array() as $clause ) { if ( in_array( $clause['compare'] ?? '', array( 'EXISTS', 'NOT EXISTS' ), true ) ) { return true; } } return false; } /** * Add DISTINCT to WP_User_Query to prevent row duplication from EXISTS meta joins. * * @since 1.0.0 * * @param WP_User_Query $query The user query object. * @return void */ public function add_user_query_distinct( WP_User_Query $query ): void { $query->query_fields = 'DISTINCT ' . $query->query_fields; } private function extract_fields( WP_User $user, array $selected_fields ): array { $item = array(); foreach ( $selected_fields as $field ) { switch ( $field ) { case 'id': $item['id'] = $user->ID; break; case 'display_name': $item['display_name'] = $user->display_name; break; case 'email': $item['email'] = $user->user_email; break; case 'login': $item['login'] = $user->user_login; break; case 'roles': $item['roles'] = $user->roles; break; case 'registered_date': $item['registered_date'] = $user->user_registered; break; case 'url': $item['url'] = $user->user_url; break; case 'description': $item['description'] = $user->description; break; } } return $item; } /** * Batch-fetch meta values for multiple users in a single query. * * Handles both exact keys and pattern keys (containing %). * Pattern key results are grouped under the pattern name. * * @since 1.0.0 * * @param array $user_ids List of user IDs. * @param array $meta_keys The meta keys configuration. * @return array Associative array of user_id => [ key => value ]. */ private function batch_fetch_meta( array $user_ids, array $meta_keys ): array { global $wpdb; $exact_keys = array(); $pattern_keys = array(); foreach ( $meta_keys as $meta_key => $meta_config ) { if ( empty( $meta_config['as_field'] ) ) { continue; } if ( false !== strpos( $meta_key, '%' ) ) { $pattern_keys[] = $meta_key; } else { $exact_keys[] = $meta_key; } } if ( empty( $exact_keys ) && empty( $pattern_keys ) ) { return array(); } $prepare_args = array(); $conditions = array(); // Build ID placeholders for prepare(). $id_placeholders = implode( ',', array_fill( 0, count( $user_ids ), '%d' ) ); $prepare_args = array_merge( $prepare_args, array_map( 'absint', $user_ids ) ); // Exact meta key matches. if ( ! empty( $exact_keys ) ) { $key_placeholders = implode( ',', array_fill( 0, count( $exact_keys ), '%s' ) ); $conditions[] = "meta_key IN ({$key_placeholders})"; $prepare_args = array_merge( $prepare_args, $exact_keys ); } // Pattern meta key matches — esc_like() treats _ as literal, preserve % wildcards. foreach ( $pattern_keys as $pattern ) { $parts = explode( '%', $pattern ); $parts = array_map( array( $wpdb, 'esc_like' ), $parts ); $like_pattern = implode( '%', $parts ); $conditions[] = 'meta_key LIKE %s'; $prepare_args[] = $like_pattern; } $where_keys = implode( ' OR ', $conditions ); // Prepend the table identifier so it pairs with the leading %i placeholder. array_unshift( $prepare_args, $wpdb->usermeta ); // $id_placeholders ("%d,%d,...") and $where_keys ("meta_key IN (%s,...) OR // meta_key LIKE %s") are placeholder fragments built deterministically in code // — no values are interpolated. All values flow through prepare() args. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $id_placeholders and $where_keys contain only %s/%d placeholder fragments and SQL keywords (IN, OR), assembled by code from array_fill/implode; all values pass through prepare() args. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT user_id, meta_key, meta_value FROM %i WHERE user_id IN ({$id_placeholders}) AND ({$where_keys})", ...$prepare_args ) ); // Initialize empty values for all users. $meta_map = array(); foreach ( $user_ids as $id ) { $meta_map[ $id ] = array(); foreach ( $exact_keys as $key ) { $meta_map[ $id ][ $key ] = ''; } foreach ( $pattern_keys as $pattern ) { $meta_map[ $id ][ $pattern ] = array(); } } // Map rows back by user ID. foreach ( $rows as $row ) { $uid = (int) $row->user_id; $key = $row->meta_key; $val = maybe_unserialize( $row->meta_value ); if ( in_array( $key, $exact_keys, true ) ) { $meta_map[ $uid ][ $key ] = $val; } else { foreach ( $pattern_keys as $pattern ) { if ( fnmatch( str_replace( '%', '*', $pattern ), $key ) ) { $meta_map[ $uid ][ $pattern ][ $key ] = $val; break; } } } } return $meta_map; } /** * Attach taxonomy term data to a user item. * * @since 1.0.0 * * @param WP_User $user The user object. * @param array $item The current user data array. * @param array $taxonomies The taxonomies configuration. * @return array Modified user data array with taxonomy terms attached. */ private function attach_taxonomies( WP_User $user, array $item, array $taxonomies ): array { foreach ( $taxonomies as $tax_slug => $tax_config ) { if ( empty( $tax_config['as_field'] ) ) { continue; } $terms = wp_get_object_terms( $user->ID, $tax_slug ); if ( is_wp_error( $terms ) ) { $item[ $tax_slug ] = array(); continue; } $item[ $tax_slug ] = wp_list_pluck( $terms, 'name' ); } return $item; } /** * Apply taxonomy filter arguments to the query. * * WP_User_Query doesn't support tax_query natively, * so we resolve term → user IDs via get_objects_in_term() * and pass them as an include filter. * * @since 1.0.0 * * @param array $query_args Current WP_User_Query arguments. * @param array $config The block's saved configuration. * @param array $arguments The LLM-provided input arguments. * @return array Modified WP_User_Query arguments. */ private function apply_taxonomy_filters( array $query_args, array $config, array $arguments ): array { foreach ( $config['taxonomies'] ?? array() as $tax_slug => $tax_config ) { if ( empty( $tax_config['as_filter'] ) ) { continue; } $tax_slug_sanitized = sanitize_text_field( $tax_slug ); if ( empty( $arguments[ $tax_slug_sanitized ] ) ) { continue; } $requested_term = sanitize_text_field( $arguments[ $tax_slug_sanitized ] ); $allowed_terms = $tax_config['terms'] ?? array(); if ( ! empty( $allowed_terms ) && ! in_array( $requested_term, $allowed_terms, true ) ) { continue; } $term = get_term_by( 'slug', $requested_term, $tax_slug_sanitized ); if ( ! $term ) { continue; } $object_ids = get_objects_in_term( $term->term_id, $tax_slug_sanitized ); if ( is_wp_error( $object_ids ) || empty( $object_ids ) ) { $query_args['include'] = array( 0 ); return $query_args; } if ( ! empty( $query_args['include'] ) ) { $query_args['include'] = array_intersect( $query_args['include'], $object_ids ); } else { $query_args['include'] = $object_ids; } } return $query_args; } }