Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.61% covered (warning)
88.61%
996 / 1124
76.56% covered (warning)
76.56%
49 / 64
CRAP
0.00% covered (danger)
0.00%
0 / 1
ZObjectStore
88.61% covered (warning)
88.61%
996 / 1124
76.56% covered (warning)
76.56%
49 / 64
232.46
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
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
3
 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
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
3
 insertZLanguageToLanguagesCache
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 insertZObjectLabelConflicts
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 insertZObjectAliases
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
4.00
 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
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
2.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%
35 / 35
100.00% covered (success)
100.00%
1 / 1
2
 getStringMatchCondition
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 searchFunctions
0.00% covered (danger)
0.00%
0 / 39
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
 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
 fetchAllImplementations
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 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
 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
 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
 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\Context\RequestContext;
15use MediaWiki\Exception\MWContentSerializationException;
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\ZObjects\ZPersistentObject;
21use MediaWiki\Extension\WikiLambda\ZObjects\ZReference;
22use MediaWiki\Extension\WikiLambda\ZObjects\ZResponseEnvelope;
23use MediaWiki\Extension\WikiLambda\ZObjects\ZTypedList;
24use MediaWiki\Language\MessageLocalizer;
25use MediaWiki\MediaWikiServices;
26use MediaWiki\Page\WikiPage;
27use MediaWiki\Page\WikiPageFactory;
28use MediaWiki\Revision\RevisionRecord;
29use MediaWiki\Revision\RevisionStore;
30use MediaWiki\Revision\SlotRecord;
31use MediaWiki\Title\Title;
32use MediaWiki\Title\TitleArrayFromResult;
33use MediaWiki\Title\TitleFactory;
34use MediaWiki\User\User;
35use MediaWiki\User\UserGroupManager;
36use Psr\Log\LoggerInterface;
37use Wikimedia\Rdbms\FakeResultWrapper;
38use Wikimedia\Rdbms\IConnectionProvider;
39use Wikimedia\Rdbms\IExpression;
40use Wikimedia\Rdbms\IReadableDatabase;
41use Wikimedia\Rdbms\IResultWrapper;
42use Wikimedia\Rdbms\LikeValue;
43use Wikimedia\Rdbms\SelectQueryBuilder;
44use Wikimedia\Rdbms\Subquery;
45
46class ZObjectStore {
47
48    private IConnectionProvider $dbProvider;
49    private TitleFactory $titleFactory;
50    private WikiPageFactory $wikiPageFactory;
51    private RevisionStore $revisionStore;
52    private UserGroupManager $userGroupManager;
53    private LoggerInterface $logger;
54
55    private MemcachedWrapper $zObjectCache;
56
57    // NOTE: This constant hard-codes user-provided content as starting from 'Z10000'. Now that the
58    // wiki has launched, change it will have unpredicatable effects.
59    private const PREDEFINED_TOP_LIMIT = 'Z9999';
60
61    public const ZOBJECT_CACHE_KEY_PREFIX = 'WikiLambdaObjectStorage';
62    public const TESTER_RESULT_CACHE_WRITE_INSERTED = 'inserted';
63    public const TESTER_RESULT_CACHE_WRITE_STALE = 'stale';
64    public const TESTER_RESULT_CACHE_WRITE_FAILED = 'failed';
65
66    /**
67     * @param IConnectionProvider $dbProvider
68     * @param TitleFactory $titleFactory
69     * @param WikiPageFactory $wikiPageFactory
70     * @param RevisionStore $revisionStore
71     * @param UserGroupManager $userGroupManager
72     * @param LoggerInterface $logger
73     */
74    public function __construct(
75        IConnectionProvider $dbProvider,
76        TitleFactory $titleFactory,
77        WikiPageFactory $wikiPageFactory,
78        RevisionStore $revisionStore,
79        UserGroupManager $userGroupManager,
80        LoggerInterface $logger
81    ) {
82        $this->dbProvider = $dbProvider;
83        $this->titleFactory = $titleFactory;
84        $this->wikiPageFactory = $wikiPageFactory;
85        $this->revisionStore = $revisionStore;
86        $this->userGroupManager = $userGroupManager;
87        $this->logger = $logger;
88
89        $this->zObjectCache = WikiLambdaServices::getMemcachedWrapper();
90    }
91
92    /**
93     * Find next available ZID in the database to create a new ZObject
94     *
95     * @return string Next available ZID
96     */
97    public function getNextAvailableZid(): string {
98        // Intentionally use DB_PRIMARY as we need the latest data here.
99        $dbr = $this->dbProvider->getPrimaryDatabase();
100
101        $conditions = [
102            'page_namespace' => NS_MAIN,
103            'LENGTH( page_title ) > 5',
104            $dbr->expr( 'page_title', IExpression::LIKE, new LikeValue( 'Z', $dbr->anyString() ) )
105        ];
106
107        // First try, get zid of highest page_id, skipping predefined
108        $latestZid = $dbr->newSelectQueryBuilder()
109            ->select( 'page_title' )
110            ->from( 'page' )
111            ->where( $conditions )
112            ->orderBy( 'page_id', SelectQueryBuilder::SORT_DESC )
113            ->caller( __METHOD__ )
114            ->fetchField();
115
116        if ( $latestZid === false ) {
117            $latestZid = self::PREDEFINED_TOP_LIMIT;
118        }
119        $targetZid = 'Z' . ( max( intval( substr( $latestZid, 1 ) ) + 1, 10000 ) );
120
121        // Check if target zid already exists, just in case of general zid disarray
122        // where the latest non-predefined zid is not the highest one
123        $exists = $dbr->newSelectQueryBuilder()
124            ->select( 'COUNT(*)' )
125            ->from( 'page' )
126            ->where( $conditions )
127            ->andWhere( [ 'page_title' => $targetZid ] )
128            ->caller( __METHOD__ )
129            ->fetchField();
130
131        // If Zid after latest added is available, return
132        if ( !$exists ) {
133            return $targetZid;
134        }
135
136        // If Zid after latest is not available, find Zid after highest one
137        // NOTE: this operation is much more expensive, so we should only
138        // perform it in the exceptional case that latestZid fails.
139        $highestZid = $dbr->newSelectQueryBuilder()
140            ->select( 'page_title' )
141            ->from( 'page' )
142            ->where( $conditions )
143            ->orderBy( 'CAST(SUBSTR(page_title, 2) AS INTEGER)', SelectQueryBuilder::SORT_DESC )
144            ->caller( __METHOD__ )
145            ->fetchField();
146
147        if ( $highestZid === false ) {
148            $highestZid = self::PREDEFINED_TOP_LIMIT;
149        }
150
151        $this->logger->warning(
152            __METHOD__ . ' at first got "' . $targetZid . '" — exists; slower query gets: "Z' . $highestZid . '".',
153            [ 'targetZid' => $targetZid, 'highestZid' => $highestZid ]
154        );
155
156        $targetZid = 'Z' . ( max( intval( substr( $highestZid, 1 ) ) + 1, 10000 ) );
157
158        return $targetZid;
159    }
160
161    /**
162     * Load a page revision from a given revision ID number.
163     * Returns null if no such revision can be found.
164     *
165     * @param int $id Revision ID of this revision
166     * @return RevisionRecord|null
167     */
168    public function getRevisionById( int $id ): ?RevisionRecord {
169        $revisionRecord = $this->revisionStore->getRevisionById( $id );
170        return $revisionRecord;
171    }
172
173    /**
174     * Fetch the ZObject given its title and return it wrapped in a ZObjectContent object
175     *
176     * @param Title $title The ZObject to fetch
177     * @param int|null $requestedRevision The revision ID of the page to fetch. If unset, the latest is returned.
178     * @return ZObjectContent|bool Found ZObject
179     */
180    public function fetchZObjectByTitle( Title $title, ?int $requestedRevision = null ) {
181        if ( $requestedRevision ) {
182            $revision = $this->revisionStore->getRevisionByTitle( $title, $requestedRevision, 0 );
183        } else {
184            $revision = $this->revisionStore->getKnownCurrentRevision( $title );
185        }
186
187        if ( !$revision ) {
188            // Return false: We should not throw exceptions that trigger more fetches (ZErrorException)
189            return false;
190        }
191
192        // NOTE: Hard-coding use of MAIN slot; if we're going the MCR route, we may wish to change this (or not).
193        $slot = $revision->getSlot( SlotRecord::MAIN, RevisionRecord::RAW );
194        // @phan-suppress-next-line PhanTypeMismatchReturnSuperType
195        return $slot->getContent();
196    }
197
198    /**
199     * Get the current ZPersistentObject of a given ZID, from the cache if available
200     *
201     * @param string $zid
202     * @return ZObjectContent|bool Cached or persisted ZObject, false if not found or invalid
203     */
204    public function fetchZObject( string $zid ) {
205        $cacheKey = $this->zObjectCache->makeKey( self::ZOBJECT_CACHE_KEY_PREFIX, $zid );
206        $cachedObject = $this->zObjectCache->get( $cacheKey );
207
208        if ( $cachedObject ) {
209            $cachedContent = ZObjectContentHandler::makeContent( $cachedObject, null, CONTENT_MODEL_ZOBJECT );
210            if ( $cachedContent->isValid() ) {
211                return $cachedContent;
212            }
213            // Something went wrong;
214            // the object stored in the cache is not valid, delete from cache and go on
215            $this->zObjectCache->delete( $cacheKey );
216        }
217
218        // Cache miss somehow; let's re-fetch the object, and stash it in the cache.
219
220        $title = Title::newFromText( $zid );
221        if ( !$title ) {
222            // Return false: We should not throw exceptions that trigger more fetches (ZErrorException)
223            return false;
224        }
225
226        $zObjectContent = $this->fetchZObjectByTitle( $title );
227        if ( !$zObjectContent || !$zObjectContent->isValid() ) {
228            // Return false: We should not throw exceptions that trigger more fetches (ZErrorException)
229            return false;
230        }
231
232        $this->zObjectCache->set(
233            $cacheKey,
234            $zObjectContent->getText(),
235            $this->zObjectCache::TTL_MONTH
236        );
237
238        return $zObjectContent;
239    }
240
241    /**
242     * Returns an array of ZPersistentObjects fetched from the DB given an array of their Zids
243     *
244     * Note that this will only fetch the latest revision of a ZObject; if you want a specific
245     * revision, you need to use ZObjectStore::fetchZObjectByTitle() instead.
246     *
247     * @param string[] $zids
248     * @return ZPersistentObject[]
249     */
250    public function fetchBatchZObjects( $zids ): array {
251        $dbr = $this->dbProvider->getReplicaDatabase();
252        $query = WikiPage::getQueryInfo();
253
254        $res = $dbr->newSelectQueryBuilder()
255            ->select( $query['fields'] )
256            ->from( 'page' )
257            ->where( [
258                'page_namespace' => NS_MAIN,
259                'page_title' => $zids
260            ] )
261            ->caller( __METHOD__ )
262            ->fetchResultSet();
263
264        $titleArray = new TitleArrayFromResult( $res );
265
266        $dataArray = [];
267        foreach ( $titleArray as $title ) {
268            // TODO (T300521): Handle error from fetchZObjectByTitle
269            $content = $this->fetchZObjectByTitle( $title );
270            if ( $content->isValid() ) {
271                $dataArray[ $title->getBaseText() ] = $content->getZObject();
272            }
273        }
274        return $dataArray;
275    }
276
277    /**
278     * Push a given Object into the Database, without validation.
279     *
280     * @param string $zid
281     * @param string $data
282     * @param string $summary
283     * @return bool
284     * @throws Exception
285     */
286    public function pushZObject( string $zid, string $data, string $summary ) {
287        $title = $this->titleFactory->newFromText( $zid, NS_MAIN );
288        // Error: Failed creating title due to invalid format
289        if ( !$title ) {
290            throw new ZErrorException(
291                ZErrorFactory::createZErrorInstance(
292                    ZErrorTypeRegistry::Z_ERROR_INVALID_TITLE,
293                    [ 'title' => $zid ]
294                )
295            );
296        }
297
298        $page = $this->wikiPageFactory->newFromTitle( $title );
299        $flags = $title->exists() ? EDIT_UPDATE : EDIT_NEW;
300
301        // Create system user:
302        $creatingUserName = wfMessage( 'wikilambda-systemuser' )->inLanguage( 'en' )->text();
303        $user = User::newSystemUser( $creatingUserName, [ 'steal' => true ] );
304        $this->userGroupManager->addUserToGroup( $user, 'sysop' );
305        $this->userGroupManager->addUserToGroup( $user, 'functionmaintainer' );
306        $this->userGroupManager->addUserToGroup( $user, 'functioneer' );
307        $this->userGroupManager->addUserToGroup( $user, 'wikifunctions-staff' );
308
309        try {
310            $content = ZObjectContentHandler::makeContent( $data, $title );
311        } catch ( ZErrorException $e ) {
312            // Error: Make content will only fail if JSON is invalid
313            $this->logger->warning(
314                __METHOD__ . ' triggered an error on creating content for page "' . $zid . '"',
315                [ 'responseError' => $e ]
316            );
317            throw $e;
318        }
319
320        try {
321            $status = $page->doUserEditContent( $content, $user, $summary, $flags );
322        } catch ( Exception $e ) {
323            // Error: Database or MediaWiki exception
324            $this->logger->warning(
325                __METHOD__ . ' triggered an error on publish for page "' . $zid . '"',
326                [ 'responseError' => $e ]
327            );
328            throw $e;
329        }
330
331        // Error: Other doUserEditContent related errors
332        if ( !$status->isOK() ) {
333            $statusMessage = $status->getMessage();
334            $this->logger->info(
335                __METHOD__ . ' got a non-OK Status on publish, for page "' . $zid . '"',
336                [ 'responseStatus' => var_export( $statusMessage, true ) ]
337            );
338            throw new ZErrorException(
339                ZErrorFactory::createZErrorInstance(
340                    ZErrorTypeRegistry::Z_ERROR_UNKNOWN,
341                    [ 'message' => (string)$statusMessage ]
342                )
343            );
344        }
345
346        return true;
347    }
348
349    /**
350     * Create a new ZObject, with a newly assigned ZID, and store it in the Database
351     *
352     * @param MessageLocalizer $context The context of the action operation, for localisation of messages
353     * @param string $data
354     * @param string $summary
355     * @param User $user
356     * @return ZObjectPage
357     */
358    public function createNewZObject( MessageLocalizer $context, string $data, string $summary, User $user ) {
359        // Find all placeholder ZIDs and ZKeys and replace those with the next available ZID
360        $zid = $this->getNextAvailableZid();
361        $zObjectString = ZObjectUtils::replaceNullReferencePlaceholder( $data, $zid );
362
363        return $this->updateZObject( $context, $zid, $zObjectString, $summary, $user, EDIT_NEW );
364    }
365
366    /**
367     * Create or update a ZObject it in the Database
368     *
369     * @param MessageLocalizer $context The context of the action operation, for localisation of messages
370     * @param string $zid The ZID of the page to create/update, e.g. 'Z12345'
371     * @param string $data The ZObject's JSON to store, in string form, i.e. "{ Z1K1: "Z2", Z2K1: … }"
372     * @param string $summary An edit summary to display in the page's history, Recent Changes, watchlists, etc.
373     * @param User $user The user making the edit.
374     * @param int $flags Either EDIT_UPDATE (default) if editing or EDIT_NEW if creating a page
375     * @return ZObjectPage
376     */
377    public function updateZObject(
378        MessageLocalizer $context, string $zid, string $data, string $summary, User $user, int $flags = EDIT_UPDATE
379    ) {
380        $title = $this->titleFactory->newFromText( $zid, NS_MAIN );
381
382        // ERROR: Title is empty or invalid
383        if ( !( $title instanceof Title ) ) {
384            $error = ZErrorFactory::createZErrorInstance(
385                ZErrorTypeRegistry::Z_ERROR_INVALID_TITLE,
386                [ 'title' => $zid ]
387            );
388            return ZObjectPage::newFatal( $error );
389        }
390
391        // If edit flag or title did not exist, we are creating a new object
392        $creating = ( $flags === EDIT_NEW ) || !( $title->exists() );
393
394        try {
395            $content = ZObjectContentHandler::makeContent( $data, $title );
396        } catch ( ZErrorException $e ) {
397            $this->logger->info(
398                __METHOD__ . ': makeContent threw ZErrorException for {zid}: {message}',
399                [ 'zid' => $zid, 'message' => $e->getMessage() ]
400            );
401            return ZObjectPage::newFatal( $e->getZError() );
402        } catch ( MWContentSerializationException $mwe ) {
403            $this->logger->info(
404                __METHOD__ . ': makeContent threw MWContentSerializationException for {zid}: {message}',
405                [ 'zid' => $zid, 'message' => $mwe->getMessage() ]
406            );
407            return ZObjectPage::newFatal(
408                // We can't cleanly recover the inner ZErrorException (if indeed it was even thrown by us), so
409                // for now just pass this down as a Z500, perhaps with the ErrorType as the message.
410                ZErrorFactory::createZErrorInstance(
411                    ZErrorTypeRegistry::Z_ERROR_UNKNOWN,
412                    [ 'message' => $mwe->getMessage() ]
413                )
414            );
415        }
416
417        // Error: ZObject validation errors.
418        if ( !( $content->isValid() ) ) {
419            return ZObjectPage::newFatal( $content->getErrors() );
420        }
421
422        // Validate that $zid and zObject[Z2K1] are the same
423        $zObjectId = $content->getZid();
424
425        if ( $zObjectId !== $zid ) {
426            $error = ZErrorFactory::createZErrorInstance(
427                ZErrorTypeRegistry::Z_ERROR_UNMATCHING_ZID,
428                [
429                    'zid' => $zObjectId,
430                    'title' => $zid
431                ]
432            );
433            return ZObjectPage::newFatal( $error );
434        }
435
436        $ztype = $content->getZType();
437
438        // Stop from creating and editing any types form DISALLOWED_ROOT_ZOBJECT
439        if ( in_array( $ztype, ZTypeRegistry::DISALLOWED_ROOT_ZOBJECTS ) ) {
440            $error = ZErrorFactory::createZErrorInstance(
441                ZErrorTypeRegistry::Z_ERROR_DISALLOWED_ROOT_ZOBJECT,
442                [ 'data' => $content->getZType() ]
443            );
444            return ZObjectPage::newFatal( $error );
445        }
446
447        // Find the label conflicts.
448        $labels = $content->getLabels()->getValueAsList();
449        $clashes = $this->findZObjectLabelConflicts( $zid, $ztype, $labels );
450
451        if ( count( $clashes ) > 0 ) {
452            $error = ZErrorFactory::createLabelClashZErrors( $clashes );
453            return ZObjectPage::newFatal( $error );
454        }
455
456        // Use ZObjectAuthorization service to check that the user has the required permissions
457        // while creating or editing an object
458        $fromContent = null;
459        if ( !$creating ) {
460            $currentRevision = $this->revisionStore->getKnownCurrentRevision( $title );
461            $fromContent = $currentRevision->getSlots()->getContent( SlotRecord::MAIN );
462            '@phan-var ZObjectContent $fromContent';
463        }
464        $authorizationService = WikiLambdaServices::getZObjectAuthorization();
465        $status = $authorizationService->authorize( $fromContent, $content, $user, $title );
466
467        // Return AuthorizationStatus->error if authorization service failed
468        if ( !$status->isValid() ) {
469            return ZObjectPage::newFatal( $status->getErrors() );
470        }
471
472        // Run ZObjectContent field validation
473        try {
474            $content->validateFields( $context );
475        } catch ( ZErrorException $e ) {
476            return ZObjectPage::newFatal( $e->getZError() );
477        }
478
479        // We prepare the content to be saved
480        $page = $this->wikiPageFactory->newFromTitle( $title );
481        try {
482            $status = $page->doUserEditContent( $content, $user, $summary, $flags );
483        } catch ( Exception $e ) {
484            // Error: Database or a deeper MediaWiki error, e.g. a general editing rate limit
485
486            $this->logger->warning(
487                __METHOD__ . ' triggered an error on publish, e.g. rate limited, for page "' . $zid . '"',
488                [ 'responseError' => $e ]
489            );
490
491            if ( $e instanceof ZErrorException ) {
492                // TODO (T362236): Add the rendering language as a parameter, don't default to English
493                $errorMessage = $e->getZError()->getMessage( 'en' );
494            } else {
495                $errorMessage = $e->getMessage();
496            }
497
498            $error = ZErrorFactory::createZErrorInstance(
499                ZErrorTypeRegistry::Z_ERROR_UNKNOWN,
500                [ 'message' => $errorMessage ]
501            );
502            return ZObjectPage::newFatal( $error );
503        }
504
505        if ( !$status->isOK() ) {
506
507            // TODO (T362246): Dependency-inject
508            $statusFormatter = MediaWikiServices::getInstance()->getFormatterFactory()
509                ->getStatusFormatter( $context );
510
511            // Error: Other doUserEditContent related errors
512
513            $this->logger->info(
514                __METHOD__ . ' got a non-OK Status on publish, for page "' . $zid . '"',
515                [ 'responseStatus' => var_export( $status, true ) ]
516            );
517
518            $error = ZErrorFactory::createZErrorInstance(
519                ZErrorTypeRegistry::Z_ERROR_UNKNOWN,
520                [ 'message' => $statusFormatter->getMessage( $status )->plain() ]
521            );
522            return ZObjectPage::newFatal( $error );
523        }
524
525        // Success: return WikiPage
526        return ZObjectPage::newSuccess( $page );
527    }
528
529    /**
530     * Create or update a ZObject it in the Database as a System User
531     *
532     * @param MessageLocalizer $context The context of the action operation, for localisation of messages
533     * @param string $zid
534     * @param string $data
535     * @param string $summary
536     * @param int $flags
537     * @return ZObjectPage
538     */
539    public function updateZObjectAsSystemUser(
540        MessageLocalizer $context, string $zid, string $data, string $summary, int $flags = EDIT_UPDATE
541    ) {
542        $creatingUserName = wfMessage( 'wikilambda-systemuser' )->inLanguage( 'en' )->text();
543        // System user must belong to all privileged groups in order to
544        // perform all zobject creation and editing actions:
545        $user = User::newSystemUser( $creatingUserName, [ 'steal' => true ] );
546        $this->userGroupManager->addUserToGroup( $user, 'sysop' );
547        $this->userGroupManager->addUserToGroup( $user, 'functionmaintainer' );
548        $this->userGroupManager->addUserToGroup( $user, 'functioneer' );
549        $this->userGroupManager->addUserToGroup( $user, 'wikifunctions-staff' );
550        // Make sure the edit will be marked as a bot edit
551        $flags |= EDIT_FORCE_BOT;
552        return $this->updateZObject( $context, $zid, $data, $summary, $user, $flags );
553    }
554
555    /**
556     * Delete the labels from the wikilambda_zobject_labels database that correspond
557     * to the given ZID.
558     *
559     * @param string $zid
560     */
561    public function deleteZObjectLabelsByZid( string $zid ): void {
562        $dbw = $this->dbProvider->getPrimaryDatabase();
563
564        $dbw->newDeleteQueryBuilder()
565            ->deleteFrom( 'wikilambda_zobject_labels' )
566            ->where( [ 'wlzl_zobject_zid' => $zid ] )
567            ->caller( __METHOD__ )->execute();
568    }
569
570    /**
571     * Delete the label conflicts from the wikilambda_zobject_label_conflicts database
572     * that correspond to the given ZID.
573     *
574     * @param string $zid
575     */
576    public function deleteZObjectLabelConflictsByZid( string $zid ): void {
577        $dbw = $this->dbProvider->getPrimaryDatabase();
578
579        $dbw->newDeleteQueryBuilder()
580            ->deleteFrom( 'wikilambda_zobject_label_conflicts' )
581            ->where(
582                $dbw->expr( 'wlzlc_existing_zid', '=', $zid )
583                    ->or( 'wlzlc_conflicting_zid', '=', $zid )
584            )
585            ->caller( __METHOD__ )->execute();
586    }
587
588    /**
589     * Query the wikilambda_zobject_labels database for primary labels that have
590     * the same combination of language code and value for a different ZID
591     * than the given in the parameters. These will be considered conflicting labels.
592     *
593     * @param string $zid
594     * @param string $ztype
595     * @param array<string,string> $labels Array of labels, where the key is the language code and the value
596     * is the string representation of the label in that language
597     * @return array Conflicts found in the wikilambda_zobject_labels database
598     */
599    public function findZObjectLabelConflicts( $zid, $ztype, $labels ): array {
600        $dbr = $this->dbProvider->getReplicaDatabase();
601
602        // remove labels with an undefined value
603        $labels = array_filter(
604            $labels, static function ( $value ) {
605                return $value !== "";
606            } );
607
608        if ( $labels === [] ) {
609            return [];
610        }
611
612        $labelConflictConditions = [];
613        foreach ( $labels as $language => $value ) {
614            $labelConflictConditions[] = $dbr->andExpr( [
615                'wlzl_language' => $language,
616                'wlzl_label' => $value,
617                'wlzl_label_primary' => true
618            ] );
619        }
620
621        $res = $dbr->newSelectQueryBuilder()
622            ->select( [ 'wlzl_zobject_zid', 'wlzl_language' ] )
623            ->from( 'wikilambda_zobject_labels' )
624            ->where( [
625                $dbr->expr( 'wlzl_zobject_zid', '!=', $zid ),
626                // TODO (T357552): Check against type, once we properly implement that.
627                // 'wlzl_type' => $ztype,
628                $dbr->orExpr( $labelConflictConditions )
629            ] )
630            ->caller( __METHOD__ )
631            ->fetchResultSet();
632
633        $conflicts = [];
634        foreach ( $res as $row ) {
635            // TODO (T362247): What if more than one conflicts with us on each language?
636            $conflicts[ $row->wlzl_language ] = $row->wlzl_zobject_zid;
637        }
638
639        return $conflicts;
640    }
641
642    /**
643     * Insert labels into the wikilambda_zobject_labels database for a given ZID and Type
644     *
645     * @param string $zid
646     * @param string $ztype
647     * @param array $labels Array of labels, where the key is the language code and the value
648     * is the string representation of the label in that language
649     * @param string|null $returnType
650     */
651    public function insertZObjectLabels( $zid, $ztype, $labels, $returnType = null ): void {
652        $dbw = $this->dbProvider->getPrimaryDatabase();
653
654        $updates = [];
655        foreach ( $labels as $language => $value ) {
656            $updates[] = [
657                'wlzl_zobject_zid' => $zid,
658                'wlzl_language' => $language,
659                'wlzl_type' => $ztype,
660                'wlzl_label' => $value,
661                'wlzl_label_normalised' => ZObjectUtils::comparableString( $value ),
662                'wlzl_label_primary' => true,
663                'wlzl_return_type' => $returnType
664            ];
665        }
666
667        // Exit early if there are no updates to make.
668        if ( count( $updates ) === 0 ) {
669            return;
670        }
671
672        $dbw->newInsertQueryBuilder()
673            ->insertInto( 'wikilambda_zobject_labels' )
674            ->rows( $updates )
675            ->caller( __METHOD__ )->execute();
676    }
677
678    /**
679     * Insert language code into the wikilambda_zlanguages database for a given ZID
680     *
681     * @param string $zid
682     * @param string $languageCode
683     */
684    public function insertZLanguageToLanguagesCache( string $zid, string $languageCode ): void {
685        $dbw = $this->dbProvider->getPrimaryDatabase();
686
687        $dbw->newInsertQueryBuilder()
688            ->insertInto( 'wikilambda_zlanguages' )
689            ->row( [ 'wlzlangs_zid' => $zid, 'wlzlangs_language' => $languageCode ] )
690            ->caller( __METHOD__ )->execute();
691    }
692
693    /**
694     * Insert label conflicts into the wikilambda_zobject_label_conflicts database for a given ZID
695     *
696     * @param string $zid
697     * @param array $conflicts Array of labels, where the key is the language code and the value
698     * is the other ZID for which this label is repeated
699     */
700    public function insertZObjectLabelConflicts( $zid, $conflicts ): void {
701        $dbw = $this->dbProvider->getPrimaryDatabase();
702
703        $updates = [];
704        foreach ( $conflicts as $language => $existingZid ) {
705            $updates[] = [
706                'wlzlc_existing_zid' => $existingZid,
707                'wlzlc_conflicting_zid' => $zid,
708                'wlzlc_language' => $language,
709            ];
710        }
711
712        // Exit early if there are no updates to make.
713        if ( count( $updates ) === 0 ) {
714            return;
715        }
716
717        $dbw->newInsertQueryBuilder()
718            ->insertInto( 'wikilambda_zobject_label_conflicts' )
719            ->rows( $updates )
720            ->caller( __METHOD__ )->execute();
721    }
722
723    /**
724     * Insert alias (secondary labels) into the wikilambda_zobject_labels database for a given ZID and Type
725     *
726     * @param string $zid
727     * @param string $ztype
728     * @param array $aliases Set of labels, where the key is the language code
729     * and the value is an array of strings
730     * @param string|null $returnType
731     */
732    public function insertZObjectAliases( $zid, $ztype, $aliases, $returnType = null ): void {
733        $dbw = $this->dbProvider->getPrimaryDatabase();
734
735        $updates = [];
736        foreach ( $aliases as $language => $stringset ) {
737            foreach ( $stringset as $value ) {
738                $updates[] = [
739                    'wlzl_zobject_zid' => $zid,
740                    'wlzl_language' => $language,
741                    'wlzl_type' => $ztype,
742                    'wlzl_label' => $value,
743                    'wlzl_label_normalised' => ZObjectUtils::comparableString( $value ),
744                    'wlzl_label_primary' => false,
745                    'wlzl_return_type' => $returnType
746                ];
747            }
748        }
749
750        if ( count( $updates ) === 0 ) {
751            return;
752        }
753
754        $dbw->newInsertQueryBuilder()
755            ->insertInto( 'wikilambda_zobject_labels' )
756            ->rows( $updates )
757            ->caller( __METHOD__ )->execute();
758    }
759
760    /**
761     * Gets from the secondary database a list of all Zids belonging to a given type
762     *
763     * @param string $ztype
764     * @return string[]
765     */
766    public function fetchZidsOfType( $ztype ): array {
767        $dbr = $this->dbProvider->getReplicaDatabase();
768        return $dbr->newSelectQueryBuilder()
769            ->select( 'wlzl_zobject_zid' )
770            ->distinct()
771            ->from( 'wikilambda_zobject_labels' )
772            ->where( [
773                'wlzl_type' => $ztype
774            ] )
775            ->orderBy( 'wlzl_zobject_zid', SelectQueryBuilder::SORT_ASC )
776            ->caller( __METHOD__ )
777            ->fetchFieldValues();
778    }
779
780    /**
781     * Gets from the secondary database the number of all Zids belonging to a given type
782     *
783     * @param string $ztype
784     * @return int
785     */
786    public function getCountOfTypeInstances( string $ztype ): int {
787        // Special case for all ZObjects
788        if ( $ztype === ZTypeRegistry::Z_OBJECT ) {
789            return count( $this->fetchAllZids() );
790        }
791        return count( $this->fetchZidsOfType( $ztype ) );
792    }
793
794    /**
795     * Get a list of all Zids persisted in the database
796     *
797     * @return string[] All persisted Zids
798     */
799    public function fetchAllZids(): array {
800        $dbr = $this->dbProvider->getReplicaDatabase();
801        $zids = $dbr->newSelectQueryBuilder()
802            ->select( 'page_title' )
803            ->from( 'page' )
804            ->where( [
805                'page_namespace' => NS_MAIN
806            ] )
807            ->caller( __METHOD__ )
808            ->fetchFieldValues();
809        return array_filter( $zids, ZObjectUtils::isValidZObjectReference( ... ) );
810    }
811
812    /**
813     * Gets from the secondary database a list of all natural language ZIDs,
814     * mapping from BCP47 (or MediaWiki) language code to ZID (one zid can map
815     * to multiple BCP47 codes)
816     *
817     * @return array<string,string>
818     */
819    public function fetchAllZLanguageObjects(): array {
820        $dbr = $this->dbProvider->getReplicaDatabase();
821        $res = $dbr->newSelectQueryBuilder()
822            ->select( [ 'wlzlangs_zid', 'wlzlangs_language' ] )
823            ->distinct()
824            ->from( 'wikilambda_zlanguages' )
825            ->orderBy( 'wlzlangs_zid', SelectQueryBuilder::SORT_ASC )
826            ->caller( __METHOD__ )
827            ->fetchResultSet();
828
829        $languages = [];
830        foreach ( $res as $row ) {
831            $languages[ $row->wlzlangs_language ] = $row->wlzlangs_zid;
832        }
833        return $languages;
834    }
835
836    /**
837     * Gets from the secondary database a list of all supported natural
838     * BCP47 (or MediaWiki) language codes.
839     *
840     * @return array<string>
841     */
842    public function fetchAllZLanguageCodes(): array {
843        return array_keys( $this->fetchAllZLanguageObjects() );
844    }
845
846    /**
847     * Gets from the secondary database the matching BCP47 (or MediaWiki) language code(s)
848     * for a given ZID
849     *
850     * @param string $zid The ZID of the matching ZLanguage object for which to search.
851     * @return string[] Any BCP47 (or MediaWiki) language code(s) if found; unordered.
852     */
853    public function findCodesFromZLanguage( string $zid ): array {
854        $dbr = $this->dbProvider->getReplicaDatabase();
855        return $dbr->newSelectQueryBuilder()
856            ->select( 'wlzlangs_language' )
857            ->from( 'wikilambda_zlanguages' )
858            ->where( [ 'wlzlangs_zid' => $zid ], )
859            ->caller( __METHOD__ )
860            ->fetchFieldValues();
861    }
862
863    /**
864     * Fetch all ZLanguages stored in the language cache table, and
865     * for each one, return its zid, its language code, and its label
866     * in the user language or the closest available fallback.
867     *
868     * @param string $userLang - User language BCP47 code
869     * @return IResultWrapper
870     */
871    public function fetchAllZLanguagesWithLabels( $userLang ) {
872        $dbr = $this->dbProvider->getReplicaDatabase();
873
874        // TODO (T362246): Dependency-inject
875        $zLangRegistry = ZLangRegistry::singleton();
876        $languageFallback = MediaWikiServices::getInstance()->getLanguageFallback();
877        $languages = $zLangRegistry->getListOfFallbackLanguageZids( $languageFallback, $userLang );
878
879        // Returns table with unique zids and the most preferred primary label
880        $subquery = $this->getPreferredLabelsQuery( $languages )->getSQL();
881        return $dbr->newSelectQueryBuilder()
882            ->select( [ 'wlzl_zobject_zid', 'wlzl_label', 'wlzlangs_language' ] )
883            ->rawTables( [ 'preferred_labels' => new Subquery( $subquery ) ] )
884            ->join( 'wikilambda_zlanguages', null, [ 'wlzl_zobject_zid = wlzlangs_zid' ] )
885            ->orderBy( 'wlzl_label', SelectQueryBuilder::SORT_ASC )
886            ->caller( __METHOD__ )
887            ->fetchResultSet();
888    }
889
890    /**
891     * Fetch all ZTypes that have persisted instances, with their
892     * label in the user language or the closest available fallback.
893     *
894     * @param string $userLang - User language BCP47 code
895     * @return IResultWrapper
896     */
897    public function fetchAllInstancedTypesWithLabels( $userLang ) {
898        $dbr = $this->dbProvider->getReplicaDatabase();
899
900        // TODO (T362246): Dependency-inject
901        // Returns table with unique zids and the most preferred primary label
902        $zLangRegistry = ZLangRegistry::singleton();
903        $languageFallback = MediaWikiServices::getInstance()->getLanguageFallback();
904        $languages = $zLangRegistry->getListOfFallbackLanguageZids( $languageFallback, $userLang );
905        $subquery = $this->getPreferredLabelsQuery( $languages )->getSQL();
906
907        // Fetch only those types that have instances
908        $zids = $this->fetchAllInstancedTypes();
909
910        // If there are no Types with instances (e.g. in CI), just return a fake empty result.
911        if ( $zids === [] ) {
912            return new FakeResultWrapper( $zids );
913        }
914
915        return $dbr->newSelectQueryBuilder()
916            ->select( [ 'wlzl_zobject_zid', 'wlzl_label' ] )
917            ->rawTables( [ 'preferred_labels' => new Subquery( $subquery ) ] )
918            ->where( [ 'wlzl_zobject_zid' => $zids ] )
919            ->orderBy( 'wlzl_label', SelectQueryBuilder::SORT_ASC )
920            ->caller( __METHOD__ )
921            ->fetchResultSet();
922    }
923
924    /**
925     * Returns a list of distinct type Zids that have persisted instances.
926     *
927     * @return string[]
928     */
929    public function fetchAllInstancedTypes(): array {
930        $dbr = $this->dbProvider->getReplicaDatabase();
931
932        return $dbr->newSelectQueryBuilder()
933            ->select( [ 'wlzl_type' ] )
934            ->distinct()
935            ->from( 'wikilambda_zobject_labels' )
936            ->caller( __METHOD__ )
937            ->fetchFieldValues();
938    }
939
940    /**
941     * Generates a query to get the return types for all stored functions
942     * and function calls.
943     *
944     * @return SelectQueryBuilder
945     */
946    public function getReturnTypeQuery() {
947        $dbr = $this->dbProvider->getReplicaDatabase();
948
949        $caseReturnTypes = 'CASE '
950            . 'WHEN a.wlzo_main_type =' . $dbr->addQuotes( 'Z7' ) . ' THEN a.wlzo_related_zobject '
951            . 'WHEN a.wlzo_main_type =' . $dbr->addQuotes( 'Z8' ) . ' THEN a.wlzo_main_zid '
952            . 'END';
953
954        $joinConditions = [
955            'rt.wlzo_key' => 'Z8K2',
956            'rt.wlzo_main_zid = ' . $caseReturnTypes
957        ];
958
959        $filterConditions = $dbr->orExpr( [
960            $dbr->andExpr( [ 'a.wlzo_main_type' => 'Z7' ] ),
961            $dbr->andExpr( [
962                'a.wlzo_main_type' => 'Z8',
963                'a.wlzo_key' => 'Z8K2'
964            ] )
965        ] );
966
967        $returnTypeQueryBuilder = $dbr->newSelectQueryBuilder()
968            ->select( [
969                'a.wlzo_main_zid',
970                'a.wlzo_main_type',
971                'return_type' => 'rt.wlzo_related_zobject'
972            ] )
973            ->from( 'wikilambda_zobject_join', 'a' )
974            ->leftJoin( 'wikilambda_zobject_join', 'rt', $joinConditions )
975            ->where( $filterConditions );
976
977        return $returnTypeQueryBuilder;
978    }
979
980    /**
981     * Generates a query that filters to the preferred label in the
982     * labels table, depending on the user's language fallback chain
983     * passed as parameter.
984     *
985     * @param string[] $languageChain List of language zids in order of preference
986     * @return SelectQueryBuilder
987     */
988    public function getPreferredLabelsQuery( $languageChain ) {
989        $dbr = $this->dbProvider->getReplicaDatabase();
990
991        // Build the CASE expression to assign language preference index and select the MIN
992        $caseParts = [];
993        foreach ( $languageChain as $index => $langZid ) {
994            $caseParts[] = "WHEN l1.wlzl_language = " .
995                $dbr->addQuotes( $langZid ) . " AND l1.wlzl_label_primary = " .
996                $dbr->addQuotes( true ) . " THEN " . ( $index + 1 );
997        }
998        $caseExpr = "CASE " . implode( " ", $caseParts ) . " ELSE " . ( count( $languageChain ) + 1 ) . " END";
999        $minPriorityCase = "MIN( $caseExpr )";
1000
1001        // Create subquery to get the preferred label depending on the languageChain
1002        $prefQuery = $dbr->newSelectQueryBuilder()
1003            ->select( [ 'l1.wlzl_zobject_zid', 'min_priority' => $minPriorityCase ] )
1004            ->from( 'wikilambda_zobject_labels', 'l1' )
1005            ->groupBy( [ 'l1.wlzl_zobject_zid' ] );
1006
1007        $prefJoinConditions = [
1008            'pref.wlzl_zobject_zid = l1.wlzl_zobject_zid',
1009            'pref.min_priority = ' . $caseExpr
1010        ];
1011
1012        $pageJoinConditions = [
1013            'p.page_title = l1.wlzl_zobject_zid',
1014            'p.page_namespace = ' . NS_MAIN
1015        ];
1016
1017        // Create query to select the preferred label for each zid
1018        // - Join with the preferred labels table to select the most appropriate label
1019        // - Join with zobject entries in the page table to order objects by creation date
1020        $queryBuilder = $dbr->newSelectQueryBuilder()
1021            ->select( [
1022                'p.page_id',
1023                'l1.wlzl_zobject_zid',
1024                'l1.wlzl_label',
1025                'l1.wlzl_language',
1026                'l1.wlzl_type',
1027                'l1.wlzl_label_normalised',
1028                'l1.wlzl_label_primary',
1029                'l1.wlzl_return_type'
1030            ] )
1031            ->from( 'wikilambda_zobject_labels', 'l1' )
1032            ->join( $prefQuery, 'pref', $prefJoinConditions )
1033            ->leftJoin( 'page', 'p', $pageJoinConditions );
1034
1035        return $queryBuilder;
1036    }
1037
1038    /**
1039     * Create OR aggregated LIKE statements for each token from the searchTerm.
1040     * The search term is tokenized by splitting by whitespace characters.
1041     *
1042     * @param string $searchColumn
1043     * @param string $searchTerm
1044     * @return IExpression|null
1045     */
1046    private function getStringMatchCondition( $searchColumn, $searchTerm ) {
1047        $dbr = $this->dbProvider->getReplicaDatabase();
1048
1049        $tokens = preg_split( '/\s+/', trim( $searchTerm ), -1, PREG_SPLIT_NO_EMPTY );
1050
1051        // Return null if there are no string match conditions
1052        if ( count( $tokens ) === 0 ) {
1053            return null;
1054        }
1055
1056        $conditions = [];
1057        foreach ( $tokens as $token ) {
1058            $conditions[] = $dbr->expr(
1059                $searchColumn,
1060                IExpression::LIKE,
1061                new LikeValue( $dbr->anyString(), $token, $dbr->anyString() )
1062            );
1063        }
1064
1065        // Use OR to aggregate results
1066        return $dbr->orExpr( $conditions );
1067    }
1068
1069    /**
1070     * Search functions that match a series of conditions:
1071     * * are running functions (they must have connected implementations)
1072     * * have a label that partially matches the given searchTerm, and
1073     * * either have fully renderable inputs and outputs (to be used by WP integration)
1074     * * or have the specified input and output types
1075     *
1076     * @param string $searchTerm Term to search in the label database
1077     * @param string[] $languages List of language Zids to filter by
1078     * @param bool $renderable Whether to only filter through functions with renderable IOs
1079     * @param string[] $inputTypes List of input types to match the functions
1080     * @param string|null $outputType List of output type to match the functions
1081     * @return IResultWrapper
1082     */
1083    public function searchFunctions(
1084        $searchTerm,
1085        $languages,
1086        $renderable = false,
1087        $inputTypes = [],
1088        $outputType = null
1089    ) {
1090        $dbr = $this->dbProvider->getReplicaDatabase();
1091
1092        // Subquery: Available function zids
1093        // * if renderable is true, set conditions to only renderable types
1094        // * else, set conditions to inputTypes and outputType
1095        // If no function filtering is required, $functionsQuery will be null
1096        $functionsQuery = $renderable ?
1097            $this->functionsByRenderableIOQuery() :
1098            $this->functionsByIOTypesQuery( $inputTypes, $outputType );
1099
1100        // Subquery: Preferred labels for all functions
1101        // * Only return necessary fields
1102        // * Add early condition: only functions
1103        $preferredLabelsQuery = $this->getPreferredLabelsQuery( $languages );
1104        $preferredLabelsQuery
1105            ->clearFields()
1106            ->fields( [ 'p.page_id', 'l1.wlzl_zobject_zid', 'l1.wlzl_label', 'l1.wlzl_language' ] )
1107            ->andWhere( [ 'l1.wlzl_type' => 'Z8' ] );
1108
1109        // String match condition: match tokenized searchTerm substrings (if any)
1110        $searchTerm = ZObjectUtils::comparableString( $searchTerm );
1111        $searchColumn = ZObjectUtils::isValidZObjectReference( $searchTerm ) ?
1112            'wlzl_zobject_zid' : 'wlzl_label_normalised';
1113        $stringMatchCondition = $this->getStringMatchCondition( $searchColumn, $searchTerm );
1114
1115        // Create main query builder
1116        $queryBuilder = $dbr->newSelectQueryBuilder()
1117            ->select( [
1118                'page_id',
1119                'lb.wlzl_zobject_zid',
1120                'wlzl_type',
1121                'wlzl_return_type',
1122                'lb.wlzl_language',
1123                'lb.wlzl_label',
1124                'wlzl_label_primary',
1125                'wlzl_id',
1126                'preferred_label' => 'pl.wlzl_label',
1127                'preferred_language' => 'pl.wlzl_language'
1128            ] )
1129            ->from( 'wikilambda_zobject_labels', 'lb' )
1130            // Inner join with preferred labels Subquery
1131            ->join( $preferredLabelsQuery, 'pl', 'lb.wlzl_zobject_zid = pl.wlzl_zobject_zid' )
1132            ->where( [ 'wlzl_type' => 'Z8' ] );
1133
1134        // If stringMatchCondition is not null, add where condition
1135        if ( $stringMatchCondition ) {
1136            $queryBuilder->andWhere( $stringMatchCondition );
1137        }
1138
1139        // If functionsQuery is not null, Left join and filter by wlzo_main_zid IS NOT NULL condition
1140        if ( $functionsQuery ) {
1141            $queryBuilder
1142                ->leftJoin( $functionsQuery, 'fn', 'lb.wlzl_zobject_zid = fn.wlzo_main_zid' )
1143                ->andWhere( 'fn.wlzo_main_zid IS NOT NULL' );
1144        }
1145
1146        // Return result set:
1147        return $queryBuilder
1148            ->caller( __METHOD__ )
1149            ->fetchResultSet();
1150    }
1151
1152    /**
1153     * Generates a query that returns a table with all the existing
1154     * test objects, their function, and their status:
1155     * * is_connected: whether the test is connected to the function
1156     * * is_passing: whether the test is passing against all the
1157     *   function's connected implementations
1158     *
1159     * @return string
1160     */
1161    public function getTestStatusQuery(): string {
1162        $dbr = $this->dbProvider->getReplicaDatabase();
1163
1164        $connQueryBuilder = $dbr->newSelectQueryBuilder()
1165            ->select( [ 'wlzo_related_zobject' ] )
1166            ->from( 'wikilambda_zobject_join' )
1167            ->where( [ 'wlzo_key' => ZTypeRegistry::Z_FUNCTION_TESTERS ] );
1168
1169        $resultsQueryBuilder = $dbr->newSelectQueryBuilder()
1170            ->select( [ 'wlztr_ztester_zid', 'wlztr_pass' ] )
1171            ->from( 'wikilambda_ztester_results' )
1172            ->join( 'wikilambda_zobject_join', 'c2', 'c2.wlzo_related_zobject = wlztr_zimplementation_zid' );
1173
1174        $queryBuilder = $dbr->newSelectQueryBuilder()
1175            ->select( [
1176                'test_zid' => 'wlzf_ref_zid',
1177                'function_zid' => 'wlzf_zfunction_zid',
1178                'is_passing' => 'MIN( wlztr_pass )',
1179                'is_connected' => 'CASE WHEN wlzo_related_zobject IS NOT NULL THEN 1 ELSE 0 END',
1180                'all_tests' => 'COUNT(*) OVER( PARTITION BY wlzf_zfunction_zid )'
1181            ] )
1182            ->from( 'wikilambda_zobject_function_join' )
1183            ->leftJoin( $connQueryBuilder, 'c1', 'c1.wlzo_related_zobject = wlzf_ref_zid' )
1184            ->leftJoin( $resultsQueryBuilder, 'r1', 'r1.wlztr_ztester_zid = wlzf_ref_zid' )
1185            ->where( [ 'wlzf_type' => ZTypeRegistry::Z_TESTER ] )
1186            ->groupBy( [ 'wlzf_ref_zid', 'wlzf_zfunction_zid' ] );
1187
1188        return $queryBuilder->getSQL();
1189    }
1190
1191    /**
1192     * Gets from the secondary database the ZID of a given BCP47 (or MediaWiki) language code
1193     *
1194     * @param string $code The BCP47 (or MediaWiki) language code for which to search
1195     * @return ?string The ZID of the matching ZLanguage object, or null if not found.
1196     */
1197    public function findZLanguageFromCode( string $code ): ?string {
1198        return $this->findZLanguagesFromCodes( [ $code ] )[$code] ?? null;
1199    }
1200
1201    /**
1202     * Gets from the secondary database the ZIDs for a set of BCP47 (or MediaWiki) language
1203     * codes in a single query. Only codes that are found appear in the returned map.
1204     *
1205     * @param string[] $codes The BCP47 (or MediaWiki) language codes for which to search
1206     * @return array<string,string> Map of code => ZID for all codes that were found.
1207     */
1208    public function findZLanguagesFromCodes( array $codes ): array {
1209        $dbr = $this->dbProvider->getReplicaDatabase();
1210        $res = $dbr->newSelectQueryBuilder()
1211            ->select( [ 'wlzlangs_zid', 'wlzlangs_language' ] )
1212            ->from( 'wikilambda_zlanguages' )
1213            ->where( [ 'wlzlangs_language' => $codes ] )
1214            ->caller( __METHOD__ )
1215            ->fetchResultSet();
1216
1217        $map = [];
1218        foreach ( $res as $row ) {
1219            $map[ (string)$row->wlzlangs_language ] = (string)$row->wlzlangs_zid;
1220        }
1221        return $map;
1222    }
1223
1224    /**
1225     * Search labels in the secondary database, filtering by language Zids, type or label string.
1226     *
1227     * @param string $searchTerm Term to search in the label database
1228     * @param bool $exact Whether to search by exact match
1229     * @param string[] $languages List of language Zids to filter by
1230     * @param string[] $types List of type Zids to filter by; will be aggregated with OR
1231     * @param string[] $returnTypes List of return type Zids to filter by; will be aggregated with OR
1232     * @param string|null $continue Id to start. If null, start from the first result.
1233     * @param int|null $limit Maximum number of results to return.
1234     * @return IResultWrapper
1235     */
1236    public function searchZObjectLabels(
1237        $searchTerm,
1238        $exact = false,
1239        $languages = [],
1240        $types = [],
1241        $returnTypes = [],
1242        $continue = null,
1243        $limit = null
1244    ) {
1245        $dbr = $this->dbProvider->getReplicaDatabase();
1246        $conditions = [];
1247
1248        // Set language filter if any
1249        if ( count( $languages ) > 0 ) {
1250            $conditions['wlzl_language'] = $languages;
1251        }
1252
1253        // Set type conditions
1254        if ( count( $types ) > 0 ) {
1255            $conditions[ 'wlzl_type' ] = $types;
1256        }
1257
1258        // Set minimum id bound if we are continuing a paged result
1259        if ( $continue !== null ) {
1260            $conditions[] = $dbr->expr( 'wlzl_id', '>=', $continue );
1261        }
1262
1263        // Set search term and search column
1264        if ( ZObjectUtils::isValidZObjectReference( $searchTerm ) ) {
1265            $searchColumn = 'wlzl_zobject_zid';
1266        } elseif ( $exact ) {
1267            $searchColumn = 'wlzl_label';
1268        } else {
1269            $searchColumn = 'wlzl_label_normalised';
1270            $searchTerm = ZObjectUtils::comparableString( $searchTerm );
1271        }
1272        $stringMatchCondition = $this->getStringMatchCondition( $searchColumn, $searchTerm );
1273        if ( $stringMatchCondition ) {
1274            $conditions[] = $this->getStringMatchCondition( $searchColumn, $searchTerm );
1275        }
1276
1277        // Create query builder
1278        $queryBuilder = $dbr->newSelectQueryBuilder()
1279            ->select( [
1280                'wlzl_zobject_zid',
1281                'wlzl_type',
1282                'wlzl_language',
1283                'wlzl_label',
1284                'wlzl_label_normalised',
1285                'wlzl_label_primary',
1286                'wlzl_id'
1287            ] )
1288            ->from( 'wikilambda_zobject_labels' )
1289            ->where( $conditions )
1290            ->orderBy( 'wlzl_id', SelectQueryBuilder::SORT_ASC )
1291            ->orderBy( 'wlzl_label_primary', SelectQueryBuilder::SORT_DESC );
1292
1293        // Set return type leftJoin and field if return_types is present in the filters
1294        if ( count( $returnTypes ) > 0 ) {
1295            // To support filtering by return type (including compound types like Z881(Zxxx)),
1296            // we must join a subquery (aliased as 'ret') that provides the return_type for each ZObject.
1297            // This join must be present before any filter referencing ret.return_type, otherwise the SQL
1298            // will fail with 'Unknown column'.
1299            //
1300            // MediaWiki's database abstraction layer does NOT allow SQL expressions (like CASE ... END)
1301            // as column names in WHERE clauses, for security and SQL injection protection. Therefore,
1302            // we cannot use a conditional column for fallback logic directly in the filter.
1303            //
1304            // Instead, we implement fallback logic using an OR of four conditions for each returnType:
1305            //   1. ret.return_type = $rt (exact match on joined return type)
1306            //   2. ret.return_type LIKE $rt( (compound type match on joined return type)
1307            //   3. ret.return_type IS NULL AND wlzl_type = $rt (fallback to base type if join is missing)
1308            //   4. ret.return_type IS NULL AND wlzl_type LIKE $rt( (fallback to compound base type)
1309            //
1310            // All filter conditions are built as IExpression objects for compatibility with MediaWiki's DB layer.
1311            // This approach ensures both direct and fallback return type matching in a safe, DB-compatible way.
1312            $returnTypeQueryBuilder = $this->getReturnTypeQuery();
1313            $queryBuilder->leftJoin( $returnTypeQueryBuilder, 'ret', 'wlzl_zobject_zid = ret.wlzo_main_zid' );
1314
1315            $returnTypeOrExprs = [];
1316            foreach ( $returnTypes as $rt ) {
1317                $returnTypeOrExprs[] = $dbr->orExpr( [
1318                    $dbr->expr( 'ret.return_type', '=', $rt ),
1319                    $dbr->expr( 'ret.return_type', IExpression::LIKE, new LikeValue( $rt . '(', $dbr->anyString() ) ),
1320                    $dbr->andExpr( [
1321                        $dbr->expr( 'ret.return_type', '=', null ),
1322                        $dbr->expr( 'wlzl_type', '=', $rt )
1323                    ] ),
1324                    $dbr->andExpr( [
1325                        $dbr->expr( 'ret.return_type', '=', null ),
1326                        $dbr->expr( 'wlzl_type', IExpression::LIKE, new LikeValue( $rt . '(', $dbr->anyString() ) )
1327                    ] )
1328                ] );
1329            }
1330            if ( count( $returnTypeOrExprs ) === 1 ) {
1331                $queryBuilder->andWhere( $returnTypeOrExprs[0] );
1332            } else {
1333                $queryBuilder->andWhere( $dbr->orExpr( $returnTypeOrExprs ) );
1334            }
1335        }
1336
1337        // Set limit if not null
1338        if ( $limit ) {
1339            $queryBuilder->limit( $limit );
1340        }
1341
1342        return $queryBuilder
1343            ->caller( __METHOD__ )
1344            ->fetchResultSet();
1345    }
1346
1347    /**
1348     * Fetch labels in the secondary database for a batch of objects by ZID,
1349     * in a given language.
1350     *
1351     * @param string[] $zids
1352     * @param string $languageCode Code of the language in which to fetch labels
1353     * @param bool $fallback Whether to only match in the given language, or use
1354     *   the language fallback chain (default behaviour).
1355     * @return array<string,?string> Map of ZID => best matching label or null
1356     */
1357    public function fetchZObjectLabels( array $zids, $languageCode, $fallback = true ): array {
1358        // Initialize result array with null values for each ZID
1359        $result = array_fill_keys( $zids, null );
1360
1361        // Get language registry
1362        $zLangRegistry = ZLangRegistry::singleton();
1363
1364        // Provided language code is not known, so fall back to English.
1365        $languageCode = $zLangRegistry->isLanguageKnownGivenCode( $languageCode ) ? $languageCode : 'en';
1366        $languageZid = $zLangRegistry->getLanguageZidFromCode( $languageCode );
1367
1368        // Set language filter
1369        $languages = [ $languageZid ];
1370        if ( $fallback ) {
1371            // TODO (T362246): Dependency-inject
1372            $languageFallback = MediaWikiServices::getInstance()->getLanguageFallback();
1373            $languages = $zLangRegistry->getListOfFallbackLanguageZids( $languageFallback, $languageCode );
1374        }
1375
1376        $dbr = $this->dbProvider->getReplicaDatabase();
1377        $res = $dbr->newSelectQueryBuilder()
1378            ->select( [ 'wlzl_zobject_zid', 'wlzl_language', 'wlzl_label' ] )
1379            ->from( 'wikilambda_zobject_labels' )
1380            ->where( [
1381                'wlzl_zobject_zid' => $zids,
1382                'wlzl_language' => $languages,
1383                // We only want primary labels, not aliases
1384                'wlzl_label_primary' => '1',
1385            ] )
1386            ->orderBy( 'wlzl_id', SelectQueryBuilder::SORT_ASC )
1387            ->caller( __METHOD__ )
1388            ->fetchResultSet();
1389
1390        // First pass: bucket DB rows as [ zobjectZid => [ languageZid => label ] ].
1391        // This lets us apply fallback preference order in a second pass.
1392        $labelsByZid = [];
1393        foreach ( $res as $row ) {
1394            $zid = $row->wlzl_zobject_zid;
1395            if ( !array_key_exists( $zid, $labelsByZid ) ) {
1396                $labelsByZid[$zid] = [];
1397            }
1398            $labelsByZid[$zid][ $row->wlzl_language ] = $row->wlzl_label;
1399        }
1400
1401        // Second pass: for each requested ZID, pick the first label available in
1402        // the caller's language-preference order (requested lang -> fallbacks).
1403        foreach ( $zids as $zid ) {
1404            if ( !array_key_exists( $zid, $labelsByZid ) ) {
1405                continue;
1406            }
1407            foreach ( $languages as $languageZidInOrder ) {
1408                if ( array_key_exists( $languageZidInOrder, $labelsByZid[$zid] ) ) {
1409                    $result[$zid] = $labelsByZid[$zid][ $languageZidInOrder ];
1410                    break;
1411                }
1412            }
1413        }
1414
1415        return $result;
1416    }
1417
1418    /**
1419     * Fetch the label in the secondary database for a given object by Zid, in a given language.
1420     * Returns null if no labels are found in the given language or any other language in that
1421     * language's fallback chain, including English (Z1002). If the language code given is not
1422     * recognised, this will fall back to returning the English label, if available.
1423     *
1424     * @param string $zid Term to search in the label database
1425     * @param string $languageCode Code of the language in which to fetch the label
1426     * @param bool $fallback Whether to only match in the given language, or use
1427     *   the language fallback change (default behaviour).
1428     * @return string|null
1429     */
1430    public function fetchZObjectLabel( $zid, $languageCode, $fallback = true ) {
1431        $dbr = $this->dbProvider->getReplicaDatabase();
1432
1433        $conditions = [    'wlzl_zobject_zid' => $zid ];
1434
1435        $zLangRegistry = ZLangRegistry::singleton();
1436
1437        // Provided language code is not known, so fall back to English.
1438        $languageCode = $zLangRegistry->isLanguageKnownGivenCode( $languageCode ) ? $languageCode : 'en';
1439        $languageZid = $zLangRegistry->getLanguageZidFromCode( $languageCode );
1440
1441        // Set language filter
1442        $languages = [ $languageZid ];
1443        if ( $fallback ) {
1444            // TODO (T362246): Dependency-inject
1445            $languageFallback = MediaWikiServices::getInstance()->getLanguageFallback();
1446            $languages = $zLangRegistry->getListOfFallbackLanguageZids( $languageFallback, $languageCode );
1447        }
1448        $conditions[ 'wlzl_language' ] = $languages;
1449
1450        // We only want primary labels, not aliases
1451        $conditions[ 'wlzl_label_primary' ] = '1';
1452
1453        $res = $dbr->newSelectQueryBuilder()
1454            ->select( [ 'wlzl_language', 'wlzl_label' ] )
1455            ->from( 'wikilambda_zobject_labels' )
1456            ->where( $conditions )
1457            ->orderBy( 'wlzl_id', SelectQueryBuilder::SORT_ASC )
1458            // Hard-coded performance limit just in case there's a very long / circular language fallback chain.
1459            ->limit( 5 )
1460            ->caller( __METHOD__ )
1461            ->fetchResultSet();
1462
1463        // No hits at all; allow callers to give a fallback message or trigger a DB fetch if they want.
1464        if ( $res->numRows() === 0 ) {
1465            return null;
1466        }
1467
1468        // Collapse labels into a simple array
1469        $labels = [];
1470        foreach ( $res as $row ) {
1471            $labels[$row->wlzl_language] = $row->wlzl_label;
1472        }
1473
1474        // Walk the labels in order of the language chain, so that language preference is preserved
1475        foreach ( $languages as $index => $languageZid ) {
1476            if ( array_key_exists( $languageZid, $labels ) ) {
1477                return $labels[ $languageZid ];
1478            }
1479        }
1480
1481        // Somehow we've reached this point without a hit? Oh well.
1482        return null;
1483    }
1484
1485    /**
1486     * Get the return type of a given Function Zid or null if not available
1487     *
1488     * @param string $zid
1489     * @return string|null
1490     */
1491    public function fetchZFunctionReturnType( $zid ): ?string {
1492        $dbr = $this->dbProvider->getReplicaDatabase();
1493        $res = $dbr->newSelectQueryBuilder()
1494            ->select( [ 'wlzl_return_type' ] )
1495            ->from( 'wikilambda_zobject_labels' )
1496            ->where( [
1497                'wlzl_zobject_zid' => $zid,
1498                'wlzl_type' => ZTypeRegistry::Z_FUNCTION,
1499            ] )
1500            ->limit( 1 )
1501            ->caller( __METHOD__ )
1502            ->fetchField();
1503
1504        return $res ? (string)$res : null;
1505    }
1506
1507    /**
1508     * Search implementations in the secondary database, return the first one
1509     * This function is primarily used for the example API request
1510     *
1511     * @return string
1512     */
1513    public function findFirstZImplementationFunction(): string {
1514        $dbr = $this->dbProvider->getReplicaDatabase();
1515        $res = $dbr->newSelectQueryBuilder()
1516            ->select( [ 'wlzf_zfunction_zid' ] )
1517            ->from( 'wikilambda_zobject_function_join' )
1518            ->where( [
1519                'wlzf_type' => ZTypeRegistry::Z_IMPLEMENTATION,
1520            ] )
1521            ->limit( 1 )
1522            ->caller( __METHOD__ )
1523            ->fetchField();
1524
1525        return $res ? (string)$res : '';
1526    }
1527
1528    /**
1529     * Converts findReferencedZObjectsByZFunctionId into a list of zids
1530     *
1531     * @param string $zid the ZID of the ZFunction
1532     * @param string $type the type of the ZFunction reference
1533     * @return string[] All ZIDs of referenced ZObjects associated to the ZFunction
1534     */
1535    public function findReferencedZObjectsByZFunctionIdAsList(
1536        $zid,
1537        $type
1538    ): array {
1539        $res = $this->findReferencedZObjectsByZFunctionId( $zid, $type );
1540        $zids = [];
1541        foreach ( $res as $row ) {
1542            $zids[] = $row->wlzf_ref_zid;
1543        }
1544
1545        return $zids;
1546    }
1547
1548    /**
1549     * Search implementations in the secondary database and return all matching a given ZID
1550     *
1551     * @param string $zid the ZID of the ZFunction
1552     * @param string $type the type of the ZFunction reference
1553     * @param string|null $continue Id to start. If null (the default), start from the first result.
1554     * @param int|null $limit Maximum number of results to return. Defaults to 10
1555     * @return IResultWrapper
1556     */
1557    public function findReferencedZObjectsByZFunctionId(
1558            $zid,
1559            $type,
1560            $continue = null,
1561            $limit = 10
1562        ) {
1563        $dbr = $this->dbProvider->getReplicaDatabase();
1564
1565        $conditions = [
1566            'wlzf_zfunction_zid' => $zid,
1567            'wlzf_type' => $type
1568        ];
1569
1570        // Set minimum id bound if we are continuing a paged result
1571        if ( $continue !== null ) {
1572            $conditions[] = $dbr->expr( 'wlzf_id', '>=', $continue );
1573        }
1574        $res = $dbr->newSelectQueryBuilder()
1575            ->select( [ 'wlzf_ref_zid', 'wlzf_id' ] )
1576            ->from( 'wikilambda_zobject_function_join' )
1577            ->where( $conditions )
1578            ->orderBy( 'wlzf_id', SelectQueryBuilder::SORT_ASC )
1579            ->limit( $limit )
1580            ->caller( __METHOD__ )
1581            ->fetchResultSet();
1582
1583        return $res;
1584    }
1585
1586    /**
1587     * Fetch all objects of type Z14/Implementation persisted in the
1588     * database, including connected, disconnected, labeled and unlabeled
1589     * implementations.
1590     *
1591     * TODO (T287153): This method is only needed for the migrateZ16K1StringsToZ61s
1592     * maintenance script, as using fetchZidsOfType(Z14) will not return
1593     * those implementations that aren't labeled. Once we eliminate the
1594     * maintenance script, we should remove this method, too.
1595     *
1596     * @return string[]
1597     */
1598    public function fetchAllImplementations(): array {
1599        $dbr = $this->dbProvider->getReplicaDatabase();
1600        return $dbr->newSelectQueryBuilder()
1601            ->select( 'wlzf_ref_zid' )
1602            ->distinct()
1603            ->from( 'wikilambda_zobject_function_join' )
1604            ->where( [
1605                'wlzf_type' => ZTypeRegistry::Z_IMPLEMENTATION
1606            ] )
1607            ->orderBy( 'wlzf_ref_zid', SelectQueryBuilder::SORT_ASC )
1608            ->caller( __METHOD__ )
1609            ->fetchFieldValues();
1610    }
1611
1612    /**
1613     * Add a record to the database for a given ZObject ID and ZFunction ID
1614     *
1615     * @param string $refId the ZObject ref ID
1616     * @param string $zFunctionId the ZFunction ID
1617     * @param string $type the type of the ZFunction reference
1618     * @return void|bool
1619     */
1620    public function insertZFunctionReference( $refId, $zFunctionId, $type ) {
1621        $dbw = $this->dbProvider->getPrimaryDatabase();
1622
1623        $dbw->newInsertQueryBuilder()
1624            ->insertInto( 'wikilambda_zobject_function_join' )
1625            ->rows( [
1626                [
1627                    'wlzf_ref_zid' => $refId,
1628                    'wlzf_zfunction_zid' => $zFunctionId,
1629                    'wlzf_type' => $type
1630                ]
1631            ] )
1632            ->caller( __METHOD__ )->execute();
1633    }
1634
1635    /**
1636     * Remove a given ZObject ref from the secondary database
1637     *
1638     * @param string $refId the ZObject ID
1639     * @return void
1640     */
1641    public function deleteZFunctionReference( $refId ): void {
1642        $dbw = $this->dbProvider->getPrimaryDatabase();
1643
1644        $dbw->newDeleteQueryBuilder()
1645            ->deleteFrom( 'wikilambda_zobject_function_join' )
1646            ->where( [ 'wlzf_ref_zid' => $refId ] )
1647            ->caller( __METHOD__ )->execute();
1648    }
1649
1650    /**
1651     * For the given main ZObject and key, return the related ZObjects.
1652     *
1653     * Related ZObjects may be ZIDs or string encodings of
1654     * compound ZObjects, such as "Z881(Z6)" for typed list of strings.
1655     *
1656     * @param string $mainZid ZID of the main ZObject
1657     * @param string $key ZID of the key that indicates the relationship
1658     * @return string[]
1659     */
1660    public function findRelatedZObjectsByKeyAsList( $mainZid, $key ): array {
1661        $res = $this->findRelatedZObjectsByKey( $mainZid, $key );
1662        $related = [];
1663
1664        foreach ( $res as $row ) {
1665            $related[] = $row->wlzo_related_zobject;
1666        }
1667        return $related;
1668    }
1669
1670    /**
1671     * For the given main ZObject and key, return the related ZObjects.
1672     *
1673     * Related ZObjects may be ZIDs or string encodings of
1674     * compound ZObjects, such as "Z881(Z6)" for typed list of strings.
1675     *
1676     * @param string $mainZid ZID of the main ZObject
1677     * @param string $key ZID of the key that indicates the relationship
1678     * @param string|null $continue Id to start. If null (the default), start from the first result.
1679     * @param int|null $limit Maximum number of results to return. Defaults to 10
1680     * @return IResultWrapper
1681     */
1682    public function findRelatedZObjectsByKey( $mainZid, $key, $continue = null, $limit = 10 ) {
1683        $dbr = $this->dbProvider->getReplicaDatabase();
1684
1685        $conditions = [
1686            'wlzo_main_zid' => $mainZid,
1687            'wlzo_key' => $key
1688        ];
1689
1690        // Set minimum id bound if we are continuing a paged result
1691        if ( $continue !== null ) {
1692            $conditions[] = $dbr->expr( 'wlzo_id', '>=', $continue );
1693        }
1694        $res = $dbr->newSelectQueryBuilder()
1695            ->select( [ 'wlzo_related_zobject', 'wlzo_id' ] )
1696            ->from( 'wikilambda_zobject_join' )
1697            ->where( $conditions )
1698            ->orderBy( 'wlzo_id', SelectQueryBuilder::SORT_ASC )
1699            ->limit( $limit )
1700            ->caller( __METHOD__ )
1701            ->fetchResultSet();
1702
1703        return $res;
1704    }
1705
1706    /**
1707     * For the given related ZObject and key, return the main ZObjects (e.g. functions) that reference it.
1708     *
1709     * @param string $relatedZid ZID of the related ZObject (e.g. implementation or tester)
1710     * @param string $key ZID of the key that indicates the relationship (e.g. Z8K4 or Z8K3)
1711     * @return string[] List of main ZIDs (e.g. function ZIDs) that reference the related ZObject via the key
1712     */
1713    public function findFunctionsReferencingZObjectByKey( $relatedZid, $key ): array {
1714        $dbr = $this->dbProvider->getReplicaDatabase();
1715
1716        $conditions = [
1717            'wlzo_related_zobject' => $relatedZid,
1718            'wlzo_key' => $key
1719        ];
1720        return $dbr->newSelectQueryBuilder()
1721            ->select( 'wlzo_main_zid' )
1722            ->from( 'wikilambda_zobject_join' )
1723            ->where( $conditions )
1724            ->caller( __METHOD__ )
1725            ->fetchFieldValues();
1726    }
1727
1728    /**
1729     * Find all function Zids for which all their input and output types can
1730     * be converted from and to strings.
1731     * This means that:
1732     *
1733     * * all their input types are either:
1734     * ** in PARSEABLE_INPUT_TYPES (Z6/String, Z60/Language, Wikidata types), or
1735     * ** enums, or
1736     * ** have a parser function, and
1737     *
1738     * * their output type is either:
1739     * ** in RENDERABLE_OUTPUT_TYPES (Z6/String, Z89/HTML Fragment), or
1740     * ** have a renderer function.
1741     *
1742     * @return string[]
1743     */
1744    public function findFunctionsByRenderableIO(): array {
1745        return $this->functionsByRenderableIOQuery()
1746            ->caller( __METHOD__ )
1747            ->fetchFieldValues();
1748    }
1749
1750    /**
1751     * Returns a Query that returns all the function Zids for which:
1752     * * all their inputs are of parseable type, and
1753     * * their output type is renderable.
1754     *
1755     * We understand that:
1756     *
1757     * * input types are parseable if they are:
1758     * ** in PARSEABLE_INPUT_TYPES (Z6/String, Z60/Language, Wikidata types), or
1759     * ** enums, or
1760     * ** have a parser function, and
1761     *
1762     * * output types are renderable if they are:
1763     * ** in RENDERABLE_OUTPUT_TYPES (Z6/String, Z89/HTML Fragment), or
1764     * ** have a renderer function.
1765     *
1766     * @return SelectQueryBuilder
1767     */
1768    private function functionsByRenderableIOQuery() {
1769        $dbr = $this->dbProvider->getReplicaDatabase();
1770
1771        // Subquery: Functions with input and output types that are renderable
1772        $functionJoinsSQL = $this->getRenderableIOQuery()->getSQL();
1773
1774        $outputRenderableCond = $dbr->conditional( [ 'wlzo_key' => 'Z8K2', 'renderable' => 1 ], '1', 'NULL' );
1775        $inputRenderableCond = $dbr->conditional( [ 'wlzo_key' => 'Z8K1', 'renderable' => 1 ], '1', 'NULL' );
1776        $inputCond = $dbr->conditional( [ 'wlzo_key' => 'Z8K1' ], '1', 'NULL' );
1777
1778        // Aggregate conditions:
1779        // * there's one renderable output
1780        // * the number of inputs is the same as the number of renderable inputs
1781        $aggregateConditions = [
1782            'COUNT(' . $outputRenderableCond . ') = 1',
1783            'COUNT(' . $inputCond . ') = COUNT(' . $inputRenderableCond . ')'
1784        ];
1785
1786        $functionsQuery = $dbr->newSelectQueryBuilder()
1787            ->select( 'wlzo_main_zid' )
1788            ->from( new Subquery( $functionJoinsSQL ), 'fj' )
1789            ->where( [ 'wlzo_main_type' => 'Z8' ] )
1790            ->groupBy( 'wlzo_main_zid' )
1791            ->having( $aggregateConditions );
1792
1793        return $functionsQuery;
1794    }
1795
1796    /**
1797     * Returns a Query that returns the wikilambda_zobject_join table
1798     * with an additional column named "renderable", which holds a
1799     * boolean (1/0) value:
1800     *
1801     * * For every output row (wlzo_key=Z8K2), renderable is true if:
1802     * ** output type is in RENDERABLE_OUTPUT_TYPES (Z6/String, Z89/HTML Fragment), or
1803     * ** output type has a renderer function
1804     *
1805     * * For every input row (wlzo_key=Z8K1), renderable is true if:
1806     * ** input type is in RENDERABLE_INPUT_TYPES (Z6/String, Wikidata types), or
1807     * ** input type is an enum, or
1808     * ** input type has a parser function
1809     *
1810     * @return SelectQueryBuilder
1811     */
1812    private function getRenderableIOQuery() {
1813        $dbr = $this->dbProvider->getReplicaDatabase();
1814
1815        $renderableOrExpr = [
1816            // Input has parser
1817            $dbr->expr( 'it.wlzo_id', '!=', null ),
1818            // Output has renderer
1819            $dbr->expr( 'ot.wlzo_id', '!=', null ),
1820            // Input is enum
1821            $dbr->expr( 'ite.wlzo_main_type', '!=', null ),
1822        ];
1823
1824        // Add renderable input types
1825        foreach ( ZTypeRegistry::PARSEABLE_INPUT_TYPES as $type ) {
1826            $renderableOrExpr[] = $dbr->andExpr(
1827                [ 'f.wlzo_key' => 'Z8K1', 'f.wlzo_related_zobject' => $type ]
1828            );
1829        }
1830
1831         // Add renderable output types
1832        foreach ( ZTypeRegistry::RENDERABLE_OUTPUT_TYPES as $type ) {
1833            $renderableOrExpr[] = $dbr->andExpr(
1834                [ 'f.wlzo_key' => 'Z8K2', 'f.wlzo_related_zobject' => $type ]
1835            );
1836        }
1837
1838        // Case statement conditions for the renderable column
1839        $renderableCase = $dbr->conditional(
1840            $dbr->orExpr( $renderableOrExpr ),
1841            '1',
1842            '0'
1843        );
1844
1845        // Left Join with same table to see if input type has parser
1846        $inputParserConditions = [
1847            'f.wlzo_related_zobject = it.wlzo_main_zid',
1848            'f.wlzo_key' => 'Z8K1',
1849            'it.wlzo_key' => 'Z4K6'
1850        ];
1851
1852        // Left Join with same table to see if output type has renderer
1853        $outputRendererConditions = [
1854            'f.wlzo_related_zobject = ot.wlzo_main_zid',
1855            'f.wlzo_key' => 'Z8K2',
1856            'ot.wlzo_key' => 'Z4K5'
1857        ];
1858
1859        // Left Join with subquery that finds types with enums
1860        $enumsQueryBuilder = $dbr->newSelectQueryBuilder()
1861            ->select( 'wlzo_main_type' )
1862            ->distinct()
1863            ->from( 'wikilambda_zobject_join' )
1864            ->where( [ 'wlzo_key' => 'instanceofenum' ] );
1865        $inputEnumConditions = [
1866            'f.wlzo_related_zobject = ite.wlzo_main_type',
1867            'f.wlzo_key' => 'Z8K1'
1868        ];
1869
1870        // Unique implementation table subquery to only include running functions
1871        $runningFunctionQueryBuilder = $dbr->newSelectQueryBuilder()
1872            ->select( 'wlzo_main_zid' )
1873            ->distinct()
1874            ->from( 'wikilambda_zobject_join' )
1875            ->where( [ 'wlzo_key' => 'Z8K4' ] );
1876        $runningFunctionConditions = [ 'f.wlzo_main_zid = imp.wlzo_main_zid' ];
1877
1878        // Build main query
1879        $renderableIOQuery = $dbr->newSelectQueryBuilder()
1880            ->select( [
1881                'f.wlzo_id',
1882                'f.wlzo_main_zid',
1883                'f.wlzo_main_type',
1884                'f.wlzo_key',
1885                'f.wlzo_related_zobject',
1886                'f.wlzo_related_type',
1887                'renderable' => $renderableCase
1888            ] )
1889            ->from( 'wikilambda_zobject_join', 'f' )
1890            ->leftJoin( 'wikilambda_zobject_join', 'it', $inputParserConditions )
1891            ->leftJoin( 'wikilambda_zobject_join', 'ot', $outputRendererConditions )
1892            ->leftJoin( $enumsQueryBuilder, 'ite', $inputEnumConditions )
1893            ->join( $runningFunctionQueryBuilder, 'imp', $runningFunctionConditions );
1894
1895        return $renderableIOQuery;
1896    }
1897
1898    /**
1899     * Find all functions Zids using at least the given number of each given input type, and using
1900     * the given output type, if specified. Each result value is the ZID of one such function.
1901     *
1902     * Each specified type (in $inputTypes or $outputType) may be a ZID or a string encoding of a
1903     * compound type (as made by ZObjectUtils::makeTypeFingerprint, such as "Z881(Z6)" for typed
1904     * list of strings). Each number in $inputTypes must be an integer >= 1.  (Any number < 1
1905     * is treated as if it were 1.)
1906     *
1907     * Example: for $inputTypes = ["Z881(Z1)" => 2, "Z8" => 1] and $outputType = "Z40"
1908     * the result will include "Z889", the list element equality function, and any other
1909     * functions that have at least 2 input arguments of type "Z881(Z1)", at least 1 input argument
1910     * of type "Z8", and output of type "Z40".
1911     *
1912     * There must be at least one type mentioned, either in $inputTypes or $outputType;
1913     * otherwise an empty array is returned.
1914     *
1915     * Internally, this method executes the query built by functionsByIOTypesQuery, which
1916     * can be independently used to build subqueries for other purposes.
1917     *
1918     * @param array $inputTypes array of (type => minimum number of uses)
1919     * @param string|null $outputType
1920     * @return string[]
1921     */
1922    public function findFunctionsByIOTypes( $inputTypes, $outputType = null ): array {
1923        $queryBuilder = $this->functionsByIOTypesQuery( $inputTypes, $outputType );
1924        return $queryBuilder === null ? [] :
1925            $queryBuilder
1926                ->caller( __METHOD__ )
1927                ->fetchFieldValues();
1928    }
1929
1930    /**
1931     * Returns a query that looks into the wikilambda_zobject_join table to return
1932     * all the functions whose input and output types match the given conditions.
1933     *
1934     * If no inputs or output types are specified, returns null. This way the
1935     * caller can decide whether to icorporate this subquery or avoid it if
1936     * no filters need to be applied.
1937     *
1938     * @param array $inputTypes array of (type => minimum number of uses)
1939     * @param string|null $outputType
1940     * @return SelectQueryBuilder|null
1941     */
1942    private function functionsByIOTypesQuery( $inputTypes, $outputType = null ) {
1943        $dbr = $this->dbProvider->getReplicaDatabase();
1944
1945        // Select one type/count (and key) for the first query;
1946        // leave the other type/count pairs in $remainingTypes.
1947        if ( $outputType ) {
1948            $firstKey = 'Z8K2';
1949            $firstType = $outputType;
1950            $firstCount = 1;
1951            $remainingTypes = $inputTypes;
1952        } elseif ( count( $inputTypes ) > 0 ) {
1953            $firstKey = 'Z8K1';
1954            $firstType = array_key_first( $inputTypes );
1955            $firstCount = reset( $inputTypes );
1956            $remainingTypes = array_slice( $inputTypes, 1 );
1957        } else {
1958            // Return null if there's no inputs or outputs to filter by
1959            return null;
1960        }
1961
1962        $query = $this->newIOTypeQuery( $dbr, $firstKey, $firstType, $firstCount );
1963        foreach ( $remainingTypes as $type => $count ) {
1964            $subQuery = $this->newIOTypeQuery( $dbr, 'Z8K1', $type, $count );
1965            $query = $dbr->newSelectQueryBuilder()
1966                ->select( [ 'wlzo_main_zid' => 'q1.wlzo_main_zid' ] )
1967                ->from( $query, 'q1' )
1968                ->join( $subQuery, 'q2', 'q1.wlzo_main_zid = q2.wlzo_main_zid' );
1969        }
1970
1971        return $query;
1972    }
1973
1974    /**
1975     * Returns a query that finds functions in wikilambda_zobject_join having at least
1976     * $count rows with the given $key and $type, for use by functionsByIOTypesQuery().
1977     *
1978     * @param IReadableDatabase $dbr
1979     * @param string $key
1980     * @param string $type
1981     * @param int $count
1982     * @return SelectQueryBuilder
1983     */
1984    private function newIOTypeQuery( $dbr, $key, $type, $count ) {
1985        // Match all related objects that are equals the given type,
1986        // or the related obejcts that contain the substring "type("
1987        // to include also generic types such as Z881(Z1), etc.
1988        $conditions = $dbr->andExpr( [
1989            $dbr->expr( 'wlzo_key', '=', $key ),
1990            $dbr->orExpr( [
1991                $dbr->expr( 'wlzo_related_zobject', '=', $type ),
1992                $dbr->expr( 'wlzo_related_zobject', IExpression::LIKE, new LikeValue( $type . '(', $dbr->anyString() ) )
1993            ] )
1994        ] );
1995
1996        $query = $dbr->newSelectQueryBuilder()
1997                ->select( [ 'wlzo_main_zid' ] )
1998                ->from( 'wikilambda_zobject_join' )
1999                ->where( $conditions );
2000
2001        if ( $count > 1 ) {
2002            $query
2003                ->groupBy( [ 'wlzo_main_zid' ] )
2004                ->having( [ 'COUNT(*) >= ' . $count ] );
2005        } else {
2006            $query->distinct();
2007        }
2008        return $query;
2009    }
2010
2011    /**
2012     * Add a batch of rows to the database describing the relation between
2013     * a main ZObject and a related one, given by the connecting key.
2014     *
2015     * Example: [ 'Z401', 'Z8', 'Z8K2', 'Z881(Z6)', 'Z4' ]
2016     * Indicates that the main object (Z401) of type function (Z8) has an output
2017     * type (Z8K2) with value typed list of strings (Z881(Z6)) and type type (Z4)
2018     *
2019     * @param array $relatedZObjects Array of rows to insert into the table.
2020     *   Each row must be an object with the non-empty properties zid, type, key,
2021     *   related_zid and related_type
2022     * @return void
2023     */
2024    public function insertRelatedZObjects( $relatedZObjects ): void {
2025        $rows = [];
2026        foreach ( $relatedZObjects as $zobject ) {
2027            $rows[] = [
2028                'wlzo_main_zid' => $zobject->zid,
2029                'wlzo_main_type' => $zobject->type,
2030                'wlzo_key' => $zobject->key,
2031                'wlzo_related_zobject' => $zobject->related_zid,
2032                'wlzo_related_type' => $zobject->related_type
2033            ];
2034        }
2035
2036        $dbw = $this->dbProvider->getPrimaryDatabase();
2037        $dbw->newInsertQueryBuilder()
2038            ->insertInto( 'wikilambda_zobject_join' )
2039            ->rows( $rows )
2040            ->caller( __METHOD__ )
2041            ->execute();
2042    }
2043
2044    /**
2045     * Delete all rows matching all of the non-null input values
2046     *
2047     * @param ?string $mainZid ZID of the main ZObject
2048     * @param ?string $mainType ZID of the type of the main ZObject
2049     * @param ?string $key ZID of a key indicating the relation between main and related ZObjects
2050     * @param ?string $relatedZObject The related ZObject
2051     * @param ?string $relatedType ZID of the type of the related ZObject
2052     * @return void
2053     */
2054    public function deleteRelatedZObjects(
2055        ?string $mainZid,
2056        ?string $mainType = null,
2057        ?string $key = null,
2058        ?string $relatedZObject = null,
2059        ?string $relatedType = null
2060    ): void {
2061        $dbw = $this->dbProvider->getPrimaryDatabase();
2062        $conditions = [];
2063        if ( $mainZid !== null ) {
2064            $conditions['wlzo_main_zid'] = $mainZid;
2065        }
2066        if ( $mainType !== null ) {
2067            $conditions['wlzo_main_type'] = $mainType;
2068        }
2069        if ( $key !== null ) {
2070            $conditions['wlzo_key'] = $key;
2071        }
2072        if ( $relatedZObject !== null ) {
2073            $conditions['wlzo_related_zobject'] = $relatedZObject;
2074        }
2075        if ( $relatedType !== null ) {
2076            $conditions['wlzo_related_type'] = $relatedType;
2077        }
2078
2079        $dbw->newDeleteQueryBuilder()