Add validation for abstract schema

This adds an option to the schema generating maintenance scripts to
validate abstract schemas and schema changes and a structure test to
validate exisiting schemas and schema changes. Schemas are also
validated when generating.

The validation for the schema doesn't impose limits on table, index or
column names as I couldn't find any reliable conventions for them.

The structure tests only cover MediaWiki itself as there is no
convention on where extensions store their abstract schema.
Ideally, auto detection would be possible for sql/, but for now
extensions have to define their own (thankfully trivial) tests.

A couple of invalid definitions were fixed thanks to these tests.

I aimed to be thorough, but not all parts of the abstract schema
are completely clear, and Doctrine's documentation is not complete.
As a result, not everything has a description field.

Bug: T298320
Change-Id: I681d265317d4d1584869142ebb23d4098c06885f
This commit is contained in:
mainframe98 2022-02-11 16:03:28 +01:00 committed by Mainframe98
parent c85a2a8c24
commit de0c4819d1
11 changed files with 561 additions and 4 deletions

View file

@ -0,0 +1,22 @@
{
"$schema": "https://json-schema.org/schema#",
"description": "MediaWiki abstract database schema schema",
"type": "object",
"additionalProperties": false,
"properties": {
"comment": {
"type": "string",
"description": "Comment describing the schema change"
},
"before": {
"type": "object",
"description": "Schema before the change",
"$ref": "abstract-schema-table.json"
},
"after": {
"type": "object",
"description": "Schema after the change",
"$ref": "abstract-schema-table.json"
}
}
}

View file

@ -0,0 +1,208 @@
{
"$schema": "https://json-schema.org/schema#",
"description": "Abstract description of a mediawiki database table",
"type": "object",
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"description": "Name of the table"
},
"comment": {
"type": "string",
"description": "Comment describing the table"
},
"columns": {
"type": "array",
"additionalItems": false,
"description": "Columns",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"description": "Name of the column"
},
"comment": {
"type": "string",
"description": "Comment describing the column"
},
"type": {
"type": "string",
"description": "Data type of the column",
"enum": [
"bigint",
"binary",
"blob",
"datetimetz",
"float",
"integer",
"mwenum",
"mwtimestamp",
"mwtinyint",
"smallint",
"string",
"text"
]
},
"options": {
"type": "object",
"description": "Additional options",
"additionalProperties": false,
"properties": {
"autoincrement": {
"type": "boolean",
"description": "Indicates if the field should use an autoincremented value if no value was provided",
"default": false
},
"default": {
"type": [
"number",
"string",
"null"
],
"description": "The default value of the column if no value was specified",
"default": null
},
"fixed": {
"type": "boolean",
"description": "Indicates if the column should have a fixed length",
"default": false
},
"length": {
"type": "number",
"description": "Length of the field.",
"default": null,
"minimum": 0
},
"notnull": {
"type": "boolean",
"description": "Indicates whether the column is nullable or not",
"default": true
},
"unsigned": {
"type": "boolean",
"description": "If the column should be an unsigned integer",
"default": false
},
"PlatformOptions": {
"type": "object",
"additionalProperties": false,
"properties": {
"version": {
"type": "boolean"
}
}
},
"CustomSchemaOptions": {
"type": "object",
"description": "Custom schema options",
"additionalProperties": false,
"properties": {
"allowInfinite": {
"type": "boolean"
},
"doublePrecision": {
"type": "boolean"
},
"enum_values": {
"type": "array",
"description": "Values to use with type 'mwenum'",
"additionalItems": false,
"items": {
"type": "string"
},
"uniqueItems": true
}
}
}
}
}
},
"required": [
"name",
"type"
]
}
},
"indexes": {
"type": "array",
"additionalItems": false,
"description": "Indexes",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"description": "Index name"
},
"comment": {
"type": "string",
"description": "Comment describing the index"
},
"columns": {
"type": "array",
"additionalItems": false,
"description": "Columns used by the index",
"items": {
"type": "string"
},
"uniqueItems": true
},
"unique": {
"type": "boolean",
"description": "If the index is unique"
},
"flags": {
"type": "array",
"items": {
"type": "string"
}
},
"options": {
"type": "object",
"properties": {
"lengths": {
"type": "array",
"items": {
"type": [
"number",
"null"
]
},
"minItems": 1
}
}
}
},
"required": [
"name",
"columns"
]
}
},
"pk": {
"type": "array",
"additionalItems": false,
"description": "Array of column names used in the primary key",
"items": {
"type": "string"
},
"uniqueItems": true
},
"table_options": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"name",
"columns",
"indexes"
]
}

View file

@ -0,0 +1,10 @@
{
"$schema": "https://json-schema.org/schema#",
"description": "MediaWiki abstract database schema schema",
"type": "array",
"additionalItems": false,
"items": {
"$ref": "abstract-schema-table.json"
},
"minItems": 1
}

View file

@ -0,0 +1,31 @@
<?php
/**
* 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
*/
namespace MediaWiki\DB;
use Exception;
/**
* @newable
* @since 1.38
*/
class AbstractSchemaValidationError extends Exception {
}

View file

@ -0,0 +1,117 @@
<?php
/**
* 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
*/
namespace MediaWiki\DB;
use JsonSchema\Validator;
use Seld\JsonLint\DuplicateKeyException;
use Seld\JsonLint\JsonParser;
use Seld\JsonLint\ParsingException;
use function class_exists;
use function file_get_contents;
use function is_array;
use function is_object;
/**
* Validate abstract schema json files against their JSON schema.
*
* This is used for static validation from the command-line via
* generateSchemaSql.php, generateSchemaChangeSql, and the PHPUnit structure test suite
* (AbstractSchemaValidationTest).
*
* The files are normally read by the generateSchemaSql.php and generateSchemaSqlChange.php maintenance scripts.
*
* @since 1.38
*/
class AbstractSchemaValidator {
/**
* @var callable(string):void
*/
private $missingDepCallback;
/**
* @param callable(string):void $missingDepCallback
*/
public function __construct( callable $missingDepCallback ) {
$this->missingDepCallback = $missingDepCallback;
}
/**
* @codeCoverageIgnore
* @return bool
*/
public function checkDependencies(): bool {
if ( !class_exists( Validator::class ) ) {
( $this->missingDepCallback )(
'The JsonSchema library cannot be found, please install it through composer.'
);
return false;
}
if ( !class_exists( JsonParser::class ) ) {
( $this->missingDepCallback )(
'The JSON lint library cannot be found, please install it through composer.'
);
return false;
}
return true;
}
/**
* @param string $path file to validate
* @return bool true if passes validation
* @throws AbstractSchemaValidationError on any failure
*/
public function validate( string $path ): bool {
$contents = file_get_contents( $path );
$jsonParser = new JsonParser();
try {
$data = $jsonParser->parse( $contents, JsonParser::DETECT_KEY_CONFLICTS );
} catch ( DuplicateKeyException $e ) {
throw new AbstractSchemaValidationError( $e->getMessage(), $e->getCode(), $e );
} catch ( ParsingException $e ) {
throw new AbstractSchemaValidationError( "$path is not valid JSON", $e->getCode(), $e );
}
// Regular schema's are arrays, schema changes are objects.
if ( is_array( $data ) ) {
$schemaPath = __DIR__ . '/../../docs/abstract-schema.schema.json';
} elseif ( is_object( $data ) ) {
$schemaPath = __DIR__ . '/../../docs/abstract-schema-changes.schema.json';
} else {
throw new AbstractSchemaValidationError( "$path is not a supported JSON object" );
}
$validator = new Validator;
$validator->check( $data, (object)[ '$ref' => 'file://' . $schemaPath ] );
if ( $validator->isValid() ) {
// All good.
return true;
}
$out = "$path did not pass validation.\n";
foreach ( $validator->getErrors() as $error ) {
$out .= "[{$error['property']}] {$error['message']}\n";
}
throw new AbstractSchemaValidationError( $out );
}
}

View file

@ -22,6 +22,9 @@
* @ingroup Maintenance
*/
use MediaWiki\DB\AbstractSchemaValidationError;
use MediaWiki\DB\AbstractSchemaValidator;
require_once __DIR__ . '/../Maintenance.php';
abstract class SchemaMaintenance extends Maintenance {
@ -64,6 +67,10 @@ abstract class SchemaMaintenance extends Maintenance {
false,
true
);
$this->addOption(
'validate',
'Validate the schema instead of generating sql files.'
);
}
public function execute() {
@ -80,6 +87,12 @@ abstract class SchemaMaintenance extends Maintenance {
$jsonPath = strtr( $jsonPath, '\\', '/' );
}
if ( $this->hasOption( 'validate' ) ) {
$this->getSchema( $jsonPath );
return;
}
// Allow to specify a folder and use a default name
if ( is_dir( $jsonPath ) ) {
$jsonPath .= '/tables.json';
@ -205,6 +218,15 @@ abstract class SchemaMaintenance extends Maintenance {
);
}
$validator = new AbstractSchemaValidator( function ( string $msg ): void {
$this->fatalError( $msg );
} );
try {
$validator->validate( $jsonPath );
} catch ( AbstractSchemaValidationError $e ) {
$this->fatalError( $e->getMessage() );
}
return $abstractSchema;
}
}

View file

@ -3699,7 +3699,6 @@
{
"name": "si_text",
"columns": [ "si_text" ],
"fulltext": true,
"unique": false,
"flags": [
"fulltext"

View file

@ -0,0 +1 @@
This is definitely not JSON and most certainly not an abstract schema.

View file

@ -7,19 +7,19 @@
"name": "actor_id",
"comment": "Unique ID to identify each actor",
"type": "bigint",
"options": { "Unsigned": true, "Notnull": true }
"options": { "unsigned": true, "notnull": true }
},
{
"name": "actor_user",
"comment": "Key to user.user_id, or NULL for anonymous edits",
"type": "integer",
"options": { "Unsigned": true }
"options": { "unsigned": true }
},
{
"name": "actor_name",
"comment": "Text username or IP address",
"type": "string",
"options": { "Length": 255, "Notnull": true }
"options": { "length": 255, "notnull": true }
}
],
"indexes": [

View file

@ -0,0 +1,81 @@
<?php
/**
* 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
*/
use MediaWiki\DB\AbstractSchemaValidationError;
use MediaWiki\DB\AbstractSchemaValidator;
/**
* Validates all abstract schemas against the abstract-schema schemas in the docs/ folder.
*/
class AbstractSchemaValidationTest extends PHPUnit\Framework\TestCase {
use MediaWikiCoversValidator;
/**
* @var AbstractSchemaValidator
*/
protected $validator;
protected function setUp(): void {
parent::setUp();
$this->validator = new AbstractSchemaValidator( [ $this, 'markTestSkipped' ] );
$this->validator->checkDependencies();
}
public static function provideSchemas(): array {
return [
'maintenance/tables.json' => [ __DIR__ . '/../../../maintenance/tables.json' ]
];
}
/**
* @dataProvider provideSchemas
* @param string $path Path to tables.json file
*/
public function testSchemasPassValidation( string $path ): void {
try {
$this->validator->validate( $path );
// All good
$this->assertTrue( true );
} catch ( AbstractSchemaValidationError $e ) {
$this->fail( $e->getMessage() );
}
}
public static function provideSchemaChanges(): Generator {
foreach ( glob( __DIR__ . '/../../../maintenance/abstractSchemaChanges/*.json' ) as $schemaChange ) {
$fileName = pathinfo( $schemaChange, PATHINFO_BASENAME );
yield $fileName => [ $schemaChange ];
}
}
/**
* @dataProvider provideSchemaChanges
* @param string $path Path to tables.json file
*/
public function testSchemaChangesPassValidation( string $path ): void {
try {
$this->validator->validate( $path );
// All good
$this->assertTrue( true );
} catch ( AbstractSchemaValidationError $e ) {
$this->fail( $e->getMessage() );
}
}
}

View file

@ -0,0 +1,66 @@
<?php
/**
* 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.
*
*/
use MediaWiki\DB\AbstractSchemaValidationError;
use MediaWiki\DB\AbstractSchemaValidator;
/**
* @covers \MediaWiki\DB\AbstractSchemaValidator
*/
class AbstractSchemaValidatorTest extends MediaWikiUnitTestCase {
/**
* @dataProvider provideValidate
* @param string $file
* @param string|true $expected
*/
public function testValidate( string $file, $expected ): void {
// If a dependency is missing, skip this test.
$validator = new AbstractSchemaValidator( function ( $msg ) {
$this->markTestSkipped( $msg );
} );
if ( is_string( $expected ) ) {
$this->expectException( AbstractSchemaValidationError::class );
$this->expectExceptionMessage( $expected );
}
$dir = __DIR__ . '/../../../data/db/';
$this->assertSame(
$expected,
$validator->validate( $dir . $file )
);
}
public static function provideValidate(): array {
return [
[
'tables.json',
true
],
[
'patch-drop-ct_tag.json',
true
],
[
'notschema.txt',
'notschema.txt is not valid JSON'
]
];
}
}