API continue param to streamline iteration of complex queries
authorYuri Astrakhan <yuriastrakhan@gmail.com>
Sat, 2 Mar 2013 00:06:46 +0000 (19:06 -0500)
committerYuri Astrakhan <yuriastrakhan@gmail.com>
Sat, 2 Mar 2013 00:06:46 +0000 (19:06 -0500)
Greatly simplifies query result iteration by the clients
by providing a mechanism to track sub-iterations (props in generated set)

Assuming the client has the param=>value dictionary with the original request
parameters, client will only need to perform this operation in their language
to get all results from the server regardless of what query they make.

  $request = array_merge( $request, $result['continue'] );

Related changes:
* Moved dieContinueUsageIf() from ApiQueryBase to ApiBase
* Internal calls will also return unused param warnings
* Reworked query unit tests for easier testing

Change-Id: Ieb45241fc6db2109f1d92fa3381165ec30701b63

13 files changed:
RELEASE-NOTES-1.21
includes/api/ApiBase.php
includes/api/ApiMain.php
includes/api/ApiPageSet.php
includes/api/ApiQuery.php
includes/api/ApiQueryBase.php
includes/api/ApiResult.php
tests/phpunit/StructureTest.php
tests/phpunit/includes/api/query/ApiQueryBasicTest.php
tests/phpunit/includes/api/query/ApiQueryContinue2Test.php [new file with mode: 0644]
tests/phpunit/includes/api/query/ApiQueryContinueTest.php [new file with mode: 0644]
tests/phpunit/includes/api/query/ApiQueryContinueTestBase.php [new file with mode: 0644]
tests/phpunit/includes/api/query/ApiQueryTestBase.php [new file with mode: 0644]

index 9b99b69..b651f5e 100644 (file)
@@ -243,6 +243,7 @@ production.
   HTTP OPTIONS request.
 * (bug 44923) action=upload works correctly if the entire file is uploaded in
   the first chunk.
+* Added 'continue=' parameter to streamline client iteration over complex query results
 
 === API internal changes in 1.21 ===
 * For debugging only, a new global $wgDebugAPI removes many API restrictions when true.
@@ -257,6 +258,7 @@ production.
 * ApiQueryGeneratorBase::setGeneratorMode() now requires a pageset param.
 * $wgAPIGeneratorModules is now obsolete and will be ignored.
 * Added flags ApiResult::OVERRIDE and ADD_ON_TOP to setElement() and addValue()
+* Internal API calls will now include <warnings> in case of unused parameters
 
 === Languages updated in 1.21 ===
 
index d90ea26..4a6dad3 100644 (file)
@@ -1391,6 +1391,19 @@ abstract class ApiBase extends ContextSource {
                }
        }
 
+       /**
+        * Die with the $prefix.'badcontinue' error. This call is common enough to make it into the base method.
+        * @param $condition boolean will only die if this value is true
+        * @since 1.21
+        */
+       protected function dieContinueUsageIf( $condition ) {
+               if ( $condition ) {
+                       $this->dieUsage(
+                               'Invalid continue param. You should pass the original value returned by the previous query',
+                               'badcontinue' );
+               }
+       }
+
        /**
         * Return the error message related to a certain array
         * @param $error array Element of a getUserPermissionsErrors()-style array
@@ -1526,6 +1539,13 @@ abstract class ApiBase extends ContextSource {
                                        $ret[] = array( 'missingparam', $paramName );
                                }
                        }
+                       if ( array_key_exists( 'continue', $params ) ) {
+                               $ret[] = array(
+                                       array(
+                                               'code' => 'badcontinue',
+                                               'info' => 'Invalid continue param. You should pass the original value returned by the previous query'
+                                       ) );
+                       }
                }
 
                if ( $this->mustBePosted() ) {
index 1dabbbf..2d40de8 100644 (file)
@@ -840,10 +840,9 @@ class ApiMain extends ApiBase {
                wfRunHooks( 'APIAfterExecute', array( &$module ) );
                $module->profileOut();
 
-               if ( !$this->mInternalMode ) {
-                       // Report unused params
-                       $this->reportUnusedParams();
+               $this->reportUnusedParams();
 
+               if ( !$this->mInternalMode ) {
                        //append Debug information
                        MWDebug::appendDebugInfoToApiResult( $this->getContext(), $this->getResult() );
 
@@ -927,13 +926,17 @@ class ApiMain extends ApiBase {
                $paramsUsed = $this->getParamsUsed();
                $allParams = $this->getRequest()->getValueNames();
 
-               // Printer has not yet executed; don't warn that its parameters are unused
-               $printerParams = array_map(
-                       array( $this->mPrinter, 'encodeParamName' ),
-                       array_keys( $this->mPrinter->getFinalParams() ?: array() )
-               );
+               if ( !$this->mInternalMode ) {
+                       // Printer has not yet executed; don't warn that its parameters are unused
+                       $printerParams = array_map(
+                               array( $this->mPrinter, 'encodeParamName' ),
+                               array_keys( $this->mPrinter->getFinalParams() ?: array() )
+                       );
+                       $unusedParams = array_diff( $allParams, $paramsUsed, $printerParams );
+               } else {
+                       $unusedParams = array_diff( $allParams, $paramsUsed );
+               }
 
-               $unusedParams = array_diff( $allParams, $paramsUsed, $printerParams );
                if( count( $unusedParams ) ) {
                        $s = count( $unusedParams ) > 1 ? 's' : '';
                        $this->setWarning( "Unrecognized parameter$s: '" . implode( $unusedParams, "', '" ) . "'" );
index 954e5a1..1bb7e47 100644 (file)
@@ -46,8 +46,11 @@ class ApiPageSet extends ApiBase {
         */
        const DISABLE_GENERATORS = 1;
 
-       private $mDbSource, $mParams;
-       private $mResolveRedirects, $mConvertTitles, $mAllowGenerator;
+       private $mDbSource;
+       private $mParams;
+       private $mResolveRedirects;
+       private $mConvertTitles;
+       private $mAllowGenerator;
 
        private $mAllPages = array(); // [ns][dbkey] => page_id or negative when missing
        private $mTitles = array();
@@ -86,10 +89,26 @@ class ApiPageSet extends ApiBase {
                $this->profileOut();
        }
 
+       /**
+        * In case execute() is not called, call this method to mark all relevant parameters as used
+        * This prevents unused parameters from being reported as warnings
+        */
+       public function executeDryRun() {
+               $this->executeInternal( true );
+       }
+
        /**
         * Populate the PageSet from the request parameters.
         */
        public function execute() {
+               $this->executeInternal( false );
+       }
+
+       /**
+        * Populate the PageSet from the request parameters.
+        * @param bool $isDryRun If true, instantiates generator, but only to mark relevant parameters as used
+        */
+       private function executeInternal( $isDryRun ) {
                $this->profileIn();
 
                $generatorName = $this->mAllowGenerator ? $this->mParams['generator'] : null;
@@ -114,15 +133,27 @@ class ApiPageSet extends ApiBase {
                        $tmpPageSet = new ApiPageSet( $dbSource, ApiPageSet::DISABLE_GENERATORS );
                        $generator->setGeneratorMode( $tmpPageSet );
                        $this->mCacheMode = $generator->getCacheMode( $generator->extractRequestParams() );
-                       $generator->requestExtraData( $tmpPageSet );
-                       $tmpPageSet->execute();
+
+                       if ( !$isDryRun ) {
+                               $generator->requestExtraData( $tmpPageSet );
+                       }
+                       $tmpPageSet->executeInternal( $isDryRun );
 
                        // populate this pageset with the generator output
                        $this->profileOut();
                        $generator->profileIn();
-                       $generator->executeGenerator( $this );
-                       wfRunHooks( 'APIQueryGeneratorAfterExecute', array( &$generator, &$this ) );
-                       $this->resolvePendingRedirects();
+
+                       if ( !$isDryRun ) {
+                               $generator->executeGenerator( $this );
+                               wfRunHooks( 'APIQueryGeneratorAfterExecute', array( &$generator, &$this ) );
+                               $this->resolvePendingRedirects();
+                       } else {
+                               // Prevent warnings from being reported on these parameters
+                               $main = $this->getMain();
+                               foreach ( $generator->extractRequestParams() as $paramName => $param ) {
+                                       $main->getVal( $generator->encodeParamName( $paramName ) );
+                               }
+                       }
                        $generator->profileOut();
                        $this->profileIn();
 
@@ -148,25 +179,28 @@ class ApiPageSet extends ApiBase {
                                }
                                $dataSource = 'revids';
                        }
-                       // Populate page information with the original user input
-                       switch( $dataSource ) {
-                               case 'titles':
-                                       $this->initFromTitles( $this->mParams['titles'] );
-                                       break;
-                               case 'pageids':
-                                       $this->initFromPageIds( $this->mParams['pageids'] );
-                                       break;
-                               case 'revids':
-                                       if ( $this->mResolveRedirects ) {
-                                               $this->setWarning( 'Redirect resolution cannot be used together with the revids= parameter. ' .
-                                                       'Any redirects the revids= point to have not been resolved.' );
-                                       }
-                                       $this->mResolveRedirects = false;
-                                       $this->initFromRevIDs( $this->mParams['revids'] );
-                                       break;
-                               default:
-                                       // Do nothing - some queries do not need any of the data sources.
-                                       break;
+
+                       if ( !$isDryRun ) {
+                               // Populate page information with the original user input
+                               switch( $dataSource ) {
+                                       case 'titles':
+                                               $this->initFromTitles( $this->mParams['titles'] );
+                                               break;
+                                       case 'pageids':
+                                               $this->initFromPageIds( $this->mParams['pageids'] );
+                                               break;
+                                       case 'revids':
+                                               if ( $this->mResolveRedirects ) {
+                                                       $this->setWarning( 'Redirect resolution cannot be used together with the revids= parameter. ' .
+                                                               'Any redirects the revids= point to have not been resolved.' );
+                                               }
+                                               $this->mResolveRedirects = false;
+                                               $this->initFromRevIDs( $this->mParams['revids'] );
+                                               break;
+                                       default:
+                                               // Do nothing - some queries do not need any of the data sources.
+                                               break;
+                               }
                        }
                }
                $this->profileOut();
@@ -841,7 +875,7 @@ class ApiPageSet extends ApiBase {
 
        /**
         * Given an array of title strings, convert them into Title objects.
-        * Alternativelly, an array of Title objects may be given.
+        * Alternatively, an array of Title objects may be given.
         * This method validates access rights for the title,
         * and appends normalization values to the output.
         *
index 118355c..832284f 100644 (file)
@@ -109,10 +109,11 @@ class ApiQuery extends ApiBase {
         */
        private $mPageSet;
 
-       private $params;
-       private $iwUrl;
+       private $mParams;
        private $mNamedDB = array();
        private $mModuleMgr;
+       private $mGeneratorContinue;
+       private $mUseLegacyContinue;
 
        /**
         * @param $main ApiMain
@@ -233,37 +234,37 @@ class ApiQuery extends ApiBase {
         * #5 Execute all requested modules
         */
        public function execute() {
-               $this->params = $this->extractRequestParams();
-               $this->iwUrl = $this->params['iwurl'];
+               $this->mParams = $this->extractRequestParams();
 
-               // Instantiate requested modules
-               $modules = array();
-               $this->instantiateModules( $modules, 'prop' );
-               $this->instantiateModules( $modules, 'list' );
-               $this->instantiateModules( $modules, 'meta' );
-
-               // Query modules may optimize data requests through the $this->getPageSet()
-               // object by adding extra fields from the page table.
-               // This function will gather all the extra request fields from the modules.
-               foreach ( $modules as $module ) {
-                       if ( !$this->getRequest()->wasPosted() && $module->mustBePosted() ) {
-                               $this->dieUsageMsgOrDebug( array( 'mustbeposted', $module->getModuleName() ) );
-                       }
+               // $pagesetParams is a array of parameter names used by the pageset generator
+               //   or null if pageset has already finished and is no longer needed
+               // $completeModules is a set of complete modules with the name as key
+               $this->initContinue( $pagesetParams, $completeModules );
 
-                       $module->requestExtraData( $this->mPageSet );
+               // Instantiate requested modules
+               $allModules = array();
+               $this->instantiateModules( $allModules, 'prop' );
+               $propModules = $allModules; // Keep a copy
+               $this->instantiateModules( $allModules, 'list' );
+               $this->instantiateModules( $allModules, 'meta' );
+
+               // Filter modules based on continue parameter
+               $modules = $this->initModules( $allModules, $completeModules, $pagesetParams !== null );
+
+               // Execute pageset if in legacy mode or if pageset is not done
+               if ( $completeModules === null || $pagesetParams !== null ) {
+                       // Populate page/revision information
+                       $this->mPageSet->execute();
+                       // Record page information (title, namespace, if exists, etc)
+                       $this->outputGeneralPageInfo();
+               } else {
+                       $this->mPageSet->executeDryRun();
                }
 
-               // Populate page/revision information
-               $this->mPageSet->execute();
                $cacheMode = $this->mPageSet->getCacheMode();
 
-               // Record page information (title, namespace, if exists, etc)
-               $this->outputGeneralPageInfo();
-
-               // Execute all requested modules.
-               /**
-                * @var $module ApiQueryBase
-                */
+               // Execute all unfinished modules
+               /** @var $module ApiQueryBase */
                foreach ( $modules as $module ) {
                        $params = $module->extractRequestParams();
                        $cacheMode = $this->mergeCacheMode(
@@ -276,6 +277,136 @@ class ApiQuery extends ApiBase {
 
                // Set the cache mode
                $this->getMain()->setCacheMode( $cacheMode );
+
+               if ( $completeModules === null ) {
+                       return; // Legacy continue, we are done
+               }
+
+               // Reformat query-continue result section
+               $result = $this->getResult();
+               $qc = $result->getData();
+               if ( isset( $qc['query-continue'] ) ) {
+                       $qc = $qc['query-continue'];
+                       $result->unsetValue( null, 'query-continue' );
+               } elseif ( $this->mGeneratorContinue !== null ) {
+                       $qc = array();
+               } else {
+                       // no more "continue"s, we are done!
+                       return;
+               }
+
+               // we are done with all the modules that do not have result in query-continue
+               $completeModules = array_merge( $completeModules, array_diff_key( $modules, $qc ) );
+               if ( $pagesetParams !== null ) {
+                       // The pageset is still in use, check if all props have finished
+                       $incompleteProps = array_intersect_key( $propModules, $qc );
+                       if ( count( $incompleteProps ) > 0 ) {
+                               // Properties are not done, continue with the same pageset state - copy current parameters
+                               $main = $this->getMain();
+                               $contValues = array();
+                               foreach ( $pagesetParams as $param ) {
+                                       // The param name is already prefix-encoded
+                                       $contValues[$param] = $main->getVal( $param );
+                               }
+                       } elseif ( $this->mGeneratorContinue !== null ) {
+                               // Move to the next set of pages produced by pageset, properties need to be restarted
+                               $contValues = $this->mGeneratorContinue;
+                               $pagesetParams = array_keys( $contValues );
+                               $completeModules = array_diff_key( $completeModules, $propModules );
+                       } else {
+                               // Done with the pageset, finish up with the the lists and meta modules
+                               $pagesetParams = null;
+                       }
+               }
+
+               $continue = '||' . implode( '|', array_keys( $completeModules ) );
+               if ( $pagesetParams !== null ) {
+                       // list of all pageset parameters to use in the next request
+                       $continue = implode( '|', $pagesetParams ) . $continue;
+               } else {
+                       // we are done with the pageset
+                       $contValues = array();
+                       $continue = '-' . $continue;
+               }
+               $contValues['continue'] = $continue;
+               foreach ( $qc as $qcModule ) {
+                       foreach ( $qcModule as $qcKey => $qcValue ) {
+                               $contValues[$qcKey] = $qcValue;
+                       }
+               }
+               $this->getResult()->addValue( null, 'continue', $contValues );
+       }
+
+       /**
+        * Parse 'continue' parameter into the list of complete modules and a list of generator parameters
+        * @param array|null $pagesetParams returns list of generator params or null if pageset is done
+        * @param array|null $completeModules returns list of finished modules (as keys), or null if legacy
+        */
+       private function initContinue( &$pagesetParams, &$completeModules ) {
+               $pagesetParams = array();
+               $continue = $this->mParams['continue'];
+               if ( $continue !== null ) {
+                       $this->mUseLegacyContinue = false;
+                       if ( $continue !== '' ) {
+                               // Format: ' pagesetParam1 | pagesetParam2 || module1 | module2 | module3 | ...
+                               // If pageset is done, use '-'
+                               $continue = explode( '||', $continue );
+                               $this->dieContinueUsageIf( count( $continue ) !== 2 );
+                               if ( $continue[0] === '-' ) {
+                                       $pagesetParams = null; // No need to execute pageset
+                               } elseif ( $continue[0] !== '' ) {
+                                       // list of pageset params that might need to be repeated
+                                       $pagesetParams = explode( '|', $continue[0] );
+                               }
+                               $continue = $continue[1];
+                       }
+                       if ( $continue !== '' ) {
+                               $completeModules = array_flip( explode( '|', $continue ) );
+                       } else {
+                               $completeModules = array();
+                       }
+               } else {
+                       $this->mUseLegacyContinue = true;
+                       $completeModules = null;
+               }
+       }
+
+       /**
+        * Validate sub-modules, filter out completed ones, and do requestExtraData()
+        * @param array $allModules An dict of name=>instance of all modules requested by the client
+        * @param array|null $completeModules list of finished modules, or null if legacy continue
+        * @param bool $usePageset True if pageset will be executed
+        * @return array of modules to be processed during this execution
+        */
+       private function initModules( $allModules, $completeModules, $usePageset ) {
+               $modules = $allModules;
+               $tmp = $completeModules;
+               $wasPosted = $this->getRequest()->wasPosted();
+               $main = $this->getMain();
+
+               /** @var $module ApiQueryBase */
+               foreach ( $allModules as $moduleName => $module ) {
+                       if ( !$wasPosted && $module->mustBePosted() ) {
+                               $this->dieUsageMsgOrDebug( array( 'mustbeposted', $moduleName ) );
+                       }
+                       if ( $completeModules !== null && array_key_exists( $moduleName, $completeModules ) ) {
+                               // If this module is done, mark all its params as used
+                               $module->extractRequestParams();
+                               // Make sure this module is not used during execution
+                               unset( $modules[$moduleName] );
+                               unset( $tmp[$moduleName] );
+                       } elseif ( $completeModules === null || $usePageset ) {
+                               // Query modules may optimize data requests through the $this->getPageSet()
+                               // object by adding extra fields from the page table.
+                               // This function will gather all the extra request fields from the modules.
+                               $module->requestExtraData( $this->mPageSet );
+                       } else {
+                               // Error - this prop module must have finished before generator is done
+                               $this->dieContinueUsageIf( $this->mModuleMgr->getModuleGroup( $moduleName ) === 'prop' );
+                       }
+               }
+               $this->dieContinueUsageIf( $completeModules !== null && count( $tmp ) !== 0 );
+               return $modules;
        }
 
        /**
@@ -306,9 +437,16 @@ class ApiQuery extends ApiBase {
         * @param $param string Parameter name to read modules from
         */
        private function instantiateModules( &$modules, $param ) {
-               if ( isset( $this->params[$param] ) ) {
-                       foreach ( $this->params[$param] as $moduleName ) {
-                               $modules[] = $this->mModuleMgr->getModule( $moduleName );
+               if ( isset( $this->mParams[$param] ) ) {
+                       foreach ( $this->mParams[$param] as $moduleName ) {
+                               $instance = $this->mModuleMgr->getModule( $moduleName, $param );
+                               if ( $instance === null ) {
+                                       ApiBase::dieDebug( __METHOD__, 'Error instantiating module' );
+                               }
+                               // Ignore duplicates. TODO 2.0: die()?
+                               if ( !array_key_exists( $moduleName, $modules ) ) {
+                                       $modules[$moduleName] = $instance;
+                               }
                        }
                }
        }
@@ -334,7 +472,7 @@ class ApiQuery extends ApiBase {
                if ( $values ) {
                        $result->addValue( 'query', 'converted', $values );
                }
-               $values = $pageSet->getInterwikiTitlesAsResult( $result, $this->iwUrl );
+               $values = $pageSet->getInterwikiTitlesAsResult( $result, $this->mParams['iwurl'] );
                if ( $values ) {
                        $result->addValue( 'query', 'interwiki', $values );
                }
@@ -392,7 +530,7 @@ class ApiQuery extends ApiBase {
                }
 
                if ( count( $pages ) ) {
-                       if ( $this->params['indexpageids'] ) {
+                       if ( $this->mParams['indexpageids'] ) {
                                $pageIDs = array_keys( $pages );
                                // json treats all map keys as strings - converting to match
                                $pageIDs = array_map( 'strval', $pageIDs );
@@ -403,11 +541,32 @@ class ApiQuery extends ApiBase {
                        $result->setIndexedTagName( $pages, 'page' );
                        $result->addValue( 'query', 'pages', $pages );
                }
-               if ( $this->params['export'] ) {
+               if ( $this->mParams['export'] ) {
                        $this->doExport( $pageSet, $result );
                }
        }
 
+       /**
+        * This method is called by the generator base when generator in the smart-continue
+        * mode tries to set 'query-continue' value. ApiQuery stores those values separately
+        * until the post-processing when it is known if the generation should continue or repeat.
+        * @param ApiQueryGeneratorBase $module generator module
+        * @param string $paramName
+        * @param mixed $paramValue
+        * @return bool true if processed, false if this is a legacy continue
+        */
+       public function setGeneratorContinue( $module, $paramName, $paramValue ) {
+               if ( $this->mUseLegacyContinue ) {
+                       return false;
+               }
+               $paramName = $module->encodeParamName( $paramName );
+               if ( $this->mGeneratorContinue === null ) {
+                       $this->mGeneratorContinue = array();
+               }
+               $this->mGeneratorContinue[$paramName] = $paramValue;
+               return true;
+       }
+
        /**
         * @param $pageSet ApiPageSet Pages to be exported
         * @param $result ApiResult Result to output to
@@ -440,7 +599,7 @@ class ApiQuery extends ApiBase {
                // It's not continuable, so it would cause more
                // problems than it'd solve
                $result->disableSizeCheck();
-               if ( $this->params['exportnowrap'] ) {
+               if ( $this->mParams['exportnowrap'] ) {
                        $result->reset();
                        // Raw formatter will handle this
                        $result->addValue( null, 'text', $exportxml );
@@ -471,6 +630,7 @@ class ApiQuery extends ApiBase {
                        'export' => false,
                        'exportnowrap' => false,
                        'iwurl' => false,
+                       'continue' => null,
                );
                if ( $flags ) {
                        $result += $this->getPageSet()->getFinalParams( $flags );
@@ -543,6 +703,10 @@ class ApiQuery extends ApiBase {
                        'export' => 'Export the current revisions of all given or generated pages',
                        'exportnowrap' => 'Return the export XML without wrapping it in an XML result (same format as Special:Export). Can only be used with export',
                        'iwurl' => 'Whether to get the full URL if the title is an interwiki link',
+                       'continue' => array(
+                               'When present, formats query-continue as key-value pairs that should simply be merged into the original request.',
+                               'This parameter must be set to an empty string in the initial query.',
+                               'This parameter is recommended for all new development, and will be made default in the next API version.' ),
                );
        }
 
@@ -563,8 +727,8 @@ class ApiQuery extends ApiBase {
 
        public function getExamples() {
                return array(
-                       'api.php?action=query&prop=revisions&meta=siteinfo&titles=Main%20Page&rvprop=user|comment',
-                       'api.php?action=query&generator=allpages&gapprefix=API/&prop=revisions',
+                       'api.php?action=query&prop=revisions&meta=siteinfo&titles=Main%20Page&rvprop=user|comment&continue=',
+                       'api.php?action=query&generator=allpages&gapprefix=API/&prop=revisions&continue=',
                );
        }
 
index 20751e1..1423c73 100644 (file)
@@ -374,19 +374,6 @@ abstract class ApiQueryBase extends ApiBase {
                $result->enableSizeCheck();
        }
 
-       /**
-        * Die with the $prefix.'badcontinue' error. This call is common enough to make it into the base method.
-        * @param $condition boolean will only die if this value is true
-        * @since 1.21
-        */
-       protected function dieContinueUsageIf( $condition ) {
-               if ( $condition ) {
-                       $this->dieUsage(
-                               'Invalid continue param. You should pass the original value returned by the previous query',
-                               'badcontinue' );
-               }
-       }
-
        /**
         * Get the Query database connection (read-only)
         * @return DatabaseBase
@@ -566,15 +553,6 @@ abstract class ApiQueryBase extends ApiBase {
                        array( 'invalidtitle', 'title' ),
                        array( 'invalidtitle', 'key' ),
                ) );
-               $params = $this->getFinalParams();
-               if ( array_key_exists( 'continue', $params ) ) {
-                       $errors = array_merge( $errors, array(
-                               array(
-                                       'code' => 'badcontinue',
-                                       'info' => 'Invalid continue param. You should pass the original value returned by the previous query'
-                               ),
-                       ) );
-               }
                return $errors;
        }
 }
@@ -625,6 +603,21 @@ abstract class ApiQueryGeneratorBase extends ApiQueryBase {
                }
        }
 
+       /**
+        * Overrides base in case of generator & smart continue to
+        * notify ApiQueryMain instead of adding them to the result right away.
+        * @param $paramName string Parameter name
+        * @param $paramValue string Parameter value
+        */
+       protected function setContinueEnumParameter( $paramName, $paramValue ) {
+               // If this is a generator and query->setGeneratorContinue() returns false, treat as before
+               if ( $this->mGeneratorPageSet === null
+                       || !$this->getQuery()->setGeneratorContinue( $this, $paramName, $paramValue )
+               ) {
+                       parent::setContinueEnumParameter( $paramName, $paramValue );
+               }
+       }
+
        /**
         * Execute this module as a generator
         * @param $resultPageSet ApiPageSet: All output should be appended to
index 7ecfa8e..790f2d8 100644 (file)
@@ -295,7 +295,7 @@ class ApiResult extends ApiBase {
 
                $addOnTop = $flags & ApiResult::ADD_ON_TOP;
                if ( $path !== null ) {
-                       foreach ( (array) $path as $p ) {
+                       foreach ( (array)$path as $p ) {
                                if ( !isset( $data[$p] ) ) {
                                        if ( $addOnTop ) {
                                                $data = array( $p => array() ) + $data;
@@ -340,12 +340,12 @@ class ApiResult extends ApiBase {
         * Unset a value previously added to the result set.
         * Fails silently if the value isn't found.
         * For parameters, see addValue()
-        * @param $path array
+        * @param $path array|null
         * @param $name string
         */
        public function unsetValue( $path, $name ) {
                $data = &$this->mData;
-               if ( !is_null( $path ) ) {
+               if ( $path !== null ) {
                        foreach ( (array)$path as $p ) {
                                if ( !isset( $data[$p] ) ) {
                                        return;
index e3e2138..a942098 100644 (file)
@@ -18,6 +18,8 @@ class StructureTest extends MediaWikiTestCase {
                $testClassRegex = implode( '|', array(
                        'ApiFormatTestBase',
                        'ApiTestCase',
+                       'ApiQueryTestBase',
+                       'ApiQueryContinueTestBase',
                        'MediaWikiLangTestCase',
                        'MediaWikiTestCase',
                        'PHPUnit_Framework_TestCase',
index a5ca256..6d4e371 100644 (file)
  * http://www.gnu.org/copyleft/gpl.html
  *
  * @file
- *
- * These tests validate basic functionality of the api query module
+ */
+
+require_once( 'ApiQueryTestBase.php' );
+
+/** These tests validate basic functionality of the api query module
  *
  * @group API
  * @group Database
  * @group medium
  */
-class ApiQueryBasicTest extends ApiTestCase {
+class ApiQueryBasicTest extends ApiQueryTestBase {
        /**
         * Create a set of pages. These must not change, otherwise the tests might give wrong results.
         * @see MediaWikiTestCase::addDBData()
@@ -296,20 +299,6 @@ class ApiQueryBasicTest extends ApiTestCase {
                        self::$categorymembers ) );
        }
 
-       /**
-        * Merges all requests (parameter arrays) into one
-        * @return array
-        */
-       private function merge( /*...*/ ) {
-               $request = array();
-               $expected = array();
-               foreach ( func_get_args() as $v ) {
-                       $request = array_merge_recursive( $request, $v[0] );
-                       $this->mergeExpected( $expected, $v[1] );
-               }
-               return array( $request, $expected );
-       }
-
        /**
         * Recursively merges the expected values in the $item into the $all
         */
@@ -327,38 +316,6 @@ class ApiQueryBasicTest extends ApiTestCase {
                }
        }
 
-       /**
-        * Checks that the request's result matches the expected results.
-        * @param $values array is a two element array( request, expected_results )
-        * @throws Exception
-        */
-       private function check( $values ) {
-               $request = $values[0];
-               $expected = $values[1];
-               if ( !array_key_exists( 'action', $request ) ) {
-                       $request['action'] = 'query';
-               }
-               foreach ( $request as &$val ) {
-                       if ( is_array( $val ) ) {
-                               $val = implode( '|', array_unique( $val ) );
-                       }
-               }
-               $result = $this->doApiRequest( $request );
-               $result = $result[0];
-               $expected = array( 'query' => $expected );
-               try {
-                       $this->assertQueryResults( $expected, $result );
-               } catch ( Exception $e ) {
-                       print( "\nRequest:\n" );
-                       print_r( $request );
-                       print( "\nExpected:\n" );
-                       print_r( $expected );
-                       print( "\nResult:\n" );
-                       print_r( $result );
-                       throw $e; // rethrow it
-               }
-       }
-
        /**
         * Recursively compare arrays, ignoring mismatches in numeric key and pageids.
         * @param $expected array expected values
diff --git a/tests/phpunit/includes/api/query/ApiQueryContinue2Test.php b/tests/phpunit/includes/api/query/ApiQueryContinue2Test.php
new file mode 100644 (file)
index 0000000..0a3ac1d
--- /dev/null
@@ -0,0 +1,68 @@
+<?php
+/**
+ * Copyright © 2013 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ */
+
+require_once( 'ApiQueryContinueTestBase.php' );
+
+/**
+ * @group API
+ * @group Database
+ * @group medium
+ */
+class ApiQueryContinue2Test extends ApiQueryContinueTestBase {
+       /**
+        * Create a set of pages. These must not change, otherwise the tests might give wrong results.
+        * @see MediaWikiTestCase::addDBData()
+        */
+       function addDBData() {
+               try {
+                       $this->editPage( 'AQCT73462-A', '**AQCT73462-A**  [[AQCT73462-B]] [[AQCT73462-C]]' );
+                       $this->editPage( 'AQCT73462-B', '[[AQCT73462-A]]  **AQCT73462-B** [[AQCT73462-C]]' );
+                       $this->editPage( 'AQCT73462-C', '[[AQCT73462-A]]  [[AQCT73462-B]] **AQCT73462-C**' );
+                       $this->editPage( 'AQCT73462-A', '**AQCT73462-A**  [[AQCT73462-B]] [[AQCT73462-C]]' );
+                       $this->editPage( 'AQCT73462-B', '[[AQCT73462-A]]  **AQCT73462-B** [[AQCT73462-C]]' );
+                       $this->editPage( 'AQCT73462-C', '[[AQCT73462-A]]  [[AQCT73462-B]] **AQCT73462-C**' );
+               } catch ( Exception $e ) {
+                       $this->exceptionFromAddDBData = $e;
+               }
+       }
+
+       /**
+        * @medium
+        */
+       public function testA() {
+               $this->mVerbose = false;
+               $mk = function( $g, $p, $gDir ) {
+                       return array(
+                               'generator' => 'allpages',
+                               'gapprefix' => 'AQCT73462-',
+                               'prop' => 'links',
+                               'gaplimit' => "$g",
+                               'pllimit' => "$p",
+                               'gapdir' => $gDir ? "ascending" : "descending",
+                       );
+               };
+               // generator + 1 prop + 1 list
+               $data = $this->query( $mk(99,99,true), 1, 'g1p', false );
+               $this->checkC( $data, $mk(1,1,true), 6, 'g1p-11t' );
+               $this->checkC( $data, $mk(2,2,true), 3, 'g1p-22t' );
+               $this->checkC( $data, $mk(1,1,false), 6, 'g1p-11f' );
+               $this->checkC( $data, $mk(2,2,false), 3, 'g1p-22f' );
+       }
+}
diff --git a/tests/phpunit/includes/api/query/ApiQueryContinueTest.php b/tests/phpunit/includes/api/query/ApiQueryContinueTest.php
new file mode 100644 (file)
index 0000000..cb8f181
--- /dev/null
@@ -0,0 +1,313 @@
+<?php
+/**
+ * Copyright © 2013 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ */
+
+require_once( 'ApiQueryContinueTestBase.php' );
+
+/**
+ * These tests validate the new continue functionality of the api query module by
+ * doing multiple requests with varying parameters, merging the results, and checking
+ * that the result matches the full data received in one no-limits call.
+ *
+ * @group API
+ * @group Database
+ * @group medium
+ */
+class ApiQueryContinueTest extends ApiQueryContinueTestBase {
+       /**
+        * Create a set of pages. These must not change, otherwise the tests might give wrong results.
+        * @see MediaWikiTestCase::addDBData()
+        */
+       function addDBData() {
+               try {
+                       $this->editPage( 'Template:AQCT-T1', '**Template:AQCT-T1**' );
+                       $this->editPage( 'Template:AQCT-T2', '**Template:AQCT-T2**' );
+                       $this->editPage( 'Template:AQCT-T3', '**Template:AQCT-T3**' );
+                       $this->editPage( 'Template:AQCT-T4', '**Template:AQCT-T4**' );
+                       $this->editPage( 'Template:AQCT-T5', '**Template:AQCT-T5**' );
+
+                       $this->editPage( 'AQCT-1', '**AQCT-1** {{AQCT-T2}} {{AQCT-T3}} {{AQCT-T4}} {{AQCT-T5}}' );
+                       $this->editPage( 'AQCT-2', '[[AQCT-1]] **AQCT-2** {{AQCT-T3}} {{AQCT-T4}} {{AQCT-T5}}' );
+                       $this->editPage( 'AQCT-3', '[[AQCT-1]] [[AQCT-2]] **AQCT-3** {{AQCT-T4}} {{AQCT-T5}}' );
+                       $this->editPage( 'AQCT-4', '[[AQCT-1]] [[AQCT-2]] [[AQCT-3]] **AQCT-4** {{AQCT-T5}}' );
+                       $this->editPage( 'AQCT-5', '[[AQCT-1]] [[AQCT-2]] [[AQCT-3]] [[AQCT-4]] **AQCT-5**' );
+               } catch ( Exception $e ) {
+                       $this->exceptionFromAddDBData = $e;
+               }
+       }
+
+       /**
+        * Test smart continue - list=allpages
+        * @medium
+        */
+       public function test1List() {
+               $this->mVerbose = false;
+               $mk = function( $l ) {
+                       return array(
+                               'list' => 'allpages',
+                               'apprefix' => 'AQCT-',
+                               'aplimit' => "$l",
+                       );
+               };
+               $data = $this->query( $mk(99), 1, '1L', false );
+
+               // 1 list
+               $this->checkC( $data, $mk(1), 5, '1L-1' );
+               $this->checkC( $data, $mk(2), 3, '1L-2' );
+               $this->checkC( $data, $mk(3), 2, '1L-3' );
+               $this->checkC( $data, $mk(4), 2, '1L-4' );
+               $this->checkC( $data, $mk(5), 1, '1L-5' );
+       }
+
+       /**
+        * Test smart continue - list=allpages|alltransclusions
+        * @medium
+        */
+       public function test2Lists() {
+               $this->mVerbose = false;
+               $mk = function( $l1, $l2 ) {
+                       return array(
+                               'list' => 'allpages|alltransclusions',
+                               'apprefix' => 'AQCT-',
+                               'atprefix' => 'AQCT-',
+                               'atunique' => '',
+                               'aplimit' => "$l1",
+                               'atlimit' => "$l2",
+                       );
+               };
+               // 2 lists
+               $data = $this->query( $mk(99,99), 1, '2L', false );
+               $this->checkC( $data, $mk(1,1), 5, '2L-11' );
+               $this->checkC( $data, $mk(2,2), 3, '2L-22' );
+               $this->checkC( $data, $mk(3,3), 2, '2L-33' );
+               $this->checkC( $data, $mk(4,4), 2, '2L-44' );
+               $this->checkC( $data, $mk(5,5), 1, '2L-55' );
+       }
+
+       /**
+        * Test smart continue - generator=allpages, prop=links
+        * @medium
+        */
+       public function testGen1Prop() {
+               $this->mVerbose = false;
+               $mk = function( $g, $p ) {
+                       return array(
+                               'generator' => 'allpages',
+                               'gapprefix' => 'AQCT-',
+                               'gaplimit' => "$g",
+                               'prop' => 'links',
+                               'pllimit' => "$p",
+                       );
+               };
+               // generator + 1 prop
+               $data = $this->query( $mk(99,99), 1, 'G1P', false );
+               $this->checkC( $data, $mk(1,1), 11, 'G1P-11' );
+               $this->checkC( $data, $mk(2,2), 6, 'G1P-22' );
+               $this->checkC( $data, $mk(3,3), 4, 'G1P-33' );
+               $this->checkC( $data, $mk(4,4), 3, 'G1P-44' );
+               $this->checkC( $data, $mk(5,5), 2, 'G1P-55' );
+       }
+
+       /**
+        * Test smart continue - generator=allpages, prop=links|templates
+        * @medium
+        */
+       public function testGen2Prop() {
+               $this->mVerbose = false;
+               $mk = function( $g, $p1, $p2 ) {
+                       return array(
+                               'generator' => 'allpages',
+                               'gapprefix' => 'AQCT-',
+                               'gaplimit' => "$g",
+                               'prop' => 'links|templates',
+                               'pllimit' => "$p1",
+                               'tllimit' => "$p2",
+                       );
+               };
+               // generator + 2 props
+               $data = $this->query( $mk(99,99,99), 1, 'G2P', false );
+               $this->checkC( $data, $mk(1,1,1), 16, 'G2P-111' );
+               $this->checkC( $data, $mk(2,2,2), 9, 'G2P-222' );
+               $this->checkC( $data, $mk(3,3,3), 6, 'G2P-333' );
+               $this->checkC( $data, $mk(4,4,4), 4, 'G2P-444' );
+               $this->checkC( $data, $mk(5,5,5), 2, 'G2P-555' );
+               $this->checkC( $data, $mk(5,1,1), 10, 'G2P-511' );
+               $this->checkC( $data, $mk(4,2,2), 7, 'G2P-422' );
+               $this->checkC( $data, $mk(2,3,3), 7, 'G2P-233' );
+               $this->checkC( $data, $mk(2,4,4), 5, 'G2P-244' );
+               $this->checkC( $data, $mk(1,5,5), 5, 'G2P-155' );
+       }
+
+       /**
+        * Test smart continue - generator=allpages, prop=links, list=alltransclusions
+        * @medium
+        */
+       public function testGen1Prop1List() {
+               $this->mVerbose = false;
+               $mk = function( $g, $p, $l ) {
+                       return array(
+                               'generator' => 'allpages',
+                               'gapprefix' => 'AQCT-',
+                               'gaplimit' => "$g",
+                               'prop' => 'links',
+                               'pllimit' => "$p",
+                               'list' => 'alltransclusions',
+                               'atprefix' => 'AQCT-',
+                               'atunique' => '',
+                               'atlimit' => "$l",
+                       );
+               };
+               // generator + 1 prop + 1 list
+               $data = $this->query( $mk(99,99,99), 1, 'G1P1L', false );
+               $this->checkC( $data, $mk(1,1,1), 11, 'G1P1L-111' );
+               $this->checkC( $data, $mk(2,2,2), 6, 'G1P1L-222' );
+               $this->checkC( $data, $mk(3,3,3), 4, 'G1P1L-333' );
+               $this->checkC( $data, $mk(4,4,4), 3, 'G1P1L-444' );
+               $this->checkC( $data, $mk(5,5,5), 2, 'G1P1L-555' );
+               $this->checkC( $data, $mk(5,5,1), 4, 'G1P1L-551' );
+               $this->checkC( $data, $mk(5,5,2), 2, 'G1P1L-552' );
+       }
+
+       /**
+        * Test smart continue - generator=allpages, prop=links|templates,
+        *                                         list=alllinks|alltransclusions, meta=siteinfo
+        * @medium
+        */
+       public function testGen2Prop2List1Meta() {
+               $this->mVerbose = false;
+               $mk = function( $g, $p1, $p2, $l1, $l2 ) {
+                       return array(
+                               'generator' => 'allpages',
+                               'gapprefix' => 'AQCT-',
+                               'gaplimit' => "$g",
+                               'prop' => 'links|templates',
+                               'pllimit' => "$p1",
+                               'tllimit' => "$p2",
+                               'list' => 'alllinks|alltransclusions',
+                               'alprefix' => 'AQCT-',
+                               'alunique' => '',
+                               'allimit' => "$l1",
+                               'atprefix' => 'AQCT-',
+                               'atunique' => '',
+                               'atlimit' => "$l2",
+                               'meta' => 'siteinfo',
+                               'siprop' => 'namespaces',
+                       );
+               };
+               // generator + 1 prop + 1 list
+               $data = $this->query( $mk(99,99,99,99,99), 1, 'G2P2L1M', false );
+               $this->checkC( $data, $mk(1,1,1,1,1), 16, 'G2P2L1M-11111' );
+               $this->checkC( $data, $mk(2,2,2,2,2), 9, 'G2P2L1M-22222' );
+               $this->checkC( $data, $mk(3,3,3,3,3), 6, 'G2P2L1M-33333' );
+               $this->checkC( $data, $mk(4,4,4,4,4), 4, 'G2P2L1M-44444' );
+               $this->checkC( $data, $mk(5,5,5,5,5), 2, 'G2P2L1M-55555' );
+               $this->checkC( $data, $mk(5,5,5,1,1), 4, 'G2P2L1M-55511' );
+               $this->checkC( $data, $mk(5,5,5,2,2), 2, 'G2P2L1M-55522' );
+               $this->checkC( $data, $mk(5,1,1,5,5), 10, 'G2P2L1M-51155' );
+               $this->checkC( $data, $mk(5,2,2,5,5), 5, 'G2P2L1M-52255' );
+       }
+
+       /**
+        * Test smart continue - generator=templates, prop=templates
+        * @medium
+        */
+       public function testSameGenAndProp() {
+               $this->mVerbose = false;
+               $mk = function( $g, $gDir, $p, $pDir ) {
+                       return array(
+                               'titles' => 'AQCT-1',
+                               'generator' => 'templates',
+                               'gtllimit' => "$g",
+                               'gtldir' => $gDir ? 'ascending' : 'descending',
+                               'prop' => 'templates',
+                               'tllimit' => "$p",
+                               'tldir' => $pDir ? 'ascending' : 'descending',
+                       );
+               };
+               // generator + 1 prop
+               $data = $this->query( $mk(99,true,99,true), 1, 'G=P', false );
+
+               $this->checkC( $data, $mk(1,true,1,true), 4, 'G=P-1t1t' );
+               $this->checkC( $data, $mk(2,true,2,true), 2, 'G=P-2t2t' );
+               $this->checkC( $data, $mk(3,true,3,true), 2, 'G=P-3t3t' );
+               $this->checkC( $data, $mk(1,true,3,true), 4, 'G=P-1t3t' );
+               $this->checkC( $data, $mk(3,true,1,true), 2, 'G=P-3t1t' );
+
+               $this->checkC( $data, $mk(1,true,1,false), 4, 'G=P-1t1f' );
+               $this->checkC( $data, $mk(2,true,2,false), 2, 'G=P-2t2f' );
+               $this->checkC( $data, $mk(3,true,3,false), 2, 'G=P-3t3f' );
+               $this->checkC( $data, $mk(1,true,3,false), 4, 'G=P-1t3f' );
+               $this->checkC( $data, $mk(3,true,1,false), 2, 'G=P-3t1f' );
+
+               $this->checkC( $data, $mk(1,false,1,true), 4, 'G=P-1f1t' );
+               $this->checkC( $data, $mk(2,false,2,true), 2, 'G=P-2f2t' );
+               $this->checkC( $data, $mk(3,false,3,true), 2, 'G=P-3f3t' );
+               $this->checkC( $data, $mk(1,false,3,true), 4, 'G=P-1f3t' );
+               $this->checkC( $data, $mk(3,false,1,true), 2, 'G=P-3f1t' );
+
+               $this->checkC( $data, $mk(1,false,1,false), 4, 'G=P-1f1f' );
+               $this->checkC( $data, $mk(2,false,2,false), 2, 'G=P-2f2f' );
+               $this->checkC( $data, $mk(3,false,3,false), 2, 'G=P-3f3f' );
+               $this->checkC( $data, $mk(1,false,3,false), 4, 'G=P-1f3f' );
+               $this->checkC( $data, $mk(3,false,1,false), 2, 'G=P-3f1f' );
+       }
+
+       /**
+        * Test smart continue - generator=allpages, list=allpages
+        * @medium
+        */
+       public function testSameGenList() {
+               $this->mVerbose = false;
+               $mk = function( $g, $gDir, $l, $pDir ) {
+                       return array(
+                               'generator' => 'allpages',
+                               'gapprefix' => 'AQCT-',
+                               'gaplimit' => "$g",
+                               'gapdir' => $gDir ? 'ascending' : 'descending',
+                               'list' => 'allpages',
+                               'apprefix' => 'AQCT-',
+                               'aplimit' => "$l",
+                               'apdir' => $pDir ? 'ascending' : 'descending',
+                       );
+               };
+               // generator + 1 list
+               $data = $this->query( $mk(99,true,99,true), 1, 'G=L', false );
+
+               $this->checkC( $data, $mk(1,true,1,true), 5, 'G=L-1t1t' );
+               $this->checkC( $data, $mk(2,true,2,true), 3, 'G=L-2t2t' );
+               $this->checkC( $data, $mk(3,true,3,true), 2, 'G=L-3t3t' );
+               $this->checkC( $data, $mk(1,true,3,true), 5, 'G=L-1t3t' );
+               $this->checkC( $data, $mk(3,true,1,true), 5, 'G=L-3t1t' );
+               $this->checkC( $data, $mk(1,true,1,false), 5, 'G=L-1t1f' );
+               $this->checkC( $data, $mk(2,true,2,false), 3, 'G=L-2t2f' );
+               $this->checkC( $data, $mk(3,true,3,false), 2, 'G=L-3t3f' );
+               $this->checkC( $data, $mk(1,true,3,false), 5, 'G=L-1t3f' );
+               $this->checkC( $data, $mk(3,true,1,false), 5, 'G=L-3t1f' );
+               $this->checkC( $data, $mk(1,false,1,true), 5, 'G=L-1f1t' );
+               $this->checkC( $data, $mk(2,false,2,true), 3, 'G=L-2f2t' );
+               $this->checkC( $data, $mk(3,false,3,true), 2, 'G=L-3f3t' );
+               $this->checkC( $data, $mk(1,false,3,true), 5, 'G=L-1f3t' );
+               $this->checkC( $data, $mk(3,false,1,true), 5, 'G=L-3f1t' );
+               $this->checkC( $data, $mk(1,false,1,false), 5, 'G=L-1f1f' );
+               $this->checkC( $data, $mk(2,false,2,false), 3, 'G=L-2f2f' );
+               $this->checkC( $data, $mk(3,false,3,false), 2, 'G=L-3f3f' );
+               $this->checkC( $data, $mk(1,false,3,false), 5, 'G=L-1f3f' );
+               $this->checkC( $data, $mk(3,false,1,false), 5, 'G=L-3f1f' );
+       }
+}
diff --git a/tests/phpunit/includes/api/query/ApiQueryContinueTestBase.php b/tests/phpunit/includes/api/query/ApiQueryContinueTestBase.php
new file mode 100644 (file)
index 0000000..4717479
--- /dev/null
@@ -0,0 +1,203 @@
+<?php
+/**
+ *
+ *
+ * Created on Jan 1, 2013
+ *
+ * Copyright © 2013 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+require_once( 'ApiQueryTestBase.php' );
+
+abstract class ApiQueryContinueTestBase extends ApiQueryTestBase {
+
+       /**
+        * Enable to print in-depth debugging info during the test run
+        */
+       protected $mVerbose = false;
+
+       /**
+        * Run query() and compare against expected values
+        */
+       protected function checkC( $expected, $params, $expectedCount, $id, $continue = true  ) {
+               $result = $this->query( $params, $expectedCount, $id, $continue );
+               $this->assertResult( $expected, $result, $id );
+       }
+
+       /**
+        * Run query in a loop until no more values are available
+        * @param array $params api parameters
+        * @param int $expectedCount max number of iterations
+        * @param string $id unit test id
+        * @param boolean $useContinue true to use smart continue
+        * @return mixed: merged results data array
+        * @throws Exception
+        */
+       protected function query( $params, $expectedCount, $id, $useContinue = true ) {
+               if ( isset( $params['action'] ) ) {
+                       $this->assertEquals( 'query', $params['action'], 'Invalid query action');
+               } else {
+                       $params['action'] = 'query';
+               }
+               if ( $useContinue && !isset( $params['continue'] ) ) {
+                       $params['continue'] = '';
+               }
+               $count = 0;
+               $result = array();
+               $continue = array();
+               do {
+                       $request = array_merge( $params, $continue );
+                       uksort( $request, function( $a, $b ) {
+                               // put 'continue' params at the end - lazy method
+                               $a = strpos( $a, 'continue' ) !== false ? 'zzz ' . $a : $a;
+                               $b = strpos( $b, 'continue' ) !== false ? 'zzz ' . $b : $b;
+                               return strcmp( $a, $b );
+                       } );
+                       $reqStr = http_build_query( $request );
+                       //$reqStr = str_replace( '&', ' & ', $reqStr );
+                       $this->assertLessThan( $expectedCount, $count, "$id more data: $reqStr" );
+                       if ( $this->mVerbose ) {
+                               print ("$id (#$count): $reqStr\n");
+                       }
+                       try {
+                               $data = $this->doApiRequest( $request );
+                       } catch ( Exception $e ) {
+                               throw new Exception( "$id on $count", 0, $e );
+                       }
+                       $data = $data[0];
+                       if ( isset( $data['warnings'] ) ) {
+                               $warnings = json_encode( $data['warnings'] );
+                               $this->fail( "$id Warnings on #$count in $reqStr\n$warnings" );
+                       }
+                       $this->assertArrayHasKey( 'query', $data, "$id no 'query' on #$count in $reqStr" );
+                       if ( isset( $data['continue'] ) ) {
+                               $continue = $data['continue'];
+                               unset( $data['continue'] );
+                       } else {
+                               $continue = array();
+                       }
+                       if ( $this->mVerbose ) {
+                               $this->printResult( $data );
+                       }
+                       $this->mergeResult( $result, $data );
+                       $count++;
+                       if ( empty( $continue ) ) {
+                               // $this->assertEquals( $expectedCount, $count, "$id finished early" );
+                               if ( $expectedCount > $count ) {
+                                       print "***** $id Finished early in $count turns. $expectedCount was expected\n";
+                               }
+                               return $result;
+                       } elseif ( !$useContinue ) {
+                               $this->assertFalse( 'Non-smart query must be requested all at once' );
+                       }
+               } while( true );
+       }
+
+       private function printResult( $data ) {
+               $q = $data['query'];
+               $print = array();
+               if (isset($q['pages'])) {
+                       foreach ($q['pages'] as $p) {
+                               $m = $p['title'];
+                               if (isset($p['links'])) {
+                                       $m .= '/[' . implode(',', array_map(
+                                               function ($v) {
+                                                       return $v['title'];
+                                               },
+                                               $p['links'])) . ']';
+                               }
+                               if (isset($p['categories'])) {
+                                       $m .= '/(' . implode(',', array_map(
+                                               function ($v) {
+                                                       return str_replace('Category:', '', $v['title']);
+                                               },
+                                               $p['categories'])) . ')';
+                               }
+                               $print[] = $m;
+                       }
+               }
+               if (isset($q['allcategories'])) {
+                       $print[] = '*Cats/(' . implode(',', array_map(
+                               function ($v) { return $v['*']; },
+                               $q['allcategories'])) . ')';
+               }
+               self::GetItems( $q, 'allpages', 'Pages', $print );
+               self::GetItems( $q, 'alllinks', 'Links', $print );
+               self::GetItems( $q, 'alltransclusions', 'Trnscl', $print );
+               print(' ' . implode('  ', $print) . "\n");
+       }
+
+       private static function GetItems( $q, $moduleName, $name, &$print ) {
+               if (isset($q[$moduleName])) {
+                       $print[] = "*$name/[" . implode(',',
+                               array_map( function ($v) { return $v['title']; },
+                                               $q[$moduleName])) . ']';
+               }
+       }
+
+       /**
+        * Recursively merge the new result returned from the query to the previous results.
+        * @param mixed $results
+        * @param mixed $newResult
+        * @param bool $numericIds If true, treat keys as ids to be merged instead of appending
+        */
+       protected function mergeResult( &$results, $newResult, $numericIds = false ) {
+               $this->assertEquals( is_array( $results ), is_array( $newResult ), 'Type of result and data do not match' );
+               if ( !is_array( $results ) ) {
+                       $this->assertEquals( $results, $newResult, 'Repeated result must be the same as before' );
+               } else {
+                       $sort = null;
+                       foreach( $newResult as $key => $value ) {
+                               if ( !$numericIds && $sort === null ) {
+                                       if ( !is_array( $value ) ) {
+                                               $sort = false;
+                                       } elseif ( array_key_exists( 'title', $value ) ) {
+                                               $sort = function( $a, $b ) {
+                                                       return strcmp( $a['title'], $b['title'] );
+                                               };
+                                       } else {
+                                               $sort = false;
+                                       }
+                               }
+                               $keyExists = array_key_exists( $key, $results );
+                               if ( is_numeric( $key ) ) {
+                                       if ( $numericIds ) {
+                                               if ( !$keyExists ) {
+                                                       $results[$key] = $value;
+                                               } else {
+                                                       $this->mergeResult( $results[$key], $value );
+                                               }
+                                       } else {
+                                               $results[] = $value;
+                                       }
+                               } elseif ( !$keyExists ) {
+                                       $results[$key] = $value;
+                               } else {
+                                       $this->mergeResult( $results[$key], $value, $key === 'pages' );
+                               }
+                       }
+                       if ( $numericIds ) {
+                               ksort( $results, SORT_NUMERIC );
+                       } elseif ( $sort !== null && $sort !== false ) {
+                               uasort( $results, $sort );
+                       }
+               }
+       }
+}
diff --git a/tests/phpunit/includes/api/query/ApiQueryTestBase.php b/tests/phpunit/includes/api/query/ApiQueryTestBase.php
new file mode 100644 (file)
index 0000000..7b9f8ed
--- /dev/null
@@ -0,0 +1,149 @@
+<?php
+/**
+ *
+ *
+ * Created on Feb 10, 2013
+ *
+ * Copyright © 2013 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+
+/** This class has some common functionality for testing query module
+ */
+abstract class ApiQueryTestBase extends ApiTestCase {
+
+       const PARAM_ASSERT = <<<STR
+Each parameter must be an array of two elements,
+first - an array of params to the API call,
+and the second array - expected results as returned by the API
+STR;
+
+       /**
+        * Merges all requests parameter + expected values into one
+        * @param ... list of arrays, each of which contains exactly two
+        * @return array
+        */
+       protected function merge( /*...*/ ) {
+               $request = array();
+               $expected = array();
+               foreach ( func_get_args() as $v ) {
+                       list( $req, $exp ) = $this->validateRequestExpectedPair( $v );
+                       $request = array_merge_recursive( $request, $req );
+                       $this->mergeExpected( $expected, $exp );
+               }
+               return array( $request, $expected );
+       }
+
+       /**
+        * Check that the parameter is a valid two element array,
+        * with the first element being API request and the second - expected result
+        */
+       private function validateRequestExpectedPair( $v ) {
+               $this->assertType( 'array', $v, self::PARAM_ASSERT );
+               $this->assertEquals( 2, count($v), self::PARAM_ASSERT );
+               $this->assertArrayHasKey( 0, $v, self::PARAM_ASSERT );
+               $this->assertArrayHasKey( 1, $v, self::PARAM_ASSERT );
+               $this->assertType( 'array', $v[0], self::PARAM_ASSERT );
+               $this->assertType( 'array', $v[1], self::PARAM_ASSERT );
+               return $v;
+       }
+
+       /**
+        * Recursively merges the expected values in the $item into the $all
+        */
+       private function mergeExpected( &$all, $item ) {
+               foreach ( $item as $k => $v ) {
+                       if ( array_key_exists( $k, $all ) ) {
+                               if ( is_array ( $all[$k] ) ) {
+                                       $this->mergeExpected( $all[$k], $v );
+                               } else {
+                                       $this->assertEquals( $all[$k], $v );
+                               }
+                       } else {
+                               $all[$k] = $v;
+                       }
+               }
+       }
+
+       /**
+        * Checks that the request's result matches the expected results.
+        * @param $values array is a two element array( request, expected_results )
+        * @throws Exception
+        */
+       protected function check( $values ) {
+               list( $req, $exp ) = $this->validateRequestExpectedPair( $values );
+               if ( !array_key_exists( 'action', $req ) ) {
+                       $req['action'] = 'query';
+               }
+               foreach ( $req as &$val ) {
+                       if ( is_array( $val ) ) {
+                               $val = implode( '|', array_unique( $val ) );
+                       }
+               }
+               $result = $this->doApiRequest( $req );
+               $this->assertResult( array( 'query' => $exp ), $result[0], $req );
+       }
+
+       protected function assertResult( $exp, $result, $message = '' ) {
+               try {
+                       $this->assertResultRecursive( $exp, $result );
+               } catch ( Exception $e ) {
+                       if ( is_array( $message ) ) {
+                               $message = http_build_query( $message );
+                       }
+                       print( "\nRequest: $message\n" );
+                       print( "\nExpected:\n" );
+                       print_r( $exp );
+                       print( "\nResult:\n" );
+                       print_r( $result );
+                       throw $e; // rethrow it
+               }
+       }
+
+       /**
+        * Recursively compare arrays, ignoring mismatches in numeric key and pageids.
+        * @param $expected array expected values
+        * @param $result array returned values
+        */
+       private function assertResultRecursive( $expected, $result ) {
+               reset( $expected );
+               reset( $result );
+               while ( true ) {
+                       $e = each( $expected );
+                       $r = each( $result );
+                       // If either of the arrays is shorter, abort. If both are done, success.
+                       $this->assertEquals( (bool)$e, (bool)$r );
+                       if ( !$e ) {
+                               break; // done
+                       }
+                       // continue only if keys are identical or both keys are numeric
+                       $this->assertTrue( $e['key'] === $r['key'] || ( is_numeric( $e['key'] ) && is_numeric( $r['key'] ) ) );
+                       // don't compare pageids
+                       if ( $e['key'] !== 'pageid' ) {
+                               // If values are arrays, compare recursively, otherwise compare with ===
+                               if ( is_array( $e['value'] ) && is_array( $r['value'] ) ) {
+                                       $this->assertResultRecursive( $e['value'], $r['value'] );
+                               } else {
+                                       $this->assertEquals( $e['value'], $r['value'] );
+                               }
+                       }
+               }
+       }
+}