wikimedia/mediawiki-extensions-Wikibase

View on GitHub
repo/includes/Api/ResultBuilder.php

Summary

Maintainability
F
3 days
Test Coverage
<?php

namespace Wikibase\Repo\Api;

use MediaWiki\Api\ApiResult;
use MediaWiki\Site\SiteLookup;
use MediaWiki\Status\Status;
use MediaWiki\User\UserIdentity;
use Serializers\Serializer;
use Wikibase\DataModel\Entity\EntityDocument;
use Wikibase\DataModel\Entity\EntityId;
use Wikibase\DataModel\Entity\EntityIdParser;
use Wikibase\DataModel\Reference;
use Wikibase\DataModel\Serializers\SerializerFactory;
use Wikibase\DataModel\Services\Lookup\PropertyDataTypeLookup;
use Wikibase\DataModel\SiteLinkList;
use Wikibase\DataModel\Statement\Statement;
use Wikibase\DataModel\Statement\StatementList;
use Wikibase\DataModel\Term\AliasGroupList;
use Wikibase\DataModel\Term\TermList;
use Wikibase\Lib\Serialization\CallbackFactory;
use Wikibase\Lib\Serialization\SerializationModifier;
use Wikibase\Lib\Store\EntityRevision;
use Wikibase\Lib\TermLanguageFallbackChain;
use Wikibase\Repo\AddPageInfo;
use Wikibase\Repo\Dumpers\JsonDataTypeInjector;
use Wikibase\Repo\Store\EntityTitleStoreLookup;
use Wikibase\Repo\WikibaseRepo;
use Wikimedia\Assert\Assert;

/**
 * Builder of MediaWiki ApiResult objects with various convenience functions for adding Wikibase concepts
 * and result parts to results in a uniform way.
 *
 * This class was introduced when Wikibase was reduced from 2 sets of serializers (lib & data-model) to one.
 * This class makes various modifications to the 1 standard serialization of Wikibase concepts for public exposure.
 * The resulting format can be seen as the public serialization of Wikibase concepts.
 *
 * Many concepts such as "tag name" relate to concepts explained within ApiResult.
 *
 * @license GPL-2.0-or-later
 * @author Addshore
 * @author Daniel Kinzler
 */
class ResultBuilder {

    /**
     * @var ApiResult
     */
    private $result;

    /**
     * @var EntityTitleStoreLookup
     */
    private $entityTitleStoreLookup;

    /**
     * @var SerializerFactory
     */
    private $serializerFactory;

    /**
     * @var Serializer
     */
    private $entitySerializer;

    /**
     * @var SiteLookup
     */
    private $siteLookup;

    /**
     * @var PropertyDataTypeLookup
     */
    private $dataTypeLookup;

    /**
     * @var bool|null when special elements such as '_element' are needed by the formatter.
     */
    private $addMetaData;

    /**
     * @var SerializationModifier
     */
    private $modifier;

    /**
     * @var CallbackFactory
     */
    private $callbackFactory;

    /**
     * @var int
     */
    private $missingEntityCounter = -1;

    /**
     * @var JsonDataTypeInjector
     */
    private $dataTypeInjector;

    /**
     * @var EntityIdParser
     */
    private $entityIdParser;

    /**
     * @var AddPageInfo
     */
    private $addPageInfo;

    /**
     * @param ApiResult $result
     * @param EntityTitleStoreLookup $entityTitleStoreLookup
     * @param SerializerFactory $serializerFactory
     * @param Serializer $entitySerializer
     * @param SiteLookup $siteLookup
     * @param PropertyDataTypeLookup $dataTypeLookup
     * @param EntityIdParser $entityIdParser
     * @param bool|null $addMetaData when special elements such as '_element' are needed
     */
    public function __construct(
        ApiResult $result,
        EntityTitleStoreLookup $entityTitleStoreLookup,
        SerializerFactory $serializerFactory,
        Serializer $entitySerializer,
        SiteLookup $siteLookup,
        PropertyDataTypeLookup $dataTypeLookup,
        EntityIdParser $entityIdParser,
        $addMetaData = null
    ) {
        $this->result = $result;
        $this->entityTitleStoreLookup = $entityTitleStoreLookup;
        $this->serializerFactory = $serializerFactory;
        $this->entitySerializer = $entitySerializer;
        $this->siteLookup = $siteLookup;
        $this->dataTypeLookup = $dataTypeLookup;
        $this->entityIdParser = $entityIdParser;
        $this->addMetaData = $addMetaData;

        $this->modifier = new SerializationModifier();
        $this->callbackFactory = new CallbackFactory();

        $this->dataTypeInjector = new JsonDataTypeInjector(
            $this->modifier,
            $this->callbackFactory,
            $dataTypeLookup,
            $entityIdParser
        );

        $this->addPageInfo = new AddPageInfo( $this->entityTitleStoreLookup );
    }

    /**
     * Mark the ApiResult as successful.
     *
     * { "success": 1 }
     *
     * @param bool|int|null $success
     */
    public function markSuccess( $success = true ) {
        $value = (int)$success;

        Assert::parameter(
            $value == 1 || $value == 0,
            '$success',
            '$success must evaluate to either 1 or 0 when casted to integer'
        );

        $this->result->addValue( null, 'success', $value );
    }

    /**
     * Adds a list of values for the given path and name.
     * This automatically sets the indexed tag name, if appropriate.
     *
     * To set atomic values or records, use setValue() or appendValue().
     *
     * @see ApiResult::addValue
     * @see ApiResult::setIndexedTagName
     * @see ResultBuilder::setValue()
     * @see ResultBuilder::appendValue()
     *
     * @param array|string|null $path
     * @param string $name
     * @param array $values
     * @param string $tag tag name to use for elements of $values if not already present
     */
    public function setList( $path, $name, array $values, $tag ) {
        $this->checkPathType( $path );
        Assert::parameterType( 'string', $name, '$name' );
        Assert::parameterType( 'string', $tag, '$tag' );

        if ( $this->addMetaData ) {
            if ( !array_key_exists( ApiResult::META_TYPE, $values ) ) {
                ApiResult::setArrayType( $values, 'array' );
            }
            if ( !array_key_exists( ApiResult::META_INDEXED_TAG_NAME, $values ) ) {
                ApiResult::setIndexedTagName( $values, $tag );
            }
        }

        $this->result->addValue( $path, $name, $values );
    }

    /**
     * Set an atomic value (or record) for the given path and name.
     * If the value is an array, it should be a record (associative), not a list.
     * For adding lists, use setList().
     *
     * @see ResultBuilder::setList()
     * @see ResultBuilder::appendValue()
     * @see ApiResult::addValue
     *
     * @param array|string|null $path
     * @param string $name
     * @param mixed $value
     */
    public function setValue( $path, $name, $value ) {
        $this->checkPathType( $path );
        Assert::parameterType( 'string', $name, '$name' );
        $this->checkValueIsNotList( $value );

        $this->result->addValue( $path, $name, $value );
    }

    /**
     * Appends a value to the list at the given path.
     * This automatically sets the indexed tag name, if appropriate.
     *
     * If the value is an array, it should be associative, not a list.
     * For adding lists, use setList().
     *
     * @see ResultBuilder::setList()
     * @see ResultBuilder::setValue()
     * @see ApiResult::addValue
     * @see ApiResult::setIndexedTagName_internal
     *
     * @param array|string|null $path
     * @param int|string|null $key the key to use when appending, or null for automatic.
     * May be ignored even if given, based on $this->addMetaData.
     * @param mixed $value
     * @param string $tag tag name to use for $value in indexed mode
     */
    public function appendValue( $path, $key, $value, $tag ) {
        $this->checkPathType( $path );
        $this->checkKeyType( $key );
        Assert::parameterType( 'string', $tag, '$tag' );
        $this->checkValueIsNotList( $value );

        $this->result->addValue( $path, $key, $value );
        if ( $this->addMetaData ) {
            $this->result->addIndexedTagName( $path, $tag );
        }
    }

    /**
     * @param array|string|null $path
     */
    private function checkPathType( $path ) {
        Assert::parameter(
            is_string( $path ) || is_array( $path ) || $path === null,
            '$path',
            '$path must be an array (or null)'
        );
    }

    /**
     * @param int|string|null $key the key to use when appending, or null for automatic.
     */
    private function checkKeyType( $key ) {
        Assert::parameter(
            is_string( $key ) || is_int( $key ) || $key === null,
            '$key',
            '$key must be an array (or null)'
        );
    }

    /**
     * @param mixed $value
     */
    private function checkValueIsNotList( $value ) {
        Assert::parameter(
            !( is_array( $value ) && isset( $value[0] ) ),
            '$value',
            '$value must not be a list'
        );
    }

    /**
     * Get serialized entity for the EntityRevision and add it to the result alongside other needed properties.
     *
     *
     * @param string|null $sourceEntityIdSerialization EntityId used to retrieve $entityRevision
     *        Used as the key for the entity in the 'entities' structure and for adding redirect
     *     info Will default to the entity's serialized ID if null. If given this must be the
     *     entity id before any redirects were resolved.
     * @param EntityRevision $entityRevision
     * @param string[]|string $props a list of fields to include, or "all"
     * @param string[]|null $filterSiteIds A list of site IDs to filter by
     * @param string[] $filterLangCodes A list of language codes to filter by
     * @param TermLanguageFallbackChain[] $termFallbackChains with keys of the origional language
     */
    public function addEntityRevision(
        $sourceEntityIdSerialization,
        EntityRevision $entityRevision,
        $props = 'all',
        array $filterSiteIds = null,
        array $filterLangCodes = [],
        array $termFallbackChains = []
    ) {
        $entity = $entityRevision->getEntity();
        $entityId = $entity->getId();

        if ( $sourceEntityIdSerialization === null ) {
            $sourceEntityIdSerialization = $entityId->getSerialization();
        }

        $record = [];

        // If there are no props defined only return type and id..
        // @phan-suppress-next-line PhanTypeComparisonToArray
        if ( $props === [] ) {
            $record = $this->addEntityInfoToRecord( $record, $entityId );
        } else {
            // @phan-suppress-next-line PhanTypeMismatchArgumentInternal False positive
            if ( $props == 'all' || in_array( 'info', $props ) ) {
                $record = $this->addPageInfo->add( $record, $entityRevision );
            }
            if ( $sourceEntityIdSerialization !== $entityId->getSerialization() ) {
                $record = $this->addEntityRedirectInfoToRecord( $record, $sourceEntityIdSerialization, $entityId );
            }

            $entitySerialization = $this->getModifiedEntityArray(
                $entity,
                $props,
                $filterSiteIds,
                $filterLangCodes,
                $termFallbackChains
            );

            $record = array_merge( $record, $entitySerialization );
        }

        $this->appendValue( [ 'entities' ], $sourceEntityIdSerialization, $record, 'entity' );
        if ( $this->addMetaData ) {
            $this->result->addArrayType( [ 'entities' ], 'kvp', 'id' );
            $this->result->addValue(
                [ 'entities' ],
                ApiResult::META_KVP_MERGE,
                true,
                ApiResult::OVERRIDE
            );
        }
    }

    private function addEntityInfoToRecord( array $record, EntityId $entityId ): array {
        $record['id'] = $entityId->getSerialization();
        $record['type'] = $entityId->getEntityType();
        return $record;
    }

    private function addEntityRedirectInfoToRecord( array $record, $sourceEntityIdSerialization, EntityId $entityId ): array {
        $record['redirects'] = [
            'from' => $sourceEntityIdSerialization,
            'to' => $entityId->getSerialization(),
        ];
        return $record;
    }

    /**
     * Gets the standard serialization of an EntityDocument and modifies it in a standard way.
     *
     * This code was created for Items and Properties and since new entity types have been introduced
     * it may not work in the desired way.
     * @see https://phabricator.wikimedia.org/T249206
     *
     * @see ResultBuilder::addEntityRevision
     *
     * @param EntityDocument $entity
     * @param array|string $props
     * @param string[]|null $filterSiteIds
     * @param string[] $filterLangCodes
     * @param TermLanguageFallbackChain[] $termFallbackChains
     *
     * @return array
     */
    public function getModifiedEntityArray(
        EntityDocument $entity,
        $props,
        ?array $filterSiteIds,
        array $filterLangCodes,
        array $termFallbackChains
    ) {
        $serialization = $this->entitySerializer->serialize( $entity );

        $serialization = $this->filterEntitySerializationUsingProps( $serialization, $props );

        if ( $props == 'all' || in_array( 'sitelinks/urls', $props ) ) {
            $serialization = $this->injectEntitySerializationWithSiteLinkUrls( $serialization );
        }
        $serialization = $this->sortEntitySerializationSiteLinks( $serialization );
        $serialization = $this->dataTypeInjector->injectEntitySerializationWithDataTypes( $serialization );
        $serialization = $this->filterEntitySerializationUsingSiteIds( $serialization, $filterSiteIds );
        if ( $termFallbackChains ) {
            $serialization = $this->addEntitySerializationFallbackInfo( $serialization, $termFallbackChains );
        }
        $serialization = $this->filterEntitySerializationUsingLangCodes(
            $serialization,
            $filterLangCodes
        );

        if ( $this->addMetaData ) {
            $serialization = $this->getEntitySerializationWithMetaData( $serialization );
        }

        return $serialization;
    }

    /**
     * @param array $serialization
     * @param string|array $props
     *
     * @return array
     */
    private function filterEntitySerializationUsingProps( array $serialization, $props ) {
        if ( $props !== 'all' ) {
            if ( !in_array( 'labels', $props ) ) {
                unset( $serialization['labels'] );
            }
            if ( !in_array( 'descriptions', $props ) ) {
                unset( $serialization['descriptions'] );
            }
            if ( !in_array( 'aliases', $props ) ) {
                unset( $serialization['aliases'] );
            }
            if ( !in_array( 'claims', $props ) ) {
                unset( $serialization['claims'] );
            }
            if ( !in_array( 'sitelinks', $props ) ) {
                unset( $serialization['sitelinks'] );
            }
        }
        return $serialization;
    }

    private function injectEntitySerializationWithSiteLinkUrls( array $serialization ) {
        if ( isset( $serialization['sitelinks'] ) ) {
            $serialization['sitelinks'] = $this->getSiteLinkListArrayWithUrls( $serialization['sitelinks'] );
        }
        return $serialization;
    }

    private function sortEntitySerializationSiteLinks( array $serialization ) {
        if ( isset( $serialization['sitelinks'] ) ) {
            ksort( $serialization['sitelinks'] );
        }
        return $serialization;
    }

    private function filterEntitySerializationUsingSiteIds(
        array $serialization,
        array $siteIds = null
    ) {
        if ( $siteIds && array_key_exists( 'sitelinks', $serialization ) ) {
            foreach ( $serialization['sitelinks'] as $siteId => $siteLink ) {
                if ( is_array( $siteLink ) && !in_array( $siteLink['site'], $siteIds ) ) {
                    unset( $serialization['sitelinks'][$siteId] );
                }
            }
        }
        return $serialization;
    }

    /**
     * @param array $serialization
     * @param TermLanguageFallbackChain[] $termFallbackChains
     *
     * @return array
     */
    private function addEntitySerializationFallbackInfo(
        array $serialization,
        array $termFallbackChains
    ) {
        if ( isset( $serialization['labels'] ) ) {
            $serialization['labels'] = $this->getTermsSerializationWithFallbackInfo(
                $serialization['labels'],
                $termFallbackChains
            );
        }

        if ( isset( $serialization['descriptions'] ) ) {
            $serialization['descriptions'] = $this->getTermsSerializationWithFallbackInfo(
                $serialization['descriptions'],
                $termFallbackChains
            );
        }

        return $serialization;
    }

    /**
     * @param array $serialization
     * @param TermLanguageFallbackChain[] $termFallbackChains
     *
     * @return array
     */
    private function getTermsSerializationWithFallbackInfo(
        array $serialization,
        array $termFallbackChains
    ) {
        $newSerialization = $serialization;
        foreach ( $termFallbackChains as $requestedLanguageCode => $fallbackChain ) {
            if ( !array_key_exists( $requestedLanguageCode, $serialization ) ) {
                $fallbackSerialization = $fallbackChain->extractPreferredValue( $serialization );
                if ( $fallbackSerialization !== null ) {
                    if ( $fallbackSerialization['source'] !== null ) {
                        $fallbackSerialization['source-language'] = $fallbackSerialization['source'];
                    }
                    unset( $fallbackSerialization['source'] );
                    if ( $requestedLanguageCode !== $fallbackSerialization['language'] ) {
                        $fallbackSerialization['for-language'] = $requestedLanguageCode;
                    }
                    $newSerialization[$requestedLanguageCode] = $fallbackSerialization;
                }
            }
        }
        return $newSerialization;
    }

    /**
     * @param array $serialization
     * @param string[] $langCodes
     *
     * @return array
     */
    private function filterEntitySerializationUsingLangCodes(
        array $serialization,
        array $langCodes
    ) {
        if ( $langCodes ) {
            if ( array_key_exists( 'labels', $serialization ) ) {
                foreach ( $serialization['labels'] as $langCode => $languageArray ) {
                    if ( !in_array( $langCode, $langCodes ) ) {
                        unset( $serialization['labels'][$langCode] );
                    }
                }
            }
            if ( array_key_exists( 'descriptions', $serialization ) ) {
                foreach ( $serialization['descriptions'] as $langCode => $languageArray ) {
                    if ( !in_array( $langCode, $langCodes ) ) {
                        unset( $serialization['descriptions'][$langCode] );
                    }
                }
            }
            if ( array_key_exists( 'aliases', $serialization ) ) {
                foreach ( $serialization['aliases'] as $langCode => $languageArray ) {
                    if ( !in_array( $langCode, $langCodes ) ) {
                        unset( $serialization['aliases'][$langCode] );
                    }
                }
            }
        }
        return $serialization;
    }

    private function getEntitySerializationWithMetaData( array $serialization ) {
        $serializeEmptyListsAsObjects = WikibaseRepo::getSettings()->getSetting( 'tmpSerializeEmptyListsAsObjects' );
        $modifications = [];

        $makeIdKvpCallback = $this->callbackFactory->getCallbackToSetArrayType( 'kvp', 'id' );
        $makeLanguageKvpCallback = $this->callbackFactory->getCallbackToSetArrayType( 'kvp', 'language' );
        $makeSiteKvpCallback = $this->callbackFactory->getCallbackToSetArrayType( 'kvp', 'site' );

        $modifications['aliases'][] = $makeIdKvpCallback;
        $modifications['claims/*/*/references/*/snaks'][] = $makeIdKvpCallback;
        $modifications['claims/*/*/qualifiers'][] = $makeIdKvpCallback;
        $modifications['claims'][] = $makeIdKvpCallback;
        $modifications['descriptions'][] = $makeLanguageKvpCallback;
        $modifications['labels'][] = $makeLanguageKvpCallback;
        $modifications['sitelinks'][] = $makeSiteKvpCallback;

        if ( $serializeEmptyListsAsObjects ) {
            $modifications['*/*/claims/*/*/references/*/snaks'][] = $makeIdKvpCallback;
            $modifications['*/*/claims/*/*/qualifiers'][] = $makeIdKvpCallback;
            $modifications['*/*/claims'][] = $makeIdKvpCallback;
        }

        $kvpMergeCallback = function( $array ) {
            if ( is_array( $array ) ) {
                $array[ApiResult::META_KVP_MERGE] = true;
            }
            return $array;
        };

        $modifications['descriptions'][] = $kvpMergeCallback;
        $modifications['labels'][] = $kvpMergeCallback;
        $modifications['sitelinks'][] = $kvpMergeCallback;

        $indexLabelCallback = $this->callbackFactory->getCallbackToIndexTags( 'label' );
        $indexDescriptionCallback = $this->callbackFactory->getCallbackToIndexTags( 'description' );
        $indexAliasCallback = $this->callbackFactory->getCallbackToIndexTags( 'alias' );
        $indexLanguageCallback = $this->callbackFactory->getCallbackToIndexTags( 'language' );
        $indexBadgeCallback = $this->callbackFactory->getCallbackToIndexTags( 'badge' );
        $indexSitelinkCallback = $this->callbackFactory->getCallbackToIndexTags( 'sitelink' );
        $indexQualifiersCallback = $this->callbackFactory->getCallbackToIndexTags( 'qualifiers' );
        $indexPropertyCallback = $this->callbackFactory->getCallbackToIndexTags( 'property' );
        $indexSnakCallback = $this->callbackFactory->getCallbackToIndexTags( 'snak' );
        $indexReferenceCallback = $this->callbackFactory->getCallbackToIndexTags( 'reference' );
        $indexClaimCallback = $this->callbackFactory->getCallbackToIndexTags( 'claim' );

        $modifications['labels'][] = $indexLabelCallback;
        $modifications['descriptions'][] = $indexDescriptionCallback;
        $modifications['aliases/*'][] = $indexAliasCallback;
        $modifications['aliases'][] = $indexLanguageCallback;
        $modifications['sitelinks/*/badges'][] = $indexBadgeCallback;
        $modifications['sitelinks'][] = $indexSitelinkCallback;
        $modifications['claims/*/*/qualifiers/*'][] = $indexQualifiersCallback;
        $modifications['claims/*/*/qualifiers'][] = $indexPropertyCallback;
        $modifications['claims/*/*/qualifiers-order'][] = $indexPropertyCallback;
        $modifications['claims/*/*/references/*/snaks/*'][] = $indexSnakCallback;
        $modifications['claims/*/*/references/*/snaks'][] = $indexPropertyCallback;
        $modifications['claims/*/*/references/*/snaks-order'][] = $indexPropertyCallback;
        $modifications['claims/*/*/references'][] = $indexReferenceCallback;
        $modifications['claims/*'][] = $indexClaimCallback;
        $modifications['claims'][] = $indexPropertyCallback;

        if ( $serializeEmptyListsAsObjects ) {
            $modifications['*/*/claims/*/*/qualifiers/*'][] = $indexQualifiersCallback;
            $modifications['*/*/claims/*/*/qualifiers'][] = $indexPropertyCallback;
            $modifications['*/*/claims/*/*/qualifiers-order'][] = $indexPropertyCallback;
            $modifications['*/*/claims/*/*/references/*/snaks/*'][] = $indexSnakCallback;
            $modifications['*/*/claims/*/*/references/*/snaks'][] = $indexPropertyCallback;
            $modifications['*/*/claims/*/*/references/*/snaks-order'][] = $indexPropertyCallback;
            $modifications['*/*/claims/*/*/references'][] = $indexReferenceCallback;
            $modifications['*/*/claims/*'][] = $indexClaimCallback;
            $modifications['*/*/claims'][] = $indexPropertyCallback;
        }

        return $this->modifier->modifyUsingCallbacks( $serialization, $modifications );
    }

    /**
     * Get serialized information for the EntityId and add them to result
     *
     * @param EntityId $entityId
     * @param string|array|null $path
     */
    public function addBasicEntityInformation( EntityId $entityId, $path ) {
        $this->setValue( $path, 'id', $entityId->getSerialization() );
        $this->setValue( $path, 'type', $entityId->getEntityType() );
    }

    /**
     * Get serialized labels and add them to result
     *
     * @param TermList $labels the labels to insert in the result
     * @param array|string|null $path where the data is located
     */
    public function addLabels( TermList $labels, $path ) {
        $this->addTermList( $labels, 'labels', 'label', $path );
    }

    /**
     * Adds fake serialization to show a label has been removed
     *
     * @param string $language
     * @param array|string|null $path where the data is located
     */
    public function addRemovedLabel( $language, $path ) {
        $this->addRemovedTerm( $language, 'labels', 'label', $path );
    }

    /**
     * Get serialized descriptions and add them to result
     *
     * @param TermList $descriptions the descriptions to insert in the result
     * @param array|string|null $path where the data is located
     */
    public function addDescriptions( TermList $descriptions, $path ) {
        $this->addTermList( $descriptions, 'descriptions', 'description', $path );
    }

    /**
     * Adds fake serialization to show a label has been removed
     *
     * @param string $language
     * @param array|string|null $path where the data is located
     */
    public function addRemovedDescription( $language, $path ) {
        $this->addRemovedTerm( $language, 'descriptions', 'description', $path );
    }

    /**
     * Get serialized TermList and add it to the result
     *
     * @param TermList $termList
     * @param string $name
     * @param string $tag
     * @param array|string|null $path where the data is located
     */
    private function addTermList( TermList $termList, $name, $tag, $path ) {
        $serializer = $this->serializerFactory->newTermListSerializer();
        $value = $serializer->serialize( $termList );
        if ( $this->addMetaData ) {
            ApiResult::setArrayType( $value, 'kvp', 'language' );
            $value[ApiResult::META_KVP_MERGE] = true;
        }
        $this->setList( $path, $name, $value, $tag );
    }

    /**
     * Adds fake serialization to show a term has been removed
     *
     * @param string $language
     * @param string $name
     * @param string $tag
     * @param array|string|null $path where the data is located
     */
    private function addRemovedTerm( $language, $name, $tag, $path ) {
        $value = [
            $language => [
                'language' => $language,
                'removed' => '',
            ],
        ];
        if ( $this->addMetaData ) {
            ApiResult::setArrayType( $value, 'kvp', 'language' );
            $value[ApiResult::META_KVP_MERGE] = true;
        }
        $this->setList( $path, $name, $value, $tag );
    }

    /**
     * Get serialized AliasGroupList and add it to result
     *
     * @param AliasGroupList $aliasGroupList the AliasGroupList to set in the result
     * @param array|string|null $path where the data is located
     */
    public function addAliasGroupList( AliasGroupList $aliasGroupList, $path ) {
        $serializer = $this->serializerFactory->newAliasGroupListSerializer();
        $values = $serializer->serialize( $aliasGroupList );

        if ( $this->addMetaData ) {
            $values = $this->modifier->modifyUsingCallbacks(
                $values,
                [
                    '' => $this->callbackFactory->getCallbackToSetArrayType( 'kvp', 'id' ),
                    '*' => $this->callbackFactory->getCallbackToIndexTags( 'alias' ),
                ]
            );
        }

        $this->setList( $path, 'aliases', $values, 'language' );
        ApiResult::setArrayType( $values, 'kvp', 'id' );
    }

    /**
     * Get serialized sitelinks and add them to result
     *
     * @todo use a SiteLinkListSerializer when created in DataModelSerialization here
     *
     * @param SiteLinkList $siteLinkList the site links to insert in the result
     * @param array|string|null $path where the data is located
     * @param bool $addUrl
     */
    public function addSiteLinkList( SiteLinkList $siteLinkList, $path, $addUrl = false ) {
        $serializer = $this->serializerFactory->newSiteLinkSerializer();

        $values = [];
        foreach ( $siteLinkList->toArray() as $siteLink ) {
            $values[$siteLink->getSiteId()] = $serializer->serialize( $siteLink );
        }

        if ( $addUrl ) {
            $values = $this->getSiteLinkListArrayWithUrls( $values );
        }

        if ( $this->addMetaData ) {
            $values = $this->getSiteLinkListArrayWithMetaData( $values );
        }

        $this->setList( $path, 'sitelinks', $values, 'sitelink' );
    }

    private function getSiteLinkListArrayWithUrls( array $array ) {
        $siteLookup = $this->siteLookup;
        $addUrlCallback = function( $array ) use ( $siteLookup ) {
            $site = $siteLookup->getSite( $array['site'] );
            if ( $site !== null ) {
                $array['url'] = $site->getPageUrl( $array['title'] );
            }
            return $array;
        };
        return $this->modifier->modifyUsingCallbacks( $array, [ '*' => $addUrlCallback ] );
    }

    private function getSiteLinkListArrayWithMetaData( array $array ) {
        $array[ApiResult::META_KVP_MERGE] = true;
        return $this->modifier->modifyUsingCallbacks(
            $array,
            [
                '' => $this->callbackFactory->getCallbackToSetArrayType( 'kvp', 'site' ),
                '*/badges' => $this->callbackFactory->getCallbackToIndexTags( 'badge' ),
            ]
        );
    }

    /**
     * Adds fake serialization to show a sitelink has been removed
     *
     * @param SiteLinkList $siteLinkList
     * @param array|string|null $path where the data is located
     */
    public function addRemovedSiteLinks( SiteLinkList $siteLinkList, $path ) {
        $serializer = $this->serializerFactory->newSiteLinkSerializer();
        $values = [];
        foreach ( $siteLinkList->toArray() as $siteLink ) {
            $value = $serializer->serialize( $siteLink );
            $value['removed'] = '';
            $values[$siteLink->getSiteId()] = $value;
        }
        if ( $this->addMetaData ) {
            $values = $this->modifier->modifyUsingCallbacks(
                $values,
                [ '' => $this->callbackFactory->getCallbackToSetArrayType( 'kvp', 'site' ) ]
            );
            $values[ApiResult::META_KVP_MERGE] = true;
        }
        $this->setList( $path, 'sitelinks', $values, 'sitelink' );
    }

    /**
     * Get serialized claims and add them to result
     *
     * @param StatementList $statements the labels to set in the result
     * @param array|string|null $path where the data is located
     * @param array|string $props a list of fields to include, or "all"
     */
    public function addStatements( StatementList $statements, $path, $props = 'all' ) {
        $serializer = $this->serializerFactory->newStatementListSerializer();

        $values = $serializer->serialize( $statements );

        if ( is_array( $props ) && !in_array( 'references', $props ) ) {
            $values = $this->modifier->modifyUsingCallbacks(
                $values,
                [ '*/*' => function ( $array ) {
                    unset( $array['references'] );
                    return $array;
                } ]
            );
        }

        $values = $this->getArrayWithAlteredClaims( $values, '*/*/' );

        if ( $this->addMetaData ) {
            $values = $this->getClaimsArrayWithMetaData( $values, '*/*/' );
            $values = $this->modifier->modifyUsingCallbacks( $values, [
                '' => $this->callbackFactory->getCallbackToSetArrayType( 'kvp', 'id' ),
                '*' => $this->callbackFactory->getCallbackToIndexTags( 'claim' ),
            ] );
        }

        $values = $this->modifier->modifyUsingCallbacks( $values, [
            '*/*/mainsnak' => $this->callbackFactory->getCallbackToAddDataTypeToSnak( $this->dataTypeLookup, $this->entityIdParser ),
        ] );

        if ( $this->addMetaData ) {
            ApiResult::setArrayType( $values, 'kvp', 'id' );
        }

        $this->setList( $path, 'claims', $values, 'property' );
    }

    /**
     * Get serialized claim and add it to result
     *
     * @param Statement $statement
     */
    public function addStatement( Statement $statement ) {
        $serializer = $this->serializerFactory->newStatementSerializer();

        //TODO: this is currently only used to add a Claim as the top level structure,
        //      with a null path and a fixed name. Would be nice to also allow claims
        //      to be added to a list, using a path and a id key or index.

        $value = $serializer->serialize( $statement );

        $value = $this->getArrayWithAlteredClaims( $value );

        if ( $this->addMetaData ) {
            $value = $this->getClaimsArrayWithMetaData( $value );
        }

        $value = $this->modifier->modifyUsingCallbacks( $value, [
            'mainsnak' => $this->callbackFactory->getCallbackToAddDataTypeToSnak( $this->dataTypeLookup, $this->entityIdParser ),
        ] );

        $this->setValue( null, 'claim', $value );
    }

    /**
     * @param array $array
     * @param string $claimPath to the claim array/arrays with trailing /
     *
     * @return array
     */
    private function getArrayWithAlteredClaims(
        array $array,
        $claimPath = ''
    ) {
        $groupedCallback = $this->callbackFactory->getCallbackToAddDataTypeToSnaksGroupedByProperty(
            $this->dataTypeLookup,
            $this->entityIdParser
        );
        return $this->modifier->modifyUsingCallbacks( $array, [
            $claimPath . 'references/*/snaks' => $groupedCallback,
            $claimPath . 'qualifiers' => $groupedCallback,
            $claimPath . 'mainsnak' => $this->callbackFactory->getCallbackToAddDataTypeToSnak(
                $this->dataTypeLookup,
                $this->entityIdParser
            ),
        ] );
    }

    /**
     * @param array $array
     * @param string $claimPath to the claim array/arrays with trailing /
     *
     * @return array
     */
    private function getClaimsArrayWithMetaData( array $array, $claimPath = '' ) {
        return $this->modifier->modifyUsingCallbacks( $array, [
            $claimPath . 'references/*/snaks/*' => [
                $this->callbackFactory->getCallbackToIndexTags( 'snak' ),
            ],
            $claimPath . 'references/*/snaks' => [
                $this->callbackFactory->getCallbackToSetArrayType( 'kvp', 'id' ),
                $this->callbackFactory->getCallbackToIndexTags( 'property' ),
            ],
            $claimPath . 'references/*/snaks-order' => [
                $this->callbackFactory->getCallbackToIndexTags( 'property' ),
            ],
            $claimPath . 'references' => [
                $this->callbackFactory->getCallbackToIndexTags( 'reference' ),
            ],
            $claimPath . 'qualifiers/*' => [
                $this->callbackFactory->getCallbackToIndexTags( 'qualifiers' ),
            ],
            $claimPath . 'qualifiers' => [
                $this->callbackFactory->getCallbackToSetArrayType( 'kvp', 'id' ),
                $this->callbackFactory->getCallbackToIndexTags( 'property' ),
            ],
            $claimPath . 'qualifiers-order' => [
                $this->callbackFactory->getCallbackToIndexTags( 'property' ),
            ],
            $claimPath . 'mainsnak' => [
                $this->callbackFactory->getCallbackToAddDataTypeToSnak( $this->dataTypeLookup, $this->entityIdParser ),
            ],
        ] );
    }

    /**
     * Get serialized reference and add it to result
     *
     * @param Reference $reference
     */
    public function addReference( Reference $reference ) {
        $serializer = $this->serializerFactory->newReferenceSerializer();

        //TODO: this is currently only used to add a Reference as the top level structure,
        //      with a null path and a fixed name. Would be nice to also allow references
        //      to be added to a list, using a path and a id key or index.

        $value = $serializer->serialize( $reference );

        $value = $this->modifier->modifyUsingCallbacks( $value, [
            'snaks' => $this->callbackFactory->getCallbackToAddDataTypeToSnaksGroupedByProperty(
                $this->dataTypeLookup,
                $this->entityIdParser
            ),
        ] );

        if ( $this->addMetaData ) {
            $value = $this->getReferenceArrayWithMetaData( $value );
        }

        $this->setValue( null, 'reference', $value );
    }

    private function getReferenceArrayWithMetaData( array $array ) {
        return $this->modifier->modifyUsingCallbacks( $array, [
            'snaks-order' => function ( $array ) {
                ApiResult::setIndexedTagName( $array, 'property' );
                return $array;
            },
            'snaks' => function ( $array ) {
                foreach ( $array as &$snakGroup ) {
                    if ( is_array( $snakGroup ) ) {
                        ApiResult::setArrayType( $array, 'array' );
                        ApiResult::setIndexedTagName( $snakGroup, 'snak' );
                    }
                }
                ApiResult::setArrayType( $array, 'kvp', 'id' );
                ApiResult::setIndexedTagName( $array, 'property' );
                return $array;
            },
        ] );
    }

    /**
     * Add an entry for a missing entity...
     *
     * @param string|null $key The key under which to place the missing entity in the 'entities'
     *        structure. If null, defaults to the 'id' field in $missingDetails if that is set;
     *        otherwise, it defaults to using a unique negative number.
     * @param array $missingDetails array containing key value pair missing details
     */
    public function addMissingEntity( $key, array $missingDetails ) {
        if ( $key === null && isset( $missingDetails['id'] ) ) {
            $key = $missingDetails['id'];
        }

        if ( $key === null ) {
            $key = $this->missingEntityCounter;
        }

        $this->appendValue(
            'entities',
            $key,
            array_merge( $missingDetails, [ 'missing' => "" ] ),
            'entity'
        );

        if ( $this->addMetaData ) {
            $this->result->addIndexedTagName( 'entities', 'entity' );
            $this->result->addArrayType( [ 'entities' ], 'kvp', 'id' );
            $this->result->addValue(
                [ 'entities' ],
                ApiResult::META_KVP_MERGE,
                true,
                ApiResult::OVERRIDE
            );
        }

        $this->missingEntityCounter--;
    }

    /**
     * @param string $from
     * @param string $to
     * @param string $name
     */
    public function addNormalizedTitle( $from, $to, $name = 'n' ) {
        $this->setValue(
            'normalized',
            $name,
            [ 'from' => $from, 'to' => $to ]
        );
    }

    /**
     * Adds the ID of the new revision from the Status object to the API result structure.
     * The status value is expected to be structured in the way that EditEntity::attemptSave()
     * resp WikiPage::doUserEditContent() do it: as an array, with an EntityRevision object in the
     *  'revision' field. If $oldRevId is set and the latest edit was null, a 'nochange' flag
     *  is also added.
     *
     * If no revision is found in the Status object, this method does nothing.
     *
     * @see ApiResult::addValue()
     *
     * @param Status $status The status to get the revision ID from.
     * @param string|null|array $path Where in the result to put the revision id
     * @param int|null $oldRevId The id of the latest revision of the entity before
     *        the last (possibly null) edit
     */
    public function addRevisionIdFromStatusToResult( Status $status, $path, $oldRevId = null ) {
        $value = $status->getValue();

        if ( isset( $value['revision'] ) ) {
            if ( $value['revision'] instanceof EntityRevision ) {
                // Should always be the case, but sanity check
                $revisionId = $value['revision']->getRevisionId();
            } else {
                $revisionId = 0;
            }

            $this->setValue( $path, 'lastrevid', $revisionId );

            if ( $oldRevId && $oldRevId === $revisionId ) {
                // like core's ApiEditPage
                $this->setValue( $path, 'nochange', true );
            }
        }
    }

    /**
     * Add the temp username if the Status object contains a temp user
     *
     * @param Status $status As returned by {@link \Wikibase\Repo\EditEntity\EditEntity::attemptSave()}
     * @param callable $getTempUserRedirectUrl When called with a temp user, should return a redirect URL;
     * callers should use the {@link ApiCreateTempUserTrait} and pass this as
     * `fn( $user ) => $this->getTempUserRedirectUrl( $params, $user )`
     * @return void
     */
    public function addTempUser(
        Status $status,
        callable $getTempUserRedirectUrl
    ) {
        /** @var ?UserIdentity $savedTempUser */
        $savedTempUser = $status->getValue()['savedTempUser'];
        '@phan-var ?UserIdentity $savedTempUser';
        if ( $savedTempUser ) {
            $this->setValue( null, 'tempusercreated', $savedTempUser->getName() );
            $redirectUrl = $getTempUserRedirectUrl( $savedTempUser );
            if ( $redirectUrl === '' ) {
                $redirectUrl = null;
            }
            $this->setValue( null, 'tempuserredirect', $redirectUrl );
        }
    }

}