Viewing File: /home/omtekel/www/wp-content/upgrade/backup/libs.tar

Message/ParamType.php000066600000005247151335023620010557 0ustar00<?php

namespace Wikimedia\Message;

/**
 * The constants used to specify parameter types. The values of the constants
 * are an unstable implementation detail.
 *
 * Unless otherwise noted, these should be used with an instance of ScalarParam.
 */
class ParamType {
	/** A simple text string or another MessageValue, not otherwise formatted. */
	public const TEXT = 'text';

	/** A number, to be formatted using local digits and separators */
	public const NUM = 'num';

	/**
	 * A number of seconds, to be formatted as natural language text.
	 * The value will be output exactly.
	 */
	public const DURATION_LONG = 'duration';

	/**
	 * A number of seconds, to be formatted as natural language text in an abbreviated way.
	 * The output will be rounded to an appropriate magnitude.
	 */
	public const DURATION_SHORT = 'period';

	/**
	 * An expiry time.
	 *
	 * The input is either a timestamp in one of the formats accepted by the
	 * Wikimedia\Timestamp library, or "infinity" if the thing doesn't expire.
	 *
	 * The output is a date and time in local format, or a string representing
	 * an "infinite" expiry.
	 */
	public const EXPIRY = 'expiry';

	/**
	 * A date time in one of the formats accepted by the Wikimedia\Timestamp library.
	 *
	 * The output is a date and time in local format.
	 */
	public const DATETIME = 'datetime';

	/**
	 * A date in one of the formats accepted by the Wikimedia\Timestamp library.
	 *
	 * The output is a date in local format.
	 */
	public const DATE = 'date';

	/**
	 * A time in one of the formats accepted by the Wikimedia\Timestamp library.
	 *
	 * The output is a time in local format.
	 */
	public const TIME = 'time';

	/**
	 * User Group
	 * @since 1.38
	 */
	public const GROUP = 'group';

	/**
	 * For arbitrary stringable objects
	 * @since 1.38
	 */
	public const OBJECT = 'object';

	/** A number of bytes. The output will be rounded to an appropriate magnitude. */
	public const SIZE = 'size';

	/** A number of bits per second. The output will be rounded to an appropriate magnitude. */
	public const BITRATE = 'bitrate';

	/** A list of values. Must be used with ListParam. */
	public const LIST = 'list';

	/**
	 * A text parameter which is substituted after formatter processing.
	 *
	 * The creator of the parameter and message is responsible for ensuring
	 * that the value will be safe for the intended output format, and
	 * documenting what that intended output format is.
	 */
	public const RAW = 'raw';

	/**
	 * A text parameter which is substituted after formatter processing.
	 * The output will be escaped as appropriate for the output format so
	 * as to represent plain text rather than any sort of markup.
	 */
	public const PLAINTEXT = 'plaintext';
}
Message/ListType.php000066600000000710151335023620010420 0ustar00<?php

namespace Wikimedia\Message;

/**
 * The constants used to specify list types. The values of the constants are an
 * unstable implementation detail.
 */
class ListType {
	/** A comma-separated list */
	public const COMMA = 'comma';

	/** A semicolon-separated list */
	public const SEMICOLON = 'semicolon';

	/** A pipe-separated list */
	public const PIPE = 'pipe';

	/** A natural-language list separated by "and" */
	public const AND = 'text';
}
rdbms/dbal/TimestampType.php000066600000001635151335023620012104 0ustar00<?php

namespace Wikimedia\Rdbms;

use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\Type;

/**
 * Handling timestamp edge cases in mediawiki.
 * https://www.mediawiki.org/wiki/Manual:Timestamp
 */
class TimestampType extends Type {
	public const TIMESTAMP = 'mwtimestamp';

	public function getSQLDeclaration( array $fieldDeclaration, AbstractPlatform $platform ) {
		if ( $platform->getName() == 'mysql' ) {
			// "infinite" (in expiry values has to be VARBINARY)
			if ( isset( $fieldDeclaration['allowInfinite'] ) && $fieldDeclaration['allowInfinite'] ) {
				return 'VARBINARY(14)';
			}
			return 'BINARY(14)';
		}

		if ( $platform->getName() == 'sqlite' ) {
			return 'BLOB';
		}

		if ( $platform->getName() == 'postgresql' ) {
			return 'TIMESTAMPTZ';
		}

		return $platform->getDateTimeTzTypeDeclarationSQL( $fieldDeclaration );
	}

	public function getName() {
		return self::TIMESTAMP;
	}
}
rdbms/dbal/EnumType.php000066600000004524151335023620011045 0ustar00<?php

namespace Wikimedia\Rdbms;

use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\Type;

/**
 * Custom handling for ENUM datatype
 *
 * NOTE: Use of this type is discouraged unless necessary. Please use
 * alternative types where possible. See T119173 for the RFC discussion
 * about this type and potential alternatives.
 *
 * @see https://phabricator.wikimedia.org/T119173
 */
class EnumType extends Type {
	public const ENUM = 'mwenum';

	/**
	 * Gets the SQL declaration snippet for an ENUM column
	 *
	 * @param mixed[] $column Column definition
	 * @param AbstractPlatform $platform
	 *
	 * @return string
	 */
	public function getSQLDeclaration( array $column, AbstractPlatform $platform ) {
		// SQLite does not support ENUM type
		if ( $platform->getName() == 'sqlite' ) {
			return 'TEXT';
		}

		// PostgreSQL does support but needs custom handling.
		// This just returns a string name that references the
		// actual ENUM which will be created by CREATE TYPE command
		// If 'fixed' option is not passed, this field will use TEXT
		if ( $platform->getName() == 'postgresql' ) {
			if ( !$column['fixed'] ) {
				return 'TEXT';
			}

			return strtoupper( $column['name'] . '_enum' );
		}

		if ( $platform->getName() == 'mysql' ) {
			$enumValues = $this->formatValues( $column['enum_values'] );
			return "ENUM( $enumValues )";
		}
	}

	/**
	 * Gets the sql portion to create ENUM for Postgres table column
	 *
	 * @param mixed[] $column
	 * @param AbstractPlatform $platform
	 *
	 * @see MWPostgreSqlPlatform::_getCreateTableSQL()
	 * @throws \InvalidArgumentException
	 * @return string
	 */
	public function makeEnumTypeSql( $column, $platform ) {
		if ( $platform->getName() !== 'postgresql' ) {
			throw new \InvalidArgumentException(
				__METHOD__ . ' can only be called on Postgres platform'
			);
		}

		$enumName = strtoupper( $column['name'] . '_enum' );
		$enumValues = $this->formatValues( $column['enum_values'] );
		$typeSql = "\n\nCREATE TYPE $enumName AS ENUM( $enumValues )";

		return $typeSql;
	}

	/**
	 * Get the imploded values suitable for pushing directly into ENUM();
	 *
	 * @param string[] $values
	 * @return string
	 */
	public function formatValues( $values ) {
		$values = implode( "','", $values );
		$enumValues = "'" . $values . "'";

		return $enumValues;
	}

	public function getName() {
		return self::ENUM;
	}
}
rdbms/dbal/TinyIntType.php000066600000002244151335023620011534 0ustar00<?php

namespace Wikimedia\Rdbms;

use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\PhpIntegerMappingType;
use Doctrine\DBAL\Types\Type;

/**
 * Handling smallest integer data type
 */
class TinyIntType extends Type implements PhpIntegerMappingType {
	public const TINYINT = 'mwtinyint';

	public function getSQLDeclaration( array $fieldDeclaration, AbstractPlatform $platform ) {
		if ( $platform->getName() == 'mysql' ) {
			if ( !empty( $fieldDeclaration['length'] ) && is_numeric( $fieldDeclaration['length'] ) ) {
				$length = $fieldDeclaration['length'];
				return "TINYINT($length)" . $this->getCommonIntegerTypeDeclarationForMySQL( $fieldDeclaration );
			}
			return 'TINYINT' . $this->getCommonIntegerTypeDeclarationForMySQL( $fieldDeclaration );
		}

		return $platform->getSmallIntTypeDeclarationSQL( $fieldDeclaration );
	}

	protected function getCommonIntegerTypeDeclarationForMySQL( array $columnDef ) {
		$autoinc = '';
		if ( !empty( $columnDef['autoincrement'] ) ) {
			$autoinc = ' AUTO_INCREMENT';
		}

		return !empty( $columnDef['unsigned'] ) ? ' UNSIGNED' . $autoinc : $autoinc;
	}

	public function getName() {
		return self::TINYINT;
	}
}
ParamValidator/TypeDef/EnumDef.php000066600000016715151335023620013064 0ustar00<?php

namespace Wikimedia\ParamValidator\TypeDef;

use Wikimedia\Message\DataMessageValue;
use Wikimedia\Message\ListParam;
use Wikimedia\Message\ListType;
use Wikimedia\Message\MessageParam;
use Wikimedia\Message\MessageValue;
use Wikimedia\Message\ParamType;
use Wikimedia\Message\ScalarParam;
use Wikimedia\ParamValidator\ParamValidator;
use Wikimedia\ParamValidator\TypeDef;

/**
 * Type definition for enumeration types.
 *
 * This class expects that PARAM_TYPE is an array of allowed values. Subclasses
 * may override getEnumValues() to determine the allowed values differently.
 *
 * The result from validate() is one of the defined values.
 *
 * Failure codes:
 *  - 'badvalue': The value is not a recognized value. No data.
 *
 * Additional codes may be generated when using certain PARAM constants. See
 * the constants' documentation for details.
 *
 * @since 1.34
 * @unstable
 */
class EnumDef extends TypeDef {

	/**
	 * (array) Associative array of deprecated values.
	 *
	 * Keys are the deprecated parameter values. Value is one of the following:
	 *  - null: Parameter isn't actually deprecated.
	 *  - true: Parameter is deprecated.
	 *  - MessageValue: Parameter is deprecated, and this message (converted to a DataMessageValue)
	 *    is used in place of the default for passing to $this->failure().
	 *
	 * Note that this does not add any values to the enumeration, it only
	 * documents existing values as being deprecated.
	 *
	 * Failure codes: (non-fatal)
	 *  - 'deprecated-value': A deprecated value was encountered. No data.
	 */
	public const PARAM_DEPRECATED_VALUES = 'param-deprecated-values';

	public function validate( $name, $value, array $settings, array $options ) {
		$values = $this->getEnumValues( $name, $settings, $options );

		if ( in_array( $value, $values, true ) ) {
			// Set a warning if a deprecated parameter value has been passed
			if ( empty( $options['is-default'] ) &&
				isset( $settings[self::PARAM_DEPRECATED_VALUES][$value] )
			) {
				$msg = $settings[self::PARAM_DEPRECATED_VALUES][$value];
				if ( $msg instanceof MessageValue ) {
					$message = DataMessageValue::new(
						$msg->getKey(),
						$msg->getParams(),
						'deprecated-value',
						$msg instanceof DataMessageValue ? $msg->getData() : null
					);
				} else {
					$message = $this->failureMessage( 'deprecated-value' );
				}
				$this->failure( $message, $name, $value, $settings, $options, false );
			}

			return $value;
		}

		$isMulti = isset( $options['values-list'] );
		$this->failure(
			$this->failureMessage( 'badvalue', [], $isMulti ? 'enummulti' : 'enumnotmulti' )
				->textListParams( array_map( static function ( $v ) {
					return new ScalarParam( ParamType::PLAINTEXT, $v );
				}, $values ) )
				->numParams( count( $values ) ),
			$name, $value, $settings, $options
		);
	}

	public function checkSettings( string $name, $settings, array $options, array $ret ): array {
		$ret = parent::checkSettings( $name, $settings, $options, $ret );

		$ret['allowedKeys'][] = self::PARAM_DEPRECATED_VALUES;

		$dv = $settings[self::PARAM_DEPRECATED_VALUES] ?? [];
		if ( !is_array( $dv ) ) {
			$ret['issues'][self::PARAM_DEPRECATED_VALUES] = 'PARAM_DEPRECATED_VALUES must be an array, got '
				. gettype( $dv );
		} else {
			$values = array_map( function ( $v ) use ( $name, $settings, $options ) {
				return $this->stringifyValue( $name, $v, $settings, $options );
			}, $this->getEnumValues( $name, $settings, $options ) );
			foreach ( $dv as $k => $v ) {
				$k = $this->stringifyValue( $name, $k, $settings, $options );
				if ( !in_array( $k, $values, true ) ) {
					$ret['issues'][] = "PARAM_DEPRECATED_VALUES contains \"$k\", which is not "
						. 'one of the enumerated values';
				} elseif ( $v instanceof MessageValue ) {
					$ret['messages'][] = $v;
				} elseif ( $v !== null && $v !== true ) {
					$type = $v === false ? 'false' : ( is_object( $v ) ? get_class( $v ) : gettype( $v ) );
					$ret['issues'][] = 'Values in PARAM_DEPRECATED_VALUES must be null, true, or MessageValue, '
						. "but value for \"$k\" is $type";
				}
			}
		}

		return $ret;
	}

	public function getEnumValues( $name, array $settings, array $options ) {
		return array_values( $settings[ParamValidator::PARAM_TYPE] );
	}

	public function stringifyValue( $name, $value, array $settings, array $options ) {
		if ( !is_array( $value ) ) {
			return parent::stringifyValue( $name, $value, $settings, $options );
		}

		return ParamValidator::implodeMultiValue( $value );
	}

	public function getParamInfo( $name, array $settings, array $options ) {
		$info = parent::getParamInfo( $name, $settings, $options );

		$info['type'] = $this->sortEnumValues(
			$name,
			$this->getEnumValues( $name, $settings, $options ),
			$settings,
			$options
		);

		if ( !empty( $settings[self::PARAM_DEPRECATED_VALUES] ) ) {
			$deprecatedValues = array_intersect(
				array_keys( $settings[self::PARAM_DEPRECATED_VALUES] ),
				$this->getEnumValues( $name, $settings, $options )
			);
			if ( $deprecatedValues ) {
				$deprecatedValues = $this->sortEnumValues( $name, $deprecatedValues, $settings, $options );
				$info['deprecatedvalues'] = array_values( $deprecatedValues );
			}
		}

		return $info;
	}

	public function getHelpInfo( $name, array $settings, array $options ) {
		$info = parent::getHelpInfo( $name, $settings, $options );

		$isMulti = !empty( $settings[ParamValidator::PARAM_ISMULTI] );

		$values = $this->getEnumValuesForHelp( $name, $settings, $options );
		$count = count( $values );

		$i = array_search( '', $values, true );
		if ( $i === false ) {
			$valuesParam = new ListParam( ListType::COMMA, $values );
		} else {
			unset( $values[$i] );
			$valuesParam = MessageValue::new( 'paramvalidator-help-type-enum-can-be-empty' )
				->commaListParams( $values )
				->numParams( count( $values ) );
		}

		$info[ParamValidator::PARAM_TYPE] = MessageValue::new( 'paramvalidator-help-type-enum' )
			->params( $isMulti ? 2 : 1 )
			->params( $valuesParam )
			->numParams( $count );

		// Suppress standard ISMULTI message, it should be incorporated into our type message.
		$info[ParamValidator::PARAM_ISMULTI] = null;

		return $info;
	}

	/**
	 * Sort enum values for help/param info output
	 *
	 * @param string $name Parameter name being described.
	 * @param string[] $values Values being sorted
	 * @param array $settings Parameter settings array.
	 * @param array $options Options array.
	 * @return string[]
	 */
	protected function sortEnumValues(
		string $name, array $values, array $settings, array $options
	): array {
		// sort values by deprecation status and name
		$flags = [];
		foreach ( $values as $k => $value ) {
			$flag = 0;
			if ( isset( $settings[self::PARAM_DEPRECATED_VALUES][$value] ) ) {
				$flag |= 1;
			}
			$flags[$k] = $flag;
		}
		array_multisort( $flags, $values, SORT_NATURAL );

		return $values;
	}

	/**
	 * Return enum values formatted for the help message
	 *
	 * @param string $name Parameter name being described.
	 * @param array $settings Parameter settings array.
	 * @param array $options Options array.
	 * @return (MessageParam|string)[]
	 */
	protected function getEnumValuesForHelp( $name, array $settings, array $options ) {
		$values = $this->getEnumValues( $name, $settings, $options );
		$values = $this->sortEnumValues( $name, $values, $settings, $options );

		// @todo Indicate deprecated values in some manner. Probably that needs
		// MessageValue and/or MessageParam to have a generic ability to wrap
		// values in HTML without that HTML coming out in the text format too.

		return $values;
	}

}
ParamValidator/TypeDef/PresenceBooleanDef.php000066600000004611151335023620015214 0ustar00<?php

namespace Wikimedia\ParamValidator\TypeDef;

use Wikimedia\Message\MessageValue;
use Wikimedia\ParamValidator\ParamValidator;
use Wikimedia\ParamValidator\TypeDef;

/**
 * Type definition for checkbox-like boolean types
 *
 * This boolean is considered true if the parameter is present in the request,
 * regardless of value. The only way for it to be false is for the parameter to
 * be omitted entirely.
 *
 * The result from validate() is a PHP boolean.
 *
 * @since 1.34
 * @unstable
 */
class PresenceBooleanDef extends TypeDef {

	public function getValue( $name, array $settings, array $options ) {
		return $this->callbacks->hasParam( $name, $options ) ? true : null;
	}

	public function validate( $name, $value, array $settings, array $options ) {
		return (bool)$value;
	}

	public function normalizeSettings( array $settings ) {
		// Cannot be multi-valued
		$settings[ParamValidator::PARAM_ISMULTI] = false;

		// Default the default to false so ParamValidator::getValue() returns false (T244440)
		$settings += [ ParamValidator::PARAM_DEFAULT => false ];

		return parent::normalizeSettings( $settings );
	}

	public function checkSettings( string $name, $settings, array $options, array $ret ): array {
		$ret = parent::checkSettings( $name, $settings, $options, $ret );

		if ( !empty( $settings[ParamValidator::PARAM_ISMULTI] ) &&
			!isset( $ret['issues'][ParamValidator::PARAM_ISMULTI] )
		) {
			$ret['issues'][ParamValidator::PARAM_ISMULTI] =
				'PARAM_ISMULTI cannot be used for presence-boolean-type parameters';
		}

		if ( ( $settings[ParamValidator::PARAM_DEFAULT] ?? false ) !== false &&
			!isset( $ret['issues'][ParamValidator::PARAM_DEFAULT] )
		) {
			$ret['issues'][ParamValidator::PARAM_DEFAULT] =
				'Default for presence-boolean-type parameters must be false or null';
		}

		return $ret;
	}

	public function getParamInfo( $name, array $settings, array $options ) {
		$info = parent::getParamInfo( $name, $settings, $options );

		// No need to report the default of "false"
		$info['default'] = null;

		return $info;
	}

	public function getHelpInfo( $name, array $settings, array $options ) {
		$info = parent::getHelpInfo( $name, $settings, $options );

		$info[ParamValidator::PARAM_TYPE] = MessageValue::new(
			'paramvalidator-help-type-presenceboolean'
		)->params( 1 );

		// No need to report the default of "false"
		$info[ParamValidator::PARAM_DEFAULT] = null;

		return $info;
	}

}
ParamValidator/TypeDef/BooleanDef.php000066600000003671151335023620013534 0ustar00<?php

namespace Wikimedia\ParamValidator\TypeDef;

use Wikimedia\Message\MessageValue;
use Wikimedia\Message\ParamType;
use Wikimedia\Message\ScalarParam;
use Wikimedia\ParamValidator\ParamValidator;
use Wikimedia\ParamValidator\TypeDef;

/**
 * Type definition for boolean types
 *
 * This type accepts certain defined strings to mean 'true' or 'false'.
 * The result from validate() is a PHP boolean.
 *
 * Failure codes:
 *  - 'badbool': The value is not a recognized boolean. No data.
 *
 * @since 1.34
 * @unstable
 */
class BooleanDef extends TypeDef {

	public static $TRUEVALS = [ 'true', 't', 'yes', 'y', 'on', '1' ];
	public static $FALSEVALS = [ 'false', 'f', 'no', 'n', 'off', '0' ];

	public function validate( $name, $value, array $settings, array $options ) {
		$value = strtolower( $value );
		if ( in_array( $value, self::$TRUEVALS, true ) ) {
			return true;
		}
		if ( $value === '' || in_array( $value, self::$FALSEVALS, true ) ) {
			return false;
		}

		$this->failure(
			$this->failureMessage( 'badbool' )
				->textListParams( array_map( [ $this, 'quoteVal' ], self::$TRUEVALS ) )
				->numParams( count( self::$TRUEVALS ) )
				->textListParams( array_merge(
					array_map( [ $this, 'quoteVal' ], self::$FALSEVALS ),
					[ MessageValue::new( 'paramvalidator-emptystring' ) ]
				) )
				->numParams( count( self::$FALSEVALS ) + 1 ),
			$name, $value, $settings, $options
		);
	}

	private function quoteVal( $v ) {
		return new ScalarParam( ParamType::TEXT, "\"$v\"" );
	}

	public function stringifyValue( $name, $value, array $settings, array $options ) {
		return $value ? self::$TRUEVALS[0] : self::$FALSEVALS[0];
	}

	public function getHelpInfo( $name, array $settings, array $options ) {
		$info = parent::getHelpInfo( $name, $settings, $options );

		$info[ParamValidator::PARAM_TYPE] = MessageValue::new( 'paramvalidator-help-type-boolean' )
			->params( empty( $settings[ParamValidator::PARAM_ISMULTI] ) ? 1 : 2 );

		return $info;
	}

}
ParamValidator/TypeDef/LimitDef.php000066600000004202151335023620013222 0ustar00<?php

namespace Wikimedia\ParamValidator\TypeDef;

use Wikimedia\Message\MessageValue;
use Wikimedia\ParamValidator\ParamValidator;

/**
 * Type definition for "limit" types
 *
 * A limit type is an integer type that also accepts the magic value "max".
 * IntegerDef::PARAM_MIN defaults to 0 for this type.
 *
 * @see IntegerDef
 * @since 1.34
 * @unstable
 */
class LimitDef extends IntegerDef {

	/**
	 * @inheritDoc
	 *
	 * Additional `$options` accepted:
	 *  - 'parse-limit': (bool) Default true, set false to return 'max' rather
	 *    than determining the effective value.
	 */
	public function validate( $name, $value, array $settings, array $options ) {
		if ( $value === 'max' ) {
			if ( $options['parse-limit'] ?? true ) {
				$value = $this->callbacks->useHighLimits( $options )
					? $settings[self::PARAM_MAX2] ?? $settings[self::PARAM_MAX] ?? PHP_INT_MAX
					: $settings[self::PARAM_MAX] ?? PHP_INT_MAX;
			}
			return $value;
		}

		return parent::validate( $name, $value, $settings, $options );
	}

	public function normalizeSettings( array $settings ) {
		$settings += [
			self::PARAM_MIN => 0,
		];

		// Cannot be multi-valued
		$settings[ParamValidator::PARAM_ISMULTI] = false;

		return parent::normalizeSettings( $settings );
	}

	public function checkSettings( string $name, $settings, array $options, array $ret ): array {
		$ret = parent::checkSettings( $name, $settings, $options, $ret );

		if ( !empty( $settings[ParamValidator::PARAM_ISMULTI] ) &&
			!isset( $ret['issues'][ParamValidator::PARAM_ISMULTI] )
		) {
			$ret['issues'][ParamValidator::PARAM_ISMULTI] =
				'PARAM_ISMULTI cannot be used for limit-type parameters';
		}

		if ( ( $settings[self::PARAM_MIN] ?? 0 ) < 0 ) {
			$ret['issues'][] = 'PARAM_MIN must be greater than or equal to 0';
		}
		if ( !isset( $settings[self::PARAM_MAX] ) ) {
			$ret['issues'][] = 'PARAM_MAX must be set';
		}

		return $ret;
	}

	public function getHelpInfo( $name, array $settings, array $options ) {
		$info = parent::getHelpInfo( $name, $settings, $options );

		$info[ParamValidator::PARAM_TYPE] = MessageValue::new( 'paramvalidator-help-type-limit' )
			->params( 1 );

		return $info;
	}

}
ParamValidator/TypeDef/FloatDef.php000066600000003564151335023620013223 0ustar00<?php

namespace Wikimedia\ParamValidator\TypeDef;

use Wikimedia\Message\MessageValue;
use Wikimedia\ParamValidator\ParamValidator;

/**
 * Type definition for a floating-point type
 *
 * A valid representation consists of:
 *  - an optional sign (`+` or `-`)
 *  - a decimal number, using `.` as the decimal separator and no grouping
 *  - an optional E-notation suffix: the letter 'e' or 'E', an optional
 *    sign, and an integer
 *
 * Thus, for example, "12", "-.4", "6.022e23", or "+1.7e-10".
 *
 * The result from validate() is a PHP float.
 *
 * Failure codes:
 *  - 'badfloat': The value was invalid. No data.
 *  - 'badfloat-notfinite': The value was in a valid format, but conversion resulted in
 *    infinity or NAN.
 *
 * @since 1.34
 * @unstable
 */
class FloatDef extends NumericDef {

	protected $valueType = 'double';

	public function validate( $name, $value, array $settings, array $options ) {
		// Use a regex so as to avoid any potential oddness PHP's default conversion might allow.
		if ( !preg_match( '/^[+-]?(?:\d*\.)?\d+(?:[eE][+-]?\d+)?$/D', $value ) ) {
			$this->failure( 'badfloat', $name, $value, $settings, $options );
		}

		$ret = (float)$value;
		if ( !is_finite( $ret ) ) {
			$this->failure( 'badfloat-notfinite', $name, $value, $settings, $options );
		}

		return $this->checkRange( $ret, $name, $value, $settings, $options );
	}

	public function stringifyValue( $name, $value, array $settings, array $options ) {
		// Ensure sufficient precision for round-tripping
		$digits = PHP_FLOAT_DIG;
		return sprintf( "%.{$digits}g", $value );
	}

	public function getHelpInfo( $name, array $settings, array $options ) {
		$info = parent::getHelpInfo( $name, $settings, $options );

		$info[ParamValidator::PARAM_TYPE] = MessageValue::new( 'paramvalidator-help-type-float' )
			->params( empty( $settings[ParamValidator::PARAM_ISMULTI] ) ? 1 : 2 );

		return $info;
	}

}
ParamValidator/TypeDef/NumericDef.php000066600000015205151335023620013553 0ustar00<?php

namespace Wikimedia\ParamValidator\TypeDef;

use Wikimedia\Message\MessageValue;
use Wikimedia\Message\ParamType;
use Wikimedia\Message\ScalarParam;
use Wikimedia\ParamValidator\ParamValidator;
use Wikimedia\ParamValidator\TypeDef;
use Wikimedia\ParamValidator\ValidationException;

/**
 * Type definition base class for numeric types
 *
 * * Failure codes:
 *  - 'outofrange': The value was outside of the allowed range. Data:
 *     - 'min': Minimum allowed, or null if there is no limit.
 *     - 'curmax': Current maximum allowed, or null if there is no limit.
 *     - 'max': Normal maximum allowed, or null if there is no limit.
 *     - 'highmax': High limits maximum allowed, or null if there is no limit.
 *
 * @stable to extend
 * @since 1.35
 * @unstable
 */
abstract class NumericDef extends TypeDef {

	/**
	 * (bool) Whether to enforce the specified range.
	 *
	 * If set and truthy, the 'outofrange' failure is non-fatal.
	 */
	public const PARAM_IGNORE_RANGE = 'param-ignore-range';

	/**
	 * (int|float) Minimum allowed value.
	 */
	public const PARAM_MIN = 'param-min';

	/**
	 * (int|float) Maximum allowed value (normal limits)
	 */
	public const PARAM_MAX = 'param-max';

	/**
	 * (int|float) Maximum allowed value (high limits)
	 */
	public const PARAM_MAX2 = 'param-max2';

	/** @var string PHP type (as from `gettype()`) of values this NumericDef handles */
	protected $valueType = 'integer';

	/**
	 * Check the range of a value
	 * @param int|float $value Value to check.
	 * @param string $name Parameter name being validated.
	 * @param mixed $origValue Original value being validated.
	 * @param array $settings Parameter settings array.
	 * @param array $options Options array.
	 * @return int|float Validated value, may differ from $value if
	 *   PARAM_IGNORE_RANGE was set.
	 * @throws ValidationException if the value out of range, and PARAM_IGNORE_RANGE wasn't set.
	 */
	protected function checkRange( $value, $name, $origValue, array $settings, array $options ) {
		$min = $settings[self::PARAM_MIN] ?? null;
		$max1 = $settings[self::PARAM_MAX] ?? null;
		$max2 = $settings[self::PARAM_MAX2] ?? $max1;
		$err = false;

		if ( $min !== null && $value < $min ) {
			$err = true;
			$value = $min;
		} elseif ( $max1 !== null && $value > $max1 ) {
			if ( $max2 > $max1 && $this->callbacks->useHighLimits( $options ) ) {
				if ( $value > $max2 ) {
					$err = true;
					$value = $max2;
				}
			} else {
				$err = true;
				$value = $max1;
			}
		}
		if ( $err ) {
			$what = '';
			if ( $min !== null ) {
				$what .= 'min';
			}
			if ( $max1 !== null ) {
				$what .= 'max';
			}
			$max = $max2 !== null && $max2 > $max1 && $this->callbacks->useHighLimits( $options )
				? $max2 : $max1;
			$this->failure(
				$this->failureMessage( 'outofrange', [
					'min' => $min,
					'curmax' => $max,
					'max' => $max1,
					'highmax' => $max2,
				], $what )->numParams( $min ?? '', $max ?? '' ),
				$name, $origValue, $settings, $options,
				empty( $settings[self::PARAM_IGNORE_RANGE] )
			);
		}

		return $value;
	}

	/**
	 * @inheritDoc
	 * @stable to override
	 */
	public function normalizeSettings( array $settings ) {
		if ( !isset( $settings[self::PARAM_MAX] ) ) {
			unset( $settings[self::PARAM_MAX2] );
		}

		if ( isset( $settings[self::PARAM_MAX2] ) && isset( $settings[self::PARAM_MAX] ) &&
			$settings[self::PARAM_MAX2] < $settings[self::PARAM_MAX]
		) {
			$settings[self::PARAM_MAX2] = $settings[self::PARAM_MAX];
		}

		return parent::normalizeSettings( $settings );
	}

	/**
	 * @inheritDoc
	 * @stable to override
	 */
	public function checkSettings( string $name, $settings, array $options, array $ret ): array {
		$ret = parent::checkSettings( $name, $settings, $options, $ret );

		$ret['allowedKeys'] = array_merge( $ret['allowedKeys'], [
			self::PARAM_IGNORE_RANGE, self::PARAM_MIN, self::PARAM_MAX, self::PARAM_MAX2,
		] );

		if ( !is_bool( $settings[self::PARAM_IGNORE_RANGE] ?? false ) ) {
			$ret['issues'][self::PARAM_IGNORE_RANGE] = 'PARAM_IGNORE_RANGE must be boolean, got '
				. gettype( $settings[self::PARAM_IGNORE_RANGE] );
		}

		$min = $settings[self::PARAM_MIN] ?? null;
		$max = $settings[self::PARAM_MAX] ?? null;
		$max2 = $settings[self::PARAM_MAX2] ?? null;
		if ( $min !== null && gettype( $min ) !== $this->valueType ) {
			$ret['issues'][self::PARAM_MIN] = "PARAM_MIN must be $this->valueType, got " . gettype( $min );
		}
		if ( $max !== null && gettype( $max ) !== $this->valueType ) {
			$ret['issues'][self::PARAM_MAX] = "PARAM_MAX must be $this->valueType, got " . gettype( $max );
		}
		if ( $max2 !== null && gettype( $max2 ) !== $this->valueType ) {
			$ret['issues'][self::PARAM_MAX2] = "PARAM_MAX2 must be $this->valueType, got "
				. gettype( $max2 );
		}

		if ( $min !== null && $max !== null && $min > $max ) {
			$ret['issues'][] = "PARAM_MIN must be less than or equal to PARAM_MAX, but $min > $max";
		}
		if ( $max2 !== null ) {
			if ( $max === null ) {
				$ret['issues'][] = 'PARAM_MAX2 cannot be used without PARAM_MAX';
			} elseif ( $max2 < $max ) {
				$ret['issues'][] = "PARAM_MAX2 must be greater than or equal to PARAM_MAX, but $max2 < $max";
			}
		}

		return $ret;
	}

	/**
	 * @inheritDoc
	 * @stable to override
	 */
	public function getParamInfo( $name, array $settings, array $options ) {
		$info = parent::getParamInfo( $name, $settings, $options );

		$info['min'] = $settings[self::PARAM_MIN] ?? null;
		$info['max'] = $settings[self::PARAM_MAX] ?? null;
		$info['highmax'] = $settings[self::PARAM_MAX2] ?? $info['max'];
		if ( $info['max'] === null || $info['highmax'] <= $info['max'] ) {
			unset( $info['highmax'] );
		}

		return $info;
	}

	/**
	 * @inheritDoc
	 * @stable to override
	 */
	public function getHelpInfo( $name, array $settings, array $options ) {
		$info = parent::getHelpInfo( $name, $settings, $options );

		$min = '−∞';
		$max = '∞';
		$msg = '';
		if ( isset( $settings[self::PARAM_MIN] ) ) {
			$msg .= 'min';
			$min = new ScalarParam( ParamType::NUM, $settings[self::PARAM_MIN] );
		}
		if ( isset( $settings[self::PARAM_MAX] ) ) {
			$msg .= 'max';
			$max = $settings[self::PARAM_MAX];
			if ( isset( $settings[self::PARAM_MAX2] ) && $settings[self::PARAM_MAX2] > $max &&
				$this->callbacks->useHighLimits( $options )
			) {
				$max = $settings[self::PARAM_MAX2];
			}
			$max = new ScalarParam( ParamType::NUM, $max );
		}
		if ( $msg !== '' ) {
			$isMulti = !empty( $settings[ParamValidator::PARAM_ISMULTI] );

			// Messages: paramvalidator-help-type-number-min, paramvalidator-help-type-number-max,
			// paramvalidator-help-type-number-minmax
			$info[self::PARAM_MIN] = MessageValue::new( "paramvalidator-help-type-number-$msg" )
				->params( $isMulti ? 2 : 1, $min, $max );
		}

		return $info;
	}

}
ParamValidator/TypeDef/UploadDef.php000066600000011677151335023620013406 0ustar00<?php

namespace Wikimedia\ParamValidator\TypeDef;

use InvalidArgumentException;
use Psr\Http\Message\UploadedFileInterface;
use UnexpectedValueException;
use Wikimedia\Message\MessageValue;
use Wikimedia\ParamValidator\ParamValidator;
use Wikimedia\ParamValidator\TypeDef;
use Wikimedia\ParamValidator\Util\UploadedFile;

/**
 * Type definition for upload types
 *
 * The result from validate() is an object implementing UploadedFileInterface.
 *
 * Failure codes:
 *  - 'badupload': The upload is not valid. Data:
 *     - 'code': A value indicating why the upload was not valid:
 *       - 'inisize': The upload exceeded the maximum in php.ini.
 *       - 'formsize': The upload exceeded the maximum in the form post.
 *       - 'partial': The file was only partially uploaded.
 *       - 'nofile': There was no file.
 *       - 'notmpdir': PHP has no temporary directory to store the upload.
 *       - 'cantwrite': PHP could not store the upload.
 *       - 'phpext': A PHP extension rejected the upload.
 *       - 'notupload': The field was present in the submission but was not encoded as an upload.
 *     - 'size': The configured size (in bytes), if 'code' is 'inisize'.
 *
 * @since 1.34
 * @unstable
 */
class UploadDef extends TypeDef {

	public function getValue( $name, array $settings, array $options ) {
		$ret = $this->callbacks->getUploadedFile( $name, $options );

		if ( $ret && $ret->getError() === UPLOAD_ERR_NO_FILE &&
			!$this->callbacks->hasParam( $name, $options )
		) {
			// This seems to be that the client explicitly specified "no file" for the field
			// instead of just omitting the field completely. DWTM.
			$ret = null;
		} elseif ( !$ret && $this->callbacks->hasParam( $name, $options ) ) {
			// The client didn't format their upload properly so it came in as an ordinary
			// field. Convert it to an error.
			$ret = new UploadedFile( [
				'name' => '',
				'type' => '',
				'tmp_name' => '',
				'error' => -42, // PHP's UPLOAD_ERR_* are all positive numbers.
				'size' => 0,
			] );
		}

		return $ret;
	}

	/**
	 * Fetch the value of PHP's upload_max_filesize ini setting
	 *
	 * This method exists so it can be mocked by unit tests that can't
	 * affect ini_get() directly.
	 *
	 * @codeCoverageIgnore
	 * @return string|false
	 */
	protected function getIniSize() {
		return ini_get( 'upload_max_filesize' );
	}

	public function validate( $name, $value, array $settings, array $options ) {
		static $codemap = [
			-42 => 'notupload', // Local from getValue()
			UPLOAD_ERR_FORM_SIZE => 'formsize',
			UPLOAD_ERR_PARTIAL => 'partial',
			UPLOAD_ERR_NO_FILE => 'nofile',
			UPLOAD_ERR_NO_TMP_DIR => 'notmpdir',
			UPLOAD_ERR_CANT_WRITE => 'cantwrite',
			UPLOAD_ERR_EXTENSION => 'phpext',
		];

		if ( !$value instanceof UploadedFileInterface ) {
			// Err?
			$type = is_object( $value ) ? get_class( $value ) : gettype( $value );
			throw new InvalidArgumentException( "\$value must be UploadedFileInterface, got $type" );
		}

		$err = $value->getError();
		if ( $err === UPLOAD_ERR_OK ) {
			return $value;
		} elseif ( $err === UPLOAD_ERR_INI_SIZE ) {
			static $prefixes = [
				'g' => 1024 ** 3,
				'm' => 1024 ** 2,
				'k' => 1024 ** 1,
			];
			$size = $this->getIniSize();
			$last = strtolower( substr( $size, -1 ) );
			$size = intval( $size, 10 ) * ( $prefixes[$last] ?? 1 );
			$this->failure(
				$this->failureMessage( 'badupload', [
					'code' => 'inisize',
					'size' => $size,
				], 'inisize' )->sizeParams( $size ),
				$name, '', $settings, $options
			);
		} elseif ( isset( $codemap[$err] ) ) {
			$this->failure(
				$this->failureMessage( 'badupload', [ 'code' => $codemap[$err] ], $codemap[$err] ),
				$name, '', $settings, $options
			);
		} else {
			$constant = '';
			foreach ( get_defined_constants() as $c => $v ) {
				// @phan-suppress-next-line PhanTypeComparisonFromArray
				if ( $v === $err && str_starts_with( $c, 'UPLOAD_ERR_' ) ) {
					$constant = " ($c?)";
				}
			}
			throw new UnexpectedValueException( "Unrecognized PHP upload error value $err$constant" );
		}
	}

	public function checkSettings( string $name, $settings, array $options, array $ret ): array {
		$ret = parent::checkSettings( $name, $settings, $options, $ret );

		if ( isset( $settings[ParamValidator::PARAM_DEFAULT] ) ) {
			$ret['issues'][ParamValidator::PARAM_DEFAULT] =
				'Cannot specify a default for upload-type parameters';
		}

		if ( !empty( $settings[ParamValidator::PARAM_ISMULTI] ) &&
			!isset( $ret['issues'][ParamValidator::PARAM_ISMULTI] )
		) {
			$ret['issues'][ParamValidator::PARAM_ISMULTI] =
				'PARAM_ISMULTI cannot be used for upload-type parameters';
		}

		return $ret;
	}

	public function stringifyValue( $name, $value, array $settings, array $options ) {
		// Not going to happen.
		return null;
	}

	public function getHelpInfo( $name, array $settings, array $options ) {
		$info = parent::getHelpInfo( $name, $settings, $options );

		$info[ParamValidator::PARAM_TYPE] = MessageValue::new( 'paramvalidator-help-type-upload' );

		return $info;
	}

}
ParamValidator/TypeDef/TimestampDef.php000066600000011311151335023620014106 0ustar00<?php

namespace Wikimedia\ParamValidator\TypeDef;

use InvalidArgumentException;
use Wikimedia\Message\MessageValue;
use Wikimedia\ParamValidator\Callbacks;
use Wikimedia\ParamValidator\ParamValidator;
use Wikimedia\ParamValidator\TypeDef;
use Wikimedia\ParamValidator\ValidationException;
use Wikimedia\Timestamp\ConvertibleTimestamp;
use Wikimedia\Timestamp\TimestampException;

/**
 * Type definition for timestamp types
 *
 * This uses the wikimedia/timestamp library for parsing and formatting the
 * timestamps.
 *
 * The result from validate() is a ConvertibleTimestamp by default, but this
 * may be changed by both a constructor option and a PARAM constant.
 *
 * Failure codes:
 *  - 'badtimestamp': The timestamp is not valid. No data, but the
 *    TimestampException is available via Exception::getPrevious().
 *  - 'unclearnowtimestamp': Non-fatal. The value is the empty string or "0".
 *    Use 'now' instead if you really want the current timestamp. No data.
 *
 * @since 1.34
 * @unstable
 */
class TimestampDef extends TypeDef {

	/**
	 * (string|int) Timestamp format to return from validate()
	 *
	 * Values include:
	 *  - 'ConvertibleTimestamp': A ConvertibleTimestamp object.
	 *  - 'DateTime': A PHP DateTime object
	 *  - One of ConvertibleTimestamp's TS_* constants.
	 *
	 * This does not affect the format returned by stringifyValue().
	 */
	public const PARAM_TIMESTAMP_FORMAT = 'param-timestamp-format';

	/** @var string|int */
	protected $defaultFormat;

	/** @var int */
	protected $stringifyFormat;

	/**
	 * @param Callbacks $callbacks
	 * @param array $options Options:
	 *  - defaultFormat: (string|int) Default for PARAM_TIMESTAMP_FORMAT.
	 *    Default if not specified is 'ConvertibleTimestamp'.
	 *  - stringifyFormat: (int) Format to use for stringifyValue().
	 *    Default is TS_ISO_8601.
	 */
	public function __construct( Callbacks $callbacks, array $options = [] ) {
		parent::__construct( $callbacks );

		$this->defaultFormat = $options['defaultFormat'] ?? 'ConvertibleTimestamp';
		$this->stringifyFormat = $options['stringifyFormat'] ?? TS_ISO_8601;

		// Check values by trying to convert 0
		if ( $this->defaultFormat !== 'ConvertibleTimestamp' && $this->defaultFormat !== 'DateTime' &&
			ConvertibleTimestamp::convert( $this->defaultFormat, 0 ) === false
		) {
			throw new InvalidArgumentException( 'Invalid value for $options[\'defaultFormat\']' );
		}
		if ( ConvertibleTimestamp::convert( $this->stringifyFormat, 0 ) === false ) {
			throw new InvalidArgumentException( 'Invalid value for $options[\'stringifyFormat\']' );
		}
	}

	public function validate( $name, $value, array $settings, array $options ) {
		// Confusing synonyms for the current time accepted by ConvertibleTimestamp
		if ( !$value ) {
			$this->failure( 'unclearnowtimestamp', $name, $value, $settings, $options, false );
			$value = 'now';
		}

		$format = $settings[self::PARAM_TIMESTAMP_FORMAT] ?? $this->defaultFormat;

		try {
			$timestampObj = new ConvertibleTimestamp( $value === 'now' ? false : $value );

			$timestamp = ( $format !== 'ConvertibleTimestamp' && $format !== 'DateTime' )
				? $timestampObj->getTimestamp( $format )
				: null;
		} catch ( TimestampException $ex ) {
			// $this->failure() doesn't handle passing a previous exception
			throw new ValidationException(
				$this->failureMessage( 'badtimestamp' )->plaintextParams( $name, $value ),
				$name, $value, $settings, $ex
			);
		}

		switch ( $format ) {
			case 'ConvertibleTimestamp':
				return $timestampObj;

			case 'DateTime':
				// Eew, no getter.
				return $timestampObj->timestamp;

			default:
				return $timestamp;
		}
	}

	public function checkSettings( string $name, $settings, array $options, array $ret ): array {
		$ret = parent::checkSettings( $name, $settings, $options, $ret );

		$ret['allowedKeys'] = array_merge( $ret['allowedKeys'], [
			self::PARAM_TIMESTAMP_FORMAT,
		] );

		$f = $settings[self::PARAM_TIMESTAMP_FORMAT] ?? $this->defaultFormat;
		if ( $f !== 'ConvertibleTimestamp' && $f !== 'DateTime' &&
			ConvertibleTimestamp::convert( $f, 0 ) === false
		) {
			$ret['issues'][self::PARAM_TIMESTAMP_FORMAT] = 'Value for PARAM_TIMESTAMP_FORMAT is not valid';
		}

		return $ret;
	}

	public function stringifyValue( $name, $value, array $settings, array $options ) {
		if ( !$value instanceof ConvertibleTimestamp ) {
			$value = new ConvertibleTimestamp( $value );
		}
		return $value->getTimestamp( $this->stringifyFormat );
	}

	public function getHelpInfo( $name, array $settings, array $options ) {
		$info = parent::getHelpInfo( $name, $settings, $options );

		$info[ParamValidator::PARAM_TYPE] = MessageValue::new( 'paramvalidator-help-type-timestamp' )
			->params( empty( $settings[ParamValidator::PARAM_ISMULTI] ) ? 1 : 2 );

		return $info;
	}

}
ParamValidator/TypeDef/StringDef.php000066600000012170151335023620013415 0ustar00<?php

namespace Wikimedia\ParamValidator\TypeDef;

use Wikimedia\Message\MessageValue;
use Wikimedia\ParamValidator\Callbacks;
use Wikimedia\ParamValidator\ParamValidator;
use Wikimedia\ParamValidator\TypeDef;

/**
 * Type definition for string types
 *
 * The result from validate() is a PHP string.
 *
 * Failure codes:
 *  - 'missingparam': The parameter is the empty string (and that's not allowed). No data.
 *
 * Additional codes may be generated when using certain PARAM constants. See
 * the constants' documentation for details.
 *
 * @since 1.34
 * @unstable
 */
class StringDef extends TypeDef {

	/**
	 * (integer) Maximum length of a string in bytes.
	 *
	 * Failure codes:
	 *  - 'maxbytes': The string is too long. Data:
	 *     - 'maxbytes': The maximum number of bytes allowed, or null if no limit
	 *     - 'maxchars': The maximum number of characters allowed, or null if no limit
	 */
	public const PARAM_MAX_BYTES = 'param-max-bytes';

	/**
	 * (integer) Maximum length of a string in characters (Unicode codepoints).
	 *
	 * The string is assumed to be encoded as UTF-8.
	 *
	 * Failure codes:
	 *  - 'maxchars': The string is too long. Data:
	 *     - 'maxbytes': The maximum number of bytes allowed, or null if no limit
	 *     - 'maxchars': The maximum number of characters allowed, or null if no limit
	 */
	public const PARAM_MAX_CHARS = 'param-max-chars';

	protected $allowEmptyWhenRequired = false;

	/**
	 * @param Callbacks $callbacks
	 * @param array $options Options:
	 *  - allowEmptyWhenRequired: (bool) Whether to reject the empty string when PARAM_REQUIRED.
	 *    Defaults to false.
	 */
	public function __construct( Callbacks $callbacks, array $options = [] ) {
		parent::__construct( $callbacks );

		$this->allowEmptyWhenRequired = !empty( $options['allowEmptyWhenRequired'] );
	}

	public function validate( $name, $value, array $settings, array $options ) {
		if ( !$this->allowEmptyWhenRequired && $value === '' &&
			!empty( $settings[ParamValidator::PARAM_REQUIRED] )
		) {
			$this->failure( 'missingparam', $name, $value, $settings, $options );
		}

		$len = strlen( $value );
		if ( isset( $settings[self::PARAM_MAX_BYTES] ) && $len > $settings[self::PARAM_MAX_BYTES] ) {
			$this->failure(
				$this->failureMessage( 'maxbytes', [
					'maxbytes' => $settings[self::PARAM_MAX_BYTES] ?? null,
					'maxchars' => $settings[self::PARAM_MAX_CHARS] ?? null,
				] )->numParams( $settings[self::PARAM_MAX_BYTES], $len ),
				$name, $value, $settings, $options
			);
		}
		$len = mb_strlen( $value, 'UTF-8' );
		if ( isset( $settings[self::PARAM_MAX_CHARS] ) && $len > $settings[self::PARAM_MAX_CHARS] ) {
			$this->failure(
				$this->failureMessage( 'maxchars', [
					'maxbytes' => $settings[self::PARAM_MAX_BYTES] ?? null,
					'maxchars' => $settings[self::PARAM_MAX_CHARS] ?? null,
				] )->numParams( $settings[self::PARAM_MAX_CHARS], $len ),
				$name, $value, $settings, $options
			);
		}

		return $value;
	}

	public function checkSettings( string $name, $settings, array $options, array $ret ): array {
		$ret = parent::checkSettings( $name, $settings, $options, $ret );

		$ret['allowedKeys'] = array_merge( $ret['allowedKeys'], [
			self::PARAM_MAX_BYTES, self::PARAM_MAX_CHARS,
		] );

		$maxb = $settings[self::PARAM_MAX_BYTES] ?? PHP_INT_MAX;
		if ( !is_int( $maxb ) ) {
			$ret['issues'][self::PARAM_MAX_BYTES] = 'PARAM_MAX_BYTES must be an integer, got '
				. gettype( $maxb );
		} elseif ( $maxb < 0 ) {
			$ret['issues'][self::PARAM_MAX_BYTES] = 'PARAM_MAX_BYTES must be greater than or equal to 0';
		}

		$maxc = $settings[self::PARAM_MAX_CHARS] ?? PHP_INT_MAX;
		if ( !is_int( $maxc ) ) {
			$ret['issues'][self::PARAM_MAX_CHARS] = 'PARAM_MAX_CHARS must be an integer, got '
				. gettype( $maxc );
		} elseif ( $maxc < 0 ) {
			$ret['issues'][self::PARAM_MAX_CHARS] = 'PARAM_MAX_CHARS must be greater than or equal to 0';
		}

		if ( !$this->allowEmptyWhenRequired && !empty( $settings[ParamValidator::PARAM_REQUIRED] ) ) {
			if ( $maxb === 0 ) {
				$ret['issues'][] = 'PARAM_REQUIRED is set, allowEmptyWhenRequired is not set, and '
					. 'PARAM_MAX_BYTES is 0. That\'s impossible to satisfy.';
			}
			if ( $maxc === 0 ) {
				$ret['issues'][] = 'PARAM_REQUIRED is set, allowEmptyWhenRequired is not set, and '
					. 'PARAM_MAX_CHARS is 0. That\'s impossible to satisfy.';
			}
		}

		return $ret;
	}

	public function getParamInfo( $name, array $settings, array $options ) {
		$info = parent::getParamInfo( $name, $settings, $options );

		$info['maxbytes'] = $settings[self::PARAM_MAX_BYTES] ?? null;
		$info['maxchars'] = $settings[self::PARAM_MAX_CHARS] ?? null;

		return $info;
	}

	public function getHelpInfo( $name, array $settings, array $options ) {
		$info = parent::getHelpInfo( $name, $settings, $options );

		if ( isset( $settings[self::PARAM_MAX_BYTES] ) ) {
			$info[self::PARAM_MAX_BYTES] = MessageValue::new( 'paramvalidator-help-type-string-maxbytes' )
				->numParams( $settings[self::PARAM_MAX_BYTES] );
		}
		if ( isset( $settings[self::PARAM_MAX_CHARS] ) ) {
			$info[self::PARAM_MAX_CHARS] = MessageValue::new( 'paramvalidator-help-type-string-maxchars' )
				->numParams( $settings[self::PARAM_MAX_CHARS] );
		}

		return $info;
	}

}
ParamValidator/TypeDef/ExpiryDef.php000066600000012731151335023620013432 0ustar00<?php

namespace Wikimedia\ParamValidator\TypeDef;

use InvalidArgumentException;
use Wikimedia\Message\DataMessageValue;
use Wikimedia\Message\MessageValue;
use Wikimedia\ParamValidator\ParamValidator;
use Wikimedia\ParamValidator\TypeDef;
use Wikimedia\Timestamp\ConvertibleTimestamp;

/**
 * Type definition for expiry timestamps.
 *
 * @since 1.35
 */
class ExpiryDef extends TypeDef {

	/** @var array Possible values that mean "doesn't expire". */
	public const INFINITY_VALS = [ 'infinite', 'indefinite', 'infinity', 'never' ];

	/**
	 * (bool) If truthy, the value given for the PARAM_MAX setting is used if the provided expiry
	 * exceeds it, and the 'badexpiry-duration' message is shown as a warning.
	 *
	 * If false, 'badexpiry-duration' is shown and is fatal.
	 */
	public const PARAM_USE_MAX = 'param-use-max';

	/**
	 * (int|float) Maximum non-infinity duration.
	 */
	public const PARAM_MAX = 'param-max';

	public function validate( $name, $value, array $settings, array $options ) {
		try {
			$expiry = self::normalizeExpiry( $value, TS_ISO_8601 );
		} catch ( InvalidArgumentException $e ) {
			$this->failure( 'badexpiry', $name, $value, $settings, $options );
		}

		if ( $expiry !== 'infinity' && $expiry < ConvertibleTimestamp::now( TS_ISO_8601 ) ) {
			$this->failure( 'badexpiry-past', $name, $value, $settings, $options );
		}

		$max = $settings[self::PARAM_MAX] ?? null;

		if ( self::expiryExceedsMax( $expiry, $max ) ) {
			$dontUseMax = empty( $settings[self::PARAM_USE_MAX] );
			// Show warning that expiry exceeds the max, and that the max is being used instead.
			$msg = DataMessageValue::new(
				$dontUseMax
					? 'paramvalidator-badexpiry-duration'
					: 'paramvalidator-badexpiry-duration-max',
				[ $max ]
			);
			$this->failure( $msg, $name, $value, $settings, $options, $dontUseMax );

			return self::normalizeExpiry( $max, TS_ISO_8601 );
		}

		return $expiry;
	}

	public function getHelpInfo( $name, array $settings, array $options ) {
		$info = parent::getHelpInfo( $name, $settings, $options );

		$info[ParamValidator::PARAM_TYPE] = MessageValue::new( 'paramvalidator-help-type-expiry' )
			->params( empty( $settings[ParamValidator::PARAM_ISMULTI] ) ? 1 : 2 )
			->textListParams(
				// Should be quoted or monospace for presentation purposes,
				//   but textListParams() doesn't do this.
				array_map( static function ( $val ) {
					return "\"$val\"";
				}, self::INFINITY_VALS )
			);

		return $info;
	}

	/**
	 * Normalize a user-inputted expiry in ConvertibleTimestamp.
	 * @param string|null $expiry
	 * @param int|null $style null or in a format acceptable to ConvertibleTimestamp (TS_* constants)
	 *
	 * @return ConvertibleTimestamp|string|null Timestamp as ConvertibleTimestamp if $style is null, a string
	 *  timestamp in $style is not null, 'infinity' if $expiry is one of the self::INFINITY_VALS,
	 *  or null if $expiry is null.
	 *
	 * @throws InvalidArgumentException if $expiry is invalid
	 */
	public static function normalizeExpiry( ?string $expiry = null, ?int $style = null ) {
		if ( $expiry === null ) {
			return null;
		}
		if ( in_array( $expiry, self::INFINITY_VALS, true ) ) {
			return 'infinity';
		}

		// ConvertibleTimestamp::time() used so we can fake the current time in ExpiryDefTest.
		$unix = strtotime( $expiry, ConvertibleTimestamp::time() );
		if ( $unix === false ) {
			// Invalid expiry.
			throw new InvalidArgumentException( "Invalid expiry value: {$expiry}" );
		}

		// Don't pass 0, since ConvertibleTimestamp interprets that to mean the current timestamp.
		// '00' does the right thing. Without this check, calling normalizeExpiry()
		// with 1970-01-01T00:00:00Z incorrectly returns the current time.
		$expiryConvertibleTimestamp = new ConvertibleTimestamp( $unix === 0 ? '00' : $unix );

		if ( $style !== null ) {
			return $expiryConvertibleTimestamp->getTimestamp( $style );
		}

		return $expiryConvertibleTimestamp;
	}

	/**
	 * Returns a normalized expiry or the max expiry if the given expiry exceeds it.
	 * @param string|null $expiry
	 * @param string|null $maxExpiryDuration
	 * @param int|null $style null or in a format acceptable to ConvertibleTimestamp (TS_* constants)
	 * @return ConvertibleTimestamp|string|null Timestamp as ConvertibleTimestamp if $style is null, a string
	 *  timestamp in $style is not null, 'infinity' if $expiry is one of the self::INFINITY_VALS,
	 *  or null if $expiry is null.
	 */
	public static function normalizeUsingMaxExpiry( ?string $expiry, ?string $maxExpiryDuration, ?int $style ) {
		if ( self::expiryExceedsMax( $expiry, $maxExpiryDuration ) ) {
			return self::normalizeExpiry( $maxExpiryDuration, $style );
		}
		return self::normalizeExpiry( $expiry, $style );
	}

	public function checkSettings( string $name, $settings, array $options, array $ret ): array {
		$ret = parent::checkSettings( $name, $settings, $options, $ret );

		$ret['allowedKeys'][] = self::PARAM_USE_MAX;
		$ret['allowedKeys'][] = self::PARAM_MAX;

		return $ret;
	}

	/**
	 * Given an expiry, test if the normalized value exceeds the given maximum.
	 *
	 * @param string|null $expiry
	 * @param string|null $max Relative maximum duration acceptable by strtotime() (i.e. '6 months')
	 * @return bool
	 */
	private static function expiryExceedsMax( ?string $expiry, ?string $max = null ): bool {
		$expiry = self::normalizeExpiry( $expiry );
		$max = self::normalizeExpiry( $max );

		if ( !$max || !$expiry || $expiry === 'infinity' ) {
			// Either there is no max or given expiry was invalid.
			return false;
		}

		return $expiry > $max;
	}

}
ParamValidator/TypeDef/PasswordDef.php000066600000001624151335023620013753 0ustar00<?php

namespace Wikimedia\ParamValidator\TypeDef;

use Wikimedia\ParamValidator\ParamValidator;

/**
 * Type definition for "password" types
 *
 * This is a string type that forces PARAM_SENSITIVE = true.
 *
 * @see StringDef
 * @since 1.34
 * @unstable
 */
class PasswordDef extends StringDef {

	public function normalizeSettings( array $settings ) {
		$settings[ParamValidator::PARAM_SENSITIVE] = true;
		return parent::normalizeSettings( $settings );
	}

	public function checkSettings( string $name, $settings, array $options, array $ret ): array {
		$ret = parent::checkSettings( $name, $settings, $options, $ret );

		if ( ( $settings[ParamValidator::PARAM_SENSITIVE] ?? true ) !== true &&
			!isset( $ret['issues'][ParamValidator::PARAM_SENSITIVE] )
		) {
			$ret['issues'][ParamValidator::PARAM_SENSITIVE] =
				'Cannot set PARAM_SENSITIVE to false for password-type parameters';
		}

		return $ret;
	}

}
ParamValidator/TypeDef/IntegerDef.php000066600000003311151335023620013541 0ustar00<?php

namespace Wikimedia\ParamValidator\TypeDef;

use Wikimedia\Message\MessageValue;
use Wikimedia\ParamValidator\ParamValidator;

/**
 * Type definition for integer types
 *
 * A valid representation consists of an optional sign (`+` or `-`) followed by
 * one or more decimal digits.
 *
 * The result from validate() is a PHP integer.
 *
 * * Failure codes:
 *  - 'badinteger': The value was invalid or could not be represented as a PHP
 *    integer. No data.
 *
 * @since 1.34
 * @unstable
 */
class IntegerDef extends NumericDef {

	public function validate( $name, $value, array $settings, array $options ) {
		if ( is_array( $value ) || !preg_match( '/^[+-]?\d+$/D', $value ) ) {
			$this->failure( 'badinteger', $name, $value, $settings, $options );
		}
		$ret = intval( $value, 10 );

		// intval() returns min/max on overflow, so check that
		if ( $ret === PHP_INT_MAX || $ret === PHP_INT_MIN ) {
			$tmp = ( $ret < 0 ? '-' : '' ) . ltrim( $value, '-0' );
			if ( $tmp !== (string)$ret ) {
				$this->failure( 'badinteger', $name, $value, $settings, $options );
			}
		}

		return $this->checkRange( $ret, $name, $value, $settings, $options );
	}

	public function getHelpInfo( $name, array $settings, array $options ) {
		$info = parent::getHelpInfo( $name, $settings, $options );

		$info[ParamValidator::PARAM_TYPE] = MessageValue::new( 'paramvalidator-help-type-integer' )
			->params( empty( $settings[ParamValidator::PARAM_ISMULTI] ) ? 1 : 2 );

		return $info;
	}

	public function stringifyValue( $name, $value, array $settings, array $options ) {
		if ( !is_array( $value ) ) {
			return parent::stringifyValue( $name, $value, $settings, $options );
		}

		return ParamValidator::implodeMultiValue( $value );
	}

}
ParamValidator/TypeDef.php000066600000021471151335023620011534 0ustar00<?php

namespace Wikimedia\ParamValidator;

use Wikimedia\Message\DataMessageValue;
use Wikimedia\Message\MessageValue;

/**
 * Base definition for ParamValidator types.
 *
 * Most methods in this class accept an "options array". This is just the `$options`
 * passed to ParamValidator::getValue(), ParamValidator::validateValue(), and the like
 * and is intended for communication of non-global state to the Callbacks.
 *
 * @stable to extend
 * @since 1.34
 * @unstable
 */
abstract class TypeDef {

	/** @var Callbacks */
	protected $callbacks;

	/**
	 * @stable to call
	 *
	 * @param Callbacks $callbacks
	 */
	public function __construct( Callbacks $callbacks ) {
		$this->callbacks = $callbacks;
	}

	/**
	 * Record a failure message
	 *
	 * Depending on `$fatal`, this will either throw a ValidationException or
	 * call $this->callbacks->recordCondition().
	 *
	 * Note that parameters for `$name` and `$value` are always added as `$1`
	 * and `$2`.
	 *
	 * @param DataMessageValue|string $failure Failure code or message.
	 * @param string $name Parameter name being validated.
	 * @param mixed $value Value being validated.
	 * @param array $settings Parameter settings array.
	 * @param array $options Options array.
	 * @param bool $fatal Whether the failure is fatal
	 */
	protected function failure(
		$failure, $name, $value, array $settings, array $options, $fatal = true
	) {
		if ( !is_string( $value ) ) {
			$value = (string)$this->stringifyValue( $name, $value, $settings, $options );
		}

		if ( is_string( $failure ) ) {
			$mv = $this->failureMessage( $failure )
				->plaintextParams( $name, $value );
		} else {
			$mv = DataMessageValue::new( $failure->getKey(), [], $failure->getCode(), $failure->getData() )
				->plaintextParams( $name, $value )
				->params( ...$failure->getParams() );
		}

		if ( $fatal ) {
			throw new ValidationException( $mv, $name, $value, $settings );
		}
		$this->callbacks->recordCondition( $mv, $name, $value, $settings, $options );
	}

	/**
	 * Create a DataMessageValue representing a failure
	 *
	 * The message key will be "paramvalidator-$code" or "paramvalidator-$code-$suffix".
	 *
	 * Use DataMessageValue's param mutators to add additional MessageParams.
	 * Note that `failure()` will prepend parameters for `$name` and `$value`.
	 *
	 * @param string $code Failure code.
	 * @param array|null $data Failure data.
	 * @param string|null $suffix Suffix to append when producing the message key
	 * @return DataMessageValue
	 */
	protected function failureMessage( $code, array $data = null, $suffix = null ): DataMessageValue {
		return DataMessageValue::new(
			"paramvalidator-$code" . ( $suffix !== null ? "-$suffix" : '' ),
			[], $code, $data
		);
	}

	/**
	 * Get the value from the request
	 * @stable to override
	 *
	 * @note Only override this if you need to use something other than
	 *  $this->callbacks->getValue() to fetch the value. Reformatting from a
	 *  string should typically be done by self::validate().
	 * @note Handling of ParamValidator::PARAM_DEFAULT should be left to ParamValidator,
	 *  as should PARAM_REQUIRED and the like.
	 *
	 * @param string $name Parameter name being fetched.
	 * @param array $settings Parameter settings array.
	 * @param array $options Options array.
	 * @return null|mixed Return null if the value wasn't present, otherwise a
	 *  value to be passed to self::validate().
	 */
	public function getValue( $name, array $settings, array $options ) {
		return $this->callbacks->getValue( $name, null, $options );
	}

	/**
	 * Validate the value
	 *
	 * When ParamValidator is processing a multi-valued parameter, this will be
	 * called once for each of the supplied values. Which may mean zero calls.
	 *
	 * When getValue() returned null, this will not be called.
	 *
	 * @param string $name Parameter name being validated.
	 * @param mixed $value Value to validate, from getValue().
	 * @param array $settings Parameter settings array.
	 * @param array $options Options array. Note the following values that may be set
	 *  by ParamValidator:
	 *   - is-default: (bool) If present and true, the value was taken from PARAM_DEFAULT rather
	 *     that being supplied by the client.
	 *   - values-list: (string[]) If defined, values of a multi-valued parameter are being processed
	 *     (and this array holds the full set of values).
	 * @return mixed Validated value
	 * @throws ValidationException if the value is invalid
	 */
	abstract public function validate( $name, $value, array $settings, array $options );

	/**
	 * Normalize a settings array
	 * @stable to override
	 * @param array $settings
	 * @return array
	 */
	public function normalizeSettings( array $settings ) {
		return $settings;
	}

	/**
	 * Validate a parameter settings array
	 *
	 * This is intended for validation of parameter settings during unit or
	 * integration testing, and should implement strict checks.
	 *
	 * The rest of the code should generally be more permissive.
	 *
	 * @see ParamValidator::checkSettings()
	 * @stable to override
	 *
	 * @param string $name Parameter name
	 * @param array|mixed $settings Default value or an array of settings
	 *  using PARAM_* constants.
	 * @param array $options Options array, passed through to the TypeDef and Callbacks.
	 * @param array $ret
	 *  - 'issues': (string[]) Errors detected in $settings, as English text. If the settings
	 *    are valid, this will be the empty array. Keys on input are ParamValidator constants,
	 *    allowing the typedef to easily override core validation; this need not be preserved
	 *    when returned.
	 *  - 'allowedKeys': (string[]) ParamValidator keys that are allowed in `$settings`.
	 *  - 'messages': (MessageValue[]) Messages to be checked for existence.
	 * @return array $ret, with any relevant changes.
	 */
	public function checkSettings( string $name, $settings, array $options, array $ret ): array {
		return $ret;
	}

	/**
	 * Get the values for enum-like parameters
	 *
	 * This is primarily intended for documentation and implementation of
	 * PARAM_ALL; it is the responsibility of the TypeDef to ensure that validate()
	 * accepts the values returned here.
	 * @stable to override
	 *
	 * @param string $name Parameter name being validated.
	 * @param array $settings Parameter settings array.
	 * @param array $options Options array.
	 * @return array|null All possible enumerated values, or null if this is
	 *  not an enumeration.
	 */
	public function getEnumValues( $name, array $settings, array $options ) {
		return null;
	}

	/**
	 * Convert a value to a string representation.
	 *
	 * This is intended as the inverse of getValue() and validate(): this
	 * should accept anything returned by those methods or expected to be used
	 * as PARAM_DEFAULT, and if the string from this method is passed in as client
	 * input or PARAM_DEFAULT it should give equivalent output from validate().
	 *
	 * @param string $name Parameter name being converted.
	 * @param mixed $value Parameter value being converted. Do not pass null.
	 * @param array $settings Parameter settings array.
	 * @param array $options Options array.
	 * @return string|null Return null if there is no representation of $value
	 *  reasonably satisfying the description given.
	 */
	public function stringifyValue( $name, $value, array $settings, array $options ) {
		return (string)$value;
	}

	/**
	 * Describe parameter settings in a machine-readable format.
	 *
	 * Keys should be short strings using lowercase ASCII letters. Values
	 * should generally be values that could be encoded in JSON or the like.
	 *
	 * This is intended to handle PARAM constants specific to this class. It
	 * generally shouldn't handle constants defined on ParamValidator itself.
	 * @stable to override
	 *
	 * @param string $name Parameter name.
	 * @param array $settings Parameter settings array.
	 * @param array $options Options array.
	 * @return array
	 */
	public function getParamInfo( $name, array $settings, array $options ) {
		return [];
	}

	/**
	 * Describe parameter settings in human-readable format
	 *
	 * Keys in the returned array should generally correspond to PARAM
	 * constants.
	 *
	 * If relevant, a MessageValue describing the type itself should be
	 * returned with key ParamValidator::PARAM_TYPE.
	 *
	 * The default messages for other ParamValidator-defined PARAM constants
	 * may be suppressed by returning null as the value for those constants, or
	 * replaced by returning a replacement MessageValue. Normally, however,
	 * the default messages should not be changed.
	 *
	 * MessageValues describing any other constraints applied via PARAM
	 * constants specific to this class should also be returned.
	 * @stable to override
	 *
	 * @param string $name Parameter name being described.
	 * @param array $settings Parameter settings array.
	 * @param array $options Options array.
	 * @return (MessageValue|null)[]
	 */
	public function getHelpInfo( $name, array $settings, array $options ) {
		return [];
	}

}
mime/XmlTypeCheck.php000066600000035742151335023620010563 0ustar00<?php
/**
 * XML syntax and type checker.
 *
 * Since 1.24.2, it uses XMLReader instead of xml_parse, which gives us
 * more control over the expansion of XML entities. When passed to the
 * callback, entities will be fully expanded, but may report the XML is
 * invalid if expanding the entities are likely to cause a DoS.
 *
 * 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
 */

class XmlTypeCheck {
	/**
	 * @var bool|null Will be set to true or false to indicate whether the file is
	 * well-formed XML. Note that this doesn't check schema validity.
	 */
	public $wellFormed = null;

	/**
	 * @var bool Will be set to true if the optional element filter returned
	 * a match at some point.
	 */
	public $filterMatch = false;

	/**
	 * Will contain the type of filter hit if the optional element filter returned
	 * a match at some point.
	 * @var mixed
	 */
	public $filterMatchType = false;

	/**
	 * @var string Name of the document's root element, including any namespace
	 * as an expanded URL.
	 */
	public $rootElement = '';

	/**
	 * @var string[] A stack of strings containing the data of each xml element as it's processed.
	 * Append data to the top string of the stack, then pop off the string and process it when the
	 * element is closed.
	 */
	protected $elementData = [];

	/**
	 * @var array A stack of element names and attributes, as we process them.
	 */
	protected $elementDataContext = [];

	/**
	 * @var int Current depth of the data stack.
	 */
	protected $stackDepth = 0;

	/** @var callable|null */
	protected $filterCallback;

	/**
	 * @var array Additional parsing options
	 */
	private $parserOptions = [
		'processing_instruction_handler' => null,
		'external_dtd_handler' => '',
		'dtd_handler' => '',
		'require_safe_dtd' => true
	];

	/**
	 * Allow filtering an XML file.
	 *
	 * Filters should return either true or a string to indicate something
	 * is wrong with the file. $this->filterMatch will store if the
	 * file failed validation (true = failed validation).
	 * $this->filterMatchType will contain the validation error.
	 * $this->wellFormed will contain whether the xml file is well-formed.
	 *
	 * @note If multiple filters are hit, only one of them will have the
	 *  result stored in $this->filterMatchType.
	 *
	 * @param string $input a filename or string containing the XML element
	 * @param callable|null $filterCallback (optional)
	 *        Function to call to do additional custom validity checks from the
	 *        SAX element handler event. This gives you access to the element
	 *        namespace, name, attributes, and text contents.
	 *        Filter should return a truthy value describing the error.
	 * @param bool $isFile (optional) indicates if the first parameter is a
	 *        filename (default, true) or if it is a string (false)
	 * @param array $options list of additional parsing options:
	 *        processing_instruction_handler: Callback for xml_set_processing_instruction_handler
	 *        external_dtd_handler: Callback for the url of external dtd subset
	 *        dtd_handler: Callback given the full text of the <!DOCTYPE declaration.
	 *        require_safe_dtd: Only allow non-recursive entities in internal dtd (default true)
	 */
	public function __construct( $input, $filterCallback = null, $isFile = true, $options = [] ) {
		$this->filterCallback = $filterCallback;
		$this->parserOptions = array_merge( $this->parserOptions, $options );
		$this->validateFromInput( $input, $isFile );
	}

	/**
	 * Alternative constructor: from filename
	 *
	 * @param string $fname the filename of an XML document
	 * @param callable|null $filterCallback (optional)
	 *        Function to call to do additional custom validity checks from the
	 *        SAX element handler event. This gives you access to the element
	 *        namespace, name, and attributes, but not to text contents.
	 *        Filter should return 'true' to toggle on $this->filterMatch
	 * @return XmlTypeCheck
	 */
	public static function newFromFilename( $fname, $filterCallback = null ) {
		return new self( $fname, $filterCallback, true );
	}

	/**
	 * Alternative constructor: from string
	 *
	 * @param string $string a string containing an XML element
	 * @param callable|null $filterCallback (optional)
	 *        Function to call to do additional custom validity checks from the
	 *        SAX element handler event. This gives you access to the element
	 *        namespace, name, and attributes, but not to text contents.
	 *        Filter should return 'true' to toggle on $this->filterMatch
	 * @return XmlTypeCheck
	 */
	public static function newFromString( $string, $filterCallback = null ) {
		return new self( $string, $filterCallback, false );
	}

	/**
	 * Get the root element. Simple accessor to $rootElement
	 *
	 * @return string
	 */
	public function getRootElement() {
		return $this->rootElement;
	}

	/**
	 * @param string $xml
	 * @param bool $isFile
	 */
	private function validateFromInput( $xml, $isFile ) {
		$reader = new XMLReader();
		if ( $isFile ) {
			$s = $reader->open( $xml, null, LIBXML_NOERROR | LIBXML_NOWARNING );
		} else {
			$s = $reader->XML( $xml, null, LIBXML_NOERROR | LIBXML_NOWARNING );
		}
		if ( $s !== true ) {
			// Couldn't open the XML
			$this->wellFormed = false;
		} else {
			// phpcs:ignore Generic.PHP.NoSilencedErrors -- suppress deprecation per T268847
			$oldDisable = @libxml_disable_entity_loader( true );
			$reader->setParserProperty( XMLReader::SUBST_ENTITIES, true );
			try {
				$this->validate( $reader );
			} catch ( Exception $e ) {
				// Calling this malformed, because we didn't parse the whole
				// thing. Maybe just an external entity refernce.
				$this->wellFormed = false;
				$reader->close();
				// phpcs:ignore Generic.PHP.NoSilencedErrors
				@libxml_disable_entity_loader( $oldDisable );
				throw $e;
			}
			$reader->close();
			// phpcs:ignore Generic.PHP.NoSilencedErrors
			@libxml_disable_entity_loader( $oldDisable );
		}
	}

	private function readNext( XMLReader $reader ) {
		set_error_handler( function ( $line, $file ) {
			$this->wellFormed = false;
			return true;
		} );
		$ret = $reader->read();
		restore_error_handler();
		return $ret;
	}

	private function validate( $reader ) {
		// First, move through anything that isn't an element, and
		// handle any processing instructions with the callback
		do {
			if ( !$this->readNext( $reader ) ) {
				// Hit the end of the document before any elements
				$this->wellFormed = false;
				return;
			}
			if ( $reader->nodeType === XMLReader::PI ) {
				$this->processingInstructionHandler( $reader->name, $reader->value );
			}
			if ( $reader->nodeType === XMLReader::DOC_TYPE ) {
				$this->dtdHandler( $reader );
			}
		} while ( $reader->nodeType != XMLReader::ELEMENT );

		// Process the rest of the document
		do {
			switch ( $reader->nodeType ) {
				case XMLReader::ELEMENT:
					$name = $this->expandNS(
						$reader->name,
						$reader->namespaceURI
					);
					if ( $this->rootElement === '' ) {
						$this->rootElement = $name;
					}
					$empty = $reader->isEmptyElement;
					$attrs = $this->getAttributesArray( $reader );
					$this->elementOpen( $name, $attrs );
					if ( $empty ) {
						$this->elementClose();
					}
					break;

				case XMLReader::END_ELEMENT:
					$this->elementClose();
					break;

				case XMLReader::WHITESPACE:
				case XMLReader::SIGNIFICANT_WHITESPACE:
				case XMLReader::CDATA:
				case XMLReader::TEXT:
					$this->elementData( $reader->value );
					break;

				case XMLReader::ENTITY_REF:
					// Unexpanded entity (maybe external?),
					// don't send to the filter (xml_parse didn't)
					break;

				case XMLReader::COMMENT:
					// Don't send to the filter (xml_parse didn't)
					break;

				case XMLReader::PI:
					// Processing instructions can happen after the header too
					$this->processingInstructionHandler(
						$reader->name,
						$reader->value
					);
					break;
				case XMLReader::DOC_TYPE:
					// We should never see a doctype after first
					// element.
					$this->wellFormed = false;
					break;
				default:
					// One of DOC, ENTITY, END_ENTITY,
					// NOTATION, or XML_DECLARATION
					// xml_parse didn't send these to the filter, so we won't.
			}
		} while ( $this->readNext( $reader ) );

		if ( $this->stackDepth !== 0 ) {
			$this->wellFormed = false;
		} elseif ( $this->wellFormed === null ) {
			$this->wellFormed = true;
		}
	}

	/**
	 * Get all of the attributes for an XMLReader's current node
	 * @param XMLReader $r
	 * @return array of attributes
	 */
	private function getAttributesArray( XMLReader $r ) {
		$attrs = [];
		while ( $r->moveToNextAttribute() ) {
			if ( $r->namespaceURI === 'http://www.w3.org/2000/xmlns/' ) {
				// XMLReader treats xmlns attributes as normal
				// attributes, while xml_parse doesn't
				continue;
			}
			$name = $this->expandNS( $r->name, $r->namespaceURI );
			$attrs[$name] = $r->value;
		}
		return $attrs;
	}

	/**
	 * @param string $name element or attribute name, maybe with a full or short prefix
	 * @param string $namespaceURI
	 * @return string the name prefixed with namespaceURI
	 */
	private function expandNS( $name, $namespaceURI ) {
		if ( $namespaceURI ) {
			$parts = explode( ':', $name );
			$localname = array_pop( $parts );
			return "$namespaceURI:$localname";
		}
		return $name;
	}

	/**
	 * @param string $name
	 * @param array $attribs
	 */
	private function elementOpen( $name, $attribs ) {
		$this->elementDataContext[] = [ $name, $attribs ];
		$this->elementData[] = '';
		$this->stackDepth++;
	}

	private function elementClose() {
		[ $name, $attribs ] = array_pop( $this->elementDataContext );
		$data = array_pop( $this->elementData );
		$this->stackDepth--;
		$callbackReturn = false;

		if ( is_callable( $this->filterCallback ) ) {
			$callbackReturn = ( $this->filterCallback )( $name, $attribs, $data );
		}
		if ( $callbackReturn ) {
			// Filter hit!
			$this->filterMatch = true;
			$this->filterMatchType = $callbackReturn;
		}
	}

	/**
	 * @param string $data
	 */
	private function elementData( $data ) {
		// Collect any data here, and we'll run the callback in elementClose
		$this->elementData[ $this->stackDepth - 1 ] .= trim( $data );
	}

	/**
	 * @param string $target
	 * @param string $data
	 */
	private function processingInstructionHandler( $target, $data ) {
		$callbackReturn = false;
		if ( $this->parserOptions['processing_instruction_handler'] ) {
			// @phan-suppress-next-line PhanTypeInvalidCallable false positive
			$callbackReturn = $this->parserOptions['processing_instruction_handler'](
				$target,
				$data
			);
		}
		if ( $callbackReturn ) {
			// Filter hit!
			$this->filterMatch = true;
			$this->filterMatchType = $callbackReturn;
		}
	}

	/**
	 * Handle coming across a <!DOCTYPE declaration.
	 *
	 * @param XMLReader $reader Reader currently pointing at DOCTYPE node.
	 */
	private function dtdHandler( XMLReader $reader ) {
		$externalCallback = $this->parserOptions['external_dtd_handler'];
		$generalCallback = $this->parserOptions['dtd_handler'];
		$checkIfSafe = $this->parserOptions['require_safe_dtd'];
		if ( !$externalCallback && !$generalCallback && !$checkIfSafe ) {
			return;
		}
		$dtd = $reader->readOuterXml();
		$callbackReturn = false;

		if ( $generalCallback ) {
			$callbackReturn = $generalCallback( $dtd );
		}
		if ( $callbackReturn ) {
			// Filter hit!
			$this->filterMatch = true;
			$this->filterMatchType = $callbackReturn;
			$callbackReturn = false;
		}

		$parsedDTD = $this->parseDTD( $dtd );
		if ( $externalCallback && isset( $parsedDTD['type'] ) ) {
			$callbackReturn = $externalCallback(
				$parsedDTD['type'],
				$parsedDTD['publicid'] ?? null,
				$parsedDTD['systemid'] ?? null
			);
		}
		if ( $callbackReturn ) {
			// Filter hit!
			$this->filterMatch = true;
			$this->filterMatchType = $callbackReturn;
		}

		if ( $checkIfSafe && isset( $parsedDTD['internal'] ) &&
			!$this->checkDTDIsSafe( $parsedDTD['internal'] )
		) {
			$this->wellFormed = false;
		}
	}

	/**
	 * Check if the internal subset of the DTD is safe.
	 *
	 * We whitelist an extremely restricted subset of DTD features.
	 *
	 * Safe is defined as:
	 *  * Only contains entity definitions (e.g. No <!ATLIST )
	 *  * Entity definitions are not "system" entities
	 *  * Entity definitions are not "parameter" (i.e. %) entities
	 *  * Entity definitions do not reference other entities except &amp;
	 *    and quotes. Entity aliases (where the entity contains only
	 *    another entity are allowed)
	 *  * Entity references aren't overly long (>255 bytes).
	 *  * <!ATTLIST svg xmlns:xlink CDATA #FIXED "http://www.w3.org/1999/xlink">
	 *    allowed if matched exactly for compatibility with graphviz
	 *  * Comments.
	 *
	 * @param string $internalSubset The internal subset of the DTD
	 * @return bool true if safe.
	 */
	private function checkDTDIsSafe( $internalSubset ) {
		$res = preg_match(
			'/^(?:\s*<!ENTITY\s+\S+\s+' .
				'(?:"(?:&[^"%&;]{1,64};|(?:[^"%&]|&amp;|&quot;){0,255})"' .
				'|\'(?:&[^"%&;]{1,64};|(?:[^\'%&]|&amp;|&apos;){0,255})\')\s*>' .
				'|\s*<!--(?:[^-]|-[^-])*-->' .
				'|\s*<!ATTLIST svg xmlns:xlink CDATA #FIXED ' .
				'"http:\/\/www.w3.org\/1999\/xlink">)*\s*$/',
			$internalSubset
		);

		return (bool)$res;
	}

	/**
	 * Parse DTD into parts.
	 *
	 * If there is an error parsing the dtd, sets wellFormed to false.
	 *
	 * @param string $dtd
	 * @return array Possibly containing keys publicid, systemid, type and internal.
	 */
	private function parseDTD( $dtd ) {
		$m = [];
		$res = preg_match(
			'/^<!DOCTYPE\s*\S+\s*' .
			'(?:(?P<typepublic>PUBLIC)\s*' .
				'(?:"(?P<pubquote>[^"]*)"|\'(?P<pubapos>[^\']*)\')' . // public identifer
				'\s*"(?P<pubsysquote>[^"]*)"|\'(?P<pubsysapos>[^\']*)\'' . // system identifier
			'|(?P<typesystem>SYSTEM)\s*' .
				'(?:"(?P<sysquote>[^"]*)"|\'(?P<sysapos>[^\']*)\')' .
			')?\s*' .
			'(?:\[\s*(?P<internal>.*)\])?\s*>$/s',
			$dtd,
			$m
		);
		if ( !$res ) {
			$this->wellFormed = false;
			return [];
		}
		$parsed = [];
		foreach ( $m as $field => $value ) {
			if ( $value === '' || is_numeric( $field ) ) {
				continue;
			}
			switch ( $field ) {
				case 'typepublic':
				case 'typesystem':
					$parsed['type'] = $value;
					break;
				case 'pubquote':
				case 'pubapos':
					$parsed['publicid'] = $value;
					break;
				case 'pubsysquote':
				case 'pubsysapos':
				case 'sysquote':
				case 'sysapos':
					$parsed['systemid'] = $value;
					break;
				case 'internal':
					$parsed['internal'] = $value;
					break;
			}
		}
		return $parsed;
	}
}
ParamValidator/TypeDefTest.php000066600000011755151335120240012374 0ustar00<?php

namespace Wikimedia\ParamValidator;

use Wikimedia\Message\DataMessageValue;
use Wikimedia\TestingAccessWrapper;

/**
 * @covers Wikimedia\ParamValidator\TypeDef
 */
class TypeDefTest extends \PHPUnit\Framework\TestCase {

	public function testMisc() {
		$typeDef = $this->getMockBuilder( TypeDef::class )
			->setConstructorArgs( [ new SimpleCallbacks( [] ) ] )
			->getMockForAbstractClass();

		$this->assertSame( [ 'foobar' ], $typeDef->normalizeSettings( [ 'foobar' ] ) );
		$ret = [ 'issues' => [], 'allowedKeys' => [], 'messages' => [] ];
		$this->assertSame( $ret, $typeDef->checkSettings( 'foobar', [], [], $ret ) );
		$this->assertNull( $typeDef->getEnumValues( 'foobar', [], [] ) );
		$this->assertSame( '123', $typeDef->stringifyValue( 'foobar', 123, [], [] ) );
	}

	public function testGetValue() {
		$options = [ (object)[] ];

		$callbacks = $this->getMockBuilder( Callbacks::class )->getMockForAbstractClass();
		$callbacks->expects( $this->once() )->method( 'getValue' )
			->with(
				$this->identicalTo( 'foobar' ),
				$this->identicalTo( null ),
				$this->identicalTo( $options )
			)
			->willReturn( 'zyx' );

		$typeDef = $this->getMockBuilder( TypeDef::class )
			->setConstructorArgs( [ $callbacks ] )
			->getMockForAbstractClass();

		$this->assertSame(
			'zyx',
			$typeDef->getValue( 'foobar', [ ParamValidator::PARAM_DEFAULT => 'foo' ], $options )
		);
	}

	public function testGetParamInfo() {
		$typeDef = $this->getMockBuilder( TypeDef::class )
			->setConstructorArgs( [ new SimpleCallbacks( [] ) ] )
			->getMockForAbstractClass();

		$this->assertSame( [], $typeDef->getParamInfo( 'foobar', [], [] ) );
	}

	public function testGetHelpInfo() {
		$typeDef = $this->getMockBuilder( TypeDef::class )
			->setConstructorArgs( [ new SimpleCallbacks( [] ) ] )
			->getMockForAbstractClass();

		$this->assertSame( [], $typeDef->getHelpInfo( 'foobar', [], [] ) );
	}

	/** @dataProvider provideFailureMessage */
	public function testFailureMessage( $expect, $code, array $data = null, $suffix = null ) {
		$typeDef = $this->getMockBuilder( TypeDef::class )
			->setConstructorArgs( [ new SimpleCallbacks( [] ) ] )
			->getMockForAbstractClass();
		$ret = TestingAccessWrapper::newFromObject( $typeDef )->failureMessage( $code, $data, $suffix );

		$this->assertInstanceOf( DataMessageValue::class, $ret );
		$this->assertSame( $expect, $ret->dump() );
	}

	public static function provideFailureMessage() {
		return [
			'Basic' => [
				'<datamessage key="paramvalidator-foobar" code="foobar"></datamessage>',
				'foobar',
			],
			'With data' => [
				'<datamessage key="paramvalidator-foobar" code="foobar"><data>{"x":123}</data></datamessage>',
				'foobar', [ 'x' => 123 ]
			],
			'With suffix' => [
				'<datamessage key="paramvalidator-foobar-baz" code="foobar"><data>[]</data></datamessage>',
				'foobar', [], 'baz'
			],
		];
	}

	/** @dataProvider provideFailure */
	public function testFailure_fatal(
		$expect, $failure, $name, $value, array $settings, array $options
	) {
		$callbacks = new SimpleCallbacks( [] );
		$typeDef = $this->getMockBuilder( TypeDef::class )
			->setConstructorArgs( [ $callbacks ] )
			->getMockForAbstractClass();

		try {
			TestingAccessWrapper::newFromObject( $typeDef )
				->failure( $failure, $name, $value, $settings, $options );
			$this->fail( 'Expected exception not thrown' );
		} catch ( ValidationException $ex ) {
			$this->assertSame( $expect, $ex->getFailureMessage()->dump() );
			$this->assertSame( $name, $ex->getParamName() );
			$this->assertSame( (string)$value, $ex->getParamValue() );
			$this->assertSame( $settings, $ex->getSettings() );
		}
		$this->assertSame( [], $callbacks->getRecordedConditions() );
	}

	/** @dataProvider provideFailure */
	public function testFailure_nonfatal(
		$expect, $failure, $name, $value, array $settings, array $options
	) {
		$callbacks = new SimpleCallbacks( [] );
		$typeDef = $this->getMockBuilder( TypeDef::class )
			->setConstructorArgs( [ $callbacks ] )
			->getMockForAbstractClass();

		TestingAccessWrapper::newFromObject( $typeDef )
			->failure( $failure, $name, $value, $settings, $options, false );

		$conds = $callbacks->getRecordedConditions();
		$this->assertCount( 1, $conds );
		$conds[0]['message'] = $conds[0]['message']->dump();
		$this->assertSame( [
			'message' => $expect,
			'name' => $name,
			'value' => (string)$value,
			'settings' => $settings,
		], $conds[0] );
	}

	public static function provideFailure() {
		return [
			'Basic' => [
				'<datamessage key="paramvalidator-foobar" code="foobar"><params><plaintext>test</plaintext><plaintext>1234</plaintext></params></datamessage>',
				'foobar', 'test', 1234, [], []
			],
			'DataMessageValue' => [
				'<datamessage key="XXX-msg" code="foobar"><params><plaintext>test</plaintext><plaintext>XXX</plaintext><text>a</text><text>b</text><plaintext>pt</plaintext></params><data>{"data":"!!!"}</data></datamessage>',
				DataMessageValue::new( 'XXX-msg', [ 'a', 'b' ], 'foobar', [ 'data' => '!!!' ] )
					->plaintextParams( 'pt' ),
				'test', 'XXX', [], []
			],
		];
	}

}
ParamValidator/TypeDef/EnumDefTest.php000066600000015231151335120240013710 0ustar00<?php

namespace Wikimedia\ParamValidator\TypeDef;

use Wikimedia\Message\DataMessageValue;
use Wikimedia\Message\MessageValue;
use Wikimedia\ParamValidator\ParamValidator;
use Wikimedia\ParamValidator\SimpleCallbacks;
use Wikimedia\ParamValidator\ValidationException;

/**
 * @covers Wikimedia\ParamValidator\TypeDef\EnumDef
 */
class EnumDefTest extends TypeDefTestCase {

	protected function getInstance( SimpleCallbacks $callbacks, array $options ) {
		return new EnumDef( $callbacks, $options );
	}

	public function provideValidate() {
		$settings = [
			ParamValidator::PARAM_TYPE => [ 'a', 'b', 'c', 'd', 'e' ],
			EnumDef::PARAM_DEPRECATED_VALUES => [
				'b' => MessageValue::new( 'not-to-be', [ '??' ] ),
				'c' => true,
				'e' => DataMessageValue::new( 'xyz', [ '??' ], 'bogus', [ 'x' => 'y' ] ),
			],
		];

		return [
			'Basic' => [ 'a', 'a', $settings ],
			'Deprecated' => [ 'c', 'c', $settings, [], [
				[ 'code' => 'deprecated-value', 'data' => null ],
			] ],
			'Deprecated with message' => [
				'b', 'b', $settings, [], [
				[ 'code' => 'deprecated-value', 'data' => null ]
			] ],
			'Deprecated with data message' => [
				'e', 'e', $settings, [], [
				[ 'code' => 'deprecated-value', 'data' => [ 'x' => 'y' ] ]
			] ],
			'Deprecated, from default' => [
				'c', 'c', $settings, [ 'is-default' => true ], []
			],
			'Bad value, non-multi' => [
				'x',
				new ValidationException(
					DataMessageValue::new( 'paramvalidator-badvalue-enumnotmulti', [], 'badvalue', [] ),
					'test', 'x', $settings
				),
				$settings,
			],
			'Bad value, non-multi but looks like it' => [
				'x|y',
				new ValidationException(
					DataMessageValue::new( 'paramvalidator-badvalue-enumnotmulti', [], 'badvalue', [] ),
					'test', 'x|y', $settings
				),
				$settings,
			],
			'Bad value, multi' => [
				'x|y',
				new ValidationException(
					DataMessageValue::new( 'paramvalidator-badvalue-enummulti', [], 'badvalue', [] ),
					'test', 'x|y', $settings + [ ParamValidator::PARAM_ISMULTI => true ]
				),
				$settings + [ ParamValidator::PARAM_ISMULTI => true ],
				[ 'values-list' => [ 'x|y' ] ],
			],
		];
	}

	public function provideCheckSettings() {
		return [
			'Basic test' => [
				[
					ParamValidator::PARAM_TYPE => [ 'a', 'b', 'c', 'd', 'e' ],
				],
				self::STDRET,
				[
					'issues' => [ 'X' ],
					'allowedKeys' => [ 'Y', EnumDef::PARAM_DEPRECATED_VALUES ],
					'messages' => [],
				],
			],
			'Bad type for PARAM_DEPRECATED_VALUES' => [
				[
					ParamValidator::PARAM_TYPE => [ 'a', 'b', 'c', 'd', 'e' ],
					EnumDef::PARAM_DEPRECATED_VALUES => false,
				],
				self::STDRET,
				[
					'issues' => [
						'X',
						EnumDef::PARAM_DEPRECATED_VALUES => 'PARAM_DEPRECATED_VALUES must be an array, got boolean',
					],
					'allowedKeys' => [ 'Y', EnumDef::PARAM_DEPRECATED_VALUES ],
					'messages' => [],
				],
			],
			'PARAM_DEPRECATED_VALUES value errors' => [
				[
					ParamValidator::PARAM_TYPE => [ 'a', 'b', 'c', 'd', 'e', 'f', 'g', 0, '1' ],
					EnumDef::PARAM_DEPRECATED_VALUES => [
						'b' => null,
						'c' => false,
						'd' => true,
						'e' => MessageValue::new( 'e' ),
						'f' => 'f',
						'g' => $this,
						0 => true,
						1 => true,
						'x' => null,
					],
				],
				self::STDRET,
				[
					'issues' => [
						'X',
						'Values in PARAM_DEPRECATED_VALUES must be null, true, or MessageValue, but value for "c" is false',
						'Values in PARAM_DEPRECATED_VALUES must be null, true, or MessageValue, but value for "f" is string',
						'Values in PARAM_DEPRECATED_VALUES must be null, true, or MessageValue, but value for "g" is ' . static::class,
						// phpcs:enable
						'PARAM_DEPRECATED_VALUES contains "x", which is not one of the enumerated values',
					],
					'allowedKeys' => [ 'Y', EnumDef::PARAM_DEPRECATED_VALUES ],
					'messages' => [
						MessageValue::new( 'e' ),
					],
				],
			],
		];
	}

	public function provideGetEnumValues() {
		return [
			'Basic test' => [
				[ ParamValidator::PARAM_TYPE => [ 'a', 'b', 'c', 'd' ] ],
				[ 'a', 'b', 'c', 'd' ],
			],
		];
	}

	public function provideStringifyValue() {
		return [
			'Basic test' => [ 123, '123' ],
			'Array' => [ [ 1, 2, 3 ], '1|2|3' ],
			'Array with pipes' => [ [ 1, 2, '3|4', 5 ], "\x1f1\x1f2\x1f3|4\x1f5" ],
		];
	}

	public function provideGetInfo() {
		return [
			'Non-multi' => [
				[
					ParamValidator::PARAM_TYPE => [ 'a', 'b', 'c', 'd' ],
				],
				[
					'type' => [ 'a', 'b', 'c', 'd' ],
				],
				[
					ParamValidator::PARAM_TYPE => '<message key="paramvalidator-help-type-enum"><text>1</text><list listType="comma"><text>a</text><text>b</text><text>c</text><text>d</text></list><num>4</num></message>',
					ParamValidator::PARAM_ISMULTI => null,
				],
			],
			'Multi' => [
				[
					ParamValidator::PARAM_TYPE => [ 'a', 'b', 'c', 'd' ],
					ParamValidator::PARAM_ISMULTI => true,
				],
				[
					'type' => [ 'a', 'b', 'c', 'd' ],
				],
				[
					ParamValidator::PARAM_TYPE => '<message key="paramvalidator-help-type-enum"><text>2</text><list listType="comma"><text>a</text><text>b</text><text>c</text><text>d</text></list><num>4</num></message>',
					ParamValidator::PARAM_ISMULTI => null,
				],
			],
			'Deprecated values' => [
				[
					ParamValidator::PARAM_TYPE => [ 'a', 'b', 'c', 'd' ],
					EnumDef::PARAM_DEPRECATED_VALUES => [ 'b' => 'B', 'c' => false, 'x' => true ],
				],
				[
					'type' => [ 'a', 'd', 'b', 'c' ],
					'deprecatedvalues' => [ 'b', 'c' ],
				],
				[
					ParamValidator::PARAM_TYPE => '<message key="paramvalidator-help-type-enum"><text>1</text><list listType="comma"><text>a</text><text>d</text><text>b</text><text>c</text></list><num>4</num></message>',
					ParamValidator::PARAM_ISMULTI => null,
				],
			],
			'Deprecated values are all not allowed values' => [
				[
					ParamValidator::PARAM_TYPE => [ 'a', 'b', 'c', 'd' ],
					EnumDef::PARAM_DEPRECATED_VALUES => [ 'x' => true ],
				],
				[
					'type' => [ 'a', 'b', 'c', 'd' ],
				],
				[
					ParamValidator::PARAM_TYPE => '<message key="paramvalidator-help-type-enum"><text>1</text><list listType="comma"><text>a</text><text>b</text><text>c</text><text>d</text></list><num>4</num></message>',
					ParamValidator::PARAM_ISMULTI => null,
				],
			],
			'Empty-string is a value' => [
				[
					ParamValidator::PARAM_TYPE => [ '', 'a', 'b', 'c', 'd' ],
				],
				[
					'type' => [ '', 'a', 'b', 'c', 'd' ],
				],
				[
					ParamValidator::PARAM_TYPE => '<message key="paramvalidator-help-type-enum"><text>1</text><text><message key="paramvalidator-help-type-enum-can-be-empty"><list listType="comma"><text>a</text><text>b</text><text>c</text><text>d</text></list><num>4</num></message></text><num>5</num></message>',
					ParamValidator::PARAM_ISMULTI => null,
				],
			],
		];
	}

}
ParamValidator/TypeDef/UploadDefTest.php000066600000016302151335120240014230 0ustar00<?php

namespace Wikimedia\ParamValidator\TypeDef;

use Wikimedia\Message\DataMessageValue;
use Wikimedia\ParamValidator\ParamValidator;
use Wikimedia\ParamValidator\SimpleCallbacks;
use Wikimedia\ParamValidator\Util\UploadedFile;
use Wikimedia\ParamValidator\ValidationException;

/**
 * @covers Wikimedia\ParamValidator\TypeDef\UploadDef
 */
class UploadDefTest extends TypeDefTestCase {

	protected function getCallbacks( $value, array $options ) {
		if ( $value instanceof UploadedFile ) {
			return new SimpleCallbacks( [], [ 'test' => $value ] );
		} else {
			return new SimpleCallbacks( [ 'test' => $value ] );
		}
	}

	protected function getInstance( SimpleCallbacks $callbacks, array $options ) {
		$ret = $this->getMockBuilder( UploadDef::class )
			->setConstructorArgs( [ $callbacks ] )
			->onlyMethods( [ 'getIniSize' ] )
			->getMock();
		$ret->method( 'getIniSize' )->willReturn( $options['inisize'] ?? 2 * 1024 * 1024 );
		return $ret;
	}

	private function makeUpload( $err = UPLOAD_ERR_OK ) {
		return new UploadedFile( [
			'name' => 'example.txt',
			'type' => 'text/plain',
			'size' => 0,
			'tmp_name' => '...',
			'error' => $err,
		] );
	}

	public function testGetNoFile() {
		$typeDef = $this->getInstance(
			$this->getCallbacks( $this->makeUpload( UPLOAD_ERR_NO_FILE ), [] ),
			[]
		);

		$this->assertNull( $typeDef->getValue( 'test', [], [] ) );
		$this->assertNull( $typeDef->getValue( 'nothing', [], [] ) );
	}

	public function provideValidate() {
		$okFile = $this->makeUpload();
		$iniFile = $this->makeUpload( UPLOAD_ERR_INI_SIZE );
		$exIni = new ValidationException(
			DataMessageValue::new( 'paramvalidator-badupload-inisize', [], 'badupload', [
				'code' => 'inisize',
				'size' => 2 * 1024 * 1024 * 1024,
			] ),
			'test', '', []
		);

		return [
			'Valid upload' => [ $okFile, $okFile ],
			'Not an upload' => [
				'bar',
				new ValidationException(
					DataMessageValue::new( 'paramvalidator-badupload-notupload', [], 'badupload', [
						'code' => 'notupload'
					] ),
					'test', 'bar', []
				),
			],

			'Too big (bytes)' => [ $iniFile, $exIni, [], [ 'inisize' => 2 * 1024 * 1024 * 1024 ] ],
			'Too big (k)' => [ $iniFile, $exIni, [], [ 'inisize' => ( 2 * 1024 * 1024 ) . 'k' ] ],
			'Too big (K)' => [ $iniFile, $exIni, [], [ 'inisize' => ( 2 * 1024 * 1024 ) . 'K' ] ],
			'Too big (m)' => [ $iniFile, $exIni, [], [ 'inisize' => ( 2 * 1024 ) . 'm' ] ],
			'Too big (M)' => [ $iniFile, $exIni, [], [ 'inisize' => ( 2 * 1024 ) . 'M' ] ],
			'Too big (g)' => [ $iniFile, $exIni, [], [ 'inisize' => '2g' ] ],
			'Too big (G)' => [ $iniFile, $exIni, [], [ 'inisize' => '2G' ] ],

			'Form size' => [
				$this->makeUpload( UPLOAD_ERR_FORM_SIZE ),
				new ValidationException(
					DataMessageValue::new( 'paramvalidator-badupload-formsize', [], 'badupload', [
						'code' => 'formsize',
					] ),
					'test', '', []
				),
			],
			'Partial' => [
				$this->makeUpload( UPLOAD_ERR_PARTIAL ),
				new ValidationException(
					DataMessageValue::new( 'paramvalidator-badupload-partial', [], 'badupload', [
						'code' => 'partial',
					] ),
					'test', '', []
				),
			],
			'No tmp' => [
				$this->makeUpload( UPLOAD_ERR_NO_TMP_DIR ),
				new ValidationException(
					DataMessageValue::new( 'paramvalidator-badupload-notmpdir', [], 'badupload', [
						'code' => 'notmpdir',
					] ),
					'test', '', []
				),
			],
			'Can\'t write' => [
				$this->makeUpload( UPLOAD_ERR_CANT_WRITE ),
				new ValidationException(
					DataMessageValue::new( 'paramvalidator-badupload-cantwrite', [], 'badupload', [
						'code' => 'cantwrite',
					] ),
					'test', '', []
				),
			],
			'Ext abort' => [
				$this->makeUpload( UPLOAD_ERR_EXTENSION ),
				new ValidationException(
					DataMessageValue::new( 'paramvalidator-badupload-phpext', [], 'badupload', [
						'code' => 'phpext',
					] ),
					'test', '', []
				),
			],
		];
	}

	public function testValidate_badType() {
		$callbacks = $this->getCallbacks( 'foo', [] );
		$typeDef = $this->getInstance( $callbacks, [] );

		$this->expectException( \InvalidArgumentException::class );
		$this->expectExceptionMessage( '$value must be UploadedFileInterface, got string' );
		$typeDef->validate( 'test', 'foo', [], [] );
	}

	public function testValidate_badType2() {
		$callbacks = $this->getCallbacks( 'foo', [] );
		$typeDef = $this->getInstance( $callbacks, [] );

		$this->expectException( \InvalidArgumentException::class );
		$this->expectExceptionMessage( '$value must be UploadedFileInterface, got NULL' );
		$typeDef->validate( 'test', null, [], [] );
	}

	public function testValidate_unknownError() {
		// -43 should be safe from ever being a valid UPLOAD_ERR_ constant
		$callbacks = $this->getCallbacks( $this->makeUpload( -43 ), [] );
		$typeDef = $this->getInstance( $callbacks, [] );
		$value = $typeDef->getValue( 'test', [], [] );

		$this->expectException( \UnexpectedValueException::class );
		$this->expectExceptionMessage( 'Unrecognized PHP upload error value -43' );
		$typeDef->validate( 'test', $value, [], [] );
	}

	public function testValidate_unknownError2() {
		define( 'UPLOAD_ERR_UPLOADDEFTEST', -44 );
		$callbacks = $this->getCallbacks( $this->makeUpload( UPLOAD_ERR_UPLOADDEFTEST ), [] );
		$typeDef = $this->getInstance( $callbacks, [] );
		$value = $typeDef->getValue( 'test', [], [] );

		$this->expectException( \UnexpectedValueException::class );
		$this->expectExceptionMessage(
			'Unrecognized PHP upload error value -44 (UPLOAD_ERR_UPLOADDEFTEST?)'
		);
		$typeDef->validate( 'test', $value, [], [] );
	}

	public function provideCheckSettings() {
		return [
			'Basic test' => [
				[],
				self::STDRET,
				self::STDRET,
			],
			'PARAM_ISMULTI not allowed' => [
				[
					ParamValidator::PARAM_ISMULTI => true,
				],
				self::STDRET,
				[
					'issues' => [
						'X',
						ParamValidator::PARAM_ISMULTI
							=> 'PARAM_ISMULTI cannot be used for upload-type parameters',
					],
					'allowedKeys' => [ 'Y' ],
					'messages' => [],
				],
			],
			'PARAM_ISMULTI not allowed, but another ISMULTI issue was already logged' => [
				[
					ParamValidator::PARAM_ISMULTI => true,
				],
				[
					'issues' => [
						ParamValidator::PARAM_ISMULTI => 'XXX',
					],
					'allowedKeys' => [ 'Y' ],
					'messages' => [],
				],
				[
					'issues' => [
						ParamValidator::PARAM_ISMULTI => 'XXX',
					],
					'allowedKeys' => [ 'Y' ],
					'messages' => [],
				],
			],
			'PARAM_DEFAULT can be null' => [
				[ ParamValidator::PARAM_DEFAULT => null ],
				self::STDRET,
				self::STDRET,
			],
			'PARAM_DEFAULT is otherwise not allowed' => [
				[
					ParamValidator::PARAM_DEFAULT => true,
				],
				[
					'issues' => [
						'X',
						ParamValidator::PARAM_DEFAULT => 'XXX',
					],
					'allowedKeys' => [ 'Y' ],
					'messages' => [],
				],
				[
					'issues' => [
						'X',
						ParamValidator::PARAM_DEFAULT => 'Cannot specify a default for upload-type parameters',
					],
					'allowedKeys' => [ 'Y' ],
					'messages' => [],
				],
			],
		];
	}

	public function provideStringifyValue() {
		return [
			'Yeah, right' => [ $this->makeUpload(), null ],
		];
	}

	public function provideGetInfo() {
		return [
			'Basic test' => [
				[],
				[],
				[
					ParamValidator::PARAM_TYPE => '<message key="paramvalidator-help-type-upload"></message>',
				],
			],
		];
	}

}
ParamValidator/TypeDef/TypeDefTestCase.php000066600000000713151335120240014520 0ustar00<?php

namespace Wikimedia\ParamValidator\TypeDef;

/**
 * Test case infrastructure for TypeDef subclasses
 *
 * Generally you'll only need to implement self::getInstance() and
 * data providers methods.
 */
abstract class TypeDefTestCase extends \PHPUnit\Framework\TestCase {
	use TypeDefTestCaseTrait;

	/** Standard "$ret" array for provideCheckSettings */
	protected const STDRET =
		[ 'issues' => [ 'X' ], 'allowedKeys' => [ 'Y' ], 'messages' => [] ];
}
ParamValidator/TypeDef/TimestampDefTest.php000066600000015210151335120240014744 0ustar00<?php

namespace Wikimedia\ParamValidator\TypeDef;

use Wikimedia\Message\DataMessageValue;
use Wikimedia\ParamValidator\ParamValidator;
use Wikimedia\ParamValidator\SimpleCallbacks;
use Wikimedia\ParamValidator\ValidationException;
use Wikimedia\Timestamp\ConvertibleTimestamp;

/**
 * @covers Wikimedia\ParamValidator\TypeDef\TimestampDef
 */
class TimestampDefTest extends TypeDefTestCase {

	protected function getInstance( SimpleCallbacks $callbacks, array $options ) {
		return new TimestampDef( $callbacks, $options );
	}

	/** @dataProvider provideConstructorOptions */
	public function testConstructorOptions( array $options, $ok ): void {
		if ( $ok ) {
			$this->assertTrue( true ); // dummy
		} else {
			$this->expectException( \InvalidArgumentException::class );
		}
		$this->getInstance( new SimpleCallbacks( [] ), $options );
	}

	public static function provideConstructorOptions(): array {
		return [
			'Basic test' => [ [], true ],
			'Default format ConvertibleTimestamp' => [ [ 'defaultFormat' => 'ConvertibleTimestamp' ], true ],
			'Default format DateTime' => [ [ 'defaultFormat' => 'DateTime' ], true ],
			'Default format TS_ISO_8601' => [ [ 'defaultFormat' => TS_ISO_8601 ], true ],
			'Default format invalid (string)' => [ [ 'defaultFormat' => 'foobar' ], false ],
			'Default format invalid (int)' => [ [ 'defaultFormat' => 1000 ], false ],
			'Stringify format ConvertibleTimestamp' => [
				[ 'stringifyFormat' => 'ConvertibleTimestamp' ], false
			],
			'Stringify format DateTime' => [ [ 'stringifyFormat' => 'DateTime' ], false ],
			'Stringify format TS_ISO_8601' => [ [ 'stringifyFormat' => TS_ISO_8601 ], true ],
			'Stringify format invalid (string)' => [ [ 'stringifyFormat' => 'foobar' ], false ],
			'Stringify format invalid (int)' => [ [ 'stringifyFormat' => 1000 ], false ],
		];
	}

	/** @dataProvider provideValidate */
	public function testValidate(
		$value, $expect, array $settings = [], array $options = [], array $expectConds = []
	) {
		ConvertibleTimestamp::setFakeTime( 1559764242 );
		parent::testValidate( $value, $expect, $settings, $options, $expectConds );
	}

	public function provideValidate() {
		$specific = new ConvertibleTimestamp( 1517630706 );
		$specificMs = new ConvertibleTimestamp( 1517630706.999 );
		$now = new ConvertibleTimestamp( 1559764242 );

		$formatDT = [ TimestampDef::PARAM_TIMESTAMP_FORMAT => 'DateTime' ];
		$formatMW = [ TimestampDef::PARAM_TIMESTAMP_FORMAT => TS_MW ];

		return [
			// We don't try to validate all formats supported by ConvertibleTimestamp, just
			// some of the interesting ones.
			'ISO format' => [ '2018-02-03T04:05:06Z', $specific ],
			'ISO format with TZ' => [ '2018-02-03T00:05:06-04:00', $specific ],
			'ISO format without punctuation' => [ '20180203T040506', $specific ],
			'ISO format with ms' => [ '2018-02-03T04:05:06.999000Z', $specificMs ],
			'ISO format with ms without punctuation' => [ '20180203T040506.999', $specificMs ],
			'MW format' => [ '20180203040506', $specific ],
			'Generic format' => [ '2018-02-03 04:05:06', $specific ],
			'Generic format + GMT' => [ '2018-02-03 04:05:06 GMT', $specific ],
			'Generic format + TZ +0100' => [ '2018-02-03 05:05:06+0100', $specific ],
			'Generic format + TZ -01' => [ '2018-02-03 03:05:06-01', $specific ],
			'Seconds-since-epoch format' => [ '1517630706', $specific ],
			'Seconds-since-epoch format with ms' => [ '1517630706.9990', $specificMs ],
			'Now' => [ 'now', $now ],

			// Warnings
			'Empty' => [ '', $now, [], [], [ [ 'code' => 'unclearnowtimestamp', 'data' => null ] ] ],
			'Zero' => [ '0', $now, [], [], [ [ 'code' => 'unclearnowtimestamp', 'data' => null ] ] ],

			// Error handling
			'Bad value' => [
				'bogus',
				new ValidationException(
					DataMessageValue::new( 'paramvalidator-badtimestamp', [], 'badtimestamp' ),
					'test', 'bogus', []
				),
			],
			// T272637
			'Incomplete MW format' => [
				'2014815210101',
				new ValidationException(
					DataMessageValue::new( 'paramvalidator-badtimestamp', [], 'badtimestamp' ),
					'test', '2014815210101', []
				),
				$formatMW
			],

			// Formatting
			'=> DateTime' => [ 'now', $now->timestamp, $formatDT ],
			'=> TS_MW' => [ 'now', '20190605195042', $formatMW ],
			'=> TS_MW as default' => [ 'now', '20190605195042', [], [ 'defaultFormat' => TS_MW ] ],
			'=> TS_MW overriding default'
				=> [ 'now', '20190605195042', $formatMW, [ 'defaultFormat' => TS_ISO_8601 ] ],
		];
	}

	public function provideCheckSettings() {
		$keys = [ 'Y', TimestampDef::PARAM_TIMESTAMP_FORMAT ];

		return [
			'Basic test' => [
				[],
				self::STDRET,
				[
					'issues' => [ 'X' ],
					'allowedKeys' => $keys,
					'messages' => [],
				],
			],
			'Test with format ConvertibleTimestamp' => [
				[ TimestampDef::PARAM_TIMESTAMP_FORMAT => 'ConvertibleTimestamp' ],
				self::STDRET,
				[
					'issues' => [ 'X' ],
					'allowedKeys' => $keys,
					'messages' => [],
				],
			],
			'Test with format DateTime' => [
				[ TimestampDef::PARAM_TIMESTAMP_FORMAT => 'DateTime' ],
				self::STDRET,
				[
					'issues' => [ 'X' ],
					'allowedKeys' => $keys,
					'messages' => [],
				],
			],
			'Test with format TS_ISO_8601' => [
				[ TimestampDef::PARAM_TIMESTAMP_FORMAT => TS_ISO_8601 ],
				self::STDRET,
				[
					'issues' => [ 'X' ],
					'allowedKeys' => $keys,
					'messages' => [],
				],
			],
			'Test with invalid format (string)' => [
				[ TimestampDef::PARAM_TIMESTAMP_FORMAT => 'foobar' ],
				self::STDRET,
				[
					'issues' => [
						'X',
						TimestampDef::PARAM_TIMESTAMP_FORMAT => 'Value for PARAM_TIMESTAMP_FORMAT is not valid',
					],
					'allowedKeys' => $keys,
					'messages' => [],
				],
			],
			'Test with invalid format (int)' => [
				[ TimestampDef::PARAM_TIMESTAMP_FORMAT => 1000 ],
				self::STDRET,
				[
					'issues' => [
						'X',
						TimestampDef::PARAM_TIMESTAMP_FORMAT => 'Value for PARAM_TIMESTAMP_FORMAT is not valid',
					],
					'allowedKeys' => $keys,
					'messages' => [],
				],
			],
		];
	}

	public function provideStringifyValue() {
		$specific = new ConvertibleTimestamp( '20180203040506' );

		return [
			[ '20180203040506', '2018-02-03T04:05:06Z' ],
			[ $specific, '2018-02-03T04:05:06Z' ],
			[ $specific->timestamp, '2018-02-03T04:05:06Z' ],
			[ $specific, '20180203040506', [], [ 'stringifyFormat' => TS_MW ] ],
		];
	}

	public function provideGetInfo() {
		return [
			'Basic test' => [
				[],
				[],
				[
					ParamValidator::PARAM_TYPE => '<message key="paramvalidator-help-type-timestamp"><text>1</text></message>',
				],
			],
			'Multi-valued' => [
				[ ParamValidator::PARAM_ISMULTI => true ],
				[],
				[
					ParamValidator::PARAM_TYPE => '<message key="paramvalidator-help-type-timestamp"><text>2</text></message>',
				],
			],
		];
	}

}
ParamValidator/TypeDef/FloatDefTest.php000066600000011436151335120240014054 0ustar00<?php

namespace Wikimedia\ParamValidator\TypeDef;

use Wikimedia\Message\DataMessageValue;
use Wikimedia\ParamValidator\ParamValidator;
use Wikimedia\ParamValidator\SimpleCallbacks;
use Wikimedia\ParamValidator\ValidationException;

/**
 * @covers Wikimedia\ParamValidator\TypeDef\FloatDef
 */
class FloatDefTest extends TypeDefTestCase {

	protected function getInstance( SimpleCallbacks $callbacks, array $options ) {
		return new FloatDef( $callbacks, $options );
	}

	public function provideValidate() {
		return [
			[ '123', 123.0 ],
			[ '123.4', 123.4 ],
			[ '0.4', 0.4 ],
			[ '.4', 0.4 ],

			[ '+123', 123.0 ],
			[ '+123.4', 123.4 ],
			[ '+0.4', 0.4 ],
			[ '+.4', 0.4 ],

			[ '-123', -123.0 ],
			[ '-123.4', -123.4 ],
			[ '-.4', -0.4 ],
			[ '-.4', -0.4 ],

			[ '123e5', 12300000.0 ],
			[ '123E5', 12300000.0 ],
			[ '123.4e+5', 12340000.0 ],
			[ '123E5', 12300000.0 ],
			[ '-123.4e-5', -0.001234 ],
			[ '.4E-5', 0.000004 ],

			[ '0', 0 ],
			[ '000000', 0 ],
			[ '0000.0000', 0 ],
			[ '000001.0002000000', 1.0002 ],
			[ '1e0', 1 ],
			[ '1e-0000', 1 ],
			[ '1e+00010', 1e10 ],

			'Weird, but ok' => [ '-0', 0 ],
			'Underflow is ok' => [ '1e-9999', 0 ],

			'Empty decimal part' => [ '1.', new ValidationException(
				DataMessageValue::new( 'paramvalidator-badfloat', [], 'badfloat' ),
				'test', '1.', []
			) ],
			'Bad sign' => [ ' 1', new ValidationException(
				DataMessageValue::new( 'paramvalidator-badfloat', [], 'badfloat' ),
				'test', ' 1', []
			) ],
			'Comma as decimal separator or thousands grouping?' => [ '1,234', new ValidationException(
				DataMessageValue::new( 'paramvalidator-badfloat', [], 'badfloat' ),
				'test', '1,234', []
			) ],
			'U+2212 minus' => [ '−1', new ValidationException(
				DataMessageValue::new( 'paramvalidator-badfloat', [], 'badfloat' ),
				'test', '−1', []
			) ],
			'Overflow' => [ '1e9999', new ValidationException(
				DataMessageValue::new( 'paramvalidator-badfloat-notfinite', [], 'badfloat-notfinite' ),
				'test', '1e9999', []
			) ],
			'Overflow, -INF' => [ '-1e9999', new ValidationException(
				DataMessageValue::new( 'paramvalidator-badfloat-notfinite', [], 'badfloat-notfinite' ),
				'test', '-1e9999', []
			) ],
			'Bogus value' => [ 'foo', new ValidationException(
				DataMessageValue::new( 'paramvalidator-notfinite', [], 'badfloat' ),
				'test', 'foo', []
			) ],
			'Bogus value 2' => [ '123f4', new ValidationException(
				DataMessageValue::new( 'paramvalidator-notfinite', [], 'badfloat' ),
				'test', '123f4', []
			) ],
			'Newline' => [ "123\n", new ValidationException(
				DataMessageValue::new( 'paramvalidator-notfinite', [], 'badfloat' ),
				'test', "123\n", []
			) ],
		];
	}

	public function provideCheckSettings() {
		$keys = [
			'Y', FloatDef::PARAM_IGNORE_RANGE,
			FloatDef::PARAM_MIN, FloatDef::PARAM_MAX, FloatDef::PARAM_MAX2
		];

		return [
			'Basic test' => [
				[],
				self::STDRET,
				[
					'issues' => [ 'X' ],
					'allowedKeys' => $keys,
					'messages' => [],
				],
			],
			'Test with everything' => [
				[
					FloatDef::PARAM_IGNORE_RANGE => true,
					FloatDef::PARAM_MIN => -100.0,
					FloatDef::PARAM_MAX => -90.0,
					FloatDef::PARAM_MAX2 => -80.0,
				],
				self::STDRET,
				[
					'issues' => [ 'X' ],
					'allowedKeys' => $keys,
					'messages' => [],
				],
			],
			'Bad types' => [
				[
					FloatDef::PARAM_IGNORE_RANGE => 1,
					FloatDef::PARAM_MIN => 1,
					FloatDef::PARAM_MAX => '2',
					FloatDef::PARAM_MAX2 => '3',
				],
				self::STDRET,
				[
					'issues' => [
						'X',
						FloatDef::PARAM_IGNORE_RANGE => 'PARAM_IGNORE_RANGE must be boolean, got integer',
						FloatDef::PARAM_MIN => 'PARAM_MIN must be double, got integer',
						FloatDef::PARAM_MAX => 'PARAM_MAX must be double, got string',
						FloatDef::PARAM_MAX2 => 'PARAM_MAX2 must be double, got string',
					],
					'allowedKeys' => $keys,
					'messages' => [],
				],
			],
		];
	}

	public function provideStringifyValue() {
		$digits = defined( 'PHP_FLOAT_DIG' ) ? PHP_FLOAT_DIG : 15;

		return [
			[ 1.2, '1.2' ],
			[ 10 / 3, '3.' . str_repeat( '3', $digits - 1 ) ],
			[ 1e100, '1.0e+100' ],
			[ 6.022e-23, '6.022e-23' ],
		];
	}

	public function provideGetInfo() {
		return [
			'Basic test' => [
				[],
				[ 'min' => null, 'max' => null ],
				[
					ParamValidator::PARAM_TYPE => '<message key="paramvalidator-help-type-float"><text>1</text></message>',
				],
			],
			'Various settings' => [
				[
					FloatDef::PARAM_MIN => 1,
					FloatDef::PARAM_MAX => 100,
					ParamValidator::PARAM_ISMULTI => true
				],
				[ 'min' => 1, 'max' => 100 ],
				[
					FloatDef::PARAM_MIN => '<message key="paramvalidator-help-type-number-minmax"><text>2</text><num>1</num><num>100</num></message>',
					ParamValidator::PARAM_TYPE => '<message key="paramvalidator-help-type-float"><text>2</text></message>',
				],
			],
		];
	}

}
ParamValidator/TypeDef/LimitDefTest.php000066600000010300151335120240014052 0ustar00<?php

namespace Wikimedia\ParamValidator\TypeDef;

use Wikimedia\ParamValidator\ParamValidator;
use Wikimedia\ParamValidator\SimpleCallbacks;

/**
 * @covers Wikimedia\ParamValidator\TypeDef\LimitDef
 */
class LimitDefTest extends TypeDefTestCase {

	protected function getInstance( SimpleCallbacks $callbacks, array $options ) {
		return new LimitDef( $callbacks, $options );
	}

	public function provideValidate() {
		$useHigh = [ 'useHighLimits' => true ];
		$max = [ LimitDef::PARAM_MAX => 2 ];
		$max2 = [ LimitDef::PARAM_MAX => 2, LimitDef::PARAM_MAX2 => 4 ];

		yield 'Max' => [ 'max', 2, $max ];
		yield 'Max, use high' => [ 'max', 2, $max, $useHigh ];
		yield 'Max2' => [ 'max', 2, $max2 ];
		yield 'Max2, use high' => [ 'max', 4, $max2, $useHigh ];

		// Test an arbitrary number for coverage. Actual number handling is tested via
		// the base class IntegerDef's tests.
		yield 'A number' => [ '123', 123 ];
	}

	public function provideNormalizeSettings() {
		$def = [ LimitDef::PARAM_MIN => 0, ParamValidator::PARAM_ISMULTI => false ];

		return [
			[
				[],
				$def,
			],
			[
				[ ParamValidator::PARAM_ISMULTI => true ],
				[ ParamValidator::PARAM_ISMULTI => false ] + $def,
			],
			[
				[ LimitDef::PARAM_MAX => 2 ],
				[ LimitDef::PARAM_MAX => 2 ] + $def,
			],
			[
				[ LimitDef::PARAM_MIN => 1, LimitDef::PARAM_MAX => 2, LimitDef::PARAM_MAX2 => 4 ],
				[ LimitDef::PARAM_MIN => 1, LimitDef::PARAM_MAX => 2, LimitDef::PARAM_MAX2 => 4 ] + $def,
			],
			[
				[ LimitDef::PARAM_MIN => 1, LimitDef::PARAM_MAX => 4, LimitDef::PARAM_MAX2 => 2 ],
				[ LimitDef::PARAM_MIN => 1, LimitDef::PARAM_MAX => 4, LimitDef::PARAM_MAX2 => 4 ] + $def,
			],
			[
				[ LimitDef::PARAM_MAX2 => 2 ],
				$def,
			],
		];
	}

	public function provideCheckSettings() {
		$keys = [
			'Y', IntegerDef::PARAM_IGNORE_RANGE,
			IntegerDef::PARAM_MIN, IntegerDef::PARAM_MAX, IntegerDef::PARAM_MAX2
		];

		return [
			'Basic test' => [
				[
					LimitDef::PARAM_MAX => 10,
				],
				self::STDRET,
				[
					'issues' => [ 'X' ],
					'allowedKeys' => $keys,
					'messages' => [],
				],
			],
			'Test with everything' => [
				[
					LimitDef::PARAM_IGNORE_RANGE => true,
					LimitDef::PARAM_MIN => 0,
					LimitDef::PARAM_MAX => 10,
					LimitDef::PARAM_MAX2 => 100,
				],
				self::STDRET,
				[
					'issues' => [ 'X' ],
					'allowedKeys' => $keys,
					'messages' => [],
				],
			],
			'PARAM_ISMULTI not allowed' => [
				[
					ParamValidator::PARAM_ISMULTI => true,
					LimitDef::PARAM_MAX => 10,
				],
				self::STDRET,
				[
					'issues' => [
						'X',
						ParamValidator::PARAM_ISMULTI => 'PARAM_ISMULTI cannot be used for limit-type parameters',
					],
					'allowedKeys' => $keys,
					'messages' => [],
				],
			],
			'PARAM_ISMULTI not allowed, but another ISMULTI issue was already logged' => [
				[
					ParamValidator::PARAM_ISMULTI => true,
					LimitDef::PARAM_MAX => 10,
				],
				[
					'issues' => [
						ParamValidator::PARAM_ISMULTI => 'XXX',
					],
					'allowedKeys' => [ 'Y' ],
					'messages' => [],
				],
				[
					'issues' => [
						ParamValidator::PARAM_ISMULTI => 'XXX',
					],
					'allowedKeys' => $keys,
					'messages' => [],
				],
			],
			'PARAM_MIN == 0' => [
				[
					LimitDef::PARAM_MIN => 0,
					LimitDef::PARAM_MAX => 2,
				],
				self::STDRET,
				[
					'issues' => [ 'X' ],
					'allowedKeys' => $keys,
					'messages' => [],
				],
			],
			'PARAM_MIN < 0' => [
				[
					LimitDef::PARAM_MIN => -1,
					LimitDef::PARAM_MAX => 2,
				],
				self::STDRET,
				[
					'issues' => [
						'X',
						'PARAM_MIN must be greater than or equal to 0',
					],
					'allowedKeys' => $keys,
					'messages' => [],
				],
			],
			'PARAM_MAX is required' => [
				[],
				self::STDRET,
				[
					'issues' => [
						'X',
						'PARAM_MAX must be set',
					],
					'allowedKeys' => $keys,
					'messages' => [],
				],
			],
		];
	}

	public function provideGetInfo() {
		return [
			'Basic' => [
				[],
				[ 'min' => 0, 'max' => null ],
				[
					LimitDef::PARAM_MIN => '<message key="paramvalidator-help-type-number-min"><text>1</text><num>0</num><text>∞</text></message>',
					ParamValidator::PARAM_TYPE => '<message key="paramvalidator-help-type-limit"><text>1</text></message>',
				],
			],
		];
	}

}
ParamValidator/TypeDef/IntegerDefTest.php000066600000023447151335120240014411 0ustar00<?php

namespace Wikimedia\ParamValidator\TypeDef;

use Wikimedia\Message\DataMessageValue;
use Wikimedia\ParamValidator\ParamValidator;
use Wikimedia\ParamValidator\SimpleCallbacks;
use Wikimedia\ParamValidator\ValidationException;

/**
 * @covers Wikimedia\ParamValidator\TypeDef\IntegerDef
 * @covers Wikimedia\ParamValidator\TypeDef\NumericDef
 */
class IntegerDefTest extends TypeDefTestCase {

	protected function getInstance( SimpleCallbacks $callbacks, array $options ) {
		return new IntegerDef( $callbacks, $options );
	}

	/**
	 * @param string $v Representing a positive integer
	 * @return string Representing $v + 1
	 */
	private static function plusOne( $v ) {
		for ( $i = strlen( $v ) - 1; $i >= 0; $i-- ) {
			if ( $v[$i] === '9' ) {
				$v[$i] = '0';
			} else {
				$v[$i] = $v[$i] + 1;
				return $v;
			}
		}
		return '1' . $v;
	}

	public function provideValidate() {
		$badinteger = new ValidationException(
			DataMessageValue::new( 'XXX', [], 'badinteger' ),
			'test', '...', []
		);
		$outofrange = new ValidationException(
			DataMessageValue::new( 'XXX', [], 'outofrange', [
				'min' => 0, 'curmax' => 2, 'max' => 2, 'highmax' => 2
			] ),
			'test', '...', []
		);
		$outofrange2 = new ValidationException(
			DataMessageValue::new( 'XXX', [], 'outofrange', [
				'min' => 0, 'curmax' => 2, 'max' => 2, 'highmax' => 4
			] ),
			'test', '...', []
		);
		$outofrange2h = new ValidationException(
			DataMessageValue::new( 'XXX', [], 'outofrange', [
				'min' => 0, 'curmax' => 4, 'max' => 2, 'highmax' => 4
			] ),
			'test', '...', []
		);
		$asWarn = static function ( ValidationException $ex ) {
			return [
				'code' => $ex->getFailureMessage()->getCode(),
				'data' => $ex->getFailureMessage()->getData(),
			];
		};

		$minmax = [
			IntegerDef::PARAM_MIN => 0,
			IntegerDef::PARAM_MAX => 2,
		];
		$minmax2 = [
			IntegerDef::PARAM_MIN => 0,
			IntegerDef::PARAM_MAX => 2,
			IntegerDef::PARAM_MAX2 => 4,
		];
		$ignore = [
			IntegerDef::PARAM_IGNORE_RANGE => true,
		];
		$usehigh = [ 'useHighLimits' => true ];

		return [
			[ '123', 123 ],
			[ '-123', -123 ],
			[ '000123', 123 ],
			[ '000', 0 ],
			[ '-0', 0 ],
			[ (string)PHP_INT_MAX, PHP_INT_MAX ],
			[ '0000' . PHP_INT_MAX, PHP_INT_MAX ],
			[ (string)PHP_INT_MIN, PHP_INT_MIN ],
			[ '-0000' . substr( PHP_INT_MIN, 1 ), PHP_INT_MIN ],

			'Overflow' => [ self::plusOne( (string)PHP_INT_MAX ), $badinteger ],
			'Negative overflow' => [ '-' . self::plusOne( substr( PHP_INT_MIN, 1 ) ), $badinteger ],

			'Float' => [ '1.5', $badinteger ],
			'Float (e notation)' => [ '1e1', $badinteger ],
			'Bad sign (space)' => [ ' 1', $badinteger ],
			'Bad sign (newline)' => [ "\n1", $badinteger ],
			'Bogus value' => [ 'max', $badinteger ],
			'Bogus value (2)' => [ '1foo', $badinteger ],
			'Hex value' => [ '0x123', $badinteger ],
			'Newline' => [ "1\n", $badinteger ],
			'Array' => [ [ '1.5' ], $badinteger ],

			'Ok with range' => [ '1', 1, $minmax ],
			'Below minimum' => [ '-1', $outofrange, $minmax ],
			'Below minimum, ignored' => [ '-1', 0, $minmax + $ignore, [], [ $asWarn( $outofrange ) ] ],
			'Above maximum' => [ '3', $outofrange, $minmax ],
			'Above maximum, ignored' => [ '3', 2, $minmax + $ignore, [], [ $asWarn( $outofrange ) ] ],
			'Not above max2 but can\'t use it' => [ '3', $outofrange2, $minmax2, [] ],
			'Not above max2 but can\'t use it, ignored'
				=> [ '3', 2, $minmax2 + $ignore, [], [ $asWarn( $outofrange2 ) ] ],
			'Not above max2' => [ '3', 3, $minmax2, $usehigh ],
			'Above max2' => [ '5', $outofrange2h, $minmax2, $usehigh ],
			'Above max2, ignored'
				=> [ '5', 4, $minmax2 + $ignore, $usehigh, [ $asWarn( $outofrange2h ) ] ],
		];
	}

	public function provideNormalizeSettings() {
		return [
			[ [], [] ],
			[
				[ IntegerDef::PARAM_MAX => 2 ],
				[ IntegerDef::PARAM_MAX => 2 ],
			],
			[
				[ IntegerDef::PARAM_MIN => 1, IntegerDef::PARAM_MAX => 2, IntegerDef::PARAM_MAX2 => 4 ],
				[ IntegerDef::PARAM_MIN => 1, IntegerDef::PARAM_MAX => 2, IntegerDef::PARAM_MAX2 => 4 ],
			],
			[
				[ IntegerDef::PARAM_MIN => 1, IntegerDef::PARAM_MAX => 4, IntegerDef::PARAM_MAX2 => 2 ],
				[ IntegerDef::PARAM_MIN => 1, IntegerDef::PARAM_MAX => 4, IntegerDef::PARAM_MAX2 => 4 ],
			],
			[
				[ IntegerDef::PARAM_MAX2 => 2 ],
				[],
			],
		];
	}

	public function provideCheckSettings() {
		$keys = [
			'Y', IntegerDef::PARAM_IGNORE_RANGE,
			IntegerDef::PARAM_MIN, IntegerDef::PARAM_MAX, IntegerDef::PARAM_MAX2
		];

		return [
			'Basic test' => [
				[],
				self::STDRET,
				[
					'issues' => [ 'X' ],
					'allowedKeys' => $keys,
					'messages' => [],
				],
			],
			'Test with everything' => [
				[
					IntegerDef::PARAM_IGNORE_RANGE => true,
					IntegerDef::PARAM_MIN => -100,
					IntegerDef::PARAM_MAX => -90,
					IntegerDef::PARAM_MAX2 => -80,
				],
				self::STDRET,
				[
					'issues' => [ 'X' ],
					'allowedKeys' => $keys,
					'messages' => [],
				],
			],
			'Bad types' => [
				[
					IntegerDef::PARAM_IGNORE_RANGE => 1,
					IntegerDef::PARAM_MIN => 1.0,
					IntegerDef::PARAM_MAX => '2',
					IntegerDef::PARAM_MAX2 => '3',
				],
				self::STDRET,
				[
					'issues' => [
						'X',
						IntegerDef::PARAM_IGNORE_RANGE => 'PARAM_IGNORE_RANGE must be boolean, got integer',
						IntegerDef::PARAM_MIN => 'PARAM_MIN must be integer, got double',
						IntegerDef::PARAM_MAX => 'PARAM_MAX must be integer, got string',
						IntegerDef::PARAM_MAX2 => 'PARAM_MAX2 must be integer, got string',
					],
					'allowedKeys' => $keys,
					'messages' => [],
				],
			],
			'Min == max' => [
				[
					IntegerDef::PARAM_MIN => 1,
					IntegerDef::PARAM_MAX => 1,
				],
				self::STDRET,
				[
					'issues' => [ 'X' ],
					'allowedKeys' => $keys,
					'messages' => [],
				],
			],
			'Min > max' => [
				[
					IntegerDef::PARAM_MIN => 2,
					IntegerDef::PARAM_MAX => 1,
				],
				self::STDRET,
				[
					'issues' => [
						'X',
						'PARAM_MIN must be less than or equal to PARAM_MAX, but 2 > 1',
					],
					'allowedKeys' => $keys,
					'messages' => [],
				],
			],
			'Max2 without max' => [
				[
					IntegerDef::PARAM_MAX2 => 1,
				],
				self::STDRET,
				[
					'issues' => [
						'X',
						'PARAM_MAX2 cannot be used without PARAM_MAX',
					],
					'allowedKeys' => $keys,
					'messages' => [],
				],
			],
			'Max2 == max' => [
				[
					IntegerDef::PARAM_MAX => 1,
					IntegerDef::PARAM_MAX2 => 1,
				],
				self::STDRET,
				[
					'issues' => [ 'X' ],
					'allowedKeys' => $keys,
					'messages' => [],
				],
			],
			'Max2 < max' => [
				[
					IntegerDef::PARAM_MAX => -10,
					IntegerDef::PARAM_MAX2 => -11,
				],
				self::STDRET,
				[
					'issues' => [
						'X',
						'PARAM_MAX2 must be greater than or equal to PARAM_MAX, but -11 < -10',
					],
					'allowedKeys' => $keys,
					'messages' => [],
				],
			],
		];
	}

	public function provideGetInfo() {
		return [
			'Basic' => [
				[],
				[ 'min' => null, 'max' => null ],
				[
					ParamValidator::PARAM_TYPE => '<message key="paramvalidator-help-type-integer"><text>1</text></message>',
				],
			],
			'Min' => [
				[ IntegerDef::PARAM_MIN => 0, ParamValidator::PARAM_ISMULTI => true ],
				[ 'min' => 0, 'max' => null ],
				[
					IntegerDef::PARAM_MIN => '<message key="paramvalidator-help-type-number-min"><text>2</text><num>0</num><text>∞</text></message>',
					ParamValidator::PARAM_TYPE => '<message key="paramvalidator-help-type-integer"><text>2</text></message>',
				],
			],
			'Max' => [
				[ IntegerDef::PARAM_MAX => 2 ],
				[ 'min' => null, 'max' => 2 ],
				[
					IntegerDef::PARAM_MIN => '<message key="paramvalidator-help-type-number-max"><text>1</text><text>−∞</text><num>2</num></message>',
					ParamValidator::PARAM_TYPE => '<message key="paramvalidator-help-type-integer"><text>1</text></message>',
				],
			],
			'Max2' => [
				[ IntegerDef::PARAM_MAX => 2, IntegerDef::PARAM_MAX2 => 4 ],
				[ 'min' => null, 'max' => 2, 'highmax' => 4 ],
				[
					IntegerDef::PARAM_MIN => '<message key="paramvalidator-help-type-number-max"><text>1</text><text>−∞</text><num>2</num></message>',
					ParamValidator::PARAM_TYPE => '<message key="paramvalidator-help-type-integer"><text>1</text></message>',
				],
			],
			'Max2, highlimits' => [
				[ IntegerDef::PARAM_MAX => 2, IntegerDef::PARAM_MAX2 => 4 ],
				[ 'min' => null, 'max' => 2, 'highmax' => 4 ],
				[
					IntegerDef::PARAM_MIN => '<message key="paramvalidator-help-type-number-max"><text>1</text><text>−∞</text><num>4</num></message>',
					ParamValidator::PARAM_TYPE => '<message key="paramvalidator-help-type-integer"><text>1</text></message>',
				],
				[ 'useHighLimits' => true ],
			],
			'Minmax' => [
				[ IntegerDef::PARAM_MIN => 0, IntegerDef::PARAM_MAX => 2 ],
				[ 'min' => 0, 'max' => 2 ],
				[
					IntegerDef::PARAM_MIN => '<message key="paramvalidator-help-type-number-minmax"><text>1</text><num>0</num><num>2</num></message>',
					ParamValidator::PARAM_TYPE => '<message key="paramvalidator-help-type-integer"><text>1</text></message>',
				],
			],
			'Minmax2' => [
				[ IntegerDef::PARAM_MIN => 0, IntegerDef::PARAM_MAX => 2, IntegerDef::PARAM_MAX2 => 4 ],
				[ 'min' => 0, 'max' => 2, 'highmax' => 4 ],
				[
					IntegerDef::PARAM_MIN => '<message key="paramvalidator-help-type-number-minmax"><text>1</text><num>0</num><num>2</num></message>',
					ParamValidator::PARAM_TYPE => '<message key="paramvalidator-help-type-integer"><text>1</text></message>',
				],
			],
			'Minmax2, highlimits' => [
				[
					IntegerDef::PARAM_MIN => 0, IntegerDef::PARAM_MAX => 2, IntegerDef::PARAM_MAX2 => 4,
					ParamValidator::PARAM_ISMULTI => true
				],
				[ 'min' => 0, 'max' => 2, 'highmax' => 4 ],
				[
					IntegerDef::PARAM_MIN => '<message key="paramvalidator-help-type-number-minmax"><text>2</text><num>0</num><num>4</num></message>',
					ParamValidator::PARAM_TYPE => '<message key="paramvalidator-help-type-integer"><text>2</text></message>',
				],
				[ 'useHighLimits' => true ],
			],
		];
	}

}
ParamValidator/TypeDef/ExpiryDefTest.php000066600000012067151335120240014270 0ustar00<?php

namespace Wikimedia\ParamValidator\TypeDef;

use InvalidArgumentException;
use Wikimedia\Message\DataMessageValue;
use Wikimedia\ParamValidator\SimpleCallbacks;
use Wikimedia\ParamValidator\ValidationException;
use Wikimedia\Timestamp\ConvertibleTimestamp;

/**
 * @covers \Wikimedia\ParamValidator\TypeDef\ExpiryDef
 */
class ExpiryDefTest extends TypeDefTestCase {

	protected function getInstance( SimpleCallbacks $callbacks, array $options ) {
		return new ExpiryDef( $callbacks, $options );
	}

	/**
	 * Get an entry for the provideValidate() provider, where a given value
	 * is asserted to cause a ValidationException with the given message.
	 * @param string $value
	 * @param string $msg
	 * @param array $settings
	 * @return array
	 */
	private function getValidationAssertion( string $value, string $msg, array $settings = [] ) {
		return [
			$value,
			new ValidationException(
				DataMessageValue::new( $msg ),
				'expiry',
				$value,
				[]
			),
			$settings
		];
	}

	/**
	 * @dataProvider provideValidate
	 */
	public function testValidate(
		$value, $expect, array $settings = [], array $options = [], array $expectConds = []
	) {
		ConvertibleTimestamp::setFakeTime( 1559764242 );
		parent::testValidate( $value, $expect, $settings, $options, $expectConds );
	}

	public function provideValidate() {
		$settings = [
			ExpiryDef::PARAM_MAX => '6 months',
			ExpiryDef::PARAM_USE_MAX => true,
		];

		return [
			'Valid infinity' => [ 'indefinite', 'infinity' ],
			'Invalid expiry' => $this->getValidationAssertion( 'foobar', 'badexpiry' ),
			'Expiry in past' => $this->getValidationAssertion( '20150123T12:34:56Z', 'badexpiry-past' ),
			'Expiry in past with unix 0' => $this->getValidationAssertion(
				'1970-01-01T00:00:00Z',
				'badexpiry-past'
			),
			'Expiry in past with negative unix time' => $this->getValidationAssertion(
				'1969-12-31T23:59:59Z',
				'badexpiry-past',
				$settings
			),
			'Valid expiry' => [
				'99990123123456',
				'9999-01-23T12:34:56Z'
			],
			'Valid relative expiry' => [
				'1 month',
				'2019-07-05T19:50:42Z'
			],
			'Expiry less than max' => [ '20190701123456', '2019-07-01T12:34:56Z', $settings ],
			'Relative expiry less than max' => [ '1 day', '2019-06-06T19:50:42Z', $settings ],
			'Infinity less than max' => [ 'indefinite', 'infinity', $settings ],
			'Expiry exceeds max' => [
				'9999-01-23T12:34:56Z',
				'2019-12-05T19:50:42Z',
				$settings,
				[],
				[
					[
						'code' => 'paramvalidator-badexpiry-duration-max',
						'data' => null,
					]
				],
			],
			'Relative expiry exceeds max' => [
				'10 years',
				'2019-12-05T19:50:42Z',
				$settings,
				[],
				[
					[
						'code' => 'paramvalidator-badexpiry-duration-max',
						'data' => null,
					]
				],
			],
			'Expiry exceeds max, fatal' => $this->getValidationAssertion(
				'9999-01-23T12:34:56Z',
				'paramvalidator-badexpiry-duration',
				[
					ExpiryDef::PARAM_MAX => '6 months',
				]
			),
		];
	}

	public function testNormalizeExpiry() {
		$this->assertNull( ExpiryDef::normalizeExpiry( null ) );
		$this->assertSame(
			'infinity',
			ExpiryDef::normalizeExpiry( 'indefinite' )
		);
		$this->assertSame(
			'2050-01-01T00:00:00Z',
			ExpiryDef::normalizeExpiry( '205001010000', TS_ISO_8601 )
		);
		$this->assertSame(
			'1970-01-01T00:00:00Z',
			ExpiryDef::normalizeExpiry( '1970-01-01T00:00:00Z', TS_ISO_8601 )
		);
		$this->expectException( InvalidArgumentException::class );
		$this->expectExceptionMessage( 'Invalid expiry value: 0' );
		ExpiryDef::normalizeExpiry( 0, TS_ISO_8601 );
	}

	public function provideGetInfo() {
		return [
			'Basic' => [
				[],
				[],
				[
					'param-type' => '<message key="paramvalidator-help-type-expiry"><text>1</text><list listType="text"><text>&quot;infinite&quot;</text><text>&quot;indefinite&quot;</text><text>&quot;infinity&quot;</text><text>&quot;never&quot;</text></list></message>'
				]
			]
		];
	}

	public function provideCheckSettings() {
		$keys = [ 'Y', ExpiryDef::PARAM_USE_MAX, ExpiryDef::PARAM_MAX ];
		return [
			'Basic test' => [
				[],
				self::STDRET,
				[
					'issues' => [ 'X' ],
					'allowedKeys' => $keys,
					'messages' => [],
				],
			]
		];
	}

	/**
	 * @covers \Wikimedia\ParamValidator\TypeDef\ExpiryDef::normalizeUsingMaxExpiry
	 */
	public function testNormalizeUsingMaxExpiry() {
		// Fake current time to be 2020-05-27T00:00:00Z
		$fakeTime = ConvertibleTimestamp::setFakeTime( '20200527000000' );
		$this->assertSame(
			'2020-11-27T00:00:00Z',
			ExpiryDef::normalizeUsingMaxExpiry( '10 months', '6 months', TS_ISO_8601 )
		);
		$this->assertSame(
			'2020-10-27T00:00:00Z',
			ExpiryDef::normalizeUsingMaxExpiry( '2020-10-27T00:00:00Z', '6 months', TS_ISO_8601 )
		);
		$this->assertSame(
			'infinity',
			ExpiryDef::normalizeUsingMaxExpiry( 'infinity', '6 months', TS_ISO_8601 )
		);
		$this->assertNull( ExpiryDef::normalizeUsingMaxExpiry( null, '6 months', TS_ISO_8601 ) );

		$this->expectException( InvalidArgumentException::class );
		$this->expectExceptionMessage( 'Invalid expiry value: invalid expiry' );
		ExpiryDef::normalizeUsingMaxExpiry( 'invalid expiry', '6 months', TS_ISO_8601 );
	}
}
ParamValidator/TypeDef/BooleanDefTest.php000066600000003412151335120240014361 0ustar00<?php

namespace Wikimedia\ParamValidator\TypeDef;

use Wikimedia\Message\DataMessageValue;
use Wikimedia\ParamValidator\ParamValidator;
use Wikimedia\ParamValidator\SimpleCallbacks;
use Wikimedia\ParamValidator\ValidationException;

/**
 * @covers \Wikimedia\ParamValidator\TypeDef\BooleanDef
 */
class BooleanDefTest extends TypeDefTestCase {

	protected function getInstance( SimpleCallbacks $callbacks, array $options ) {
		return new BooleanDef( $callbacks, $options );
	}

	public function provideValidate() {
		foreach ( [
			[ BooleanDef::$TRUEVALS, true ],
			[ BooleanDef::$FALSEVALS, false ],
			[ [ '' ], false ],
		] as [ $vals, $expect ] ) {
			foreach ( $vals as $v ) {
				yield "Value '$v'" => [ $v, $expect ];
				$v2 = ucfirst( $v );
				if ( $v2 !== $v ) {
					yield "Value '$v2'" => [ $v2, $expect ];
				}
				$v3 = strtoupper( $v );
				if ( $v3 !== $v2 ) {
					yield "Value '$v3'" => [ $v3, $expect ];
				}
			}
		}

		yield "Value '2'" => [ 2, new ValidationException(
			DataMessageValue::new( 'paramvalidator-badbool', [], 'badbool' ),
			'test', '2', []
		) ];

		yield "Value 'foobar'" => [ 'foobar', new ValidationException(
			DataMessageValue::new( 'paramvalidator-badbool', [], 'badbool' ),
			'test', 'foobar', []
		) ];
	}

	public function provideStringifyValue() {
		return [
			[ true, 'true' ],
			[ false, 'false' ],
		];
	}

	public function provideGetInfo() {
		return [
			'Basic test' => [
				[],
				[],
				[
					ParamValidator::PARAM_TYPE => '<message key="paramvalidator-help-type-boolean"><text>1</text></message>',
				],
			],
			'Multi-valued' => [
				[ ParamValidator::PARAM_ISMULTI => true ],
				[],
				[
					ParamValidator::PARAM_TYPE => '<message key="paramvalidator-help-type-boolean"><text>2</text></message>',
				],
			],
		];
	}

}
ParamValidator/TypeDef/StringDefTest.php000066600000012531151335120240014252 0ustar00<?php

namespace Wikimedia\ParamValidator\TypeDef;

use Wikimedia\Message\DataMessageValue;
use Wikimedia\ParamValidator\ParamValidator;
use Wikimedia\ParamValidator\SimpleCallbacks;
use Wikimedia\ParamValidator\ValidationException;

/**
 * @covers Wikimedia\ParamValidator\TypeDef\StringDef
 */
class StringDefTest extends TypeDefTestCase {

	protected function getInstance( SimpleCallbacks $callbacks, array $options ) {
		return new StringDef( $callbacks, $options );
	}

	public function provideValidate() {
		$req = [
			ParamValidator::PARAM_REQUIRED => true,
		];
		$maxBytes = [
			StringDef::PARAM_MAX_BYTES => 4,
		];
		$maxChars = [
			StringDef::PARAM_MAX_CHARS => 2,
		];

		return [
			'Basic' => [ '123', '123' ],
			'Empty' => [ '', '' ],
			'Empty, required' => [
				'',
				new ValidationException(
					DataMessageValue::new( 'paramvalidator-missingparam', [], 'missingparam' ),
					'test', '', []
				),
				$req,
			],
			'Empty, required, allowed' => [ '', '', $req, [ 'allowEmptyWhenRequired' => true ] ],
			'Max bytes, ok' => [ 'abcd', 'abcd', $maxBytes ],
			'Max bytes, exceeded' => [
				'abcde',
				new ValidationException(
					DataMessageValue::new( 'paramvalidator-maxbytes', [], 'maxbytes', [
						'maxbytes' => 4, 'maxchars' => null,
					] ),
					'test', '', []
				),
				$maxBytes,
			],
			'Max bytes, ok (2)' => [ '😄', '😄', $maxBytes ],
			'Max bytes, exceeded (2)' => [
				'😭?',
				new ValidationException(
					DataMessageValue::new( 'paramvalidator-maxbytes', [], 'maxbytes', [
						'maxbytes' => 4, 'maxchars' => null,
					] ),
					'test', '', []
				),
				$maxBytes,
			],
			'Max chars, ok' => [ 'ab', 'ab', $maxChars ],
			'Max chars, exceeded' => [
				'abc',
				new ValidationException(
					DataMessageValue::new( 'paramvalidator-maxchars', [], 'maxchars', [
						'maxbytes' => null, 'maxchars' => 2,
					] ),
					'test', '', []
				),
				$maxChars,
			],
			'Max chars, ok (2)' => [ '😄😄', '😄😄', $maxChars ],
			'Max chars, exceeded (2)' => [
				'😭??',
				new ValidationException(
					DataMessageValue::new( 'paramvalidator-maxchars', [], 'maxchars', [
						'maxbytes' => null, 'maxchars' => 2,
					] ),
					'test', '', []
				),
				$maxChars,
			],
		];
	}

	public function provideCheckSettings() {
		$keys = [ 'Y', StringDef::PARAM_MAX_BYTES, StringDef::PARAM_MAX_CHARS ];

		return [
			'Basic test' => [
				[],
				self::STDRET,
				[
					'issues' => [ 'X' ],
					'allowedKeys' => $keys,
					'messages' => [],
				],
			],
			'Test with everything' => [
				[
					StringDef::PARAM_MAX_BYTES => 255,
					StringDef::PARAM_MAX_CHARS => 100,
				],
				self::STDRET,
				[
					'issues' => [ 'X' ],
					'allowedKeys' => $keys,
					'messages' => [],
				],
			],
			'Bad types' => [
				[
					StringDef::PARAM_MAX_BYTES => '255',
					StringDef::PARAM_MAX_CHARS => 100.0,
				],
				self::STDRET,
				[
					'issues' => [
						'X',
						StringDef::PARAM_MAX_BYTES => 'PARAM_MAX_BYTES must be an integer, got string',
						StringDef::PARAM_MAX_CHARS => 'PARAM_MAX_CHARS must be an integer, got double',
					],
					'allowedKeys' => $keys,
					'messages' => [],
				],
			],
			'Out of range' => [
				[
					StringDef::PARAM_MAX_BYTES => -1,
					StringDef::PARAM_MAX_CHARS => -1,
				],
				self::STDRET,
				[
					'issues' => [
						'X',
						StringDef::PARAM_MAX_BYTES => 'PARAM_MAX_BYTES must be greater than or equal to 0',
						StringDef::PARAM_MAX_CHARS => 'PARAM_MAX_CHARS must be greater than or equal to 0',
					],
					'allowedKeys' => $keys,
					'messages' => [],
				],
			],
			'Zero not allowed when required and !allowEmptyWhenRequired' => [
				[
					ParamValidator::PARAM_REQUIRED => true,
					StringDef::PARAM_MAX_BYTES => 0,
					StringDef::PARAM_MAX_CHARS => 0,
				],
				self::STDRET,
				[
					'issues' => [
						'X',
						'PARAM_REQUIRED is set, allowEmptyWhenRequired is not set, and PARAM_MAX_BYTES is 0. That\'s impossible to satisfy.',
						'PARAM_REQUIRED is set, allowEmptyWhenRequired is not set, and PARAM_MAX_CHARS is 0. That\'s impossible to satisfy.',
					],
					'allowedKeys' => $keys,
					'messages' => [],
				],
				[ 'allowEmptyWhenRequired' => false ],
			],
			'Zero allowed when not required' => [
				[
					StringDef::PARAM_MAX_BYTES => 0,
					StringDef::PARAM_MAX_CHARS => 0,
				],
				self::STDRET,
				[
					'issues' => [ 'X' ],
					'allowedKeys' => $keys,
					'messages' => [],
				],
				[ 'allowEmptyWhenRequired' => false ],
			],
			'Zero allowed when allowEmptyWhenRequired' => [
				[
					ParamValidator::PARAM_REQUIRED => true,
					StringDef::PARAM_MAX_BYTES => 0,
					StringDef::PARAM_MAX_CHARS => 0,
				],
				self::STDRET,
				[
					'issues' => [ 'X' ],
					'allowedKeys' => $keys,
					'messages' => [],
				],
				[ 'allowEmptyWhenRequired' => true ],
			],
		];
	}

	public function provideGetInfo() {
		return [
			'Basic test' => [
				[],
				[ 'maxbytes' => null, 'maxchars' => null ],
				[],
			],
			'With settings' => [
				[
					StringDef::PARAM_MAX_BYTES => 4,
					StringDef::PARAM_MAX_CHARS => 2,
					ParamValidator::PARAM_ISMULTI => true,
				],
				[ 'maxbytes' => 4, 'maxchars' => 2 ],
				[
					StringDef::PARAM_MAX_BYTES => '<message key="paramvalidator-help-type-string-maxbytes"><num>4</num></message>',
					StringDef::PARAM_MAX_CHARS => '<message key="paramvalidator-help-type-string-maxchars"><num>2</num></message>',
				],
			],
		];
	}

}
ParamValidator/TypeDef/PresenceBooleanDefTest.php000066600000006253151335120240016054 0ustar00<?php

namespace Wikimedia\ParamValidator\TypeDef;

use Wikimedia\ParamValidator\ParamValidator;
use Wikimedia\ParamValidator\SimpleCallbacks;

/**
 * @covers Wikimedia\ParamValidator\TypeDef\PresenceBooleanDef
 */
class PresenceBooleanDefTest extends TypeDefTestCase {

	protected function getInstance( SimpleCallbacks $callbacks, array $options ) {
		return new PresenceBooleanDef( $callbacks, $options );
	}

	public function provideValidate() {
		return [
			[ null, false ],
			[ '', true ],
			[ '0', true ],
			[ '1', true ],
			[ 'anything really', true ],
		];
	}

	public function provideNormalizeSettings() {
		return [
			[
				[],
				[ ParamValidator::PARAM_ISMULTI => false, ParamValidator::PARAM_DEFAULT => false ],
			],
			[
				[ ParamValidator::PARAM_ISMULTI => true ],
				[ ParamValidator::PARAM_ISMULTI => false, ParamValidator::PARAM_DEFAULT => false ],
			],
			[
				[ ParamValidator::PARAM_DEFAULT => null ],
				[ ParamValidator::PARAM_DEFAULT => null, ParamValidator::PARAM_ISMULTI => false ],
			],
		];
	}

	public function provideCheckSettings() {
		return [
			'Basic test' => [
				[],
				self::STDRET,
				self::STDRET,
			],
			'PARAM_ISMULTI not allowed' => [
				[
					ParamValidator::PARAM_ISMULTI => true,
				],
				self::STDRET,
				[
					'issues' => [
						'X',
						ParamValidator::PARAM_ISMULTI
							=> 'PARAM_ISMULTI cannot be used for presence-boolean-type parameters',
					],
					'allowedKeys' => [ 'Y' ],
					'messages' => [],
				],
			],
			'PARAM_ISMULTI not allowed, but another ISMULTI issue was already logged' => [
				[
					ParamValidator::PARAM_ISMULTI => true,
				],
				[
					'issues' => [
						ParamValidator::PARAM_ISMULTI => 'XXX',
					],
					'allowedKeys' => [ 'Y' ],
					'messages' => [],
				],
				[
					'issues' => [
						ParamValidator::PARAM_ISMULTI => 'XXX',
					],
					'allowedKeys' => [ 'Y' ],
					'messages' => [],
				],
			],
			'PARAM_DEFAULT can be false' => [
				[ ParamValidator::PARAM_DEFAULT => false ],
				self::STDRET,
				self::STDRET,
			],
			'PARAM_DEFAULT can be null' => [
				[ ParamValidator::PARAM_DEFAULT => null ],
				self::STDRET,
				self::STDRET,
			],
			'PARAM_DEFAULT cannot be true' => [
				[
					ParamValidator::PARAM_DEFAULT => true,
				],
				self::STDRET,
				[
					'issues' => [
						'X',
						ParamValidator::PARAM_DEFAULT
							=> 'Default for presence-boolean-type parameters must be false or null',
					],
					'allowedKeys' => [ 'Y' ],
					'messages' => [],
				],
			],
			'PARAM_DEFAULT invalid, but another DEFAULT issue was already logged' => [
				[
					ParamValidator::PARAM_DEFAULT => true,
				],
				[
					'issues' => [
						ParamValidator::PARAM_DEFAULT => 'XXX',
					],
					'allowedKeys' => [ 'Y' ],
					'messages' => [],
				],
				[
					'issues' => [
						ParamValidator::PARAM_DEFAULT => 'XXX',
					],
					'allowedKeys' => [ 'Y' ],
					'messages' => [],
				],
			],
		];
	}

	public function provideGetInfo() {
		return [
			'Basic test' => [
				[],
				[ 'default' => null ],
				[
					ParamValidator::PARAM_TYPE => '<message key="paramvalidator-help-type-presenceboolean"><text>1</text></message>',
					ParamValidator::PARAM_DEFAULT => null,
				],
			],
		];
	}

}
ParamValidator/TypeDef/PasswordDefTest.php000066600000003070151335120240014604 0ustar00<?php

namespace Wikimedia\ParamValidator\TypeDef;

use Wikimedia\ParamValidator\ParamValidator;
use Wikimedia\ParamValidator\SimpleCallbacks;

require_once __DIR__ . '/StringDefTest.php';

/**
 * @covers Wikimedia\ParamValidator\TypeDef\PasswordDef
 */
class PasswordDefTest extends StringDefTest {

	protected function getInstance( SimpleCallbacks $callbacks, array $options ) {
		return new PasswordDef( $callbacks, $options );
	}

	public function provideNormalizeSettings() {
		return [
			[ [], [ ParamValidator::PARAM_SENSITIVE => true ] ],
			[ [ ParamValidator::PARAM_SENSITIVE => false ], [ ParamValidator::PARAM_SENSITIVE => true ] ],
		];
	}

	public function provideCheckSettings() {
		$keys = [ 'Y', StringDef::PARAM_MAX_BYTES, StringDef::PARAM_MAX_CHARS ];

		yield from parent::provideCheckSettings();

		yield 'PARAM_SENSITIVE cannot be false' => [
			[
				ParamValidator::PARAM_SENSITIVE => false,
			],
			self::STDRET,
			[
				'issues' => [
					'X',
					ParamValidator::PARAM_SENSITIVE
						=> 'Cannot set PARAM_SENSITIVE to false for password-type parameters',
				],
				'allowedKeys' => $keys,
				'messages' => [],
			],
		];
		yield 'PARAM_SENSITIVE cannot be false, but another PARAM_SENSITIVE issue was already logged' => [
			[
				ParamValidator::PARAM_SENSITIVE => false,
			],
			[
				'issues' => [
					ParamValidator::PARAM_SENSITIVE => 'XXX',
				],
				'allowedKeys' => [ 'Y' ],
				'messages' => [],
			],
			[
				'issues' => [
					ParamValidator::PARAM_SENSITIVE => 'XXX',
				],
				'allowedKeys' => $keys,
				'messages' => [],
			],
		];
	}

}
ParamValidator/TypeDef/TypeDefTestCaseTrait.php000066600000016142151335120240015527 0ustar00<?php

namespace Wikimedia\ParamValidator\TypeDef;

use Wikimedia\Message\DataMessageValue;
use Wikimedia\Message\MessageValue;
use Wikimedia\ParamValidator\SimpleCallbacks;
use Wikimedia\ParamValidator\TypeDef;
use Wikimedia\ParamValidator\ValidationException;
use Wikimedia\Timestamp\ConvertibleTimestamp;

/**
 * Test case infrastructure for TypeDef subclasses
 *
 * Generally you'll only need to implement self::getInstance() and
 * data providers methods.
 */
trait TypeDefTestCaseTrait {

	/**
	 * Reset any fake timestamps so that they don't mess with any other tests.
	 *
	 * @after
	 */
	protected function fakeTimestampTearDown() {
		ConvertibleTimestamp::setFakeTime( null );
	}

	/**
	 * Create a SimpleCallbacks for testing
	 *
	 * The object created here should result in a call to the TypeDef's
	 * `getValue( 'test' )` returning an appropriate result for testing.
	 *
	 * @param mixed $value Value to return for 'test'
	 * @param array $options Options array.
	 * @return SimpleCallbacks
	 */
	protected function getCallbacks( $value, array $options ) {
		return new SimpleCallbacks( $value === null ? [] : [ 'test' => $value ] );
	}

	/**
	 * Create an instance of the TypeDef subclass being tested
	 *
	 * @param SimpleCallbacks $callbacks From $this->getCallbacks()
	 * @param array $options Options array.
	 * @return TypeDef
	 */
	abstract protected function getInstance( SimpleCallbacks $callbacks, array $options );

	/**
	 * @dataProvider provideValidate
	 * @param mixed $value Value for getCallbacks()
	 * @param mixed|ValidationException $expect Expected result from TypeDef::validate().
	 *  If a ValidationException, it is expected that a ValidationException
	 *  with matching failure code and data will be thrown. Otherwise, the return value must be equal.
	 * @param array $settings Settings array.
	 * @param array $options Options array
	 * @param array[] $expectConds Expected condition codes and data reported.
	 */
	public function testValidate(
		$value, $expect, array $settings = [], array $options = [], array $expectConds = []
	) {
		$callbacks = $this->getCallbacks( $value, $options );
		$typeDef = $this->getInstance( $callbacks, $options );
		$settings = $typeDef->normalizeSettings( $settings );

		if ( $expect instanceof ValidationException ) {
			try {
				$v = $typeDef->getValue( 'test', $settings, $options );
				$typeDef->validate( 'test', $v, $settings, $options );
				$this->fail( 'Expected exception not thrown' );
			} catch ( ValidationException $ex ) {
				$this->assertSame(
					$expect->getFailureMessage()->getCode(),
					$ex->getFailureMessage()->getCode()
				);
				$this->assertSame(
					$expect->getFailureMessage()->getData(),
					$ex->getFailureMessage()->getData()
				);
			}
		} else {
			$v = $typeDef->getValue( 'test', $settings, $options );
			$this->assertEquals( $expect, $typeDef->validate( 'test', $v, $settings, $options ) );
		}

		$conditions = [];
		foreach ( $callbacks->getRecordedConditions() as $c ) {
			$conditions[] = [ 'code' => $c['message']->getCode(), 'data' => $c['message']->getData() ];
		}
		$this->assertSame( $expectConds, $conditions );
	}

	/**
	 * @return array|Iterable
	 */
	abstract public function provideValidate();

	/**
	 * @param array $settings
	 * @param array $expect
	 * @param array $options
	 * @dataProvider provideNormalizeSettings
	 */
	public function testNormalizeSettings( array $settings, array $expect, array $options = [] ) {
		$typeDef = $this->getInstance( new SimpleCallbacks( [] ), $options );
		$this->assertSame( $expect, $typeDef->normalizeSettings( $settings ) );
	}

	/**
	 * @return array|Iterable
	 */
	public function provideNormalizeSettings() {
		return [
			'Basic test' => [ [ 'param-foo' => 'bar' ], [ 'param-foo' => 'bar' ] ],
		];
	}

	/**
	 * @dataProvider provideCheckSettings
	 * @param array $settings
	 * @param array $ret Input $ret array
	 * @param array $expect
	 * @param array $options Options array
	 */
	public function testCheckSettings(
		array $settings,
		array $ret,
		array $expect,
		array $options = []
	): void {
		$typeDef = $this->getInstance( new SimpleCallbacks( [] ), $options );
		$this->assertEquals( $expect, $typeDef->checkSettings( 'test', $settings, $options, $ret ) );
	}

	/**
	 * @return array|Iterable
	 */
	public function provideCheckSettings() {
		return [
			'Basic test' => [ [], self::STDRET, self::STDRET ],
		];
	}

	/**
	 * @param array $settings
	 * @param array|null $expect
	 * @param array $options Options array
	 * @dataProvider provideGetEnumValues
	 */
	public function testGetEnumValues( array $settings, $expect, array $options = [] ) {
		$typeDef = $this->getInstance( new SimpleCallbacks( [] ), $options );
		$settings = $typeDef->normalizeSettings( $settings );

		$this->assertSame( $expect, $typeDef->getEnumValues( 'test', $settings, $options ) );
	}

	/**
	 * @return array|Iterable
	 */
	public function provideGetEnumValues() {
		return [
			'Basic test' => [ [], null ],
		];
	}

	/**
	 * @param mixed $value
	 * @param string $expect
	 * @param array $settings
	 * @param array $options
	 * @dataProvider provideStringifyValue
	 *
	 */
	public function testStringifyValue( $value, $expect, array $settings = [], array $options = [] ) {
		$typeDef = $this->getInstance( new SimpleCallbacks( [] ), $options );
		$settings = $typeDef->normalizeSettings( $settings );

		$this->assertSame( $expect, $typeDef->stringifyValue( 'test', $value, $settings, $options ) );
	}

	/**
	 * @return array|Iterable
	 */
	public function provideStringifyValue() {
		return [
			'Basic test' => [ 123, '123' ],
		];
	}

	/**
	 * @param array $settings
	 * @param array $expectParamInfo
	 * @param array $expectHelpInfo
	 * @param array $options
	 * @dataProvider provideGetInfo
	 */
	public function testGetInfo(
		array $settings, array $expectParamInfo, array $expectHelpInfo, array $options = []
	) {
		$typeDef = $this->getInstance( new SimpleCallbacks( [] ), $options );
		$settings = $typeDef->normalizeSettings( $settings );

		$this->assertSame(
			$expectParamInfo,
			$typeDef->getParamInfo( 'test', $settings, $options )
		);

		$actual = [];
		$constraint = \PHPUnit\Framework\Assert::logicalOr(
			$this->isNull(),
			$this->isInstanceOf( MessageValue::class )
		);
		foreach ( $typeDef->getHelpInfo( 'test', $settings, $options ) as $k => $v ) {
			$this->assertThat( $v, $constraint );
			$actual[$k] = $v ? $v->dump() : null;
		}
		$this->assertSame( $expectHelpInfo, $actual );
	}

	/**
	 * @return array|Iterable
	 */
	public function provideGetInfo() {
		return [
			'Basic test' => [ [], [], [] ],
		];
	}

	/**
	 * Create a ValidationException that's identical to what the typedef object would throw.
	 * @param string $code Error code, same as what was passed to TypeDef::failure().
	 * @param mixed $value Parameter value (ie. second argument to TypeDef::validate()).
	 * @param array $settings Settings object (ie. third argument to TypeDef::validate()).
	 * @return ValidationException
	 */
	protected function getValidationException(
		string $code, $value, array $settings = []
	): ValidationException {
		return new ValidationException(
			DataMessageValue::new( "paramvalidator-$code", [], $code ),
			'test', $value, $settings );
	}

}
XmlTypeCheckTest.php000066600000004207151335120240010460 0ustar00<?php
/**
 * @author physikerwelt
 * @group Xml
 * @covers XmlTypeCheck
 */
class XmlTypeCheckTest extends PHPUnit\Framework\TestCase {
	use MediaWikiCoversValidator;

	private const WELL_FORMED_XML = "<root><child /></root>";
	private const MAL_FORMED_XML = "<root><child /></error>";
	private const XML_WITH_PIH = '<?xml version="1.0"?><?xml-stylesheet type="text/xsl" href="/w/index.php"?><svg><child /></svg>';

	public function testWellFormedXML() {
		$testXML = XmlTypeCheck::newFromString( self::WELL_FORMED_XML );
		$this->assertTrue( $testXML->wellFormed );
		$this->assertEquals( 'root', $testXML->getRootElement() );
	}

	public function testMalFormedXML() {
		$testXML = XmlTypeCheck::newFromString( self::MAL_FORMED_XML );
		$this->assertFalse( $testXML->wellFormed );
	}

	/**
	 * Verify we check for recursive entity DOS
	 *
	 * (If the DOS isn't properly handled, the test runner will probably go OOM...)
	 */
	public function testRecursiveEntity() {
		$xml = <<<'XML'
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE foo [
	<!ENTITY test "&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;">
	<!ENTITY a "&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;">
	<!ENTITY b "&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;">
	<!ENTITY c "&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;">
	<!ENTITY d "&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;">
	<!ENTITY e "&f;&f;&f;&f;&f;&f;&f;&f;&f;&f;&f;&f;&f;&f;&f;&f;&f;&f;&f;&f;&f;&f;&f;&f;">
	<!ENTITY f "&g;&g;&g;&g;&g;&g;&g;&g;&g;&g;&g;&g;&g;&g;&g;&g;&g;&g;&g;&g;&g;&g;&g;&g;">
	<!ENTITY g "-00000000000000000000000000000000000000000000000000000000000000000000000-">
]>
<foo>
<bar>&test;</bar>
</foo>
XML;
		$check = XmlTypeCheck::newFromString( $xml );
		$this->assertFalse( $check->wellFormed );
	}

	public function testProcessingInstructionHandler() {
		$called = false;
		$testXML = new XmlTypeCheck(
			self::XML_WITH_PIH,
			null,
			false,
			[
				'processing_instruction_handler' => static function () use ( &$called ) {
					$called = true;
				}
			]
		);
		$this->assertTrue( $called );
	}
}
rdbms/database/DatabaseTest.php000066600000071002151335120240012474 0ustar00<?php

use MediaWiki\Tests\Unit\Libs\Rdbms\AddQuoterMock;
use MediaWiki\Tests\Unit\Libs\Rdbms\SQLPlatformTestHelper;
use Psr\Log\NullLogger;
use Wikimedia\Rdbms\Database;
use Wikimedia\Rdbms\Database\DatabaseFlags;
use Wikimedia\Rdbms\DatabaseDomain;
use Wikimedia\Rdbms\DBLanguageError;
use Wikimedia\Rdbms\DBReadOnlyRoleError;
use Wikimedia\Rdbms\DBTransactionStateError;
use Wikimedia\Rdbms\DBUnexpectedError;
use Wikimedia\Rdbms\IDatabase;
use Wikimedia\Rdbms\IResultWrapper;
use Wikimedia\Rdbms\LBFactorySingle;
use Wikimedia\Rdbms\Platform\SQLPlatform;
use Wikimedia\Rdbms\QueryStatus;
use Wikimedia\Rdbms\Replication\ReplicationReporter;
use Wikimedia\Rdbms\TransactionManager;
use Wikimedia\RequestTimeout\CriticalSectionScope;
use Wikimedia\TestingAccessWrapper;

/**
 * @dataProvider provideAddQuotes
 * @covers Wikimedia\Rdbms\Database
 * @covers Wikimedia\Rdbms\Database\DatabaseFlags
 * @covers Wikimedia\Rdbms\Platform\SQLPlatform
 */
class DatabaseTest extends PHPUnit\Framework\TestCase {

	use MediaWikiCoversValidator;

	/** @var DatabaseTestHelper */
	private $db;

	protected function setUp(): void {
		$this->db = new DatabaseTestHelper( __CLASS__ . '::' . $this->getName() );
	}

	public static function provideAddQuotes() {
		return [
			[ null, 'NULL' ],
			[ 1234, "1234" ],
			[ 1234.5678, "'1234.5678'" ],
			[ 'string', "'string'" ],
			[ 'string\'s cause trouble', "'string\'s cause trouble'" ],
		];
	}

	/**
	 * @dataProvider provideAddQuotes
	 */
	public function testAddQuotes( $input, $expected ) {
		$this->assertEquals( $expected, $this->db->addQuotes( $input ) );
	}

	public static function provideTableName() {
		// Formatting is mostly ignored since addIdentifierQuotes is abstract.
		// For testing of addIdentifierQuotes, see actual Database subclas tests.
		return [
			'local' => [
				'tablename',
				'tablename',
				'quoted',
			],
			'local-raw' => [
				'tablename',
				'tablename',
				'raw',
			],
			'shared' => [
				'sharedb.tablename',
				'tablename',
				'quoted',
				[ 'dbname' => 'sharedb', 'schema' => null, 'prefix' => '' ],
			],
			'shared-raw' => [
				'sharedb.tablename',
				'tablename',
				'raw',
				[ 'dbname' => 'sharedb', 'schema' => null, 'prefix' => '' ],
			],
			'shared-prefix' => [
				'sharedb.sh_tablename',
				'tablename',
				'quoted',
				[ 'dbname' => 'sharedb', 'schema' => null, 'prefix' => 'sh_' ],
			],
			'shared-prefix-raw' => [
				'sharedb.sh_tablename',
				'tablename',
				'raw',
				[ 'dbname' => 'sharedb', 'schema' => null, 'prefix' => 'sh_' ],
			],
			'foreign' => [
				'databasename.tablename',
				'databasename.tablename',
				'quoted',
			],
			'foreign-raw' => [
				'databasename.tablename',
				'databasename.tablename',
				'raw',
			],
		];
	}

	/**
	 * @dataProvider provideTableName
	 */
	public function testTableName( $expected, $table, $format, array $alias = null ) {
		if ( $alias ) {
			$this->db->setTableAliases( [ $table => $alias ] );
		}
		$this->assertEquals(
			$expected,
			$this->db->tableName( $table, $format ?: 'quoted' )
		);
	}

	public static function provideTableNamesWithIndexClauseOrJOIN() {
		return [
			'one-element array' => [
				[ 'table' ], [], 'table '
			],
			'comma join' => [
				[ 'table1', 'table2' ], [], 'table1,table2 '
			],
			'real join' => [
				[ 'table1', 'table2' ],
				[ 'table2' => [ 'LEFT JOIN', 't1_id = t2_id' ] ],
				'table1 LEFT JOIN table2 ON ((t1_id = t2_id))'
			],
			'real join with multiple conditionals' => [
				[ 'table1', 'table2' ],
				[ 'table2' => [ 'LEFT JOIN', [ 't1_id = t2_id', 't2_x = \'X\'' ] ] ],
				'table1 LEFT JOIN table2 ON ((t1_id = t2_id) AND (t2_x = \'X\'))'
			],
			'join with parenthesized group' => [
				[ 'table1', 'n' => [ 'table2', 'table3' ] ],
				[
					'table3' => [ 'JOIN', 't2_id = t3_id' ],
					'n' => [ 'LEFT JOIN', 't1_id = t2_id' ],
				],
				'table1 LEFT JOIN (table2 JOIN table3 ON ((t2_id = t3_id))) ON ((t1_id = t2_id))'
			],
			'join with degenerate parenthesized group' => [
				[ 'table1', 'n' => [ 't2' => 'table2' ] ],
				[
					'n' => [ 'LEFT JOIN', 't1_id = t2_id' ],
				],
				'table1 LEFT JOIN table2 t2 ON ((t1_id = t2_id))'
			],
		];
	}

	/**
	 * @dataProvider provideTableNamesWithIndexClauseOrJOIN
	 */
	public function testTableNamesWithIndexClauseOrJOIN( $tables, $join_conds, $expect ) {
		$clause = TestingAccessWrapper::newFromObject( ( new SQLPlatformTestHelper( new AddQuoterMock() ) ) )
			->tableNamesWithIndexClauseOrJOIN( $tables, [], [], $join_conds );
		$this->assertSame( $expect, $clause );
	}

	public function testTransactionIdle() {
		$db = $this->db;

		$db->clearFlag( DBO_TRX );
		$called = false;
		$flagSet = null;
		$callback = static function ( $trigger, IDatabase $db ) use ( &$flagSet, &$called ) {
			$called = true;
			$flagSet = $db->getFlag( DBO_TRX );
		};

		$db->onTransactionCommitOrIdle( $callback, __METHOD__ );
		$this->assertTrue( $called, 'Callback reached' );
		$this->assertFalse( $flagSet, 'DBO_TRX off in callback' );
		$this->assertFalse( $db->getFlag( DBO_TRX ), 'DBO_TRX still default' );

		$flagSet = null;
		$called = false;
		$db->startAtomic( __METHOD__ );
		$db->onTransactionCommitOrIdle( $callback, __METHOD__ );
		$this->assertFalse( $called, 'Callback not reached during TRX' );
		$db->endAtomic( __METHOD__ );

		$this->assertTrue( $called, 'Callback reached after COMMIT' );
		$this->assertFalse( $flagSet, 'DBO_TRX off in callback' );
		$this->assertFalse( $db->getFlag( DBO_TRX ), 'DBO_TRX restored to default' );

		$db->clearFlag( DBO_TRX );
		$db->onTransactionCommitOrIdle(
			static function ( $trigger, IDatabase $db ) {
				$db->setFlag( DBO_TRX );
			},
			__METHOD__
		);
		$this->assertFalse( $db->getFlag( DBO_TRX ), 'DBO_TRX restored to default' );
	}

	public function testTransactionIdle_TRX() {
		$db = $this->getMockDB( [ 'isOpen', 'ping', 'getDBname' ] );
		$db->method( 'isOpen' )->willReturn( true );
		$db->method( 'ping' )->willReturn( true );
		$db->method( 'getDBname' )->willReturn( '' );
		$db->setFlag( DBO_TRX );

		$lbFactory = LBFactorySingle::newFromConnection( $db );
		// Ask for the connection so that LB sets internal state
		// about this connection being the primary connection
		$lb = $lbFactory->getMainLB();
		$conn = $lb->getConnectionInternal( $lb->getWriterIndex() );
		$this->assertSame( $db, $conn, 'Same DB instance' );
		$this->assertTrue( $db->getFlag( DBO_TRX ), 'DBO_TRX is set' );

		$called = false;
		$flagSet = null;
		$callback = static function () use ( $db, &$flagSet, &$called ) {
			$called = true;
			$flagSet = $db->getFlag( DBO_TRX );
		};

		$db->onTransactionCommitOrIdle( $callback, __METHOD__ );
		$this->assertTrue( $called, 'Called when idle if DBO_TRX is set' );
		$this->assertFalse( $flagSet, 'DBO_TRX off in callback' );
		$this->assertTrue( $db->getFlag( DBO_TRX ), 'DBO_TRX still default' );

		$called = false;
		$lbFactory->beginPrimaryChanges( __METHOD__ );
		$db->onTransactionCommitOrIdle( $callback, __METHOD__ );
		$this->assertFalse( $called, 'Not called when lb-transaction is active' );

		$lbFactory->commitPrimaryChanges( __METHOD__ );
		$this->assertTrue( $called, 'Called when lb-transaction is committed' );

		$called = false;
		$lbFactory->beginPrimaryChanges( __METHOD__ );
		$db->onTransactionCommitOrIdle( $callback, __METHOD__ );
		$this->assertFalse( $called, 'Not called when lb-transaction is active' );

		$lbFactory->rollbackPrimaryChanges( __METHOD__ );
		$this->assertFalse( $called, 'Not called when lb-transaction is rolled back' );

		$lbFactory->commitPrimaryChanges( __METHOD__ );
		$this->assertFalse( $called, 'Not called in next round commit' );

		$db->setFlag( DBO_TRX );
		try {
			$db->onTransactionCommitOrIdle( static function () {
				throw new RuntimeException( 'test' );
			} );
			$this->fail( "Exception not thrown" );
		} catch ( RuntimeException $e ) {
			$this->assertTrue( $db->getFlag( DBO_TRX ) );
		}

		$lbFactory->rollbackPrimaryChanges( __METHOD__ );
		$lbFactory->flushPrimarySessions( __METHOD__ );
	}

	public function testTransactionPreCommitOrIdle() {
		$db = $this->getMockDB( [ 'isOpen' ] );
		$db->method( 'isOpen' )->willReturn( true );
		$db->clearFlag( DBO_TRX );

		$this->assertFalse( $db->getFlag( DBO_TRX ), 'DBO_TRX is not set' );

		$called = false;
		$db->onTransactionPreCommitOrIdle(
			static function ( IDatabase $db ) use ( &$called ) {
				$called = true;
			},
			__METHOD__
		);
		$this->assertTrue( $called, 'Called when idle' );

		$db->begin( __METHOD__ );
		$called = false;
		$db->onTransactionPreCommitOrIdle(
			static function ( IDatabase $db ) use ( &$called ) {
				$called = true;
			},
			__METHOD__
		);
		$this->assertFalse( $called, 'Not called when transaction is active' );
		$db->commit( __METHOD__ );
		$this->assertTrue( $called, 'Called when transaction is committed' );
	}

	public function testTransactionPreCommitOrIdle_TRX() {
		$db = $this->getMockDB( [ 'isOpen', 'ping', 'getDBname' ] );
		$db->method( 'isOpen' )->willReturn( true );
		$db->method( 'ping' )->willReturn( true );
		$db->method( 'getDBname' )->willReturn( 'unittest' );
		$db->setFlag( DBO_TRX );

		$lbFactory = LBFactorySingle::newFromConnection( $db );
		// Ask for the connection so that LB sets internal state
		// about this connection being the primary connection
		$lb = $lbFactory->getMainLB();
		$conn = $lb->getConnectionInternal( $lb->getWriterIndex() );
		$this->assertSame( $db, $conn, 'Same DB instance' );

		$this->assertFalse( $lb->hasPrimaryChanges() );
		$this->assertTrue( $db->getFlag( DBO_TRX ), 'DBO_TRX is set' );
		$called = false;
		$callback = static function ( IDatabase $db ) use ( &$called ) {
			$called = true;
		};
		$db->onTransactionPreCommitOrIdle( $callback, __METHOD__ );
		$this->assertTrue( $called, 'Called when idle if DBO_TRX is set' );
		$called = false;
		$lbFactory->commitPrimaryChanges();
		$this->assertFalse( $called );

		$called = false;
		$lbFactory->beginPrimaryChanges( __METHOD__ );
		$db->onTransactionPreCommitOrIdle( $callback, __METHOD__ );
		$this->assertFalse( $called, 'Not called when lb-transaction is active' );
		$lbFactory->commitPrimaryChanges( __METHOD__ );
		$this->assertTrue( $called, 'Called when lb-transaction is committed' );

		$called = false;
		$lbFactory->beginPrimaryChanges( __METHOD__ );
		$db->onTransactionPreCommitOrIdle( $callback, __METHOD__ );
		$this->assertFalse( $called, 'Not called when lb-transaction is active' );

		$lbFactory->rollbackPrimaryChanges( __METHOD__ );
		$this->assertFalse( $called, 'Not called when lb-transaction is rolled back' );

		$lbFactory->commitPrimaryChanges( __METHOD__ );
		$this->assertFalse( $called, 'Not called in next round commit' );

		$lbFactory->flushPrimarySessions( __METHOD__ );
	}

	public function testTransactionResolution() {
		$db = $this->db;

		$db->clearFlag( DBO_TRX );
		$db->begin( __METHOD__ );
		$called = false;
		$db->onTransactionResolution( static function ( $trigger, IDatabase $db ) use ( &$called ) {
			$called = true;
			$db->setFlag( DBO_TRX );
		} );
		$db->commit( __METHOD__ );
		$this->assertFalse( $db->getFlag( DBO_TRX ), 'DBO_TRX restored to default' );
		$this->assertTrue( $called, 'Callback reached' );

		$db->clearFlag( DBO_TRX );
		$db->begin( __METHOD__ );
		$called = false;
		$db->onTransactionResolution( static function ( $trigger, IDatabase $db ) use ( &$called ) {
			$called = true;
			$db->setFlag( DBO_TRX );
		} );
		$db->rollback( __METHOD__ );
		$this->assertFalse( $db->getFlag( DBO_TRX ), 'DBO_TRX restored to default' );
		$this->assertTrue( $called, 'Callback reached' );
	}

	public function testTransactionListener() {
		$db = $this->db;

		$db->setTransactionListener( 'ping', static function () use ( &$called ) {
			$called = true;
		} );

		$called = false;
		$db->begin( __METHOD__ );
		$db->commit( __METHOD__ );
		$this->assertTrue( $called, 'Callback reached' );

		$called = false;
		$db->begin( __METHOD__ );
		$db->commit( __METHOD__ );
		$this->assertTrue( $called, 'Callback still reached' );

		$called = false;
		$db->begin( __METHOD__ );
		$db->rollback( __METHOD__ );
		$this->assertTrue( $called, 'Callback reached' );

		$db->setTransactionListener( 'ping', null );
		$called = false;
		$db->begin( __METHOD__ );
		$db->commit( __METHOD__ );
		$this->assertFalse( $called, 'Callback not reached' );
	}

	/**
	 * Use this mock instead of DatabaseTestHelper for cases where
	 * DatabaseTestHelper is too inflexibile due to mocking too much
	 * or being too restrictive about fname matching (e.g. for tests
	 * that assert behaviour when the name is a mismatch, we need to
	 * catch the error here instead of there).
	 *
	 * @param string[] $methods
	 * @return Database
	 */
	private function getMockDB( $methods = [] ) {
		static $abstractMethods = [
			'lastInsertId',
			'closeConnection',
			'doSingleStatementQuery',
			'fieldInfo',
			'getSoftwareLink',
			'getServerVersion',
			'getType',
			'indexInfo',
			'insertId',
			'lastError',
			'lastErrno',
			'open',
			'strencode',
			'tableExists',
			'getServer'
		];
		$db = $this->getMockBuilder( Database::class )
			->disableOriginalConstructor()
			->onlyMethods( array_values( array_unique( array_merge(
				$abstractMethods,
				$methods
			) ) ) )
			->getMock();
		$wdb = TestingAccessWrapper::newFromObject( $db );
		$wdb->logger = new NullLogger();
		$wdb->errorLogger = static function ( Throwable $e ) {
		};
		$wdb->deprecationLogger = static function ( $msg ) {
		};
		$wdb->currentDomain = DatabaseDomain::newUnspecified();
		$wdb->platform = new SQLPlatform( new AddQuoterMock() );
		$wdb->flagsHolder = new DatabaseFlags( 0 );
		// Info used for logging/errors
		$wdb->connectionParams = [
			'host' => 'localhost',
			'user' => 'testuser'
		];
		$wdb->replicationReporter = new ReplicationReporter( IDatabase::ROLE_STREAMING_MASTER, new NullLogger(), new HashBagOStuff() );

		$db->method( 'getServer' )->willReturn( '*dummy*' );
		$db->setTransactionManager( new TransactionManager() );

		$qs = new QueryStatus( false, 0, '', 0 );
		$qs->res = true;
		$db->method( 'doSingleStatementQuery' )->willReturn( $qs );

		return $db;
	}

	public function testFlushSnapshot() {
		$db = $this->getMockDB( [ 'isOpen' ] );
		$db->method( 'isOpen' )->willReturn( true );

		$db->flushSnapshot( __METHOD__ ); // ok
		$db->flushSnapshot( __METHOD__ ); // ok

		$db->setFlag( DBO_TRX, IDatabase::REMEMBER_PRIOR );
		$db->query( 'SELECT 1', __METHOD__ );
		$this->assertTrue( (bool)$db->trxLevel(), "Transaction started." );
		$db->flushSnapshot( __METHOD__ ); // ok
		$db->restoreFlags( $db::RESTORE_PRIOR );

		$this->assertFalse( (bool)$db->trxLevel(), "Transaction cleared." );
	}

	public function testGetScopedLock() {
		$db = $this->getMockDB( [ 'isOpen', 'getDBname' ] );
		$db->method( 'isOpen' )->willReturn( true );
		$db->method( 'getDBname' )->willReturn( 'unittest' );

		$this->assertSame( 0, $db->trxLevel() );
		$this->assertTrue( $db->lockIsFree( 'x', __METHOD__ ) );
		$this->assertTrue( $db->lock( 'x', __METHOD__ ) );
		$this->assertFalse( $db->lockIsFree( 'x', __METHOD__ ) );
		$this->assertTrue( $db->unlock( 'x', __METHOD__ ) );
		$this->assertTrue( $db->lockIsFree( 'x', __METHOD__ ) );
		$this->assertSame( 0, $db->trxLevel() );

		$db->setFlag( DBO_TRX );
		$this->assertTrue( $db->lockIsFree( 'x', __METHOD__ ) );
		$this->assertTrue( $db->lock( 'x', __METHOD__ ) );
		$this->assertFalse( $db->lockIsFree( 'x', __METHOD__ ) );
		$this->assertTrue( $db->unlock( 'x', __METHOD__ ) );
		$this->assertTrue( $db->lockIsFree( 'x', __METHOD__ ) );
		$db->clearFlag( DBO_TRX );

		// Pending writes with DBO_TRX
		$this->assertSame( 0, $db->trxLevel() );
		$this->assertTrue( $db->lockIsFree( 'meow', __METHOD__ ) );
		$db->setFlag( DBO_TRX );
		$db->query( "DELETE FROM test WHERE t = 1" ); // trigger DBO_TRX transaction before lock
		try {
			$lock = $db->getScopedLockAndFlush( 'meow', __METHOD__, 1 );
			$this->fail( "Exception not reached" );
		} catch ( DBUnexpectedError $e ) {
			$this->assertSame( 1, $db->trxLevel(), "Transaction not committed." );
			$this->assertTrue( $db->lockIsFree( 'meow', __METHOD__ ), 'Lock not acquired' );
		}
		$db->rollback( __METHOD__, IDatabase::FLUSHING_ALL_PEERS );
		// Pending writes without DBO_TRX
		$db->clearFlag( DBO_TRX );
		$this->assertSame( 0, $db->trxLevel() );
		$this->assertTrue( $db->lockIsFree( 'meow2', __METHOD__ ) );
		$db->begin( __METHOD__ );
		$db->query( "DELETE FROM test WHERE t = 1" ); // trigger DBO_TRX transaction before lock
		try {
			$lock = $db->getScopedLockAndFlush( 'meow2', __METHOD__, 1 );
			$this->fail( "Exception not reached" );
		} catch ( DBUnexpectedError $e ) {
			$this->assertSame( 1, $db->trxLevel(), "Transaction not committed." );
			$this->assertTrue( $db->lockIsFree( 'meow2', __METHOD__ ), 'Lock not acquired' );
		}
		$db->rollback( __METHOD__ );
		// No pending writes, with DBO_TRX
		$db->setFlag( DBO_TRX );
		$this->assertSame( 0, $db->trxLevel() );
		$this->assertTrue( $db->lockIsFree( 'wuff', __METHOD__ ) );
		$db->query( "SELECT 1", __METHOD__ );
		$this->assertSame( 1, $db->trxLevel() );
		$lock = $db->getScopedLockAndFlush( 'wuff', __METHOD__, 1 );
		$this->assertSame( 0, $db->trxLevel() );
		$this->assertFalse( $db->lockIsFree( 'wuff', __METHOD__ ), 'Lock already acquired' );
		$db->rollback( __METHOD__, IDatabase::FLUSHING_ALL_PEERS );
		// No pending writes, without DBO_TRX
		$db->clearFlag( DBO_TRX );
		$this->assertSame( 0, $db->trxLevel() );
		$this->assertTrue( $db->lockIsFree( 'wuff2', __METHOD__ ) );
		$db->begin( __METHOD__ );
		try {
			$lock = $db->getScopedLockAndFlush( 'wuff2', __METHOD__, 1 );
			$this->fail( "Exception not reached" );
		} catch ( DBUnexpectedError $e ) {
			$this->assertSame( 1, $db->trxLevel(), "Transaction not committed." );
			$this->assertFalse( $db->lockIsFree( 'wuff2', __METHOD__ ), 'Lock not acquired' );
		}
		$db->rollback( __METHOD__ );
	}

	public function testFlagSetting() {
		$db = $this->db;
		$origTrx = $db->getFlag( DBO_TRX );
		$origNoBuffer = $db->getFlag( DBO_NOBUFFER );

		$origTrx
			? $db->clearFlag( DBO_TRX, $db::REMEMBER_PRIOR )
			: $db->setFlag( DBO_TRX, $db::REMEMBER_PRIOR );
		$this->assertEquals( !$origTrx, $db->getFlag( DBO_TRX ) );

		$origNoBuffer
			? $db->clearFlag( DBO_NOBUFFER, $db::REMEMBER_PRIOR )
			: $db->setFlag( DBO_NOBUFFER, $db::REMEMBER_PRIOR );
		$this->assertEquals( !$origNoBuffer, $db->getFlag( DBO_NOBUFFER ) );

		$db->restoreFlags( $db::RESTORE_INITIAL );
		$this->assertEquals( $origTrx, $db->getFlag( DBO_TRX ) );
		$this->assertEquals( $origNoBuffer, $db->getFlag( DBO_NOBUFFER ) );

		$origTrx
			? $db->clearFlag( DBO_TRX, $db::REMEMBER_PRIOR )
			: $db->setFlag( DBO_TRX, $db::REMEMBER_PRIOR );
		$origNoBuffer
			? $db->clearFlag( DBO_NOBUFFER, $db::REMEMBER_PRIOR )
			: $db->setFlag( DBO_NOBUFFER, $db::REMEMBER_PRIOR );

		$db->restoreFlags();
		$this->assertEquals( $origNoBuffer, $db->getFlag( DBO_NOBUFFER ) );
		$this->assertEquals( !$origTrx, $db->getFlag( DBO_TRX ) );

		$db->restoreFlags();
		$this->assertEquals( $origNoBuffer, $db->getFlag( DBO_NOBUFFER ) );
		$this->assertEquals( $origTrx, $db->getFlag( DBO_TRX ) );
	}

	public static function provideImmutableDBOFlags() {
		return [
			[ Database::DBO_IGNORE ],
			[ Database::DBO_DEFAULT ],
			[ Database::DBO_PERSISTENT ]
		];
	}

	/**
	 * @dataProvider provideImmutableDBOFlags
	 * @param int $flag
	 */
	public function testDBOCannotSet( $flag ) {
		$flagsHolder = new DatabaseFlags( 0 );

		$this->expectException( DBLanguageError::class );
		$flagsHolder->setFlag( $flag );
	}

	/**
	 * @dataProvider provideImmutableDBOFlags
	 * @param int $flag
	 */
	public function testDBOCannotClear( $flag ) {
		$flagsHolder = new DatabaseFlags( 0 );

		$this->expectException( DBLanguageError::class );
		$flagsHolder->clearFlag( $flag );
	}

	public function testSchemaAndPrefixMutators() {
		$ud = DatabaseDomain::newUnspecified();

		$this->assertEquals( $ud->getId(), $this->db->getDomainID() );

		$oldDomain = $this->db->getDomainID();
		$oldSchema = $this->db->dbSchema();
		$oldPrefix = $this->db->tablePrefix();
		$this->assertIsString( $oldDomain, 'DB domain is string' );
		$this->assertIsString( $oldSchema, 'DB schema is string' );
		$this->assertIsString( $oldPrefix, 'Prefix is string' );
		$this->assertSame( $oldSchema, $this->db->dbSchema(), "Schema unchanged" );
		$this->assertSame( $oldPrefix, $this->db->tablePrefix(), "Prefix unchanged" );

		$this->assertSame( $oldPrefix, $this->db->tablePrefix( 'xxx_' ), "Prior prefix upon set" );
		$this->assertSame( 'xxx_', $this->db->tablePrefix(), "Prefix set" );
		$this->assertSame( $oldSchema, $this->db->dbSchema(), "Schema unchanged" );

		$this->db->tablePrefix( $oldPrefix );
		$this->assertNotEquals( 'xxx_', $this->db->tablePrefix(), "Prior prefix upon set" );
		$this->assertSame( $oldPrefix, $this->db->tablePrefix(), "Prefix restored" );
		$this->assertSame( $oldSchema, $this->db->dbSchema(), "Schema unchanged" );
		$this->assertSame( $oldDomain, $this->db->getDomainID(), "DB domain restored" );

		$newDbDomain = new DatabaseDomain(
			'y',
			( $oldSchema !== '' ) ? $oldSchema : null,
			$oldPrefix
		);

		$this->db->selectDomain( $newDbDomain );
		$this->assertSame( 'y', $this->db->getDBname(), "DB name set" );
		$this->assertSame( $oldSchema, $this->db->dbSchema(), "Schema unchanged" );
		$this->assertSame( $oldPrefix, $this->db->tablePrefix(), "Prefix unchanged" );

		$this->assertSame( $oldSchema, $this->db->dbSchema( 'xxx' ), "Prior schema upon set" );
		$this->assertSame( 'xxx', $this->db->dbSchema(), "Schema set" );
		$this->assertSame( 'y', $this->db->getDBname(), "DB name unchanged" );
		$this->assertSame( $oldPrefix, $this->db->tablePrefix(), "Prefix unchanged" );

		$this->assertSame( 'xxx', $this->db->dbSchema( $oldSchema ), "Prior schema upon set" );
		$this->assertEquals( $oldSchema, $this->db->dbSchema(), 'Schema restored' );
	}

	public function testSchemaWithNoDB() {
		$ud = DatabaseDomain::newUnspecified();

		$this->assertEquals( $ud->getId(), $this->db->getDomainID() );
		$this->assertSame( '', $this->db->dbSchema() );

		$this->expectException( DBUnexpectedError::class );
		$this->db->dbSchema( 'xxx' );
	}

	public function testSelectDomain() {
		$oldDomain = $this->db->getDomainID();
		$oldDatabase = $this->db->getDBname();
		$oldSchema = $this->db->dbSchema();
		$oldPrefix = $this->db->tablePrefix();

		$this->db->selectDomain( 'testselectdb-xxx_' );
		$this->assertSame( 'testselectdb', $this->db->getDBname() );
		$this->assertSame( '', $this->db->dbSchema() );
		$this->assertSame( 'xxx_', $this->db->tablePrefix() );

		$this->db->selectDomain( $oldDomain );
		$this->assertSame( $oldDatabase, $this->db->getDBname() );
		$this->assertSame( $oldSchema, $this->db->dbSchema() );
		$this->assertSame( $oldPrefix, $this->db->tablePrefix() );
		$this->assertSame( $oldDomain, $this->db->getDomainID() );

		$this->db->selectDomain( 'testselectdb-schema-xxx_' );
		$this->assertSame( 'testselectdb', $this->db->getDBname() );
		$this->assertSame( 'schema', $this->db->dbSchema() );
		$this->assertSame( 'xxx_', $this->db->tablePrefix() );

		$this->db->selectDomain( $oldDomain );
		$this->assertSame( $oldDatabase, $this->db->getDBname() );
		$this->assertSame( $oldSchema, $this->db->dbSchema() );
		$this->assertSame( $oldPrefix, $this->db->tablePrefix() );
		$this->assertSame( $oldDomain, $this->db->getDomainID() );
	}

	public function testGetSetLBInfo() {
		$db = $this->getMockDB();

		$this->assertEquals( [], $db->getLBInfo() );
		$this->assertNull( $db->getLBInfo( 'pringles' ) );

		$db->setLBInfo( 'soda', 'water' );
		$this->assertEquals( [ 'soda' => 'water' ], $db->getLBInfo() );
		$this->assertNull( $db->getLBInfo( 'pringles' ) );
		$this->assertEquals( 'water', $db->getLBInfo( 'soda' ) );

		$db->setLBInfo( 'basketball', 'Lebron' );
		$this->assertEquals( [ 'soda' => 'water', 'basketball' => 'Lebron' ], $db->getLBInfo() );
		$this->assertEquals( 'water', $db->getLBInfo( 'soda' ) );
		$this->assertEquals( 'Lebron', $db->getLBInfo( 'basketball' ) );

		$db->setLBInfo( 'soda', null );
		$this->assertEquals( [ 'basketball' => 'Lebron' ], $db->getLBInfo() );

		$db->setLBInfo( [ 'King' => 'James' ] );
		$this->assertNull( $db->getLBInfo( 'basketball' ) );
		$this->assertEquals( [ 'King' => 'James' ], $db->getLBInfo() );
	}

	public function testShouldRejectPersistentWriteQueryOnReplicaDatabaseConnection() {
		$this->expectException( DBReadOnlyRoleError::class );
		$this->expectExceptionMessage( 'Server is configured as a read-only replica database.' );

		$dbr = new DatabaseTestHelper(
			__CLASS__ . '::' . $this->getName(),
			[ 'topologyRole' => Database::ROLE_STREAMING_REPLICA ]
		);

		// phpcs:ignore MediaWiki.Usage.DbrQueryUsage.DbrQueryFound
		$dbr->query( "INSERT INTO test_table (a_column) VALUES ('foo');", __METHOD__ );
	}

	public function testShouldAcceptTemporaryTableOperationsOnReplicaDatabaseConnection() {
		$dbr = new DatabaseTestHelper(
			__CLASS__ . '::' . $this->getName(),
			[ 'topologyRole' => Database::ROLE_STREAMING_REPLICA ]
		);

		// phpcs:ignore MediaWiki.Usage.DbrQueryUsage.DbrQueryFound
		$resCreate = $dbr->query(
			"CREATE TEMPORARY TABLE temp_test_table (temp_column int);",
			__METHOD__
		);

		// phpcs:ignore MediaWiki.Usage.DbrQueryUsage.DbrQueryFound
		$resModify = $dbr->query(
			"INSERT INTO temp_test_table (temp_column) VALUES (42);",
			__METHOD__
		);

		$this->assertInstanceOf( IResultWrapper::class, $resCreate );
		$this->assertInstanceOf( IResultWrapper::class, $resModify );
	}

	public function testShouldRejectPseudoPermanentTemporaryTableOperationsOnReplicaDatabaseConnection() {
		$this->expectException( DBReadOnlyRoleError::class );
		$this->expectExceptionMessage( 'Server is configured as a read-only replica database.' );

		$dbr = new DatabaseTestHelper(
			__CLASS__ . '::' . $this->getName(),
			[ 'topologyRole' => Database::ROLE_STREAMING_REPLICA ]
		);

		// phpcs:ignore MediaWiki.Usage.DbrQueryUsage.DbrQueryFound
		$dbr->query(
			"CREATE TEMPORARY TABLE temp_test_table (temp_column int);",
			__METHOD__,
			Database::QUERY_PSEUDO_PERMANENT
		);
	}

	public function testShouldAcceptWriteQueryOnPrimaryDatabaseConnection() {
		$dbr = new DatabaseTestHelper(
			__CLASS__ . '::' . $this->getName(),
			[ 'topologyRole' => Database::ROLE_STREAMING_MASTER ]
		);

		// phpcs:ignore MediaWiki.Usage.DbrQueryUsage.DbrQueryFound
		$res = $dbr->query( "INSERT INTO test_table (a_column) VALUES ('foo');", __METHOD__ );

		$this->assertInstanceOf( IResultWrapper::class, $res );
	}

	public function testShouldRejectWriteQueryOnPrimaryDatabaseConnectionWhenReplicaQueryRoleFlagIsSet() {
		$this->expectException( DBReadOnlyRoleError::class );
		$this->expectExceptionMessage( 'Cannot write; target role is DB_REPLICA' );

		$dbr = new DatabaseTestHelper(
			__CLASS__ . '::' . $this->getName(),
			[ 'topologyRole' => Database::ROLE_STREAMING_MASTER ]
		);

		// phpcs:ignore MediaWiki.Usage.DbrQueryUsage.DbrQueryFound
		$dbr->query(
			"INSERT INTO test_table (a_column) VALUES ('foo');",
			__METHOD__,
			Database::QUERY_REPLICA_ROLE
		);
	}

	public function testCriticalSectionErrorSelect() {
		$this->expectException( DBTransactionStateError::class );

		$db = TestingAccessWrapper::newFromObject( $this->db );
		try {
			$this->corruptDbState( $db );
		} catch ( RuntimeException $e ) {
			$this->assertEquals( "Unexpected error", $e->getMessage() );
		}

		$db->query( "SELECT 1", __METHOD__ );
	}

	public function testCriticalSectionErrorRollback() {
		$db = TestingAccessWrapper::newFromObject( $this->db );
		try {
			$this->corruptDbState( $db );
		} catch ( RuntimeException $e ) {
			$this->assertEquals( "Unexpected error", $e->getMessage() );
		}

		$db->rollback( __METHOD__, IDatabase::FLUSHING_ALL_PEERS );
		$this->assertTrue( true, "No exception on ROLLBACK" );

		$db->flushSession( __METHOD__, IDatabase::FLUSHING_ALL_PEERS );
		$this->assertTrue( true, "No exception on session flush" );

		$db->query( "SELECT 1", __METHOD__ );
		$this->assertTrue( true, "No exception on next query" );
	}

	public function testCriticalSectionErrorWithTrxRollback() {
		$hits = 0;
		$db = TestingAccessWrapper::newFromObject( $this->db );
		$db->begin( __METHOD__, IDatabase::TRANSACTION_INTERNAL );
		$db->onTransactionResolution( static function () use ( &$hits ) {
			++$hits;
		} );

		try {
			$this->corruptDbState( $db );
		} catch ( RuntimeException $e ) {
			$this->assertEquals( "Unexpected error", $e->getMessage() );
		}

		$db->rollback( __METHOD__, IDatabase::FLUSHING_ALL_PEERS );
		$this->assertTrue( true, "No exception on ROLLBACK" );

		$db->flushSession( __METHOD__, IDatabase::FLUSHING_ALL_PEERS );
		$this->assertTrue( true, "No exception on session flush" );

		$db->query( "SELECT 1", __METHOD__ );
		$this->assertTrue( true, "No exception on next query" );
	}

	private function corruptDbState( $db ) {
		$cs = $db->commenceCriticalSection( __METHOD__ );
		$this->assertInstanceOf( CriticalSectionScope::class, $cs );
		throw new RuntimeException( "Unexpected error" );
	}
}
rdbms/database/DatabaseDomainTest.php000066600000015271151335120240013632 0ustar00<?php

use Wikimedia\Rdbms\DatabaseDomain;

/**
 * @covers Wikimedia\Rdbms\DatabaseDomain
 */
class DatabaseDomainTest extends PHPUnit\Framework\TestCase {

	use MediaWikiCoversValidator;

	public static function provideConstruct() {
		return [
			'All strings' =>
				[ 'foo', 'bar', 'baz_', 'foo-bar-baz_' ],
			'Nothing' =>
				[ null, null, '', '' ],
			'Invalid $database' =>
				[ 0, 'bar', '', '', true ],
			'Invalid $schema' =>
				[ 'foo', 0, '', '', true ],
			'Invalid $prefix' =>
				[ 'foo', 'bar', 0, '', true ],
			'Dash' =>
				[ 'foo-bar', 'baz', 'baa_', 'foo?hbar-baz-baa_' ],
			'Question mark' =>
				[ 'foo?bar', 'baz', 'baa_', 'foo??bar-baz-baa_' ],
		];
	}

	/**
	 * @dataProvider provideConstruct
	 */
	public function testConstruct( $db, $schema, $prefix, $id, $exception = false ) {
		if ( $exception ) {
			$this->expectException( InvalidArgumentException::class );
			new DatabaseDomain( $db, $schema, $prefix );
			return;
		}

		$domain = new DatabaseDomain( $db, $schema, $prefix );
		$this->assertInstanceOf( DatabaseDomain::class, $domain );
		$this->assertEquals( $db, $domain->getDatabase() );
		$this->assertEquals( $schema, $domain->getSchema() );
		$this->assertEquals( $prefix, $domain->getTablePrefix() );
		$this->assertEquals( $id, $domain->getId() );
		$this->assertEquals( $id, strval( $domain ), 'toString' );
	}

	public static function provideNewFromId() {
		return [
			'Basic' =>
				[ 'foo', 'foo', null, '' ],
			'db+prefix' =>
				[ 'foo-bar_', 'foo', null, 'bar_' ],
			'db+schema+prefix' =>
				[ 'foo-bar-baz_', 'foo', 'bar', 'baz_' ],
			'?h -> -' =>
				[ 'foo?hbar-baz-baa_', 'foo-bar', 'baz', 'baa_' ],
			'?? -> ?' =>
				[ 'foo??bar-baz-baa_', 'foo?bar', 'baz', 'baa_' ],
			'? is left alone' =>
				[ 'foo?bar-baz-baa_', 'foo?bar', 'baz', 'baa_' ],
			'too many parts' =>
				[ 'foo-bar-baz-baa_', '', '', '', true ],
			'from instance' =>
				[ DatabaseDomain::newUnspecified(), null, null, '' ],
		];
	}

	/**
	 * @dataProvider provideNewFromId
	 */
	public function testNewFromId( $id, $db, $schema, $prefix, $exception = false ) {
		if ( $exception ) {
			$this->expectException( InvalidArgumentException::class );
			DatabaseDomain::newFromId( $id );
			return;
		}
		$domain = DatabaseDomain::newFromId( $id );
		$this->assertInstanceOf( DatabaseDomain::class, $domain );
		$this->assertEquals( $db, $domain->getDatabase() );
		$this->assertEquals( $schema, $domain->getSchema() );
		$this->assertEquals( $prefix, $domain->getTablePrefix() );
	}

	public static function provideEquals() {
		return [
			'Basic' =>
				[ 'foo', 'foo', null, '' ],
			'db+prefix' =>
				[ 'foo-bar_', 'foo', null, 'bar_' ],
			'db+schema+prefix' =>
				[ 'foo-bar-baz_', 'foo', 'bar', 'baz_' ],
			'?h -> -' =>
				[ 'foo?hbar-baz-baa_', 'foo-bar', 'baz', 'baa_' ],
			'?? -> ?' =>
				[ 'foo??bar-baz-baa_', 'foo?bar', 'baz', 'baa_' ],
			'Nothing' =>
				[ '', null, null, '' ],
		];
	}

	/**
	 * @dataProvider provideEquals
	 */
	public function testEquals( $id, $db, $schema, $prefix ) {
		$fromId = DatabaseDomain::newFromId( $id );
		$this->assertInstanceOf( DatabaseDomain::class, $fromId );

		$constructed = new DatabaseDomain( $db, $schema, $prefix );

		$this->assertTrue( $constructed->equals( $id ), 'constructed equals string' );
		$this->assertTrue( $fromId->equals( $id ), 'fromId equals string' );

		$this->assertTrue( $constructed->equals( $fromId ), 'compare constructed to newId' );
		$this->assertTrue( $fromId->equals( $constructed ), 'compare newId to constructed' );
	}

	public function testNewUnspecified() {
		$domain = DatabaseDomain::newUnspecified();
		$this->assertInstanceOf( DatabaseDomain::class, $domain );
		$this->assertTrue( $domain->equals( '' ) );
		$this->assertSame( null, $domain->getDatabase() );
		$this->assertSame( null, $domain->getSchema() );
		$this->assertSame( '', $domain->getTablePrefix() );
	}

	public static function provideIsCompatible() {
		return [
			'Basic' =>
				[ 'foo', 'foo', null, '', true ],
			'db+prefix' =>
				[ 'foo-bar_', 'foo', null, 'bar_', true ],
			'db+schema+prefix' =>
				[ 'foo-bar-baz_', 'foo', 'bar', 'baz_', true ],
			'db+dontcare_schema+prefix' =>
				[ 'foo-bar-baz_', 'foo', null, 'baz_', false ],
			'?h -> -' =>
				[ 'foo?hbar-baz-baa_', 'foo-bar', 'baz', 'baa_', true ],
			'?? -> ?' =>
				[ 'foo??bar-baz-baa_', 'foo?bar', 'baz', 'baa_', true ],
			'Nothing' =>
				[ '', null, null, '', true ],
			'dontcaredb+dontcaredbschema+prefix' =>
				[ 'mywiki-mediawiki-prefix_', null, null, 'prefix_', false ],
			'db+dontcareschema+prefix' =>
				[ 'mywiki-schema-prefix_', 'mywiki', null, 'prefix_', false ],
			'postgres-db-jobqueue' =>
				[ 'postgres-mediawiki-', 'postgres', null, '', false ]
		];
	}

	/**
	 * @dataProvider provideIsCompatible
	 */
	public function testIsCompatible( $id, $db, $schema, $prefix, $transitive ) {
		$compareIdObj = DatabaseDomain::newFromId( $id );
		$this->assertInstanceOf( DatabaseDomain::class, $compareIdObj );

		$fromId = new DatabaseDomain( $db, $schema, $prefix );

		$this->assertTrue( $fromId->isCompatible( $id ), 'constructed equals string' );
		$this->assertTrue( $fromId->isCompatible( $compareIdObj ), 'fromId equals string' );

		$this->assertEquals( $transitive, $compareIdObj->isCompatible( $fromId ),
			'test transitivity of nulls components' );
	}

	public static function provideIsCompatible2() {
		return [
			'db+schema+prefix' =>
				[ 'mywiki-schema-prefix_', 'thatwiki', 'schema', 'prefix_' ],
			'dontcaredb+dontcaredbschema+prefix' =>
				[ 'thatwiki-mediawiki-otherprefix_', null, null, 'prefix_' ],
			'db+dontcareschema+prefix' =>
				[ 'notmywiki-schema-prefix_', 'mywiki', null, 'prefix_' ],
		];
	}

	/**
	 * @dataProvider provideIsCompatible2
	 */
	public function testIsCompatible2( $id, $db, $schema, $prefix ) {
		$compareIdObj = DatabaseDomain::newFromId( $id );
		$this->assertInstanceOf( DatabaseDomain::class, $compareIdObj );

		$fromId = new DatabaseDomain( $db, $schema, $prefix );

		$this->assertFalse( $fromId->isCompatible( $id ), 'constructed equals string' );
		$this->assertFalse( $fromId->isCompatible( $compareIdObj ), 'fromId equals string' );
	}

	public function testSchemaWithNoDB1() {
		$this->expectException( InvalidArgumentException::class );
		new DatabaseDomain( null, 'schema', '' );
	}

	public function testSchemaWithNoDB2() {
		$this->expectException( InvalidArgumentException::class );
		DatabaseDomain::newFromId( '-schema-prefix' );
	}

	public function testIsUnspecified() {
		$domain = new DatabaseDomain( null, null, '' );
		$this->assertTrue( $domain->isUnspecified() );
		$domain = new DatabaseDomain( 'mywiki', null, '' );
		$this->assertFalse( $domain->isUnspecified() );
		$domain = new DatabaseDomain( 'mywiki', null, '' );
		$this->assertFalse( $domain->isUnspecified() );
	}
}
rdbms/database/DatabaseMysqlBaseTest.php000066600000043250151335120240014321 0ustar00<?php
/**
 * Holds tests for DatabaseMysqlBase class.
 *
 * 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
 * @author Antoine Musso
 * @copyright © 2013 Antoine Musso
 * @copyright © 2013 Wikimedia Foundation and contributors
 */

use MediaWiki\Tests\Unit\Libs\Rdbms\AddQuoterMock;
use Wikimedia\Rdbms\DatabaseDomain;
use Wikimedia\Rdbms\DatabaseMySQL;
use Wikimedia\Rdbms\FakeResultWrapper;
use Wikimedia\Rdbms\IDatabase;
use Wikimedia\Rdbms\IMaintainableDatabase;
use Wikimedia\Rdbms\MySQLPrimaryPos;
use Wikimedia\Rdbms\Platform\MySQLPlatform;
use Wikimedia\Rdbms\Replication\MysqlReplicationReporter;
use Wikimedia\TestingAccessWrapper;

/**
 * @covers \Wikimedia\Rdbms\DatabaseMySQL
 */
class DatabaseMysqlBaseTest extends PHPUnit\Framework\TestCase {

	use MediaWikiCoversValidator;

	private function getMockForViews(): IMaintainableDatabase {
		$db = $this->getMockBuilder( DatabaseMySQL::class )
			->disableOriginalConstructor()
			->onlyMethods( [ 'query', 'getDBname' ] )
			->getMock();

		$db->method( 'query' )
			->with( $this->anything() )
			->willReturn( new FakeResultWrapper( [
				(object)[ 'Tables_in_' => 'view1' ],
				(object)[ 'Tables_in_' => 'view2' ],
				(object)[ 'Tables_in_' => 'myview' ]
			] ) );
		$db->method( 'getDBname' )->willReturn( '' );

		return $db;
	}

	public function testListviews() {
		$db = $this->getMockForViews();

		$this->assertEquals( [ 'view1', 'view2', 'myview' ],
			$db->listViews() );

		// Prefix filtering
		$this->assertEquals( [ 'view1', 'view2' ],
			$db->listViews( 'view' ) );
		$this->assertEquals( [ 'myview' ],
			$db->listViews( 'my' ) );
		$this->assertEquals( [],
			$db->listViews( 'UNUSED_PREFIX' ) );
		$this->assertEquals( [ 'view1', 'view2', 'myview' ],
			$db->listViews( '' ) );
	}

	/**
	 * @covers \Wikimedia\Rdbms\MySQLPrimaryPos
	 */
	public function testBinLogName() {
		$pos = new MySQLPrimaryPos( "db1052.2424/4643", 1 );

		$this->assertEquals( "db1052.2424", $pos->getLogFile() );
		$this->assertEquals( [ 2424, 4643 ], $pos->getLogPosition() );
	}

	/**
	 * @dataProvider provideComparePositions
	 * @covers \Wikimedia\Rdbms\MySQLPrimaryPos
	 */
	public function testHasReached(
		MySQLPrimaryPos $lowerPos, MySQLPrimaryPos $higherPos, $match, $hetero
	) {
		if ( $match ) {
			if ( $hetero ) {
				// Each position is has one channel higher than the other
				$this->assertFalse( $higherPos->hasReached( $lowerPos ) );
			} else {
				$this->assertTrue( $higherPos->hasReached( $lowerPos ) );
			}
			$this->assertTrue( $lowerPos->hasReached( $lowerPos ) );
			$this->assertTrue( $higherPos->hasReached( $higherPos ) );
			$this->assertFalse( $lowerPos->hasReached( $higherPos ) );
		} else { // channels don't match

			$this->assertFalse( $higherPos->hasReached( $lowerPos ) );
			$this->assertFalse( $lowerPos->hasReached( $higherPos ) );
		}
	}

	public static function provideComparePositions() {
		$now = microtime( true );

		return [
			// Binlog style
			[
				new MySQLPrimaryPos( 'db1034-bin.000976/843431247', $now ),
				new MySQLPrimaryPos( 'db1034-bin.000976/843431248', $now ),
				true,
				false
			],
			[
				new MySQLPrimaryPos( 'db1034-bin.000976/999', $now ),
				new MySQLPrimaryPos( 'db1034-bin.000976/1000', $now ),
				true,
				false
			],
			[
				new MySQLPrimaryPos( 'db1034-bin.000976/999', $now ),
				new MySQLPrimaryPos( 'db1035-bin.000976/1000', $now ),
				false,
				false
			],
			// MySQL GTID style
			[
				new MySQLPrimaryPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:1-23', $now ),
				new MySQLPrimaryPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:5-24', $now ),
				true,
				false
			],
			[
				new MySQLPrimaryPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:5-99', $now ),
				new MySQLPrimaryPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:1-100', $now ),
				true,
				false
			],
			[
				new MySQLPrimaryPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:1-99', $now ),
				new MySQLPrimaryPos( '1E11FA47-71CA-11E1-9E33-C80AA9429562:1-100', $now ),
				false,
				false
			],
			// MariaDB GTID style
			[
				new MySQLPrimaryPos( '255-11-23', $now ),
				new MySQLPrimaryPos( '255-11-24', $now ),
				true,
				false
			],
			[
				new MySQLPrimaryPos( '255-11-99', $now ),
				new MySQLPrimaryPos( '255-11-100', $now ),
				true,
				false
			],
			[
				new MySQLPrimaryPos( '255-11-999', $now ),
				new MySQLPrimaryPos( '254-11-1000', $now ),
				false,
				false
			],
			[
				new MySQLPrimaryPos( '255-11-23,256-12-50', $now ),
				new MySQLPrimaryPos( '255-11-24', $now ),
				true,
				false
			],
			[
				new MySQLPrimaryPos( '255-11-99,256-12-50,257-12-50', $now ),
				new MySQLPrimaryPos( '255-11-1000', $now ),
				true,
				false
			],
			[
				new MySQLPrimaryPos( '255-11-23,256-12-50', $now ),
				new MySQLPrimaryPos( '255-11-24,155-52-63', $now ),
				true,
				false
			],
			[
				new MySQLPrimaryPos( '255-11-99,256-12-50,257-12-50', $now ),
				new MySQLPrimaryPos( '255-11-1000,256-12-51', $now ),
				true,
				false
			],
			[
				new MySQLPrimaryPos( '255-11-99,256-12-50', $now ),
				new MySQLPrimaryPos( '255-13-1000,256-14-49', $now ),
				true,
				true
			],
			[
				new MySQLPrimaryPos( '253-11-999,255-11-999', $now ),
				new MySQLPrimaryPos( '254-11-1000', $now ),
				false,
				false
			],
		];
	}

	/**
	 * @dataProvider provideCommonDomainGTIDs
	 * @covers \Wikimedia\Rdbms\MySQLPrimaryPos
	 */
	public function testGetRelevantActiveGTIDs( MySQLPrimaryPos $pos, MySQLPrimaryPos $ref, $gtids ) {
		$this->assertEquals( $gtids, MySQLPrimaryPos::getRelevantActiveGTIDs( $pos, $ref ) );
	}

	public static function provideCommonDomainGTIDs() {
		return [
			[
				new MySQLPrimaryPos( '255-13-99,256-12-50,257-14-50', 1 ),
				new MySQLPrimaryPos( '255-11-1000', 1 ),
				[ '255-13-99' ]
			],
			[
				( new MySQLPrimaryPos( '255-13-99,256-12-50,257-14-50', 1 ) )
					->setActiveDomain( 257 ),
				new MySQLPrimaryPos( '255-11-1000,257-14-30', 1 ),
				[ '257-14-50' ]
			],
			[
				new MySQLPrimaryPos(
					'2E11FA47-71CA-11E1-9E33-C80AA9429562:1-5,' .
					'3E11FA47-71CA-11E1-9E33-C80AA9429562:20-99,' .
					'7E11FA47-71CA-11E1-9E33-C80AA9429562:1-30',
					1
				),
				new MySQLPrimaryPos(
					'1E11FA47-71CA-11E1-9E33-C80AA9429562:30-100,' .
					'3E11FA47-71CA-11E1-9E33-C80AA9429562:30-66',
					1
				),
				[ '3E11FA47-71CA-11E1-9E33-C80AA9429562:20-99' ]
			]
		];
	}

	/**
	 * @dataProvider provideLagAmounts
	 */
	public function testPtHeartbeat( $lag ) {
		/** @var IDatabase $db */
		$db = $this->getMockBuilder( IDatabase::class )
			->disableOriginalConstructor()
			->getMock();
		$db->setLBInfo( 'replica', true );

		$replicationReporter = $this->getMockBuilder( MysqlReplicationReporter::class )
			->disableOriginalConstructor()
			->onlyMethods( [ 'fetchSecondsSinceHeartbeat' ] )
			->getMock();

		TestingAccessWrapper::newFromObject( $replicationReporter )->lagDetectionMethod = 'pt-heartbeat';
		$replicationReporter->method( 'fetchSecondsSinceHeartbeat' )->willReturn( $lag );

		$lagEst = $replicationReporter->getLag( $db );

		$this->assertGreaterThan( $lag - 0.010, $lagEst, "Correct heatbeat lag" );
		$this->assertLessThan( $lag + 0.010, $lagEst, "Correct heatbeat lag" );
	}

	public static function provideLagAmounts() {
		return [
			[ 0 ],
			[ 0.3 ],
			[ 6.5 ],
			[ 10.1 ],
			[ 200.2 ],
			[ 400.7 ],
			[ 600.22 ],
			[ 1000.77 ],
		];
	}

	/**
	 * @dataProvider provideGtidData
	 * @covers \Wikimedia\Rdbms\MySQLPrimaryPos
	 * @covers \Wikimedia\Rdbms\DatabaseMySQL
	 */
	public function testServerGtidTable( $gtable, $rBLtable, $mBLtable, $rGTIDs, $mGTIDs ) {
		$db = $this->getMockBuilder( IDatabase::class )
			->disableOriginalConstructor()
			->getMock();
		$replicationReporter = $this->getMockBuilder( MysqlReplicationReporter::class )
			->disableOriginalConstructor()
			->onlyMethods( [
				'useGTIDs',
				'getServerGTIDs',
				'getServerRoleStatus',
				'getServerId',
				'getServerUUID'
			] )
			->getMock();

		$replicationReporter->method( 'useGTIDs' )->willReturn( true );
		$replicationReporter->method( 'getServerGTIDs' )->willReturn( $gtable );
		$replicationReporter->method( 'getServerRoleStatus' )->willReturnCallback(
			static function ( $db, $role ) use ( $rBLtable, $mBLtable ) {
				if ( $role === 'SLAVE' ) {
					return $rBLtable;
				} elseif ( $role === 'MASTER' ) {
					return $mBLtable;
				}

				return null;
			}
		);
		$replicationReporter->method( 'getServerId' )->willReturn( 1 );
		$replicationReporter->method( 'getServerUUID' )->willReturn( '2E11FA47-71CA-11E1-9E33-C80AA9429562' );

		/** @var DatabaseMySQL $replicationReporter */
		if ( is_array( $rGTIDs ) ) {
			$this->assertEquals( $rGTIDs, $replicationReporter->getReplicaPos( $db )->getGTIDs() );
		} else {
			$this->assertFalse( $replicationReporter->getReplicaPos( $db ) );
		}
		if ( is_array( $mGTIDs ) ) {
			$this->assertEquals( $mGTIDs, $replicationReporter->getPrimaryPos( $db )->getGTIDs() );
		} else {
			$this->assertFalse( $replicationReporter->getPrimaryPos( $db ) );
		}
	}

	public static function provideGtidData() {
		return [
			// MariaDB
			[
				[
					'gtid_domain_id' => 100,
					'gtid_current_pos' => '100-13-77',
					'gtid_binlog_pos' => '100-13-77',
					'gtid_slave_pos' => null // master
				],
				[
					'Relay_Master_Log_File' => 'host.1600',
					'Exec_Master_Log_Pos' => '77'
				],
				[
					'File' => 'host.1600',
					'Position' => '77'
				],
				[],
				[ '100' => '100-13-77' ]
			],
			[
				[
					'gtid_domain_id' => 100,
					'gtid_current_pos' => '100-13-77',
					'gtid_binlog_pos' => '100-13-77',
					'gtid_slave_pos' => '100-13-77' // replica
				],
				[
					'Relay_Master_Log_File' => 'host.1600',
					'Exec_Master_Log_Pos' => '77'
				],
				[],
				[ '100' => '100-13-77' ],
				[ '100' => '100-13-77' ]
			],
			[
				[
					'gtid_current_pos' => '100-13-77',
					'gtid_binlog_pos' => '100-13-77',
					'gtid_slave_pos' => '100-13-77' // replica
				],
				[
					'Relay_Master_Log_File' => 'host.1600',
					'Exec_Master_Log_Pos' => '77'
				],
				[],
				[ '100' => '100-13-77' ],
				[ '100' => '100-13-77' ]
			],
			// MySQL
			[
				[
					'gtid_executed' => '2E11FA47-71CA-11E1-9E33-C80AA9429562:1-77'
				],
				[
					'Relay_Master_Log_File' => 'host.1600',
					'Exec_Master_Log_Pos' => '77'
				],
				[], // only a replica
				[ '2E11FA47-71CA-11E1-9E33-C80AA9429562'
					=> '2E11FA47-71CA-11E1-9E33-C80AA9429562:1-77' ],
				// replica/master use same var
				[ '2E11FA47-71CA-11E1-9E33-C80AA9429562'
					=> '2E11FA47-71CA-11E1-9E33-C80AA9429562:1-77' ],
			],
			[
				[
					'gtid_executed' => '2E11FA47-71CA-11E1-9E33-C80AA9429562:1-49,' .
						'2E11FA47-71CA-11E1-9E33-C80AA9429562:51-77'
				],
				[
					'Relay_Master_Log_File' => 'host.1600',
					'Exec_Master_Log_Pos' => '77'
				],
				[], // only a replica
				[ '2E11FA47-71CA-11E1-9E33-C80AA9429562'
					=> '2E11FA47-71CA-11E1-9E33-C80AA9429562:51-77' ],
				// replica/master use same var
				[ '2E11FA47-71CA-11E1-9E33-C80AA9429562'
					=> '2E11FA47-71CA-11E1-9E33-C80AA9429562:51-77' ],
			],
			[
				[
					'gtid_executed' => null, // not enabled?
					'gtid_binlog_pos' => null
				],
				[
					'Relay_Master_Log_File' => 'host.1600',
					'Exec_Master_Log_Pos' => '77'
				],
				[], // only a replica
				[], // binlog fallback
				false
			],
			[
				[
					'gtid_executed' => null, // not enabled?
					'gtid_binlog_pos' => null
				],
				[], // no replication
				[], // no replication
				false,
				false
			]
		];
	}

	/**
	 * @covers \Wikimedia\Rdbms\MySQLPrimaryPos
	 */
	public function testSerialize() {
		$pos = new MySQLPrimaryPos( '3E11FA47-71CA-11E1-9E33-C80AA9429562:99', 53636363 );
		$roundtripPos = unserialize( serialize( $pos ) );

		$this->assertEquals( $pos, $roundtripPos );

		$pos = new MySQLPrimaryPos( '255-11-23', 53636363 );
		$roundtripPos = unserialize( serialize( $pos ) );

		$this->assertEquals( $pos, $roundtripPos );
	}

	public function testBuildIntegerCast() {
		$db = $this->createPartialMock( DatabaseMySQL::class, [] );
		TestingAccessWrapper::newFromObject( $db )->platform = new MySQLPlatform( new AddQuoterMock() );

		/** @var IDatabase $db */
		$output = $db->buildIntegerCast( 'fieldName' );
		$this->assertSame( 'CAST( fieldName AS SIGNED )', $output );
	}

	/**
	 * @covers \Wikimedia\Rdbms\Platform\MySQLPlatform
	 */
	public function testNormalizeJoinType() {
		$platform = new MySQLPlatform( new AddQuoterMock() );
		$sql = $platform->selectSQLText(
			[ 'a', 'b' ],
			'aa',
			[],
			'',
			[],
			[ 'b' => [ 'STRAIGHT_JOIN', 'bb=aa' ] ]
		);
		$this->assertSame(
			'SELECT  aa  FROM `a` STRAIGHT_JOIN `b` ON ((bb=aa))    ',
			$sql
		);
	}

	/**
	 * @covers \Wikimedia\Rdbms\Platform\MySQLPlatform
	 */
	public function testNormalizeJoinTypeSqb() {
		$db = $this->createPartialMock( DatabaseMySQL::class, [] );

		TestingAccessWrapper::newFromObject( $db )->currentDomain =
			new DatabaseDomain( null, null, '' );
		TestingAccessWrapper::newFromObject( $db )->platform =
			new MySQLPlatform( new AddQuoterMock() );

		/** @var IDatabase $db */
		$sql = $db->newSelectQueryBuilder()
			->select( 'aa' )
			->from( 'a' )
			->straightJoin( 'b', null, [ 'bb=aa' ] )
			->getSQL();
		$this->assertSame(
			'SELECT  aa  FROM `a` STRAIGHT_JOIN `b` ON ((bb=aa))    ',
			$sql
		);
	}

	/**
	 * @covers \Wikimedia\Rdbms\Database
	 * @covers \Wikimedia\Rdbms\DatabaseMySQL
	 * @covers \Wikimedia\Rdbms\Platform\MySQLPlatform
	 */
	public function testIndexAliases() {
		$db = $this->getMockBuilder( DatabaseMySQL::class )
			->disableOriginalConstructor()
			->onlyMethods( [ 'strencode', 'dbSchema', 'tablePrefix' ] )
			->getMock();
		$db->method( 'strencode' )->willReturnCallback(
			static function ( $s ) {
				return str_replace( "'", "\\'", $s );
			}
		);
		$wdb = TestingAccessWrapper::newFromObject( $db );
		$wdb->platform = new MySQLPlatform( new AddQuoterMock() );

		/** @var IDatabase $db */
		$db->setIndexAliases( [ 'a_b_idx' => 'a_c_idx' ] );
		$sql = $db->newSelectQueryBuilder()
			->select( 'field' )
			->from( 'zend' )
			->where( [ 'a' => 'x' ] )
			->useIndex( 'a_b_idx' )
			->caller( __METHOD__ )->getSQL();

		$this->assertSameSql(
			"SELECT  field  FROM `zend` FORCE INDEX (a_c_idx)    WHERE a = 'x'  ",
			$sql
		);

		$db->setIndexAliases( [] );
		$sql = $db->newSelectQueryBuilder()
			->select( 'field' )
			->from( 'zend' )
			->where( [ 'a' => 'x' ] )
			->useIndex( 'a_b_idx' )
			->caller( __METHOD__ )->getSQL();

		$this->assertSameSql(
			"SELECT  field  FROM `zend` FORCE INDEX (a_b_idx)    WHERE a = 'x'",
			$sql
		);
	}

	/**
	 * @covers \Wikimedia\Rdbms\Database
	 * @covers \Wikimedia\Rdbms\Platform\SQLPlatform
	 * @covers \Wikimedia\Rdbms\Platform\MySQLPlatform
	 */
	public function testTableAliases() {
		$db = $this->getMockBuilder( DatabaseMySQL::class )
			->disableOriginalConstructor()
			->onlyMethods( [ 'strencode', 'dbSchema', 'tablePrefix' ] )
			->getMock();
		$db->method( 'strencode' )->willReturnCallback(
			static function ( $s ) {
				return str_replace( "'", "\\'", $s );
			}
		);
		$wdb = TestingAccessWrapper::newFromObject( $db );
		$wdb->platform = new MySQLPlatform( new AddQuoterMock() );

		/** @var IDatabase $db */
		$db->setTableAliases( [
			'meow' => [ 'dbname' => 'feline', 'schema' => null, 'prefix' => 'cat_' ]
		] );
		$sql = $db->newSelectQueryBuilder()
			->select( 'field' )
			->from( 'meow' )
			->where( [ 'a' => 'x' ] )
			->caller( __METHOD__ )->getSQL();

		$this->assertSameSql(
			"SELECT  field  FROM `feline`.`cat_meow`    WHERE a = 'x'  ",
			$sql
		);

		$db->setTableAliases( [] );
		$sql = $db->newSelectQueryBuilder()
			->select( 'field' )
			->from( 'meow' )
			->where( [ 'a' => 'x' ] )
			->caller( __METHOD__ )->getSQL();

		$this->assertSameSql(
			"SELECT  field  FROM `meow`    WHERE a = 'x'  ",
			$sql
		);
	}

	/**
	 * @covers \Wikimedia\Rdbms\DatabaseMySQL
	 * @covers \Wikimedia\Rdbms\Platform\SQLPlatform
	 * @covers \Wikimedia\Rdbms\Platform\MySQLPlatform
	 */
	public function testMaxExecutionTime() {
		$db = $this->getMockBuilder( DatabaseMySQL::class )
			->disableOriginalConstructor()
			->onlyMethods( [ 'getServerVersion', 'dbSchema', 'tablePrefix' ] )
			->getMock();
		$db->method( 'getServerVersion' )->willReturn( '10.4.28-MariaDB-log' );
		$wdb = TestingAccessWrapper::newFromObject( $db );
		$wdb->platform = new MySQLPlatform( new AddQuoterMock() );

		/** @var IDatabase $db */
		$sql = $wdb->selectSQLText( 'image',
			'img_metadata',
			'*',
			'',
			[ 'MAX_EXECUTION_TIME' => 1 ]
		);

		$this->assertSameSql(
			"SET STATEMENT max_statement_time=0.001 FOR SELECT  img_metadata  FROM `image`     ",
			$sql
		);
	}

	/**
	 * @covers \Wikimedia\Rdbms\Database
	 * @covers \Wikimedia\Rdbms\DatabaseMySQL
	 */
	public function testStreamStatementEnd() {
		/** @var DatabaseMySQL $db */
		$db = $this->getMockForAbstractClass( DatabaseMySQL::class, [], '', false );
		$sql = '';

		$newLine = "delimiter\n!! ?";
		$this->assertFalse( $db->streamStatementEnd( $sql, $newLine ) );
		$this->assertSame( '', $newLine );

		$newLine = 'JUST A TEST!!!';
		$this->assertTrue( $db->streamStatementEnd( $sql, $newLine ) );
		$this->assertSame( 'JUST A TEST!', $newLine );
	}

	private function assertSameSql( $expected, $actual, $message = '' ) {
		$this->assertSame( trim( $expected ), trim( $actual ), $message );
	}
}
rdbms/querybuilder/InsertQueryBuilderTest.php000066600000007550151335120240015550 0ustar00<?php

/**
 * @covers \Wikimedia\Rdbms\InsertQueryBuilder
 */
class InsertQueryBuilderTest extends PHPUnit\Framework\TestCase {
	use MediaWikiCoversValidator;

	/** @var DatabaseTestHelper */
	private $db;

	/** @var InsertQueryBuilderTest */
	private $iqb;

	protected function setUp(): void {
		$this->db = new DatabaseTestHelper( __CLASS__ . '::' . $this->getName() );
		$this->iqb = $this->db->newInsertQueryBuilder();
	}

	private function assertSQL( $expected, $fname ) {
		$this->iqb->caller( $fname )->execute();
		$actual = $this->db->getLastSqls();
		$actual = preg_replace( '/ +/', ' ', $actual );
		$actual = rtrim( $actual, " " );
		$this->assertEquals( $expected, $actual );
	}

	public function testSimpleInsert() {
		$this->iqb
			->insertInto( 'a' )
			->row( [ 'f' => 'g', 'd' => 'l' ] );
		$this->assertSQL( "INSERT INTO a (f,d) VALUES ('g','l')", __METHOD__ );
	}

	public function testIgnore() {
		$this->iqb
			->insertInto( 'a' )
			->ignore()
			->row( [ 'f' => 'g', 'd' => 'l' ] );
		$this->assertSQL( "INSERT IGNORE INTO a (f,d) VALUES ('g','l')", __METHOD__ );
	}

	public function testUpsert() {
		$this->iqb
			->insertInto( 'a' )
			->row( [ 'f' => 'g', 'd' => 'l' ] )
			->onDuplicateKeyUpdate()
			->uniqueIndexFields( [ 'd' ] )
			->set( [ 'f' => 'm' ] );
		$this->assertSQL(
			"BEGIN; UPDATE a SET f = 'm' WHERE (d = 'l'); INSERT INTO a (f,d) VALUES ('g','l'); COMMIT",
			__METHOD__
		);
	}

	public function testUpsertWithStringKey() {
		$this->iqb
			->insertInto( 'a' )
			->row( [ 'f' => 'g', 'd' => 'l' ] )
			->onDuplicateKeyUpdate()
			->uniqueIndexFields( 'd' )
			->set( [ 'f' => 'm' ] );
		$this->assertSQL(
			"BEGIN; UPDATE a SET f = 'm' WHERE (d = 'l'); INSERT INTO a (f,d) VALUES ('g','l'); COMMIT",
			__METHOD__
		);
	}

	public function testOption() {
		$this->iqb
			->insertInto( 't' )
			->row( [ 'f' => 'g' ] )
			->option( 'IGNORE' );
		$this->assertSQL( "INSERT IGNORE INTO t (f) VALUES ('g')", __METHOD__ );
	}

	public function testOptions() {
		$this->iqb
			->insertInto( 't' )
			->row( [ 'f' => 'g' ] )
			->options( [ 'IGNORE' ] );
		$this->assertSQL( "INSERT IGNORE INTO t (f) VALUES ('g')", __METHOD__ );
	}

	public function testExecute() {
		$this->iqb->insertInto( 't' )->rows( [ 'a' => 'b' ] )->caller( __METHOD__ );
		$this->iqb->execute();
		$this->assertEquals( "INSERT INTO t (a) VALUES ('b')", $this->db->getLastSqls() );
	}

	public function testGetQueryInfo() {
		$this->iqb
			->insertInto( 't' )
			->ignore()
			->row( [ 'a' => 'b', 'd' => 'l' ] )
			->caller( 'foo' );
		$this->assertEquals(
			[
				'table' => 't' ,
				'rows' => [ [ 'a' => 'b', 'd' => 'l' ] ],
				'options' => [ 'IGNORE' ],
				'upsert' => false,
				'set' => [],
				'uniqueIndexFields' => [],
				'caller' => 'foo',
			],
			$this->iqb->getQueryInfo() );
	}

	public function testGetQueryInfoUpsert() {
		$this->iqb
			->insertInto( 't' )
			->row( [ 'f' => 'g', 'd' => 'l' ] )
			->onDuplicateKeyUpdate()
			->uniqueIndexFields( [ 'd' ] )
			->set( [ 'f' => 'm' ] )
			->caller( 'foo' );
		$this->assertEquals(
			[
				'table' => 't' ,
				'rows' => [ [ 'f' => 'g', 'd' => 'l' ] ],
				'upsert' => true,
				'set' => [ 'f' => 'm' ],
				'uniqueIndexFields' => [ 'd' ],
				'options' => [],
				'caller' => 'foo'
			],
			$this->iqb->getQueryInfo() );
	}

	public function testQueryInfo() {
		$this->iqb->queryInfo(
			[
				'table' => 't',
				'rows' => [ [ 'f' => 'g', 'd' => 'l' ] ],
				'options' => [ 'IGNORE' ],
			]
		);
		$this->assertSQL( "INSERT IGNORE INTO t (f,d) VALUES ('g','l')", __METHOD__ );
	}

	public function testQueryInfoUpsert() {
		$this->iqb->queryInfo(
			[
				'table' => 't',
				'rows' => [ [ 'f' => 'g', 'd' => 'l' ] ],
				'upsert' => true,
				'set' => [ 'f' => 'm' ],
				'uniqueIndexFields' => [ 'd' ],
			]
		);
		$this->assertSQL(
			"BEGIN; UPDATE t SET f = 'm' WHERE (d = 'l'); INSERT INTO t (f,d) VALUES ('g','l'); COMMIT",
			__METHOD__
		);
	}
}
rdbms/querybuilder/DeleteQueryBuilderTest.php000066600000003776151335120240015514 0ustar00<?php

use Wikimedia\Rdbms\DeleteQueryBuilder;
use Wikimedia\Rdbms\Platform\ISQLPlatform;

/**
 * @covers \Wikimedia\Rdbms\DeleteQueryBuilder
 */
class DeleteQueryBuilderTest extends PHPUnit\Framework\TestCase {
	use MediaWikiCoversValidator;

	/** @var DatabaseTestHelper */
	private $db;

	/** @var DeleteQueryBuilder */
	private $dqb;

	protected function setUp(): void {
		$this->db = new DatabaseTestHelper( __CLASS__ . '::' . $this->getName() );
		$this->dqb = $this->db->newDeleteQueryBuilder();
	}

	private function assertSQL( $expected, $fname ) {
		$this->dqb->caller( $fname )->execute();
		$actual = $this->db->getLastSqls();
		$actual = preg_replace( '/ +/', ' ', $actual );
		$actual = rtrim( $actual, " " );
		$this->assertEquals( $expected, $actual );
	}

	public function testCondsEtc() {
		$this->dqb
			->table( 'a' )
			->where( '1' )
			->andWhere( '2' )
			->conds( '3' );
		$this->assertSQL( 'DELETE FROM a WHERE (1) AND (2) AND (3)', __METHOD__ );
	}

	public function testConflictingConds() {
		$this->dqb
			->deleteFrom( '1' )
			->where( [ 'k' => 'v1' ] )
			->andWhere( [ 'k' => 'v2' ] );
		$this->assertSQL( 'DELETE FROM 1 WHERE k = \'v1\' AND (k = \'v2\')', __METHOD__ );
	}

	public function testCondsAllRows() {
		$this->dqb
			->table( 'a' )
			->where( ISQLPlatform::ALL_ROWS );
		$this->assertSQL( 'DELETE FROM a', __METHOD__ );
	}

	public function testExecute() {
		$this->dqb->deleteFrom( 't' )->where( 'c' )->caller( __METHOD__ );
		$this->dqb->execute();
		$this->assertEquals( 'DELETE FROM t WHERE (c)', $this->db->getLastSqls() );
	}

	public function testGetQueryInfo() {
		$this->dqb
			->deleteFrom( 't' )
			->where( [ 'a' => 'b' ] )
			->caller( 'foo' );
		$this->assertEquals(
			[
				'table' => 't' ,
				'conds' => [ 'a' => 'b' ],
				'caller' => 'foo',
			],
			$this->dqb->getQueryInfo() );
	}

	public function testQueryInfo() {
		$this->dqb->queryInfo(
			[
				'table' => 't',
				'conds' => [ 'a' => 'b' ],
			]
		);
		$this->assertSQL( "DELETE FROM t WHERE a = 'b'", __METHOD__ );
	}
}
Back to Directory File Manager