src/Propel/Generator/Builder/Util/SchemaReader.php
<?php
/**
* MIT License. This file is part of the Propel package.
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Propel\Generator\Builder\Util;
use Propel\Generator\Config\GeneratorConfigInterface;
use Propel\Generator\Exception\SchemaException;
use Propel\Generator\Model\Index;
use Propel\Generator\Model\Schema;
use Propel\Generator\Model\Unique;
use Propel\Generator\Platform\PlatformInterface;
/**
* A class that is used to parse an input xml schema file and creates a Schema
* PHP object.
*
* @author Hans Lellelid <hans@xmpl.org> (Propel)
* @author Leon Messerschmidt <leon@opticode.co.za> (Torque)
* @author Jason van Zyl <jvanzyl@apache.org> (Torque)
* @author Martin Poeschl <mpoeschl@marmot.at> (Torque)
* @author Daniel Rall <dlr@collab.net> (Torque)
* @author Hugo Hamon <webmaster@apprendre-php.com>
*/
class SchemaReader
{
/**
* @var bool
*/
public const DEBUG = false;
/**
* @var \Propel\Generator\Model\Schema
*/
private $schema;
/**
* @var \XMLParser|resource
*/
private $parser;
/**
* @var \Propel\Generator\Model\Database
*/
private $currDB;
/**
* @var \Propel\Generator\Model\Table
*/
private $currTable;
/**
* @var \Propel\Generator\Model\Column
*/
private $currColumn;
/**
* @var \Propel\Generator\Model\ForeignKey
*/
private $currFK;
/**
* @var \Propel\Generator\Model\Index
*/
private $currIndex;
/**
* @var \Propel\Generator\Model\Unique
*/
private $currUnique;
/**
* @var \Propel\Generator\Model\Behavior
*/
private $currBehavior;
/**
* @var \Propel\Generator\Model\VendorInfo
*/
private $currVendorObject;
/**
* @var bool
*/
private $isForReferenceOnly = false;
/**
* @var string|null
*/
private $currentPackage;
/**
* @var string|null
*/
private $currentXmlFile;
/**
* @var string|null
*/
private $defaultPackage;
/**
* @var array|null
*/
private $currParameterListCollector;
/**
* Two-dimensional array,
* first dimension is for schemas(key is the path to the schema file),
* second is for tags within the schema.
*
* @var array
*/
private $schemasTagsStack = [];
/**
* Creates a new instance for the specified database type.
*
* @param \Propel\Generator\Platform\PlatformInterface|null $defaultPlatform The default database platform for the application.
* @param string|null $defaultPackage the default PHP package used for the om
*/
public function __construct(?PlatformInterface $defaultPlatform = null, ?string $defaultPackage = null)
{
$this->schema = new Schema($defaultPlatform);
$this->defaultPackage = $defaultPackage;
}
/**
* Set the Schema generator configuration
*
* @param \Propel\Generator\Config\GeneratorConfigInterface $generatorConfig
*
* @return void
*/
public function setGeneratorConfig(GeneratorConfigInterface $generatorConfig): void
{
$this->schema->setGeneratorConfig($generatorConfig);
}
/**
* Parses a XML input file and returns a newly created and
* populated Schema structure.
*
* @param string $xmlFile The input file to parse.
*
* @return \Propel\Generator\Model\Schema|null
*/
public function parseFile(string $xmlFile): ?Schema
{
// we don't want infinite recursion
if ($this->isAlreadyParsed($xmlFile)) {
return null;
}
return $this->parseString((string)file_get_contents($xmlFile), $xmlFile);
}
/**
* Parses a XML input string and returns a newly created and
* populated Schema structure.
*
* @param string $xmlString The input string to parse.
* @param string|null $xmlFile The input file name.
*
* @throws \Propel\Generator\Exception\SchemaException
*
* @return \Propel\Generator\Model\Schema|null
*/
public function parseString(string $xmlString, ?string $xmlFile = null): ?Schema
{
// we don't want infinite recursion
if ($xmlFile && $this->isAlreadyParsed($xmlFile)) {
return null;
}
// store current schema file path
$this->schemasTagsStack[$xmlFile] = [];
$this->currentXmlFile = $xmlFile;
$parserStash = $this->parser;
$this->parser = xml_parser_create();
xml_parser_set_option($this->parser, XML_OPTION_CASE_FOLDING, 0);
xml_set_object($this->parser, $this);
xml_set_element_handler($this->parser, [$this, 'startElement'], [$this, 'endElement']);
if (!xml_parse($this->parser, $xmlString)) {
throw new SchemaException(
sprintf(
'XML error: %s at line %d',
xml_error_string(xml_get_error_code($this->parser)),
xml_get_current_line_number($this->parser),
),
);
}
xml_parser_free($this->parser);
$this->parser = $parserStash;
array_pop($this->schemasTagsStack);
return $this->schema;
}
/**
* @param resource $parser
* @param string $tagName
* @param array $attributes
*
* @throws \Propel\Generator\Exception\SchemaException
*
* @return void
*/
public function startElement($parser, string $tagName, array $attributes): void
{
$parentTag = $this->peekCurrentSchemaTag();
if ($parentTag === false) {
switch ($tagName) {
case 'database':
if ($this->isExternalSchema()) {
$this->currentPackage = $attributes['package'] ?? null;
if ($this->currentPackage === null) {
$this->currentPackage = $this->defaultPackage;
}
} else {
$this->currDB = $this->schema->addDatabase($attributes);
}
break;
default:
$this->throwInvalidTagException($tagName);
}
} elseif ($parentTag === 'database') {
switch ($tagName) {
case 'external-schema':
$xmlFile = $attributes['filename'] ?? null;
// 'referenceOnly' attribute is valid in the main schema XML file only,
// and it's ignored in the nested external-schemas
if (!$this->isExternalSchema()) {
$isForRefOnly = $attributes['referenceOnly'] ?? null;
$this->isForReferenceOnly = ($isForRefOnly !== null ? (strtolower($isForRefOnly) === 'true') : true); // defaults to TRUE
}
if ($xmlFile[0] !== '/') {
$xmlFile = (string)realpath(dirname($this->currentXmlFile) . DIRECTORY_SEPARATOR . $xmlFile);
if (!file_exists($xmlFile)) {
throw new SchemaException(sprintf('Unknown include external `%s`', $xmlFile));
}
}
$this->parseFile($xmlFile);
break;
case 'domain':
$this->currDB->addDomain($attributes);
break;
case 'table':
if (
!isset($attributes['schema'])
&& $this->currDB->getSchema() && $this->currDB->getPlatform()->supportsSchemas()
&& strpos($attributes['name'], $this->currDB->getPlatform()->getSchemaDelimiter()) === false
) {
$attributes['schema'] = $this->currDB->getSchema();
}
$this->currTable = $this->currDB->addTable($attributes);
if ($this->isExternalSchema()) {
$this->currTable->setForReferenceOnly($this->isForReferenceOnly);
$this->currTable->setPackage($this->currentPackage);
}
break;
case 'vendor':
$this->currVendorObject = $this->currDB->addVendorInfo($attributes);
break;
case 'behavior':
$this->currBehavior = $this->currDB->addBehavior($attributes);
break;
default:
$this->throwInvalidTagException($tagName);
}
} elseif ($parentTag === 'table') {
switch ($tagName) {
case 'column':
$this->currColumn = $this->currTable->addColumn($attributes);
break;
case 'foreign-key':
$this->currFK = $this->currTable->addForeignKey($attributes);
break;
case 'index':
$this->currIndex = new Index();
$this->currIndex->setTable($this->currTable);
$this->currIndex->loadMapping($attributes);
break;
case 'unique':
$this->currUnique = new Unique();
$this->currUnique->setTable($this->currTable);
$this->currUnique->loadMapping($attributes);
break;
case 'vendor':
$this->currVendorObject = $this->currTable->addVendorInfo($attributes);
break;
case 'id-method-parameter':
$this->currTable->addIdMethodParameter($attributes);
break;
case 'behavior':
$this->currBehavior = $this->currTable->addBehavior($attributes);
break;
default:
$this->throwInvalidTagException($tagName);
}
} elseif ($parentTag === 'column') {
switch ($tagName) {
case 'inheritance':
$this->currColumn->addInheritance($attributes);
break;
case 'vendor':
$this->currVendorObject = $this->currColumn->addVendorInfo($attributes);
break;
default:
$this->throwInvalidTagException($tagName);
}
} elseif ($parentTag === 'foreign-key') {
switch ($tagName) {
case 'reference':
$this->currFK->addReference($attributes);
break;
case 'vendor':
$this->currVendorObject = $this->currFK->addVendorInfo($attributes);
break;
default:
$this->throwInvalidTagException($tagName);
}
} elseif ($parentTag === 'index') {
switch ($tagName) {
case 'index-column':
$this->currIndex->addColumn($attributes);
break;
case 'vendor':
$this->currVendorObject = $this->currIndex->addVendorInfo($attributes);
break;
default:
$this->throwInvalidTagException($tagName);
}
} elseif ($parentTag === 'unique') {
switch ($tagName) {
case 'unique-column':
$this->currUnique->addColumn($attributes);
break;
case 'vendor':
$this->currVendorObject = $this->currUnique->addVendorInfo($attributes);
break;
default:
$this->throwInvalidTagException($tagName);
}
} elseif ($parentTag === 'behavior') {
switch ($tagName) {
case 'parameter':
$this->currBehavior->addParameter($attributes);
break;
case 'parameter-list':
$this->initParameterListCollector($attributes);
break;
default:
$this->throwInvalidTagException($tagName);
}
} elseif ($parentTag === 'parameter-list') {
switch ($tagName) {
case 'parameter-list-item':
$this->addItemToParameterListCollector();
break;
default:
$this->throwInvalidTagException($tagName);
}
} elseif ($parentTag === 'parameter-list-item') {
switch ($tagName) {
case 'parameter':
$this->addAttributeToParameterListItem($attributes);
break;
default:
$this->throwInvalidTagException($tagName);
}
} elseif ($parentTag === 'vendor') {
switch ($tagName) {
case 'parameter':
$this->currVendorObject->setParameter($attributes['name'], $attributes['value']);
break;
default:
$this->throwInvalidTagException($tagName);
}
} else {
// it must be an invalid tag
$this->throwInvalidTagException($tagName);
}
$this->pushCurrentSchemaTag($tagName);
}
/**
* @param string $tagName
*
* @return void
*/
protected function throwInvalidTagException(string $tagName): void
{
$this->throwSchemaExceptionWithLocation('Unexpected tag <%s>', $tagName);
}
/**
* @param string $format
* @param mixed $args sprintf arguments
*
* @throws \Propel\Generator\Exception\SchemaException
*
* @return void
*/
private function throwSchemaExceptionWithLocation(string $format, ...$args): void
{
$format .= ' in %s';
$args[] = $this->getLocationDescription();
$message = vsprintf($format, $args);
throw new SchemaException($message);
}
/**
* Builds a human-readable description of the current location in the parser, i.e. "file schema.xml line 42, column 43"
*
* @return string
*/
private function getLocationDescription(): string
{
$location = ($this->currentXmlFile !== null) ? sprintf('file %s,', $this->currentXmlFile) : '';
/**
* @phpstan-ignore-next-line PHPStan is expecting XMLParser only, while resource is valid too.
*/
$currentLineNumber = xml_get_current_line_number($this->parser);
if ($currentLineNumber) {
$location .= sprintf('line %d', $currentLineNumber);
}
/**
* @phpstan-ignore-next-line PHPStan is expecting XMLParser only, while resource is valid too.
*/
$currentColumnNumber = xml_get_current_column_number($this->parser);
if ($currentColumnNumber) {
$location .= sprintf(', column %d', $currentColumnNumber);
}
return $location;
}
/**
* @param resource $parser
* @param string $tagName
*
* @return void
*/
public function endElement($parser, string $tagName): void
{
if ($tagName === 'index') {
$this->currTable->addIndex($this->currIndex);
} elseif ($tagName === 'unique') {
$this->currTable->addUnique($this->currUnique);
}
if (static::DEBUG) {
print('endElement(' . $tagName . ") called\n");
}
$this->popCurrentSchemaTag();
if ($tagName === 'parameter-list') {
$this->finalizeParameterList();
}
}
/**
* @return string|false
*/
protected function peekCurrentSchemaTag()
{
$keys = array_keys($this->schemasTagsStack);
return end($this->schemasTagsStack[end($keys)]);
}
/**
* @return string|false
*/
protected function popCurrentSchemaTag()
{
$keys = array_keys($this->schemasTagsStack);
return array_pop($this->schemasTagsStack[end($keys)]);
}
/**
* @param string $tag
*
* @return void
*/
protected function pushCurrentSchemaTag(string $tag): void
{
$keys = array_keys($this->schemasTagsStack);
$this->schemasTagsStack[end($keys)][] = $tag;
}
/**
* @return bool
*/
protected function isExternalSchema(): bool
{
return count($this->schemasTagsStack) > 1;
}
/**
* @param string $filePath
*
* @return bool
*/
protected function isAlreadyParsed(string $filePath): bool
{
return isset($this->schemasTagsStack[$filePath]);
}
/**
* @param array $attributes attributes of parameter-list tag
*
* @return void
*/
private function initParameterListCollector(array $attributes): void
{
$parameterName = $this->getExpectedValue($attributes, 'name');
$this->currParameterListCollector = [
'name' => $parameterName,
'value' => [],
];
}
/**
* Add a new item to the parameter list.
*
* @return void
*/
private function addItemToParameterListCollector(): void
{
$this->currParameterListCollector['value'][] = [];
}
/**
* Add a parameter to the last added item in the parameter list.
*
* @param array $attributes
*
* @return void
*/
private function addAttributeToParameterListItem(array $attributes): void
{
$name = $this->getExpectedValue($attributes, 'name');
$value = $this->getExpectedValue($attributes, 'value');
$items = &$this->currParameterListCollector['value'];
end($items);
$currentItem = &$items[key($items)];
$currentItem[$name] = $value;
}
/**
* Feeds the current parameter list to its parent and clears the collector.
*
* @return void
*/
private function finalizeParameterList(): void
{
$parentTag = $this->peekCurrentSchemaTag();
if ($parentTag === 'behavior') {
$this->currBehavior->addParameter($this->currParameterListCollector);
} else {
$this->throwSchemaExceptionWithLocation('Cannot add parameter list to tag <%s>', $parentTag);
}
$this->currParameterListCollector = null;
}
/**
* Checks if the given array contains the given key with a non-empty value.
*
* @param array $attributes
* @param string $key
*
* @return string the non-empty value
*/
private function getExpectedValue(array $attributes, string $key): string
{
if (empty($attributes[$key])) {
$this->throwSchemaExceptionWithLocation('Parameter misses expected attribute "%s"', $key);
}
return $attributes[$key];
}
}