• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Yoast / wordpress-seo / f3f2679ef1234f7ff859d3158a98c2b7d16ee543

15 Jul 2026 12:38PM UTC coverage: 54.93% (-0.2%) from 55.156%
f3f2679ef1234f7ff859d3158a98c2b7d16ee543

push

github

web-flow
Merge pull request #23369 from Yoast/add/read-write-post-abilities

Introduce two new abilities for getting and setting SEO data for given posts

9539 of 17290 branches covered (55.17%)

Branch coverage included in aggregate %.

341 of 385 new or added lines in 8 files covered. (88.57%)

38548 of 70252 relevant lines covered (54.87%)

42838.88 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

92.73
/src/abilities/infrastructure/post-identifier-resolver.php
1
<?php
2

3
// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.
4
namespace Yoast\WP\SEO\Abilities\Infrastructure;
5

6
use WP_Error;
7
use Yoast\WP\SEO\Models\Indexable;
8
use Yoast\WP\SEO\Repositories\Indexable_Repository;
9

10
/**
11
 * Resolves a user-friendly post identifier (ID, permalink, or title keywords) into post indexables.
12
 *
13
 * Lets callers find a post without knowing its numeric ID: by URL, or by a few
14
 * remembered title keywords. Shared by the read ability, the write ability, and
15
 * the write permission check.
16
 */
17
class Post_Identifier_Resolver {
18

19
        /**
20
         * The indexable repository.
21
         *
22
         * @var Indexable_Repository
23
         */
24
        private $indexable_repository;
25

26
        /**
27
         * Constructor.
28
         *
29
         * @param Indexable_Repository $indexable_repository The indexable repository.
30
         */
NEW
31
        public function __construct(
×
32
                Indexable_Repository $indexable_repository
33
        ) {
NEW
34
                $this->indexable_repository = $indexable_repository;
×
35
        }
36

37
        /**
38
         * Resolves the input to exactly one post indexable.
39
         *
40
         * Used by operations that have to target a single, unambiguous post.
41
         * Title keywords are deliberately not accepted here: they can match several
42
         * posts, so only an exact identifier (post ID or permalink) is allowed.
43
         *
44
         * @param array<string, int|string|bool|null> $input The input containing one of 'post_id' or 'permalink'.
45
         *
46
         * @return Indexable|WP_Error The matching indexable, or an error (missing or unknown identifier).
47
         */
48
        public function resolve_one( array $input ) {
14✔
49
                if ( $this->has( $input, 'post_id' ) ) {
14✔
50
                        return $this->by_id( (int) $input['post_id'] );
4✔
51
                }
52

53
                if ( $this->has( $input, 'permalink' ) ) {
10✔
54
                        return $this->by_permalink( (string) $input['permalink'] );
6✔
55
                }
56

57
                return new WP_Error(
4✔
58
                        'yoast_seo_missing_identifier',
4✔
59
                        \__( 'Provide a post_id or a permalink to identify the post.', 'wordpress-seo' ),
4✔
60
                        [ 'status' => 400 ],
4✔
61
                );
4✔
62
        }
63

64
        /**
65
         * Resolves the input to all matching post indexables.
66
         *
67
         * Used by the read path. A title search may match several posts; with no
68
         * identifier at all, an error is returned.
69
         *
70
         * @param array<string, int|string|bool|null> $input The input containing a 'post_id', 'permalink', or 'title', plus an optional 'page' for a title search.
71
         *
72
         * @return Indexable[]|WP_Error The matching indexables, or an error for a missing or unknown id/permalink.
73
         */
74
        public function resolve_many( array $input ) {
16✔
75
                if ( $this->has( $input, 'post_id' ) ) {
16✔
76
                        $indexable = $this->by_id( (int) $input['post_id'] );
4✔
77

78
                        return ( $indexable instanceof WP_Error ) ? $indexable : [ $indexable ];
4✔
79
                }
80

81
                if ( $this->has( $input, 'permalink' ) ) {
12✔
NEW
82
                        $indexable = $this->by_permalink( (string) $input['permalink'] );
×
83

NEW
84
                        return ( $indexable instanceof WP_Error ) ? $indexable : [ $indexable ];
×
85
                }
86

87
                if ( $this->has( $input, 'title' ) ) {
12✔
88
                        $page = ( isset( $input['page'] ) ) ? (int) $input['page'] : 1;
10✔
89

90
                        return $this->by_title( (string) $input['title'], $page );
10✔
91
                }
92

93
                return new WP_Error(
2✔
94
                        'yoast_seo_missing_identifier',
2✔
95
                        \__( 'Provide a post_id, a permalink, or title keywords to identify the post.', 'wordpress-seo' ),
2✔
96
                        [ 'status' => 400 ],
2✔
97
                );
2✔
98
        }
99

100
        /**
101
         * Resolves a post by its ID.
102
         *
103
         * @param int $post_id The post ID.
104
         *
105
         * @return Indexable|WP_Error The matching indexable, or a not-found error.
106
         */
107
        private function by_id( int $post_id ) {
4✔
108
                $indexable = $this->indexable_repository->find_by_id_and_type( $post_id, 'post', false );
4✔
109

110
                if ( ! $indexable ) {
4✔
111
                        return $this->invalid_identifier( 'post_id' );
2✔
112
                }
113

114
                return $indexable;
2✔
115
        }
116

117
        /**
118
         * Resolves a post by its permalink.
119
         *
120
         * Matches the permalink exactly against the indexable table; no WordPress URL
121
         * resolution is performed, so the exact stored permalink must be provided.
122
         *
123
         * @param string $permalink The permalink.
124
         *
125
         * @return Indexable|WP_Error The matching indexable, or a not-found error.
126
         */
127
        private function by_permalink( string $permalink ) {
6✔
128
                $indexable = $this->indexable_repository->find_by_permalink( $permalink );
6✔
129

130
                // The permalink lookup is not scoped by object type, so a term, author, or
131
                // archive URL can match too; only posts are valid targets here.
132
                if ( ! $indexable || $indexable->object_type !== 'post' ) {
6✔
133
                        return $this->invalid_identifier( 'permalink' );
4✔
134
                }
135

136
                return $indexable;
2✔
137
        }
138

139
        /**
140
         * Resolves posts by title keywords.
141
         *
142
         * A title search may match several posts; no match on the first page (or an
143
         * unexpected result that is not a list of indexables) is treated as not-found,
144
         * mirroring the by-id and by-permalink resolvers. An empty later page is valid
145
         * pagination past the last result, as the page parameter's schema documents.
146
         *
147
         * @param string $title The title keywords.
148
         * @param int    $page  The 1-based result page.
149
         *
150
         * @return Indexable[]|WP_Error The matching indexables, or a not-found error.
151
         */
152
        private function by_title( string $title, int $page ) {
10✔
153
                $indexables = $this->indexable_repository->find_posts_by_title_keywords( $title, $page );
10✔
154

155
                if ( $indexables === [] ) {
10✔
156
                        // An empty later page means the client paged past the last result, not a bad identifier.
157
                        if ( $page > 1 ) {
4✔
158
                                return [];
2✔
159
                        }
160

161
                        return $this->invalid_identifier( 'title' );
2✔
162
                }
163

164
                foreach ( $indexables as $indexable ) {
6✔
165
                        if ( ! ( $indexable instanceof Indexable ) ) {
6✔
166
                                return $this->invalid_identifier( 'title' );
2✔
167
                        }
168
                }
169

170
                return $indexables;
4✔
171
        }
172

173
        /**
174
         * Builds the error for an identifier that does not resolve to a post.
175
         *
176
         * Coded yoast_seo_invalid_<field> so a caller knows which identifier to correct and retry.
177
         *
178
         * @param string $field The identifier that failed to resolve: 'post_id', 'permalink', or 'title'.
179
         *
180
         * @return WP_Error The error for the unresolvable identifier.
181
         */
182
        private function invalid_identifier( string $field ): WP_Error {
6✔
183
                return new WP_Error(
6✔
184
                        'yoast_seo_invalid_' . $field,
6✔
185
                        \__( 'No post could be found for the given identifier.', 'wordpress-seo' ),
6✔
186
                        [ 'status' => 404 ],
6✔
187
                );
6✔
188
        }
189

190
        /**
191
         * Checks whether a non-empty identifier of the given key is present in the input.
192
         *
193
         * @param array<string, int|string|bool|null> $input The input.
194
         * @param string                              $key   The identifier key.
195
         *
196
         * @return bool Whether the identifier is present and non-empty.
197
         */
198
        private function has( array $input, string $key ): bool {
2✔
199
                return ( isset( $input[ $key ] ) && $input[ $key ] !== '' );
2✔
200
        }
201
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc