Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.83% covered (warning)
89.83%
1298 / 1445
76.39% covered (warning)
76.39%
55 / 72
CRAP
0.00% covered (danger)
0.00%
0 / 1
ZObjectStore
89.83% covered (warning)
89.83%
1298 / 1445
76.39% covered (warning)
76.39%
55 / 72
302.15
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getNextAvailableZid
97.50% covered (success)
97.50%
39 / 40
0.00% covered (danger)
0.00%
0 / 1
4
 getRevisionById
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 fetchZObjectByTitle
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 fetchZObject
63.16% covered (warning)
63.16%
12 / 19
0.00% covered (danger)
0.00%
0 / 1
7.80
 fetchBatchZObjects
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
3
 pushZObject
32.56% covered (danger)
32.56%
14 / 43
0.00% covered (danger)
0.00%
0 / 1
17.04
 createNewZObject
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 updateZObject
61.80% covered (warning)
61.80%
55 / 89
0.00% covered (danger)
0.00%
0 / 1
27.54
 updateZObjectAsSystemUser
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 deleteZObjectLabelsByZid
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 deleteZObjectLabelConflictsByZid
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 findZObjectLabelConflicts
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
4
 insertZObjectLabels
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
3.00
 insertZLanguageToLanguagesCache
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 insertZObjectLabelConflicts
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
3.00
 insertZObjectAliases
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
20
 synchroniseZObjectLabels
100.00% covered (success)
100.00%
72 / 72
100.00% covered (success)
100.00%
1 / 1
11
 synchroniseZObjectLabelConflicts
100.00% covered (success)
100.00%
54 / 54
100.00% covered (success)
100.00%
1 / 1
9
 makeLabelRowKey
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 makeConflictRowKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fetchZidsOfType
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 getCountOfTypeInstances
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 fetchAllZids
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 fetchAllZLanguageObjects
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 fetchAllZLanguageCodes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 findCodesFromZLanguage
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 fetchAllZLanguagesWithLabels
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 fetchAllInstancedTypesWithLabels
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
3.00
 fetchAllInstancedTypes
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 getReturnTypeQuery
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
1
 getPreferredLabelsQuery
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
1 / 1
4
 getStringMatchCondition
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
4
 searchFunctions
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
30
 getTestStatusQuery
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
1
 getTestStatusForLatestRevisions
100.00% covered (success)
100.00%
56 / 56
100.00% covered (success)
100.00%
1 / 1
2
 findZLanguageFromCode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 findZLanguagesFromCodes
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 searchZObjectLabels
100.00% covered (success)
100.00%
56 / 56
100.00% covered (success)
100.00%
1 / 1
11
 fetchZObjectLabels
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
9
 fetchZObjectLabel
96.55% covered (success)
96.55%
28 / 29
0.00% covered (danger)
0.00%
0 / 1
7
 fetchZFunctionReturnType
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 findFirstZImplementationFunction
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 findReferencedZObjectsByZFunctionIdAsList
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 findReferencedZObjectsByZFunctionId
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
2.00
 insertZFunctionReference
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 deleteZFunctionReference
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 synchroniseZFunctionReference
100.00% covered (success)
100.00%
41 / 41
100.00% covered (success)
100.00%
1 / 1
10
 findRelatedZObjectsByKeyAsList
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 findRelatedZObjectsByKey
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
2.00
 findFunctionsReferencingZObjectByKey
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 findFunctionsByRenderableIO
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 functionsByRenderableIOQuery
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
1
 getRenderableIOQuery
100.00% covered (success)
100.00%
60 / 60
100.00% covered (success)
100.00%
1 / 1
3
 findFunctionsByIOTypes
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 functionsByIOTypesQuery
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
4
 newIOTypeQuery
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
2
 insertRelatedZObjects
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 deleteRelatedZObjects
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
6.01
 synchroniseRelatedZObjects
100.00% covered (success)
100.00%
63 / 63
100.00% covered (success)
100.00%
1 / 1
10
 makeRelatedRowKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 findZTesterResult
91.67% covered (success)
91.67%
55 / 60
0.00% covered (danger)
0.00%
0 / 1
11.07
 insertZTesterResult
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
3
 isLatestRevisionTuple
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 isLatestRevision
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 deleteZFunctionFromZTesterResultsCache
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 deleteZImplementationFromZTesterResultsCache
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 deleteZTesterFromZTesterResultsCache
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 deleteZLanguageFromLanguagesCache
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 synchroniseZLanguageCodes
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
8
 removeFunctionReferenceIfImplementationOrTester
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
5.01
 removeReferenceFromFunction
88.89% covered (warning)
88.89%
40 / 45
0.00% covered (danger)
0.00%
0 / 1
6.05
1<?php
2/**
3 * WikiLambda Data Access Object service
4 *
5 * @file
6 * @ingroup Extensions
7 * @copyright 2020– Abstract Wikipedia team; see AUTHORS.txt
8 * @license MIT
9 */
10
11namespace MediaWiki\Extension\WikiLambda;
12
13use Exception;
14use MediaWiki\Content\ContentSerializationException;
15use MediaWiki\Context\RequestContext;
16use MediaWiki\Extension\WikiLambda\Cache\MemcachedWrapper;
17use MediaWiki\Extension\WikiLambda\Registry\ZErrorTypeRegistry;
18use MediaWiki\Extension\WikiLambda\Registry\ZLangRegistry;
19use MediaWiki\Extension\WikiLambda\Registry\ZTypeRegistry;
20use MediaWiki\Extension\WikiLambda\ZObjectContent\ZObjectContent;
21use MediaWiki\Extension\WikiLambda\ZObjectContent\ZObjectContentHandler;
22use MediaWiki\Extension\WikiLambda\ZObjectContent\ZObjectPage;
23use MediaWiki\Extension\WikiLambda\ZObjects\ZPersistentObject;
24use MediaWiki\Extension\WikiLambda\ZObjects\ZReference;
25use MediaWiki\Extension\WikiLambda\ZObjects\ZResponseEnvelope;
26use MediaWiki\Extension\WikiLambda\ZObjects\ZTypedList;
27use MediaWiki\Language\MessageLocalizer;
28use MediaWiki\MediaWikiServices;
29use MediaWiki\Page\WikiPage;
30use MediaWiki\Page\WikiPageFactory;
31use MediaWiki\Permissions\Authority;
32use MediaWiki\Revision\RevisionRecord;
33use MediaWiki\Revision\RevisionStore;
34use MediaWiki\Revision\SlotRecord;
35use MediaWiki\Title\Title;
36use MediaWiki\Title\TitleArrayFromResult;
37use MediaWiki\Title\TitleFactory;
38use MediaWiki\User\User;
39use MediaWiki\User\UserGroupManager;
40use Psr\Log\LoggerInterface;
41use Wikimedia\Rdbms\FakeResultWrapper;
42use Wikimedia\Rdbms\IConnectionProvider;
43use Wikimedia\Rdbms\IExpression;
44use Wikimedia\Rdbms\IReadableDatabase;
45use Wikimedia\Rdbms\IResultWrapper;
46use Wikimedia\Rdbms\LikeValue;
47use Wikimedia\Rdbms\SelectQueryBuilder;
48use Wikimedia\Rdbms\Subquery;
49
50class ZObjectStore {
51
52    private MemcachedWrapper $zObjectCache;
53
54    // NOTE: This constant hard-codes user-provided content as starting from 'Z10000'. Now that the
55    // wiki has launched, change it will have unpredicatable effects.
56    private const PREDEFINED_TOP_LIMIT = 'Z9999';
57
58    public const ZOBJECT_CACHE_KEY_PREFIX = 'WikiLambdaObjectStorage';
59    public const TESTER_RESULT_CACHE_WRITE_INSERTED = 'inserted';
60    public const TESTER_RESULT_CACHE_WRITE_STALE = 'stale';
61    public const TESTER_RESULT_CACHE_WRITE_FAILED = 'failed';
62
63    /**
64     * @param IConnectionProvider $dbProvider
65     * @param TitleFactory $titleFactory
66     * @param WikiPageFactory $wikiPageFactory
67     * @param RevisionStore $revisionStore
68     * @param UserGroupManager $userGroupManager
69     * @param LoggerInterface $logger
70     */
71    public function __construct(
72        private readonly IConnectionProvider $dbProvider,
73        private readonly TitleFactory $titleFactory,
74        private readonly WikiPageFactory $wikiPageFactory,
75        private readonly RevisionStore $revisionStore,
76        private readonly UserGroupManager $userGroupManager,
77        private readonly LoggerInterface $logger
78    ) {
79        // Non-injected items
80        $this->zObjectCache = WikiLambdaServices::getMemcachedWrapper();
81    }
82
83    /**
84     * Find next available ZID in the database to create a new ZObject
85     *
86     * @return string Next available ZID
87     */
88    public function getNextAvailableZid(): string {
89        // Intentionally use DB_PRIMARY as we need the latest data here.
90        $dbr = $this->dbProvider->getPrimaryDatabase();
91
92        $conditions = [
93            'page_namespace' => NS_MAIN,
94            'LENGTH( page_title ) > 5',
95            $dbr->expr( 'page_title', IExpression::LIKE, new LikeValue( 'Z', $dbr->anyString() ) )
96        ];
97
98        // First try, get zid of highest page_id, skipping predefined
99        $latestZid = $dbr->newSelectQueryBuilder()
100            ->select( 'page_title' )
101            ->from( 'page' )
102            ->where( $conditions )
103            ->orderBy( 'page_id', SelectQueryBuilder::SORT_DESC )
104            ->caller( __METHOD__ )
105            ->fetchField();
106
107        if ( $latestZid === false ) {
108            $latestZid = self::PREDEFINED_TOP_LIMIT;
109        }
110        $targetZid = 'Z' . ( max( intval( substr( $latestZid, 1 ) ) + 1, 10000 ) );
111
112        // Check if target zid already exists, just in case of general zid disarray
113        // where the latest non-predefined zid is not the highest one
114        $exists = $dbr->newSelectQueryBuilder()
115            ->select( 'COUNT(*)' )
116            ->from( 'page' )
117            ->where( $conditions )
118            ->andWhere( [ 'page_title' => $targetZid ] )
119            ->caller( __METHOD__ )
120            ->fetchField();
121
122        // If Zid after latest added is available, return
123        if ( !$exists ) {
124            return $targetZid;
125        }
126
127        // If Zid after latest is not available, find Zid after highest one
128        // NOTE: this operation is much more expensive, so we should only
129        // perform it in the exceptional case that latestZid fails.
130        $highestZid = $dbr->newSelectQueryBuilder()
131            ->select( 'page_title' )
132            ->from( 'page' )
133            ->where( $conditions )
134            ->orderBy( 'CAST(SUBSTR(page_title, 2) AS INTEGER)', SelectQueryBuilder::SORT_DESC )
135            ->caller( __METHOD__ )
136            ->fetchField();
137
138        if ( $highestZid === false ) {
139            $highestZid = self::PREDEFINED_TOP_LIMIT;
140        }
141
142        $this->logger->warning(
143            __METHOD__ . ' at first got "' . $targetZid . '" â€” exists; slower query gets: "Z' . $highestZid . '".',
144            [ 'targetZid' => $targetZid, 'highestZid' => $highestZid ]
145        );
146
147        $targetZid = 'Z' . ( max( intval( substr( $highestZid, 1 ) ) + 1, 10000 ) );
148
149        return $targetZid;
150    }
151
152    /**
153     * Load a page revision from a given revision ID number.
154     * Returns null if no such revision can be found.
155     *
156     * @param int $id Revision ID of this revision
157     * @return RevisionRecord|null
158     */
159    public function getRevisionById( int $id ): ?RevisionRecord {
160        $revisionRecord = $this->revisionStore->getRevisionById( $id );
161        return $revisionRecord;
162    }
163
164    /**
165     * Fetch the ZObject given its title and return it wrapped in a ZObjectContent object
166     *
167     * When a performer is supplied the content is read for that user's audience,
168     * so a RevisionDelete'd or suppressed revision they may not see yields false;
169     * internal/system callers pass no performer and read raw.
170     *
171     * TODO: Once the security fix is deployed, make the visibility audience
172     * explicit â€” an $audience parameter mirroring RevisionRecord::getContent(),
173     * so the ~22 internal/system callers state RAW deliberately â€” rather than
174     * inferring it from whether a performer is passed. Deferred to keep this
175     * patch minimal; do it under regular code review.
176     *
177     * @param Title $title The ZObject to fetch
178     * @param int|null $requestedRevision The revision ID of the page to fetch. If unset, the latest is returned.
179     * @param Authority|null $performer User to check deletion/suppression visibility for, if any
180     * @return ZObjectContent|bool Found ZObject
181     */
182    public function fetchZObjectByTitle( Title $title, ?int $requestedRevision = null, ?Authority $performer = null ) {
183        if ( $requestedRevision ) {
184            $revision = $this->revisionStore->getRevisionByTitle( $title, $requestedRevision, 0 );
185        } else {
186            $revision = $this->revisionStore->getKnownLatestRevision( $title );
187        }
188
189        if ( !$revision ) {
190            // Return false: We should not throw exceptions that trigger more fetches (ZErrorException)
191            return false;
192        }
193
194        // NOTE: Hard-coding use of MAIN slot; if we're going the MCR route, we may wish to change this (or not).
195        // Honour deletion/suppression for a specific user; null-fail if they may not see it.
196        $audience = $performer ? RevisionRecord::FOR_THIS_USER : RevisionRecord::RAW;
197        return $revision->getContent( SlotRecord::MAIN, $audience, $performer ) ?? false;
198    }
199
200    /**
201     * Get the current ZPersistentObject of a given ZID, from the cache if available
202     *
203     * @param string $zid
204     * @return ZObjectContent|bool Cached or persisted ZObject, false if not found or invalid
205     */
206    public function fetchZObject( string $zid ) {
207        $cacheKey = $this->zObjectCache->makeKey( self::ZOBJECT_CACHE_KEY_PREFIX, $zid );
208        $cachedObject = $this->zObjectCache->get( $cacheKey );
209
210        if ( $cachedObject ) {
211            $cachedContent = ZObjectContentHandler::makeContent( $cachedObject, null, CONTENT_MODEL_ZOBJECT );
212            if ( $cachedContent->isValid() ) {
213                return $cachedContent;
214            }
215            // Something went wrong;
216            // the object stored in the cache is not valid, delete from cache and go on
217            $this->zObjectCache->delete( $cacheKey );
218        }
219
220        // Cache miss somehow; let's re-fetch the object, and stash it in the cache.
221
222        $title = Title::newFromText( $zid );
223        if ( !$title ) {
224            // Return false: We should not throw exceptions that trigger more fetches (ZErrorException)
225            return false;
226        }
227
228        $zObjectContent = $this->fetchZObjectByTitle( $title );
229        if ( !$zObjectContent || !$zObjectContent->isValid() ) {
230            // Return false: We should not throw exceptions that trigger more fetches (ZErrorException)
231            return false;
232        }
233
234        $this->zObjectCache->set(
235            $cacheKey,
236            $zObjectContent->getText(),
237            $this->zObjectCache::TTL_MONTH
238        );
239
240        return $zObjectContent;
241    }
242
243    /**
244     * Returns an array of ZPersistentObjects fetched from the DB given an array of their Zids
245     *
246     * Note that this will only fetch the latest revision of a ZObject; if you want a specific
247     * revision, you need to use ZObjectStore::fetchZObjectByTitle() instead.
248     *
249     * @param string[] $zids
250     * @return ZPersistentObject[]
251     */
252    public function fetchBatchZObjects( $zids ): array {
253        $dbr = $this->dbProvider->getReplicaDatabase();
254        $query = WikiPage::getQueryInfo();
255
256        $res = $dbr->newSelectQueryBuilder()
257            ->select( $query['fields'] )
258            ->from( 'page' )
259            ->where( [
260                'page_namespace' => NS_MAIN,
261                'page_title' => $zids
262            ] )
263            ->caller( __METHOD__ )
264            ->fetchResultSet();
265
266        $titleArray = new TitleArrayFromResult( $res );
267
268        $dataArray = [];
269        foreach ( $titleArray as $title ) {
270            // TODO (T300521): Handle error from fetchZObjectByTitle
271            $content = $this->fetchZObjectByTitle( $title );
272            if ( $content->isValid() ) {
273                $dataArray[ $title->getBaseText() ] = $content->getZObject();
274            }
275        }
276        return $dataArray;
277    }
278
279    /**
280     * Push a given Object into the Database, without validation.
281     *
282     * @param string $zid
283     * @param string $data
284     * @param string $summary
285     * @return bool True if a new revision was created; false on a null edit (no change)
286     * @throws Exception
287     */
288    public function pushZObject( string $zid, string $data, string $summary ) {
289        $title = $this->titleFactory->newFromText( $zid, NS_MAIN );
290        // Error: Failed creating title due to invalid format
291        if ( !$title ) {
292            throw new ZErrorException(
293                ZErrorFactory::createZErrorInstance(
294                    ZErrorTypeRegistry::Z_ERROR_INVALID_TITLE,
295                    [ 'title' => $zid ]
296                )
297            );
298        }
299
300        $page = $this->wikiPageFactory->newFromTitle( $title );
301        $flags = $title->exists() ? EDIT_UPDATE : EDIT_NEW;
302
303        // Create system user:
304        $creatingUserName = wfMessage( 'wikilambda-systemuser' )->inLanguage( 'en' )->text();
305        $user = User::newSystemUser( $creatingUserName, [ 'steal' => true ] );
306        $this->userGroupManager->addUserToGroup( $user, 'sysop' );
307        $this->userGroupManager->addUserToGroup( $user, 'functionmaintainer' );
308        $this->userGroupManager->addUserToGroup( $user, 'functioneer' );
309        $this->userGroupManager->addUserToGroup( $user, 'wikifunctions-staff' );
310
311        try {
312            $content = ZObjectContentHandler::makeContent( $data, $title );
313        } catch ( ZErrorException $e ) {
314            // Error: Make content will only fail if JSON is invalid
315            $this->logger->warning(
316                __METHOD__ . ' triggered an error on creating content for page "' . $zid . '"',
317                [ 'responseError' => $e ]
318            );
319            throw $e;
320        }
321
322        try {
323            $status = $page->doUserEditContent( $content, $user, $summary, $flags );
324        } catch ( Exception $e ) {
325            // Error: Database or MediaWiki exception
326            $this->logger->warning(
327                __METHOD__ . ' triggered an error on publish for page "' . $zid . '"',
328                [ 'responseError' => $e ]
329            );
330            throw $e;
331        }
332
333        // Error: Other doUserEditContent related errors
334        if ( !$status->isOK() ) {
335            $statusMessage = $status->getMessage();
336            $this->logger->info(
337                __METHOD__ . ' got a non-OK Status on publish, for page "' . $zid . '"',
338                [ 'responseStatus' => var_export( $statusMessage, true ) ]
339            );
340            throw new ZErrorException(
341                ZErrorFactory::createZErrorInstance(
342                    ZErrorTypeRegistry::Z_ERROR_UNKNOWN,
343                    [ 'message' => (string)$statusMessage ]
344                )
345            );
346        }
347
348        return $status->wasRevisionCreated();
349    }
350
351    /**
352     * Create a new ZObject, with a newly assigned ZID, and store it in the Database
353     *
354     * @param MessageLocalizer $context The context of the action operation, for localisation of messages
355     * @param string $data
356     * @param string $summary
357     * @param User $user
358     * @return ZObjectPage
359     */
360    public function createNewZObject( MessageLocalizer $context, string $data, string $summary, User $user ) {
361        // Find all placeholder ZIDs and ZKeys and replace those with the next available ZID
362        $zid = $this->getNextAvailableZid();
363        $zObjectString = ZObjectUtils::replaceNullReferencePlaceholder( $data, $zid );
364
365        return $this->updateZObject( $context, $zid, $zObjectString, $summary, $user, EDIT_NEW );
366    }
367
368    /**
369     * Create or update a ZObject it in the Database
370     *
371     * @param MessageLocalizer $context The context of the action operation, for localisation of messages
372     * @param string $zid The ZID of the page to create/update, e.g. 'Z12345'
373     * @param string $data The ZObject's JSON to store, in string form, i.e. "{ Z1K1: "Z2", Z2K1: â€¦ }"
374     * @param string $summary An edit summary to display in the page's history, Recent Changes, watchlists, etc.
375     * @param User $user The user making the edit.
376     * @param int $flags Either EDIT_UPDATE (default) if editing or EDIT_NEW if creating a page
377     * @return ZObjectPage
378     */
379    public function updateZObject(
380        MessageLocalizer $context, string $zid, string $data, string $summary, User $user, int $flags = EDIT_UPDATE
381    ) {
382        $title = $this->titleFactory->newFromText( $zid, NS_MAIN );
383
384        // ERROR: Title is empty or invalid
385        if ( !( $title instanceof Title ) ) {
386            $error = ZErrorFactory::createZErrorInstance(
387                ZErrorTypeRegistry::Z_ERROR_INVALID_TITLE,
388                [ 'title' => $zid ]
389            );
390            return ZObjectPage::newFatal( $error );
391        }
392
393        // If edit flag or title did not exist, we are creating a new object
394        $creating = ( $flags === EDIT_NEW ) || !( $title->exists() );
395
396        try {
397            $content = ZObjectContentHandler::makeContent( $data, $title );
398        } catch ( ZErrorException $e ) {
399            $this->logger->info(
400                __METHOD__ . ': makeContent threw ZErrorException for {zid}: {message}',
401                [ 'zid' => $zid, 'message' => $e->getMessage() ]
402            );
403            return ZObjectPage::newFatal( $e->getZError() );
404        } catch ( ContentSerializationException $mwe ) {
405            $this->logger->info(
406                __METHOD__ . ': makeContent threw ContentSerializationException for {zid}: {message}',
407                [ 'zid' => $zid, 'message' => $mwe->getMessage() ]
408            );
409            return ZObjectPage::newFatal(
410                // We can't cleanly recover the inner ZErrorException (if indeed it was even thrown by us), so
411                // for now just pass this down as a Z500, perhaps with the ErrorType as the message.
412                ZErrorFactory::createZErrorInstance(
413                    ZErrorTypeRegistry::Z_ERROR_UNKNOWN,
414                    [ 'message' => $mwe->getMessage() ]
415                )
416            );
417        }
418
419        // Error: ZObject validation errors.
420        if ( !( $content->isValid() ) ) {
421            return ZObjectPage::newFatal( $content->getErrors() );
422        }
423
424        // Validate that $zid and zObject[Z2K1] are the same
425        $zObjectId = $content->getZid();
426
427        if ( $zObjectId !== $zid ) {
428            $error = ZErrorFactory::createZErrorInstance(
429                ZErrorTypeRegistry::Z_ERROR_UNMATCHING_ZID,
430                [
431                    'zid' => $zObjectId,
432                    'title' => $zid
433                ]
434            );
435            return ZObjectPage::newFatal( $error );
436        }
437
438        $ztype = $content->getZType();
439
440        // Stop from creating and editing any types form DISALLOWED_ROOT_ZOBJECT
441        if ( in_array( $ztype, ZTypeRegistry::DISALLOWED_ROOT_ZOBJECTS ) ) {
442            $error = ZErrorFactory::createZErrorInstance(
443                ZErrorTypeRegistry::Z_ERROR_DISALLOWED_ROOT_ZOBJECT,
444                [ 'data' => $content->getZType() ]
445            );
446            return ZObjectPage::newFatal( $error );
447        }
448
449        // Find the label conflicts.
450        $labels = $content->getLabels()->getValueAsList();
451        $clashes = $this->findZObjectLabelConflicts( $zid, $ztype, $labels );
452
453        if ( count( $clashes ) > 0 ) {
454            $error = ZErrorFactory::createLabelClashZErrors( $clashes );
455            return ZObjectPage::newFatal( $error );
456        }
457
458        // Use ZObjectAuthorization service to check that the user has the required permissions
459        // while creating or editing an object
460        $fromContent = null;
461        if ( !$creating ) {
462            $currentRevision = $this->revisionStore->getKnownLatestRevision( $title );
463            $fromContent = $currentRevision->getSlots()->getContent( SlotRecord::MAIN );
464            '@phan-var ZObjectContent $fromContent';
465        }
466        $authorizationService = WikiLambdaServices::getZObjectAuthorization();
467        $status = $authorizationService->authorize( $fromContent, $content, $user, $title );
468
469        // Return AuthorizationStatus->error if authorization service failed
470        if ( !$status->isValid() ) {
471            return ZObjectPage::newFatal( $status->getErrors() );
472        }
473
474        // Run ZObjectContent field validation
475        try {
476            $content->validateFields( $context );
477        } catch ( ZErrorException $e ) {
478            return ZObjectPage::newFatal( $e->getZError() );
479        }
480
481        // We prepare the content to be saved
482        $page = $this->wikiPageFactory->newFromTitle( $title );
483        try {
484            $status = $page->doUserEditContent( $content, $user, $summary, $flags );
485        } catch ( Exception $e ) {
486            // Error: Database or a deeper MediaWiki error, e.g. a general editing rate limit
487
488            $this->logger->warning(
489                __METHOD__ . ' triggered an error on publish, e.g. rate limited, for page "' . $zid . '"',
490                [ 'responseError' => $e ]
491            );
492
493            if ( $e instanceof ZErrorException ) {
494                // TODO (T362236): Add the rendering language as a parameter, don't default to English
495                $errorMessage = $e->getZError()->getMessage( 'en' );
496            } else {
497                $errorMessage = $e->getMessage();
498            }
499
500            $error = ZErrorFactory::createZErrorInstance(
501                ZErrorTypeRegistry::Z_ERROR_UNKNOWN,
502                [ 'message' => $errorMessage ]
503            );
504            return ZObjectPage::newFatal( $error );
505        }
506
507        if ( !$status->isOK() ) {
508
509            // TODO (T362246): Dependency-inject
510            $statusFormatter = MediaWikiServices::getInstance()->getFormatterFactory()
511                ->getStatusFormatter( $context );
512
513            // Error: Other doUserEditContent related errors
514
515            $this->logger->info(
516                __METHOD__ . ' got a non-OK Status on publish, for page "' . $zid . '"',
517                [ 'responseStatus' => var_export( $status, true ) ]
518            );
519
520            $error = ZErrorFactory::createZErrorInstance(
521                ZErrorTypeRegistry::Z_ERROR_UNKNOWN,
522                [ 'message' => $statusFormatter->getMessage( $status )->plain() ]
523            );
524            return ZObjectPage::newFatal( $error );
525        }
526
527        // Success: return WikiPage
528        return ZObjectPage::newSuccess( $page );
529    }
530
531    /**
532     * Create or update a ZObject it in the Database as a System User
533     *
534     * @param MessageLocalizer $context The context of the action operation, for localisation of messages
535     * @param string $zid
536     * @param string $data
537     * @param string $summary
538     * @param int $flags
539     * @return ZObjectPage
540     */
541    public function updateZObjectAsSystemUser(
542        MessageLocalizer $context, string $zid, string $data, string $summary, int $flags = EDIT_UPDATE
543    ) {
544        $creatingUserName = wfMessage( 'wikilambda-systemuser' )->inLanguage( 'en' )->text();
545        // System user must belong to all privileged groups in order to
546        // perform all zobject creation and editing actions:
547        $user = User::newSystemUser( $creatingUserName, [ 'steal' => true ] );
548        $this->userGroupManager->addUserToGroup( $user, 'sysop' );
549        $this->userGroupManager->addUserToGroup( $user, 'functionmaintainer' );
550        $this->userGroupManager->addUserToGroup( $user, 'functioneer' );
551        $this->userGroupManager->addUserToGroup( $user, 'wikifunctions-staff' );
552        // Make sure the edit will be marked as a bot edit
553        $flags |= EDIT_FORCE_BOT;
554        return $this->updateZObject( $context, $zid, $data, $summary, $user, $flags );
555    }
556
557    /**
558     * Delete the labels from the wikilambda_zobject_labels database that correspond
559     * to the given ZID.
560     *
561     * @param string $zid
562     */
563    public function deleteZObjectLabelsByZid( string $zid ): void {
564        $dbw = $this->dbProvider->getPrimaryDatabase();
565
566        $dbw->newDeleteQueryBuilder()
567            ->deleteFrom( 'wikilambda_zobject_labels' )
568            ->where( [ 'wlzl_zobject_zid' => $zid ] )
569            ->caller( __METHOD__ )->execute();
570    }
571
572    /**
573     * Delete the label conflicts from the wikilambda_zobject_label_conflicts database
574     * that correspond to the given ZID.
575     *
576     * @param string $zid
577     */
578    public function deleteZObjectLabelConflictsByZid( string $zid ): void {
579        $dbw = $this->dbProvider->getPrimaryDatabase();
580
581        $dbw->newDeleteQueryBuilder()
582            ->deleteFrom( 'wikilambda_zobject_label_conflicts' )
583            ->where(
584                $dbw->expr( 'wlzlc_existing_zid', '=', $zid )
585                    ->or( 'wlzlc_conflicting_zid', '=', $zid )
586            )
587            ->caller( __METHOD__ )->execute();
588    }
589
590    /**
591     * Query the wikilambda_zobject_labels database for primary labels that have
592     * the same combination of language code and value for a different ZID
593     * than the given in the parameters. These will be considered conflicting labels.
594     *
595     * @param string $zid
596     * @param string $ztype
597     * @param array<string,string> $labels Array of labels, where the key is the language code and the value
598     * is the string representation of the label in that language
599     * @return array Conflicts found in the wikilambda_zobject_labels database
600     */
601    public function findZObjectLabelConflicts( $zid, $ztype, $labels ): array {
602        $dbr = $this->dbProvider->getReplicaDatabase();
603
604        // remove labels with an undefined value
605        $labels = array_filter(
606            $labels, static function ( $value ) {
607                return $value !== "";
608            } );
609
610        if ( $labels === [] ) {
611            return [];
612        }
613
614        $labelConflictConditions = [];
615        foreach ( $labels as $language => $value ) {
616            $labelConflictConditions[] = $dbr->andExpr( [
617                'wlzl_language' => $language,
618                'wlzl_label' => $value,
619                'wlzl_label_primary' => true
620            ] );
621        }
622
623        $res = $dbr->newSelectQueryBuilder()
624            ->select( [ 'wlzl_zobject_zid', 'wlzl_language' ] )
625            ->from( 'wikilambda_zobject_labels' )
626            ->where( [
627                $dbr->expr( 'wlzl_zobject_zid', '!=', $zid ),
628                // TODO (T357552): Check against type, once we properly implement that.
629                // 'wlzl_type' => $ztype,
630                $dbr->orExpr( $labelConflictConditions )
631            ] )
632            ->caller( __METHOD__ )
633            ->fetchResultSet();
634
635        $conflicts = [];
636        foreach ( $res as $row ) {
637            // TODO (T362247): What if more than one conflicts with us on each language?
638            $conflicts[ $row->wlzl_language ] = $row->wlzl_zobject_zid;
639        }
640
641        return $conflicts;
642    }
643
644    /**
645     * Insert labels into the wikilambda_zobject_labels database for a given ZID and Type
646     *
647     * @param string $zid
648     * @param string $ztype
649     * @param array $labels Array of labels, where the key is the language code and the value
650     * is the string representation of the label in that language
651     * @param string|null $returnType
652     */
653    public function insertZObjectLabels( $zid, $ztype, $labels, $returnType = null ): void {
654        $dbw = $this->dbProvider->getPrimaryDatabase();
655
656        $updates = [];
657        foreach ( $labels as $language => $value ) {
658            $updates[] = [
659                'wlzl_zobject_zid' => $zid,
660                'wlzl_language' => $language,
661                'wlzl_type' => $ztype,
662                'wlzl_label' => $value,
663                'wlzl_label_normalised' => ZObjectUtils::comparableString( $value ),
664                'wlzl_label_primary' => true,
665                'wlzl_return_type' => $returnType
666            ];
667        }
668
669        // Exit early if there are no updates to make.
670        if ( count( $updates ) === 0 ) {
671            return;
672        }
673
674        $dbw->newInsertQueryBuilder()
675            ->insertInto( 'wikilambda_zobject_labels' )
676            ->rows( $updates )
677            ->caller( __METHOD__ )->execute();
678    }
679
680    /**
681     * Insert language code into the wikilambda_zlanguages database for a given ZID
682     *
683     * @param string $zid
684     * @param string $languageCode
685     */
686    public function insertZLanguageToLanguagesCache( string $zid, string $languageCode ): void {
687        $dbw = $this->dbProvider->getPrimaryDatabase();
688
689        $dbw->newInsertQueryBuilder()
690            ->insertInto( 'wikilambda_zlanguages' )
691            ->row( [ 'wlzlangs_zid' => $zid, 'wlzlangs_language' => $languageCode ] )
692            ->caller( __METHOD__ )->execute();
693    }
694
695    /**
696     * Insert label conflicts into the wikilambda_zobject_label_conflicts database for a given ZID
697     *
698     * @param string $zid
699     * @param array $conflicts Array of labels, where the key is the language code and the value
700     * is the other ZID for which this label is repeated
701     */
702    public function insertZObjectLabelConflicts( $zid, $conflicts ): void {
703        $dbw = $this->dbProvider->getPrimaryDatabase();
704
705        $updates = [];
706        foreach ( $conflicts as $language => $existingZid ) {
707            $updates[] = [
708                'wlzlc_existing_zid' => $existingZid,
709                'wlzlc_conflicting_zid' => $zid,
710                'wlzlc_language' => $language,
711            ];
712        }
713
714        // Exit early if there are no updates to make.
715        if ( count( $updates ) === 0 ) {
716            return;
717        }
718
719        $dbw->newInsertQueryBuilder()
720            ->insertInto( 'wikilambda_zobject_label_conflicts' )
721            ->rows( $updates )
722            ->caller( __METHOD__ )->execute();
723    }
724
725    /**
726     * Insert alias (secondary labels) into the wikilambda_zobject_labels database for a given ZID and Type
727     *
728     * @param string $zid
729     * @param string $ztype
730     * @param array $aliases Set of labels, where the key is the language code
731     * and the value is an array of strings
732     * @param string|null $returnType
733     */
734    public function insertZObjectAliases( $zid, $ztype, $aliases, $returnType = null ): void {
735        $dbw = $this->dbProvider->getPrimaryDatabase();
736
737        $updates = [];
738        foreach ( $aliases as $language => $stringset ) {
739            foreach ( $stringset as $value ) {
740                $updates[] = [
741                    'wlzl_zobject_zid' => $zid,
742                    'wlzl_language' => $language,
743                    'wlzl_type' => $ztype,
744                    'wlzl_label' => $value,
745                    'wlzl_label_normalised' => ZObjectUtils::comparableString( $value ),
746                    'wlzl_label_primary' => false,
747                    'wlzl_return_type' => $returnType
748                ];
749            }
750        }
751
752        if ( count( $updates ) === 0 ) {
753            return;
754        }
755
756        $dbw->newInsertQueryBuilder()
757            ->insertInto( 'wikilambda_zobject_labels' )
758            ->rows( $updates )
759            ->caller( __METHOD__ )->execute();
760    }
761
762    /**
763     * Reconcile the rows in wikilambda_zobject_labels for a given ZID with the desired
764     * set of primary labels and aliases, writing only the differences.
765     *
766     * Existing rows whose content tuple matches a desired row are preserved (their
767     * wlzl_id is re-used); unmatched existing rows are deleted by id; desired rows
768     * with no match are inserted. When the desired set is identical to the existing
769     * set, no writes are issued at all. (T300522)
770     *
771     * Eventually we could replace this with a simpler upsert(), but that would need a
772     * DB schema change.
773     *
774     * @param string $zid
775     * @param string $ztype
776     * @param array<string,string> $primaryLabels Labels keyed by language code
777     * @param array<string,string[]> $aliases Aliases keyed by language code, with a list of strings each
778     * @param string|null $returnType
779     */
780    public function synchroniseZObjectLabels(
781        string $zid,
782        string $ztype,
783        array $primaryLabels,
784        array $aliases,
785        ?string $returnType = null
786    ): void {
787        $dbw = $this->dbProvider->getPrimaryDatabase();
788
789        // Build the desired row payloads from primary labels and aliases.
790        $desiredRows = [];
791        foreach ( $primaryLabels as $language => $value ) {
792            $desiredRows[] = [
793                'wlzl_zobject_zid' => $zid,
794                'wlzl_language' => $language,
795                'wlzl_type' => $ztype,
796                'wlzl_label' => $value,
797                'wlzl_label_normalised' => ZObjectUtils::comparableString( $value ),
798                'wlzl_label_primary' => true,
799                'wlzl_return_type' => $returnType,
800            ];
801        }
802        foreach ( $aliases as $language => $stringset ) {
803            foreach ( $stringset as $value ) {
804                $desiredRows[] = [
805                    'wlzl_zobject_zid' => $zid,
806                    'wlzl_language' => $language,
807                    'wlzl_type' => $ztype,
808                    'wlzl_label' => $value,
809                    'wlzl_label_normalised' => ZObjectUtils::comparableString( $value ),
810                    'wlzl_label_primary' => false,
811                    'wlzl_return_type' => $returnType,
812                ];
813            }
814        }
815
816        // Read existing rows from the primary DB: replica lag could otherwise
817        // produce spurious inserts (existing row not yet visible) or deletes.
818        $existingRows = $dbw->newSelectQueryBuilder()
819            ->select( [
820                'wlzl_id',
821                'wlzl_language',
822                'wlzl_type',
823                'wlzl_label',
824                'wlzl_label_primary',
825                'wlzl_return_type',
826            ] )
827            ->from( 'wikilambda_zobject_labels' )
828            ->where( [ 'wlzl_zobject_zid' => $zid ] )
829            ->caller( __METHOD__ )
830            ->fetchResultSet();
831
832        // Bucket existing rows by their content tuple so each desired row can
833        // claim one matching existing row (preserving its wlzl_id).
834        $existingByKey = [];
835        foreach ( $existingRows as $row ) {
836            $key = self::makeLabelRowKey(
837                $row->wlzl_language,
838                $row->wlzl_type,
839                $row->wlzl_label,
840                (bool)$row->wlzl_label_primary,
841                $row->wlzl_return_type
842            );
843            $existingByKey[$key][] = (int)$row->wlzl_id;
844        }
845
846        $toInsert = [];
847        foreach ( $desiredRows as $row ) {
848            $key = self::makeLabelRowKey(
849                $row['wlzl_language'],
850                $row['wlzl_type'],
851                $row['wlzl_label'],
852                $row['wlzl_label_primary'],
853                $row['wlzl_return_type']
854            );
855            if ( !empty( $existingByKey[$key] ) ) {
856                array_shift( $existingByKey[$key] );
857            } else {
858                $toInsert[] = $row;
859            }
860        }
861
862        $toDeleteIds = [];
863        foreach ( $existingByKey as $ids ) {
864            foreach ( $ids as $id ) {
865                $toDeleteIds[] = $id;
866            }
867        }
868
869        if ( $toDeleteIds !== [] ) {
870            $dbw->newDeleteQueryBuilder()
871                ->deleteFrom( 'wikilambda_zobject_labels' )
872                ->where( [ 'wlzl_id' => $toDeleteIds ] )
873                ->caller( __METHOD__ )->execute();
874        }
875
876        if ( $toInsert !== [] ) {
877            $dbw->newInsertQueryBuilder()
878                ->insertInto( 'wikilambda_zobject_labels' )
879                ->rows( $toInsert )
880                ->caller( __METHOD__ )->execute();
881        }
882    }
883
884    /**
885     * Reconcile the rows in wikilambda_zobject_label_conflicts touching the given ZID
886     * with the desired set of conflicts, writing only the differences. (T300522)
887     *
888     * The scope mirrors deleteZObjectLabelConflictsByZid: rows where the ZID appears
889     * as either side of the conflict. Desired rows are those where this ZID is the
890     * conflicting (incoming) side.
891     *
892     * @param string $zid
893     * @param array<string,string> $conflicts Map of language code to existing ZID that owns the label
894     */
895    public function synchroniseZObjectLabelConflicts( string $zid, array $conflicts ): void {
896        $dbw = $this->dbProvider->getPrimaryDatabase();
897
898        $desiredRows = [];
899        foreach ( $conflicts as $language => $existingZid ) {
900            $desiredRows[] = [
901                'wlzlc_existing_zid' => $existingZid,
902                'wlzlc_conflicting_zid' => $zid,
903                'wlzlc_language' => $language,
904            ];
905        }
906
907        $existingRows = $dbw->newSelectQueryBuilder()
908            ->select( [
909                'wlzlc_id',
910                'wlzlc_existing_zid',
911                'wlzlc_conflicting_zid',
912                'wlzlc_language',
913            ] )
914            ->from( 'wikilambda_zobject_label_conflicts' )
915            ->where(
916                $dbw->expr( 'wlzlc_existing_zid', '=', $zid )
917                    ->or( 'wlzlc_conflicting_zid', '=', $zid )
918            )
919            ->caller( __METHOD__ )
920            ->fetchResultSet();
921
922        $existingByKey = [];
923        foreach ( $existingRows as $row ) {
924            $key = self::makeConflictRowKey(
925                $row->wlzlc_existing_zid,
926                $row->wlzlc_conflicting_zid,
927                $row->wlzlc_language
928            );
929            $existingByKey[$key][] = (int)$row->wlzlc_id;
930        }
931
932        $toInsert = [];
933        foreach ( $desiredRows as $row ) {
934            $key = self::makeConflictRowKey(
935                $row['wlzlc_existing_zid'],
936                $row['wlzlc_conflicting_zid'],
937                $row['wlzlc_language']
938            );
939            if ( !empty( $existingByKey[$key] ) ) {
940                array_shift( $existingByKey[$key] );
941            } else {
942                $toInsert[] = $row;
943            }
944        }
945
946        $toDeleteIds = [];
947        foreach ( $existingByKey as $ids ) {
948            foreach ( $ids as $id ) {
949                $toDeleteIds[] = $id;
950            }
951        }
952
953        if ( $toDeleteIds !== [] ) {
954            $dbw->newDeleteQueryBuilder()
955                ->deleteFrom( 'wikilambda_zobject_label_conflicts' )
956                ->where( [ 'wlzlc_id' => $toDeleteIds ] )
957                ->caller( __METHOD__ )->execute();
958        }
959
960        if ( $toInsert !== [] ) {
961            $dbw->newInsertQueryBuilder()
962                ->insertInto( 'wikilambda_zobject_label_conflicts' )
963                ->rows( $toInsert )
964                ->caller( __METHOD__ )->execute();
965        }
966    }
967
968    /**
969     * Build a content-tuple key for a wikilambda_zobject_labels row, used to
970     * detect rows that should be preserved across a label update.
971     *
972     * @param string $language
973     * @param string $type
974     * @param string $label
975     * @param bool $isPrimary
976     * @param string|null $returnType
977     * @return string
978     */
979    private static function makeLabelRowKey(
980        string $language,
981        string $type,
982        string $label,
983        bool $isPrimary,
984        ?string $returnType
985    ): string {
986        // \x1f (Unit Separator) is non-printable and cannot occur in any of these columns.
987        return implode( "\x1f", [
988            $language,
989            $type,
990            $label,
991            $isPrimary ? '1' : '0',
992            $returnType ?? '',
993        ] );
994    }
995
996    /**
997     * Build a content-tuple key for a wikilambda_zobject_label_conflicts row.
998     *
999     * @param string $existingZid
1000     * @param string $conflictingZid
1001     * @param string $language
1002     * @return string
1003     */
1004    private static function makeConflictRowKey(
1005        string $existingZid,
1006        string $conflictingZid,
1007        string $language
1008    ): string {
1009        return implode( "\x1f", [ $existingZid, $conflictingZid, $language ] );
1010    }
1011
1012    /**
1013     * Gets from the secondary database a list of all Zids belonging to a given type
1014     *
1015     * @param string $ztype
1016     * @return string[]
1017     */
1018    public function fetchZidsOfType( $ztype ): array {
1019        $dbr = $this->dbProvider->getReplicaDatabase();
1020        return $dbr->newSelectQueryBuilder()
1021            ->select( 'wlzl_zobject_zid' )
1022            ->distinct()
1023            ->from( 'wikilambda_zobject_labels' )
1024            ->where( [
1025                'wlzl_type' => $ztype
1026            ] )
1027            ->orderBy( 'wlzl_zobject_zid', SelectQueryBuilder::SORT_ASC )
1028            ->caller( __METHOD__ )
1029            ->fetchFieldValues();
1030    }
1031
1032    /**
1033     * Gets from the secondary database the number of all Zids belonging to a given type
1034     *
1035     * @param string $ztype
1036     * @return int
1037     */
1038    public function getCountOfTypeInstances( string $ztype ): int {
1039        // Special case for all ZObjects
1040        if ( $ztype === ZTypeRegistry::Z_OBJECT ) {
1041            return count( $this->fetchAllZids() );
1042        }
1043        return count( $this->fetchZidsOfType( $ztype ) );
1044    }
1045
1046    /**
1047     * Get a list of all Zids persisted in the database
1048     *
1049     * @return string[] All persisted Zids
1050     */
1051    public function fetchAllZids(): array {
1052        $dbr = $this->dbProvider->getReplicaDatabase();
1053        $zids = $dbr->newSelectQueryBuilder()
1054            ->select( 'page_title' )
1055            ->from( 'page' )
1056            ->where( [
1057                'page_namespace' => NS_MAIN
1058            ] )
1059            ->caller( __METHOD__ )
1060            ->fetchFieldValues();
1061        return array_filter( $zids, ZObjectUtils::isValidZObjectReference( ... ) );
1062    }
1063
1064    /**
1065     * Gets from the secondary database a list of all natural language ZIDs,
1066     * mapping from BCP47 (or MediaWiki) language code to ZID (one zid can map
1067     * to multiple BCP47 codes)
1068     *
1069     * @return array<string,string>
1070     */
1071    public function fetchAllZLanguageObjects(): array {
1072        $dbr = $this->dbProvider->getReplicaDatabase();
1073        $res = $dbr->newSelectQueryBuilder()
1074            ->select( [ 'wlzlangs_zid', 'wlzlangs_language' ] )
1075            ->distinct()
1076            ->from( 'wikilambda_zlanguages' )
1077            ->orderBy( 'wlzlangs_zid', SelectQueryBuilder::SORT_ASC )
1078            ->caller( __METHOD__ )
1079            ->fetchResultSet();
1080
1081        $languages = [];
1082        foreach ( $res as $row ) {
1083            $languages[ $row->wlzlangs_language ] = $row->wlzlangs_zid;
1084        }
1085        return $languages;
1086    }
1087
1088    /**
1089     * Gets from the secondary database a list of all supported natural
1090     * BCP47 (or MediaWiki) language codes.
1091     *
1092     * @return array<string>
1093     */
1094    public function fetchAllZLanguageCodes(): array {
1095        return array_keys( $this->fetchAllZLanguageObjects() );
1096    }
1097
1098    /**
1099     * Gets from the secondary database the matching BCP47 (or MediaWiki) language code(s)
1100     * for a given ZID
1101     *
1102     * @param string $zid The ZID of the matching ZLanguage object for which to search.
1103     * @return string[] Any BCP47 (or MediaWiki) language code(s) if found; unordered.
1104     */
1105    public function findCodesFromZLanguage( string $zid ): array {
1106        $dbr = $this->dbProvider->getReplicaDatabase();
1107        return $dbr->newSelectQueryBuilder()
1108            ->select( 'wlzlangs_language' )
1109            ->from( 'wikilambda_zlanguages' )
1110            ->where( [ 'wlzlangs_zid' => $zid ], )
1111            ->caller( __METHOD__ )
1112            ->fetchFieldValues();
1113    }
1114
1115    /**
1116     * Fetch all ZLanguages stored in the language cache table, and
1117     * for each one, return its zid, its language code, and its label
1118     * in the user language or the closest available fallback.
1119     *
1120     * @param string $userLang - User language BCP47 code
1121     * @return IResultWrapper
1122     */
1123    public function fetchAllZLanguagesWithLabels( $userLang ) {
1124        $dbr = $this->dbProvider->getReplicaDatabase();
1125
1126        // TODO (T362246): Dependency-inject
1127        $zLangRegistry = ZLangRegistry::singleton();
1128        $languageFallback = MediaWikiServices::getInstance()->getLanguageFallback();
1129        $languages = $zLangRegistry->getListOfFallbackLanguageZids( $languageFallback, $userLang );
1130
1131        // Returns table with unique zids and the most preferred primary label
1132        $subquery = $this->getPreferredLabelsQuery( $languages )->getSQL();
1133        return $dbr->newSelectQueryBuilder()
1134            ->select( [ 'wlzl_zobject_zid', 'wlzl_label', 'wlzlangs_language' ] )
1135            ->rawTables( [ 'preferred_labels' => new Subquery( $subquery ) ] )
1136            ->join( 'wikilambda_zlanguages', null, [ 'wlzl_zobject_zid = wlzlangs_zid' ] )
1137            ->orderBy( 'wlzl_label', SelectQueryBuilder::SORT_ASC )
1138            ->caller( __METHOD__ )
1139            ->fetchResultSet();
1140    }
1141
1142    /**
1143     * Fetch all ZTypes that have persisted instances, with their
1144     * label in the user language or the closest available fallback.
1145     *
1146     * @param string $userLang - User language BCP47 code
1147     * @param bool $includeZ1 - Whether to include Z1/ZObject in the results
1148     * @return IResultWrapper
1149     */
1150    public function fetchAllInstancedTypesWithLabels( string $userLang, bool $includeZ1 = false ) {
1151        $dbr = $this->dbProvider->getReplicaDatabase();
1152
1153        // TODO (T362246): Dependency-inject
1154        // Returns table with unique zids and the most preferred primary label
1155        $zLangRegistry = ZLangRegistry::singleton();
1156        $languageFallback = MediaWikiServices::getInstance()->getLanguageFallback();
1157        $languages = $zLangRegistry->getListOfFallbackLanguageZids( $languageFallback, $userLang );
1158        $subquery = $this->getPreferredLabelsQuery( $languages )->getSQL();
1159
1160        // Fetch only those types that have instances
1161        $zids = $this->fetchAllInstancedTypes();
1162
1163        // If Z1 should be included, add it to the list of ZIDs
1164        if ( $includeZ1 ) {
1165            $zids[] = 'Z1';
1166        }
1167
1168        // If there are no Types with instances (e.g. in CI), just return a fake empty result.
1169        if ( $zids === [] ) {
1170            return new FakeResultWrapper( $zids );
1171        }
1172
1173        return $dbr->newSelectQueryBuilder()
1174            ->select( [ 'wlzl_zobject_zid', 'wlzl_label' ] )
1175            ->rawTables( [ 'preferred_labels' => new Subquery( $subquery ) ] )
1176            ->where( [ 'wlzl_zobject_zid' => $zids ] )
1177            ->orderBy( 'wlzl_label', SelectQueryBuilder::SORT_ASC )
1178            ->caller( __METHOD__ )
1179            ->fetchResultSet();
1180    }
1181
1182    /**
1183     * Returns a list of distinct type Zids that have persisted instances.
1184     *
1185     * @return string[]
1186     */
1187    public function fetchAllInstancedTypes(): array {
1188        $dbr = $this->dbProvider->getReplicaDatabase();
1189
1190        return $dbr->newSelectQueryBuilder()
1191            ->select( [ 'wlzl_type' ] )
1192            ->distinct()
1193            ->from( 'wikilambda_zobject_labels' )
1194            ->caller( __METHOD__ )
1195            ->fetchFieldValues();
1196    }
1197
1198    /**
1199     * Generates a query to get the return types for all stored functions
1200     * and function calls.
1201     *
1202     * @return SelectQueryBuilder
1203     */
1204    public function getReturnTypeQuery() {
1205        $dbr = $this->dbProvider->getReplicaDatabase();
1206
1207        $caseReturnTypes = 'CASE '
1208            . 'WHEN a.wlzo_main_type =' . $dbr->addQuotes( 'Z7' ) . ' THEN a.wlzo_related_zobject '
1209            . 'WHEN a.wlzo_main_type =' . $dbr->addQuotes( 'Z8' ) . ' THEN a.wlzo_main_zid '
1210            . 'END';
1211
1212        $joinConditions = [
1213            'rt.wlzo_key' => 'Z8K2',
1214            'rt.wlzo_main_zid = ' . $caseReturnTypes
1215        ];
1216
1217        $filterConditions = $dbr->orExpr( [
1218            $dbr->andExpr( [ 'a.wlzo_main_type' => 'Z7' ] ),
1219            $dbr->andExpr( [
1220                'a.wlzo_main_type' => 'Z8',
1221                'a.wlzo_key' => 'Z8K2'
1222            ] )
1223        ] );
1224
1225        $returnTypeQueryBuilder = $dbr->newSelectQueryBuilder()
1226            ->select( [
1227                'a.wlzo_main_zid',
1228                'a.wlzo_main_type',
1229                'return_type' => 'rt.wlzo_related_zobject'
1230            ] )
1231            ->from( 'wikilambda_zobject_join', 'a' )
1232            ->leftJoin( 'wikilambda_zobject_join', 'rt', $joinConditions )
1233            ->where( $filterConditions );
1234
1235        return $returnTypeQueryBuilder;
1236    }
1237
1238    /**
1239     * Generates a query that filters to the preferred label in the
1240     * labels table, depending on the user's language fallback chain
1241     * passed as parameter.
1242     *
1243     * @param string[] $languageChain List of language zids in order of preference
1244     * @param string|null $type Optional type zid to restrict the query to; callers
1245     *   that only want objects of one type should pass it here rather than filter
1246     *   the returned query, so that only that type's rows are ranked (T430853)
1247     * @return SelectQueryBuilder
1248     */
1249    public function getPreferredLabelsQuery( $languageChain, ?string $type = null ) {
1250        $dbr = $this->dbProvider->getReplicaDatabase();
1251
1252        // Build the CASE expression to assign each label row a language preference
1253        // index: the position of its language in the fallback chain if it is that
1254        // language's primary label, otherwise a shared worst index.
1255        $caseParts = [];
1256        foreach ( $languageChain as $index => $langZid ) {
1257            $caseParts[] = "WHEN l1.wlzl_language = " .
1258                $dbr->addQuotes( $langZid ) . " AND l1.wlzl_label_primary = " .
1259                $dbr->addQuotes( true ) . " THEN " . ( $index + 1 );
1260        }
1261        $caseExpr = "CASE " . implode( " ", $caseParts ) . " ELSE " . ( count( $languageChain ) + 1 ) . " END";
1262
1263        // Rank each label row within its zid and keep only the single best one.
1264        // The tiebreakers after the preference index make the choice deterministic
1265        // even when no fallback-chain language has a primary label (in which case
1266        // every row shares the ELSE index): prefer a primary label, then the
1267        // earliest language, then the row id (wlzl_id, the table's PK). Without
1268        // this a zid with no preferred label was emitted once per label (T430847).
1269        $rankExpr = "ROW_NUMBER() OVER ( PARTITION BY l1.wlzl_zobject_zid ORDER BY " .
1270            "$caseExpr, l1.wlzl_label_primary DESC, l1.wlzl_language, l1.wlzl_id )";
1271
1272        $rankedQuery = $dbr->newSelectQueryBuilder()
1273            ->select( [ 'l1.wlzl_id', 'row_num' => $rankExpr ] )
1274            ->from( 'wikilambda_zobject_labels', 'l1' );
1275
1276        // Restricting by type before ranking cannot change any per-zid winner:
1277        // a zid has exactly one wlzl_type across all of its label rows, so the
1278        // filter keeps all-or-none of each partition. Applying it here rather
1279        // than on the outer query prunes the window sort to one type's rows.
1280        if ( $type !== null ) {
1281            $rankedQuery->where( [ 'l1.wlzl_type' => $type ] );
1282        }
1283
1284        // Subquery yielding exactly one winning label row id per zid
1285        $prefQuery = $dbr->newSelectQueryBuilder()
1286            ->select( [ 'wlzl_id' ] )
1287            ->rawTables( [ 'ranked' => new Subquery( $rankedQuery->getSQL() ) ] )
1288            ->where( [ 'row_num' => 1 ] );
1289
1290        $pageJoinConditions = [
1291            'p.page_title = l1.wlzl_zobject_zid',
1292            'p.page_namespace = ' . NS_MAIN
1293        ];
1294
1295        // Create query to select the preferred label for each zid
1296        // - Inner join on the winning label row id to keep one row per zid
1297        // - Join with zobject entries in the page table to order objects by creation date
1298        $queryBuilder = $dbr->newSelectQueryBuilder()
1299            ->select( [
1300                'p.page_id',
1301                'l1.wlzl_zobject_zid',
1302                'l1.wlzl_label',
1303                'l1.wlzl_language',
1304                'l1.wlzl_type',
1305                'l1.wlzl_label_normalised',
1306                'l1.wlzl_label_primary',
1307                'l1.wlzl_return_type'
1308            ] )
1309            ->from( 'wikilambda_zobject_labels', 'l1' )
1310            ->join( $prefQuery, 'pref', 'l1.wlzl_id = pref.wlzl_id' )
1311            ->leftJoin( 'page', 'p', $pageJoinConditions );
1312
1313        // Semantically redundant with the ranked-subquery filter above (the
1314        // join on the winning wlzl_id already restricts rows to this type),
1315        // but restated here so the optimiser can prune this scan too rather
1316        // than reading and sorting every label row.
1317        if ( $type !== null ) {
1318            $queryBuilder->where( [ 'l1.wlzl_type' => $type ] );
1319        }
1320
1321        return $queryBuilder;
1322    }
1323
1324    /**
1325     * Create OR aggregated LIKE statements for the whole (trimmed) substring, and
1326     * for each large token (>2 chars) from the searchTerm.
1327     * The search term is tokenized by splitting by whitespace characters.
1328     *
1329     * @param string $searchColumn
1330     * @param string $searchTerm
1331     * @return IExpression|null
1332     */
1333    private function getStringMatchCondition( $searchColumn, $searchTerm ) {
1334        $dbr = $this->dbProvider->getReplicaDatabase();
1335        $tokens = preg_split( '/\s+/', trim( $searchTerm ), -1, PREG_SPLIT_NO_EMPTY );
1336
1337        // Return null if there are no string match conditions
1338        if ( count( $tokens ) === 0 ) {
1339            return null;
1340        }
1341
1342        $conditions = [];
1343
1344        // Add condition for whole substring (trimmed)
1345        $conditions[] = $dbr->expr(
1346            $searchColumn,
1347            IExpression::LIKE,
1348            new LikeValue( $dbr->anyString(), trim( $searchTerm ), $dbr->anyString() )
1349        );
1350
1351        // Add additional conditions for largest tokens
1352        foreach ( $tokens as $token ) {
1353            if ( strlen( $token ) > 2 ) {
1354                // NOTE: length this is not a good method to determine stop words and is not
1355                // applicable to all languages, but the whole substring is already part of the
1356                // search and this method effective to filter out most of the bad matches.
1357                $conditions[] = $dbr->expr(
1358                    $searchColumn,
1359                    IExpression::LIKE,
1360                    new LikeValue( $dbr->anyString(), $token, $dbr->anyString() )
1361                );
1362            }
1363        }
1364
1365        // Use OR to aggregate results
1366        return $dbr->orExpr( $conditions );
1367    }
1368
1369    /**
1370     * Search functions that match a series of conditions:
1371     * * are running functions (they must have connected implementations)
1372     * * have a label that partially matches the given searchTerm, and
1373     * * either have fully renderable inputs and outputs (to be used by WP integration)
1374     * * or have the specified input and output types
1375     *
1376     * @param string $searchTerm Term to search in the label database
1377     * @param string[] $languages List of language Zids to filter by
1378     * @param bool $renderable Whether to only filter through functions with renderable IOs
1379     * @param string[] $inputTypes List of input types to match the functions
1380     * @param string|null $outputType List of output type to match the functions
1381     * @return IResultWrapper
1382     */
1383    public function searchFunctions(
1384        $searchTerm,
1385        $languages,
1386        $renderable = false,
1387        $inputTypes = [],
1388        $outputType = null
1389    ) {
1390        $dbr = $this->dbProvider->getReplicaDatabase();
1391
1392        // Subquery: Available function zids
1393        // * if renderable is true, set conditions to only renderable types
1394        // * else, set conditions to inputTypes and outputType
1395        // If no function filtering is required, $functionsQuery will be null
1396        $functionsQuery = $renderable ?
1397            $this->functionsByRenderableIOQuery() :
1398            $this->functionsByIOTypesQuery( $inputTypes, $outputType );
1399
1400        // Subquery: Preferred labels for all functions
1401        // * Only rank and return labels of functions
1402        // * Only return necessary fields
1403        $preferredLabelsQuery = $this->getPreferredLabelsQuery( $languages, ZTypeRegistry::Z_FUNCTION );
1404        $preferredLabelsQuery
1405            ->clearFields()
1406            ->fields( [ 'p.page_id', 'l1.wlzl_zobject_zid', 'l1.wlzl_label', 'l1.wlzl_language' ] );
1407
1408        // String match condition: match tokenized searchTerm substrings (if any)
1409        $searchTerm = ZObjectUtils::comparableString( $searchTerm );
1410        $searchColumn = ZObjectUtils::isValidZObjectReference( $searchTerm ) ?
1411            'wlzl_zobject_zid' : 'wlzl_label_normalised';
1412
1413        // Create main query builder
1414        $queryBuilder = $dbr->newSelectQueryBuilder()
1415            ->select( [
1416                'page_id',
1417                'lb.wlzl_zobject_zid',
1418                'wlzl_type',
1419                'wlzl_return_type',
1420                'lb.wlzl_language',
1421                'lb.wlzl_label',
1422                'wlzl_label_primary',
1423                'wlzl_id',
1424                'preferred_label' => 'pl.wlzl_label',
1425                'preferred_language' => 'pl.wlzl_language'
1426            ] )
1427            ->from( 'wikilambda_zobject_labels', 'lb' )
1428            // Inner join with preferred labels Subquery
1429            ->join( $preferredLabelsQuery, 'pl', 'lb.wlzl_zobject_zid = pl.wlzl_zobject_zid' )
1430            ->where( [ 'wlzl_type' => 'Z8' ] );
1431
1432        // If searchTerm is not empty, add string match conditions
1433        $stringMatchCondition = $this->getStringMatchCondition( $searchColumn, $searchTerm );
1434        if ( $stringMatchCondition ) {
1435            $queryBuilder->andWhere( $stringMatchCondition );
1436        }
1437
1438        // If functionsQuery is not null, Left join and filter by wlzo_main_zid IS NOT NULL condition
1439        if ( $functionsQuery ) {
1440            $queryBuilder
1441                ->leftJoin( $functionsQuery, 'fn', 'lb.wlzl_zobject_zid = fn.wlzo_main_zid' )
1442                ->andWhere( 'fn.wlzo_main_zid IS NOT NULL' );
1443        }
1444
1445        // Return result set:
1446        return $queryBuilder
1447            ->caller( __METHOD__ )
1448            ->fetchResultSet();
1449    }
1450
1451    /**
1452     * Generates a query that returns a table with all the existing
1453     * test objects, their function, and their status:
1454     * * is_connected: whether the test is connected to the function
1455     * * is_passing: whether the test is passing against all the
1456     *   function's connected implementations
1457     *
1458     * @return string
1459