array( 'modified_gmt' ), 'dir' => 'desc', ), ); public const QUERY_OPS = array( 'eq', 'neq', 'in', 'not_in', 'lt', 'lte', 'gt', 'gte', 'contains', 'exists' ); private const CORE_QUERY = array( 'id', 'post_type', 'status', 'slug', 'parent', 'menu_order', 'comment_status', 'ping_status', 'title.raw', 'excerpt.raw', 'date_gmt', 'modified_gmt', 'author.id' ); private const CORE_ORDER = array( 'id', 'post_type', 'status', 'slug', 'parent', 'menu_order', 'date_gmt', 'modified_gmt', 'author.id' ); /** * Run the shared validate/execute pipeline for one wp.posts.query command. * * @param array $input Command input document. * @param string $phase Either `validate` or `execute`. * @return array Internal result: `['data' => array]` or `['error' => array]`. */ public function run( array $input, string $phase ): array { $validated = $this->validate_input( $input ); if ( isset( $validated['error'] ) ) { return $validated; } if ( 'validate' === $phase ) { return array( 'data' => array( 'valid' => true ) ); } $query_args = $this->query_args( $input ); $query = new \WP_Query( $query_args ); $posts = is_array( $query->posts ?? null ) ? array_values( $query->posts ) : array(); $posts = array_values( array_filter( $posts, fn ( $post ): bool => $this->post_matches( $post, $input['where'] ?? null, $input['search'] ?? '' ) ) ); $posts = $this->sort_posts( $posts, $input['order_by'] ?? self::DEFAULT_ORDER_BY ); $total = count( $posts ); $limit = isset( $input['limit'] ) ? (int) $input['limit'] : self::DEFAULT_LIMIT; $page_info = CommandPagination::page( $posts, $limit, $input['cursor'] ?? null, $this->cursor_context( $input ) ); if ( isset( $page_info['error'] ) ) { return $page_info; } $page = $page_info['items']; $items = array(); foreach ( $page as $post ) { if ( ! current_user_can( 'read_post', (int) $post->ID ) ) { continue; } $projection = $this->project( $post, $input['select'] ?? null ); if ( isset( $projection['error'] ) ) { return $projection; } $items[] = array( 'target' => array( 'resource' => 'posts', 'id' => (int) $post->ID, 'post_type' => (string) $post->post_type, ), 'document' => $projection['document'], 'facts' => $this->facts( $post ), ); } return array( 'data' => array( 'items' => $items, 'page' => array( 'next_cursor' => $page_info['next_cursor'], 'limit' => $limit, ), ), 'meta' => array( 'total' => $total, 'returned_count' => count( $items ), ), ); } /** * Validate the query document shape and path support. * * @param array $input Command input document. */ private function validate_input( array $input ): array { if ( isset( $input['where'] ) ) { $where = $this->validate_group( $input['where'], array( 'input', 'where' ) ); if ( isset( $where['error'] ) ) { return $where; } } if ( isset( $input['search'] ) && ! is_scalar( $input['search'] ) ) { return $this->error( 'invalid_command', 'search must be a scalar string.', array( 'input', 'search' ) ); } if ( isset( $input['select'] ) && ! $this->is_path_list( $input['select'] ) ) { return $this->error( 'invalid_command', 'select must be a list of array-segment paths.', array( 'input', 'select' ) ); } if ( isset( $input['order_by'] ) ) { if ( ! is_array( $input['order_by'] ) ) { return $this->error( 'invalid_command', 'order_by must be a list of path sort objects.', array( 'input', 'order_by' ) ); } foreach ( $input['order_by'] as $index => $order ) { if ( ! is_array( $order ) || ! isset( $order['path'] ) || ! $this->is_path( $order['path'] ) ) { return $this->error( 'invalid_command', 'order_by entries require an array-segment path.', array( 'input', 'order_by', $index ) ); } $dir = isset( $order['dir'] ) && is_scalar( $order['dir'] ) ? strtolower( (string) $order['dir'] ) : 'asc'; if ( ! in_array( $dir, array( 'asc', 'desc' ), true ) ) { return $this->error( 'invalid_command', 'order_by dir must be asc or desc.', array( 'input', 'order_by', $index, 'dir' ) ); } if ( ! $this->is_orderable_path( $order['path'] ) ) { return $this->error( 'invalid_path', 'Requested order_by path is not orderable for posts.', array( 'input', 'order_by', $index, 'path' ) ); } } } if ( isset( $input['limit'] ) ) { if ( ! is_numeric( $input['limit'] ) || (int) $input['limit'] < 1 ) { return $this->error( 'invalid_value', 'limit must be a positive integer.', array( 'input', 'limit' ) ); } if ( (int) $input['limit'] > self::MAX_LIMIT ) { return $this->error( 'limit_exceeded', 'limit exceeds the wp.posts.query cap.', array( 'input', 'limit' ), array( 'max' => self::MAX_LIMIT ) ); } } if ( isset( $input['cursor'] ) && null !== $input['cursor'] && ! is_string( $input['cursor'] ) ) { return $this->error( 'invalid_value', 'cursor must be null or an opaque string.', array( 'input', 'cursor' ) ); } $cursor = CommandPagination::validate( $input['cursor'] ?? null, $this->cursor_context( $input ) ); if ( isset( $cursor['error'] ) ) { return $cursor; } return array( 'data' => array( 'valid' => true ) ); } /** * Validate a nested all/any group. * * @param mixed $group Query group. * @param array $path Error path. */ private function validate_group( $group, array $path ): array { if ( ! is_array( $group ) ) { return $this->error( 'invalid_command', 'where must be a predicate group.', $path ); } $keys = array_values( array_intersect( array_keys( $group ), array( 'all', 'any' ) ) ); if ( 1 !== count( $keys ) || ! is_array( $group[ $keys[0] ] ) ) { return $this->error( 'invalid_command', 'Predicate groups must contain exactly one all or any list.', $path ); } foreach ( $group[ $keys[0] ] as $index => $item ) { $item_path = array_merge( $path, array( $keys[0], $index ) ); if ( is_array( $item ) && ( isset( $item['all'] ) || isset( $item['any'] ) ) ) { $nested = $this->validate_group( $item, $item_path ); if ( isset( $nested['error'] ) ) { return $nested; } continue; } $predicate = $this->validate_predicate( $item, $item_path ); if ( isset( $predicate['error'] ) ) { return $predicate; } } return array( 'data' => array( 'valid' => true ) ); } /** * Validate one predicate. * * @param mixed $predicate Predicate object. * @param array $path Error path. */ private function validate_predicate( $predicate, array $path ): array { if ( ! is_array( $predicate ) || ! isset( $predicate['path'], $predicate['op'] ) || ! $this->is_path( $predicate['path'] ) ) { return $this->error( 'invalid_command', 'Predicates require path and op fields.', $path ); } $op = is_scalar( $predicate['op'] ) ? (string) $predicate['op'] : ''; if ( ! in_array( $op, self::QUERY_OPS, true ) ) { return $this->error( 'invalid_value', 'Predicate op is not part of the query grammar.', array_merge( $path, array( 'op' ) ) ); } if ( ! array_key_exists( 'value', $predicate ) ) { return $this->error( 'invalid_command', 'Predicates require a value field.', array_merge( $path, array( 'value' ) ) ); } if ( ! $this->is_queryable_path( $predicate['path'] ) ) { return $this->error( 'invalid_path', 'Requested predicate path is not queryable for posts.', array_merge( $path, array( 'path' ) ) ); } return array( 'data' => array( 'valid' => true ) ); } /** * Build broad WP_Query arguments. Nested correctness is enforced by the * grammar evaluator below; these args still push common namespace filters * into WordPress where its query model can represent them safely. * * @param array $input Command input document. */ private function query_args( array $input ): array { $args = array( 'post_type' => 'any', 'post_status' => 'any', 'posts_per_page' => -1, 'no_found_rows' => false, 'ignore_sticky_posts' => true, ); if ( isset( $input['search'] ) && '' !== (string) $input['search'] ) { $args['s'] = (string) $input['search']; } $this->collect_query_args( $input['where'] ?? null, $args ); return $args; } /** * Collect simple all-group predicates into WP_Query args. * * @param mixed $group Predicate group. * @param array $args WP_Query args. */ private function collect_query_args( $group, array &$args ): void { if ( ! is_array( $group ) || ! isset( $group['all'] ) || ! is_array( $group['all'] ) ) { return; } foreach ( $group['all'] as $item ) { if ( ! is_array( $item ) || isset( $item['any'] ) ) { continue; } if ( isset( $item['all'] ) ) { $this->collect_query_args( $item, $args ); continue; } $key = $this->path_key( $item['path'] ?? array() ); $op = (string) ( $item['op'] ?? '' ); $value = $item['value'] ?? null; if ( 'post_type' === $key && in_array( $op, array( 'eq', 'in' ), true ) ) { $args['post_type'] = 'eq' === $op ? (string) $value : array_values( array_map( 'strval', is_array( $value ) ? $value : array() ) ); } if ( 'status' === $key && in_array( $op, array( 'eq', 'in' ), true ) ) { $args['post_status'] = 'eq' === $op ? (string) $value : array_values( array_map( 'strval', is_array( $value ) ? $value : array() ) ); } if ( 'id' === $key && 'in' === $op && is_array( $value ) ) { $args['post__in'] = array_values( array_map( 'intval', $value ) ); } if ( 'id' === $key && 'not_in' === $op && is_array( $value ) ) { // Faithful mapping of the grammar's `id not_in` query op; the // result set is paginated and capped, so the VIP over-fetch // alternative buys nothing here. // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in $args['post__not_in'] = array_values( array_map( 'intval', $value ) ); } } } /** * Return whether a post satisfies the full query document. * * @param object $post WordPress post object. * @param mixed $where Predicate group. * @param string|null $search Search text. */ private function post_matches( object $post, $where, ?string $search ): bool { if ( '' !== (string) $search ) { $haystack = strtolower( (string) $post->post_title . ' ' . (string) $post->post_excerpt . ' ' . (string) $post->post_content ); if ( false === strpos( $haystack, strtolower( (string) $search ) ) ) { return false; } } return null === $where || $this->group_matches( $post, $where ); } /** * Evaluate a predicate group against one post. * * @param object $post WordPress post object. * @param array $group Predicate group. */ private function group_matches( object $post, array $group ): bool { $key = isset( $group['any'] ) ? 'any' : 'all'; foreach ( $group[ $key ] as $item ) { if ( isset( $item['all'] ) || isset( $item['any'] ) ) { $matches = $this->group_matches( $post, $item ); } else { $matches = $this->predicate_matches( $post, $item ); } if ( 'any' === $key && $matches ) { return true; } if ( 'all' === $key && ! $matches ) { return false; } } return 'all' === $key; } /** * Evaluate one predicate against one post. * * @param object $post WordPress post object. * @param array $predicate Predicate object. */ private function predicate_matches( object $post, array $predicate ): bool { $actual = $this->query_value( $post, $predicate['path'] ); $value = $predicate['value']; $exists = true === $actual['exists']; $actual = $actual['value'] ?? null; switch ( (string) $predicate['op'] ) { case 'exists': return (bool) $value ? $exists : ! $exists; case 'eq': return $this->value_equals( $actual, $value ); case 'neq': return ! $this->value_equals( $actual, $value ); case 'in': return $this->value_in( $actual, $value ); case 'not_in': return ! $this->value_in( $actual, $value ); case 'contains': return $this->value_contains( $actual, $value ); case 'lt': case 'lte': case 'gt': case 'gte': return $this->ordered_compare( $actual, $value, (string) $predicate['op'] ); } return false; } /** * Read a query/order value by path. * * @param object $post WordPress post object. * @param array $path Array-segment path. */ private function query_value( object $post, array $path ): array { $key = $this->path_key( $path ); switch ( $key ) { case 'id': return $this->query_value_result( true, (int) $post->ID ); case 'post_type': return $this->query_value_result( true, (string) $post->post_type ); case 'status': return $this->query_value_result( true, (string) $post->post_status ); case 'slug': return $this->query_value_result( true, (string) $post->post_name ); case 'parent': return $this->query_value_result( true, (int) ( $post->post_parent ?? 0 ) ); case 'menu_order': return $this->query_value_result( true, (int) ( $post->menu_order ?? 0 ) ); case 'comment_status': return $this->query_value_result( true, (string) ( $post->comment_status ?? 'open' ) ); case 'ping_status': return $this->query_value_result( true, (string) ( $post->ping_status ?? 'open' ) ); case 'title.raw': return $this->query_value_result( true, (string) $post->post_title ); case 'excerpt.raw': return $this->query_value_result( true, (string) $post->post_excerpt ); case 'date_gmt': return $this->query_value_result( true, (string) $post->post_date_gmt ); case 'modified_gmt': return $this->query_value_result( true, (string) $post->post_modified_gmt ); case 'author.id': return $this->query_value_result( true, (int) ( $post->post_author ?? 0 ) ); } if ( 2 === count( $path ) && 'meta' === (string) $path[0] ) { $meta = $this->meta( (int) $post->ID ); $name = (string) $path[1]; return $this->query_value_result( array_key_exists( $name, $meta ), $meta[ $name ] ?? null ); } if ( 2 === count( $path ) && 'terms' === (string) $path[0] ) { $ids = $this->term_ids( (int) $post->ID, (string) $path[1] ); return $this->query_value_result( array() !== $ids, $ids ); } return $this->query_value_result( false, null ); } /** * Build a query-value result tuple. * * @param bool $exists Whether the value exists. * @param mixed $value Query value. */ private function query_value_result( bool $exists, $value ): array { return array( 'exists' => $exists, 'value' => $value, ); } /** * Project selected paths from one post document. * * @param object $post WordPress post object. * @param array|null $select List of paths. */ private function project( object $post, ?array $select ): array { $document = $this->document( $post ); if ( null === $select || array() === $select ) { return array( 'document' => $this->default_projection( $document ) ); } $projected = array(); foreach ( $select as $index => $path ) { if ( ! $this->path_exists( $document, $path ) ) { return $this->error( 'invalid_path', 'Selected path is not part of the posts query document model.', array( 'input', 'select', $index ) ); } $projected = $this->path_set( $projected, $path, $this->path_get( $document, $path ) ); } return array( 'document' => $projected ); } /** * Build the readable post document. * * @param object $post WordPress post object. */ private function document( object $post ): array { return array( 'id' => (int) $post->ID, 'post_type' => (string) $post->post_type, 'status' => (string) $post->post_status, 'slug' => (string) $post->post_name, 'parent' => (int) ( $post->post_parent ?? 0 ), 'menu_order' => (int) ( $post->menu_order ?? 0 ), 'comment_status' => (string) ( $post->comment_status ?? 'open' ), 'ping_status' => (string) ( $post->ping_status ?? 'open' ), 'render_url' => $this->render_url( $post ), 'public_url' => $this->public_url( $post ), 'preview_url' => $this->preview_url( $post ), 'title' => array( 'raw' => (string) $post->post_title, 'rendered' => (string) $post->post_title, ), 'excerpt' => array( 'raw' => (string) $post->post_excerpt ), 'content' => array( 'raw' => (string) $post->post_content ), 'date_gmt' => (string) $post->post_date_gmt, 'modified_gmt' => (string) $post->post_modified_gmt, 'author' => array( 'id' => (int) ( $post->post_author ?? 0 ) ), 'terms' => $this->terms_document( (int) $post->ID, (string) $post->post_type ), 'meta' => $this->meta( (int) $post->ID ), 'seo_meta' => $this->seo_meta( (int) $post->ID ), ); } /** * Read the family default projection. * * @param array $document Full document. */ private function default_projection( array $document ): array { return array( 'id' => $document['id'], 'post_type' => $document['post_type'], 'status' => $document['status'], 'slug' => $document['slug'], 'title' => $document['title'], 'modified_gmt' => $document['modified_gmt'], ); } /** * Return the frontend URL that can render this post. * * @param object $post WordPress post object. */ private function render_url( object $post ): string { $status = (string) $post->post_status; if ( ! in_array( $status, array( 'publish', 'private' ), true ) ) { $preview = $this->preview_url( $post ); if ( '' !== $preview ) { return $preview; } } return $this->public_url( $post ); } private function public_url( object $post ): string { $permalink = function_exists( 'get_permalink' ) ? get_permalink( $post ) : ''; return is_string( $permalink ) ? $permalink : ''; } private function preview_url( object $post ): string { if ( ! function_exists( 'get_preview_post_link' ) ) { return ''; } $preview = get_preview_post_link( $post ); return is_string( $preview ) ? $preview : ''; } /** * Sort posts by the requested multi-key order. * * @param array $posts Post objects. * @param array $order_by Order entries. */ private function sort_posts( array $posts, array $order_by ): array { usort( $posts, function ( object $a, object $b ) use ( $order_by ): int { foreach ( $order_by as $order ) { $dir = 1; if ( isset( $order['dir'] ) && 'desc' === strtolower( (string) $order['dir'] ) ) { $dir = -1; } $av = $this->query_value( $a, $order['path'] )['value'] ?? null; $bv = $this->query_value( $b, $order['path'] )['value'] ?? null; $cmp = $this->compare_values( $av, $bv ); if ( 0 !== $cmp ) { return $cmp * $dir; } } return (int) $a->ID <=> (int) $b->ID; } ); return $posts; } /** * Return whether a predicate path is queryable according to this schema slice. * * @param array $path Array-segment path. */ private function is_queryable_path( array $path ): bool { $key = $this->path_key( $path ); if ( in_array( $key, self::CORE_QUERY, true ) ) { return true; } if ( 2 === count( $path ) && 'meta' === (string) $path[0] ) { return array_key_exists( (string) $path[1], $this->registered_post_meta() ); } if ( 2 === count( $path ) && 'terms' === (string) $path[0] ) { return in_array( (string) $path[1], $this->registered_taxonomies(), true ); } return false; } /** * Return whether a path can be used for ordering. * * @param array $path Array-segment path. */ private function is_orderable_path( array $path ): bool { return in_array( $this->path_key( $path ), self::CORE_ORDER, true ); } /** * Read flattened post meta as a path-addressable namespace. * * @param int $post_id Post id. */ private function meta( int $post_id ): array { $raw = get_post_meta( $post_id ); $meta = array(); if ( ! is_array( $raw ) ) { return $meta; } foreach ( $raw as $key => $values ) { if ( is_array( $values ) ) { $meta[ (string) $key ] = 1 === count( $values ) ? array_values( $values )[0] : array_values( $values ); continue; } $meta[ (string) $key ] = $values; } return $meta; } /** * Read SEO-owned post meta without exposing the whole custom-field bag. * * @param int $post_id Post id. */ private function seo_meta( int $post_id ): array { return array_filter( $this->meta( $post_id ), fn ( $value, string $key ): bool => $this->is_seo_meta_key( $key ), ARRAY_FILTER_USE_BOTH ); } /** * Return whether a post meta key is owned by a supported SEO plugin. * * @param string $key Meta key. */ private function is_seo_meta_key( string $key ): bool { foreach ( array( '_yoast_wpseo_', 'rank_math_', '_aioseo_' ) as $prefix ) { if ( str_starts_with( $key, $prefix ) ) { return true; } } return false; } /** * Build terms projection values keyed by taxonomy. * * @param int $post_id Post id. * @param string $post_type Post type. */ private function terms_document( int $post_id, string $post_type ): array { $terms = array(); foreach ( $this->registered_taxonomies( $post_type ) as $taxonomy ) { $terms[ $taxonomy ] = $this->term_ids( $post_id, $taxonomy ); } return $terms; } /** * Return assigned term ids for one post/taxonomy. * * @param int $post_id Post id. * @param string $taxonomy Taxonomy name. */ private function term_ids( int $post_id, string $taxonomy ): array { if ( ! function_exists( 'wp_get_post_terms' ) ) { return array(); } $terms = wp_get_post_terms( $post_id, $taxonomy, array( 'fields' => 'ids' ) ); if ( ! is_array( $terms ) ) { return array(); } return array_values( array_map( 'intval', $terms ) ); } /** * Read registered REST-visible post meta keys across registered post types. */ private function registered_post_meta(): array { if ( ! function_exists( 'get_registered_meta_keys' ) ) { return array(); } $keys = array(); foreach ( $this->registered_post_types() as $post_type ) { $registered = get_registered_meta_keys( 'post', $post_type ); if ( ! is_array( $registered ) ) { continue; } foreach ( $registered as $name => $schema ) { if ( is_array( $schema ) && ! empty( $schema['show_in_rest'] ) ) { $keys[ (string) $name ] = $schema; } } } return $keys; } /** * Return registered taxonomy names for posts. * * @param string|null $post_type Optional post type scope. */ private function registered_taxonomies( ?string $post_type = null ): array { if ( ! function_exists( 'get_object_taxonomies' ) ) { return array( 'category', 'post_tag' ); } $scope = null !== $post_type && '' !== $post_type ? $post_type : 'post'; $taxonomies = get_object_taxonomies( $scope, 'names' ); return is_array( $taxonomies ) ? array_values( array_map( 'strval', $taxonomies ) ) : array(); } /** * Return registered post type names. */ private function registered_post_types(): array { $post_types = function_exists( 'get_post_types' ) ? get_post_types( array(), 'names' ) : array( 'post', 'page' ); return is_array( $post_types ) ? array_values( array_map( 'strval', $post_types ) ) : array(); } /** * Build read facts for one post. * * @param object $post WordPress post object. */ private function facts( object $post ): array { return array( 'content_hash' => PostsGetCapability::content_hash( (string) $post->post_content ), 'modified_gmt' => (string) $post->post_modified_gmt, 'revision_id' => PostsGetCapability::latest_revision_id( (int) $post->ID ), ); } private function cursor_context( array $input ): array { return array( 'resource' => 'posts', 'where' => $input['where'] ?? null, 'search' => $input['search'] ?? null, 'order_by' => $input['order_by'] ?? self::DEFAULT_ORDER_BY, ); } /** * Compare actual and expected values. * * @param mixed $actual Actual value. * @param mixed $value Expected value. */ private function value_equals( $actual, $value ): bool { if ( is_array( $actual ) ) { return in_array( $value, $actual, true ); } return (string) $actual === (string) $value; } /** * Return whether actual is represented by a list value. * * @param mixed $actual Actual value. * @param mixed $value Expected list. */ private function value_in( $actual, $value ): bool { $list = is_array( $value ) ? $value : array( $value ); if ( is_array( $actual ) ) { foreach ( $actual as $item ) { if ( in_array( $item, $list, true ) ) { return true; } } return false; } return in_array( $actual, $list, true ); } /** * Return whether actual contains a value. * * @param mixed $actual Actual value. * @param mixed $value Needle. */ private function value_contains( $actual, $value ): bool { if ( is_array( $actual ) ) { return in_array( $value, $actual, true ); } return false !== stripos( (string) $actual, (string) $value ); } /** * Compare ordered values for lt/lte/gt/gte. * * @param mixed $actual Actual value. * @param mixed $value Expected value. * @param string $op Operator. */ private function ordered_compare( $actual, $value, string $op ): bool { $cmp = $this->compare_values( $actual, $value ); switch ( $op ) { case 'lt': return $cmp < 0; case 'lte': return $cmp <= 0; case 'gt': return $cmp > 0; case 'gte': return $cmp >= 0; } return false; } /** * Compare scalars using numeric, date, then string ordering. * * @param mixed $left Left value. * @param mixed $right Right value. */ private function compare_values( $left, $right ): int { if ( is_numeric( $left ) && is_numeric( $right ) ) { return (float) $left <=> (float) $right; } $left_time = is_scalar( $left ) ? strtotime( (string) $left ) : false; $right_time = is_scalar( $right ) ? strtotime( (string) $right ) : false; if ( false !== $left_time && false !== $right_time ) { return $left_time <=> $right_time; } return strcmp( (string) $left, (string) $right ); } /** * Return whether a path resolves to an existing node in the document. * * @param mixed $document Document subtree. * @param array $segments Path segments. */ private function path_exists( $document, array $segments ): bool { $node = $document; foreach ( $segments as $segment ) { $key = is_scalar( $segment ) ? (string) $segment : ''; if ( ! is_array( $node ) || ! array_key_exists( $key, $node ) ) { return false; } $node = $node[ $key ]; } return true; } /** * Read the value at a path. * * @param mixed $document Document subtree. * @param array $segments Path segments. * @return mixed */ private function path_get( $document, array $segments ) { $node = $document; foreach ( $segments as $segment ) { $node = $node[ (string) $segment ]; } return $node; } /** * Set a value at a path, building intermediate objects. * * @param array $document Document being assembled. * @param array $segments Path segments. * @param mixed $value Value to set. */ private function path_set( array $document, array $segments, $value ): array { $key = (string) $segments[0]; if ( 1 === count( $segments ) ) { $document[ $key ] = $value; return $document; } $child = isset( $document[ $key ] ) && is_array( $document[ $key ] ) ? $document[ $key ] : array(); $document[ $key ] = $this->path_set( $child, array_slice( $segments, 1 ), $value ); return $document; } /** * Return a dot key for a path. * * @param array $path Array-segment path. */ private function path_key( array $path ): string { return implode( '.', array_map( 'strval', $path ) ); } /** * Return whether a value is a path. * * @param mixed $path Candidate path. */ private function is_path( $path ): bool { if ( ! is_array( $path ) || array() === $path ) { return false; } foreach ( $path as $segment ) { if ( ! is_scalar( $segment ) ) { return false; } } return true; } /** * Return whether select is a list of paths. * * @param mixed $select Candidate select. */ private function is_path_list( $select ): bool { if ( ! is_array( $select ) ) { return false; } foreach ( $select as $path ) { if ( ! $this->is_path( $path ) ) { return false; } } return true; } /** * Build an internal error result. * * @param string $code Twelve-code error code. * @param string $message Human/LLM message. * @param array|null $path Pointer into the request, if any. * @param array|null $details Extra actionable detail, if any. */ private function error( string $code, string $message, ?array $path = null, ?array $details = null ): array { $error = array( 'code' => $code, 'message' => $message, ); if ( null !== $path ) { $error['path'] = $path; } if ( null !== $details ) { $error['details'] = $details; } return array( 'error' => $error ); } }