├── readme.md └── src └── Schematron.php /readme.md: -------------------------------------------------------------------------------- 1 | [Schematron](https://github.com/milo/schematron/blob/master/src/Schematron.php) 2 | ============ 3 | This library is an implementation of the [ISO Schematron](http://www.schematron.com/spec.html) (with Schematron 1.5 back compatibility). It is done by pure DOM processing and does not require any XSLT sheets nor XSLT PHP extension. It was a requirement for a development. 4 | 5 | 6 | Usage 7 | ===== 8 | Install the Schematron by the Composer or download a release package. 9 | ```php 10 | require 'src/Schematron.php'; 11 | 12 | use Milo\Schematron; 13 | 14 | $schematron = new Schematron; 15 | $schematron->load('schema.xml'); 16 | 17 | $document = new DOMDocument; 18 | $document->load('document.xml'); 19 | $result = $schematron->validate($document); 20 | 21 | var_dump($result); 22 | ``` 23 | 24 | 25 | Format of the `Schematron::validate()` result depends on its second argument. E.g. an imaginary results: 26 | ```php 27 | # Flat array of failed asserts and successful reports (it is default) 28 | $result = $schematron->validate($document, Schematron::RESULT_SIMPLE); 29 | # array (2) 30 | # 0 => "Person must have surname." 31 | # 1 => "Phone number is required." 32 | 33 | 34 | # More complex structure 35 | $result = $schematron->validate($document, Schematron::RESULT_COMPLEX); 36 | # array (3) 37 | # 0 => stdClass (2) 38 | # title => "Pattern 1" (9) 39 | # rules => array (3) 40 | # 2 => stdClass (2) 41 | # context => "/" 42 | # errors => array (2) 43 | # 0 => stdClass (3) 44 | # | test => "false()" (7) 45 | # | message => "S5 - fail" (9) 46 | # | path => "/" 47 | # 1 => stdClass (3) 48 | # test => "true()" (6) 49 | # message => "S6 - fail" (9) 50 | # path => "/" 51 | 52 | 53 | # Or throws exception of first error occurence 54 | try { 55 | $result = $schematron->validate($document, Schematron::RESULT_EXCEPTION); 56 | } catch (Milo\SchematronException $e) { 57 | echo $e->getMessage(); # Person must have surname. 58 | } 59 | ``` 60 | 61 | 62 | A validation phase can be passed by 3rd argument: 63 | ```php 64 | $schematron->validate($document, Schematron::RESULT_SIMPLE, 'phase-base-rules'); 65 | ``` 66 | 67 | 68 | Schematron performs a schema namespace (ISO or v1.5) autodetection but the namespace can be passed manually: 69 | ```php 70 | $schematron = new Schmeatron(Schematron::NS_ISO); 71 | ``` 72 | 73 | 74 | By `Schematron::setOptions($options)` you can adjust the Schematron behaviour. The $options is a mask of following flags: 75 | ```php 76 | # Allows to schema does not contain a element, 77 | # so s stands alone in XML, e.g. in Relax NG schema 78 | Schematron::ALLOW_MISSING_SCHEMA_ELEMENT 79 | 80 | # are ignored and do not expand 81 | Schematron::IGNORE_INCLUDE 82 | 83 | # are forbidden and loading fails if occures 84 | Schematron::FORBID_INCLUDE 85 | 86 | # with the same @context as any rule before is skipped 87 | # This arises from official Universal Tests (http://www.schematron.com/validators/universalTests.sch) 88 | Schematron::SKIP_DUPLICIT_RULE_CONTEXT 89 | 90 | # needn't to contain s 91 | Schematron::ALLOW_EMPTY_SCHEMA 92 | 93 | # needn't to contain s 94 | Schematron::ALLOW_EMPTY_PATTERN 95 | 96 | # needn't to contain s nor s 97 | Schematron::ALLOW_EMPTY_RULE 98 | ``` 99 | 100 | 101 | An `` processing is affected by setting `Schematron::setAllowedInclude($allowed)` mask which permits types of include uri and `Schematron::setMaxIncludeDepth($depth)`: 102 | ```php 103 | # Remote URLs 104 | Schematron::INCLUDE_URL 105 | 106 | # Absolute and relative filesystem paths 107 | Schematron::INCLUDE_ABSOLUTE_PATH 108 | Schematron::INCLUDE_RELATIVE_PATH 109 | 110 | # Any URI 111 | Schematron::INCLUDE_ALL 112 | ``` 113 | 114 | 115 | And two basic attributes of loaded schema are accesible over: 116 | ```php 117 | $schematron->getSchemaVersion(); 118 | $schematron->getSchemaTitle(); 119 | ``` 120 | 121 | 122 | 123 | Licence 124 | ======= 125 | You may use all files under the terms of the New BSD Licence, or the GNU Public Licence (GPL) version 2 or 3, or the MIT Licence. 126 | 127 | 128 | 129 | Tests 130 | ===== 131 | The Schematron tests are written for [Nette Tester](https://github.com/nette/tester). Two steps are required to run them: 132 | ```sh 133 | # Download the Tester tool 134 | composer.phar update --dev 135 | 136 | # Run the tests 137 | vendor/bin/tester tests 138 | ``` 139 | 140 | 141 | 142 | ------ 143 | 144 | [![Build Status](https://travis-ci.org/milo/schematron.png?branch=master)](https://travis-ci.org/milo/schematron) 145 | -------------------------------------------------------------------------------- /src/Schematron.php: -------------------------------------------------------------------------------- 1 | is not required, so you can validate schematron in 32 | * Relax NG documents. But set ALLOW_MISSING_SCHEMA_ELEMENT option to enable it. 33 | * 34 | * Not implemented elements: let, diagnostic, diagnostics, dir, emph, flag, fpi, 35 | * icon, p, role, see, span, subject. Almost all of them are for documentation 36 | * purpose. Open issue on repository if you wish to be implemented. 37 | * 38 | * Example of usage: 39 | * 40 | * use Milo\Schematron; 41 | * 42 | * $validator = new Schematron(Schematron::NS_ISO); 43 | * $validator->load('personal-schema.sch'); 44 | * 45 | * $doc = new DOMDocument; 46 | * $doc->load($xmlDocument); 47 | * 48 | * $result = $validator->validate($doc, Schematron::RESULT_COMPLEX); 49 | * var_dump($result); 50 | * 51 | * 52 | * You can choose one of four licences: 53 | * @licence New BSD License 54 | * @licence GNU General Public License version 2 55 | * @licence GNU General Public License version 3 56 | * @licence MIT 57 | * 58 | * @version > 59 | * @see https://github.com/milo/schematron 60 | * 61 | * @author Miloslav Hůla (https://github.com/milo) 62 | */ 63 | class Schematron 64 | { 65 | /** Class version */ 66 | const 67 | VERSION = '1.0.0'; 68 | 69 | /** Namespace of supported schematron versions */ 70 | const 71 | NS_DETECT = NULL, 72 | NS_ISO = 'http://purl.oclc.org/dsdl/schematron', 73 | NS_1_5 = 'http://www.ascc.net/xml/schematron'; 74 | 75 | /** Type of {@link self::validate()} return value */ 76 | const 77 | RESULT_SIMPLE = 'simple', 78 | RESULT_COMPLEX = 'complex', 79 | RESULT_EXCEPTION = 'exception'; 80 | 81 | /** Standardized validation phase */ 82 | const 83 | PHASE_ALL = '#ALL', 84 | PHASE_DEFAULT = '#DEFAULT'; 85 | 86 | /** Type of include URIs for {@link self::setAllowedInclude()} */ 87 | const 88 | INCLUDE_URL = 0x01, 89 | INCLUDE_ABSOLUTE_PATH = 0x02, 90 | INCLUDE_RELATIVE_PATH = 0x04, 91 | INCLUDE_ALL = 0xFF; 92 | 93 | 94 | const 95 | /** Default options */ 96 | DEFAULT_OPTIONS = 0x0000, 97 | 98 | /** Allow missing (useful for Relax NG) */ 99 | ALLOW_MISSING_SCHEMA_ELEMENT = 0x0001, 100 | 101 | /** Ignore , do not expand them */ 102 | IGNORE_INCLUDE = 0x0002, 103 | 104 | /** Forbid , do not allow them */ 105 | FORBID_INCLUDE = 0x0004, 106 | 107 | /** Skip with same context as any rule before */ 108 | SKIP_DUPLICIT_RULE_CONTEXT = 0x0008, 109 | 110 | /** Allow to do not contain */ 111 | ALLOW_EMPTY_SCHEMA = 0x0010, 112 | 113 | /** Allow to do not contain */ 114 | ALLOW_EMPTY_PATTERN = 0x0020, 115 | 116 | /** Allow to do not contain nor */ 117 | ALLOW_EMPTY_RULE = 0x0040; 118 | 119 | 120 | 121 | /** XPath class used in this class */ 122 | public static $xPathClass = 'Milo\SchematronXPath'; 123 | 124 | /** @var bool schema has been loaded */ 125 | private $loaded = FALSE; 126 | 127 | /** @var int */ 128 | private $options = self::DEFAULT_OPTIONS; 129 | 130 | /** @var string schema namespace */ 131 | private $ns; 132 | 133 | /** @var string|NULL absolute path for relative paths */ 134 | private $directory; 135 | 136 | /** @var int LibXML options which were used for schema loading */ 137 | private $domOptions; 138 | 139 | /** @var string|NULL loaded from @schemaVersion in */ 140 | private $version; 141 | 142 | /** @var string|NULL loaded from in */ 143 | private $title; 144 | 145 | /** @var string default validation phase */ 146 | private $defaultPhase = self::PHASE_ALL; 147 | 148 | /** @var int|FALSE|NULL restrictions on ; self::INCLUDE_* value/mask */ 149 | private $allowedInclude = self::INCLUDE_RELATIVE_PATH; 150 | 151 | /** @var int how deep can be */ 152 | private $maxIncludeDepth = 10; 153 | 154 | 155 | /** @var SchematronXPath */ 156 | protected $xPath; 157 | 158 | /** @var array[prefix => URI] loaded from */ 159 | protected $namespaces = array(); 160 | 161 | /** @var stdClass[] {@see self::findPatterns()} */ 162 | protected $patterns = array(); 163 | 164 | /** @var array[id => value] {@see self::findPhases()} */ 165 | protected $phases = array(); 166 | 167 | 168 | 169 | 170 | /** 171 | * @param string schema namespace (self::NS_*) 172 | * @throws InvalidArgumentException when unsupported namespace passed 173 | */ 174 | public function __construct($namespace = self::NS_DETECT) 175 | { 176 | if (!in_array($namespace, array(self::NS_DETECT, self::NS_ISO, self::NS_1_5), TRUE)) { 177 | throw new InvalidArgumentException("Unsupported schema namespace '$namespace'."); 178 | } 179 | 180 | $this->ns = $namespace; 181 | } 182 | 183 | 184 | 185 | /** 186 | * Loads schematron schema from file. 187 | * @param string path/URI to schema file 188 | * @param int LibXML options 189 | * @throws SchematronException when schema loading fails 190 | */ 191 | public function load($file, $options = NULL) 192 | { 193 | $this->domOptions = $options === NULL ? (LIBXML_NOENT | LIBXML_NOBLANKS) : $options; 194 | 195 | $doc = new DOMDocument; 196 | Helpers::handleXmlErrors(); 197 | $doc->load($file, $this->domOptions); 198 | if ($e = Helpers::fetchXmlErrors()) { 199 | throw new SchematronException("Cannot load schema from file '$file'.", 0, $e); 200 | } 201 | 202 | if (is_file($file)) { 203 | $this->directory = dirname(realpath($file)); 204 | } 205 | 206 | return $this->loadDom($doc); 207 | } 208 | 209 | 210 | 211 | /** 212 | * Loads schematron schema from DOMDocument. 213 | * @return self 214 | * @throws SchematronException when schema loading fails 215 | * @throws RuntimeException when expanding fails 216 | */ 217 | public function loadDom(DOMDocument $schema) 218 | { 219 | if ($this->ns === self::NS_DETECT) { 220 | $this->ns = $schema->getElementsByTagNameNS(self::NS_ISO, '*')->length 221 | ? self::NS_ISO 222 | : self::NS_1_5; 223 | } 224 | 225 | $this->expandIncludes($schema); 226 | 227 | $this->xPath = new self::$xPathClass($schema); 228 | $this->xPath->registerNamespace('sch', $this->ns); 229 | 230 | $this->loadSchemaBasics($schema); 231 | $this->namespaces = $this->findNamespaces($schema); 232 | $this->patterns = $this->findPatterns($schema); 233 | if (!count($this->patterns) && !($this->options & self::ALLOW_EMPTY_SCHEMA)) { 234 | throw new SchematronException('None found in schema.'); 235 | } 236 | $this->phases = $this->findPhases($schema); 237 | 238 | $this->loaded = TRUE; 239 | 240 | return $this; 241 | } 242 | 243 | 244 | 245 | /** 246 | * Validate document over against loaded schema. 247 | * @param DOMDocument document to validate 248 | * @param string type of return value 249 | * @param string validation phase 250 | * @return array 251 | * @throws RuntimeException when schema has not been loaded yet 252 | * @throws InvalidArgumentException when validation $phase is not defined 253 | * @throws SchematronException when $result is RESULT_EXCEPTION and document is not valid 254 | */ 255 | public function validate(DOMDocument $doc, $result = self::RESULT_SIMPLE, $phase = self::PHASE_DEFAULT) 256 | { 257 | if (!$this->loaded) { 258 | throw new RuntimeException('Schema has not been loaded yet. Load it before validation.'); 259 | } 260 | 261 | $xpath = new self::$xPathClass($doc); 262 | foreach ($this->namespaces as $prefix => $uri) { 263 | $xpath->registerNamespace($prefix, $uri); 264 | } 265 | 266 | if ($phase === self::PHASE_DEFAULT) { 267 | $phase = $this->defaultPhase; 268 | } 269 | 270 | if ($phase === self::PHASE_ALL) { 271 | $activePatternKeys = array_keys($this->patterns); 272 | } elseif (!array_key_exists($phase, $this->phases)) { 273 | throw new InvalidArgumentException("Validation phase '$phase' is not defined."); 274 | } else { 275 | $activePatternKeys = array_keys($this->phases[$phase]); 276 | } 277 | 278 | $return = array(); 279 | foreach ($activePatternKeys as $patternKey) { 280 | $pattern = $this->patterns[$patternKey]; 281 | foreach ($pattern->rules as $ruleKey => $rule) { 282 | foreach ($xpath->queryContext($rule->context, $doc) as $currentNode) { 283 | foreach ($rule->statements as $statement) { 284 | if ($statement->isAssert ^ $xpath->evaluate("boolean($statement->test)", $currentNode)) { 285 | $message = $this->statementToMessage($statement->node, $xpath, $currentNode); 286 | 287 | switch ($result) { 288 | case self::RESULT_EXCEPTION: 289 | throw new SchematronException($message); 290 | 291 | case self::RESULT_COMPLEX: 292 | if (!isset($return[$patternKey])) { 293 | $return[$patternKey] = (object) array( 294 | 'title' => $pattern->title, 295 | 'rules' => array(), 296 | ); 297 | } 298 | 299 | if (!isset($return[$patternKey]->rules[$ruleKey])) { 300 | $return[$patternKey]->rules[$ruleKey] = (object) array( 301 | 'context' => $rule->context, 302 | 'errors' => array(), 303 | ); 304 | } 305 | 306 | $return[$patternKey]->rules[$ruleKey]->errors[] = (object) array( 307 | 'test' => $statement->test, 308 | 'message' => $message, 309 | 'path' => $currentNode->getNodePath(), 310 | ); 311 | break; 312 | 313 | default: 314 | $return[] = $message; 315 | break; 316 | } 317 | } // test 318 | } // statements for context 319 | } // context elements 320 | } // rules 321 | } // patterns 322 | 323 | return $return; 324 | } 325 | 326 | 327 | 328 | /** 329 | * Returns version loaded from @schemaVersion on 330 | * @return string|NULL 331 | */ 332 | public function getSchemaVersion() 333 | { 334 | return $this->version; 335 | } 336 | 337 | 338 | 339 | /** 340 | * Returns title loaded from in 341 | * @return string|NULL 342 | */ 343 | public function getSchemaTitle() 344 | { 345 | return $this->title; 346 | } 347 | 348 | 349 | 350 | /** 351 | * Set processing options, {@link self::DEFAULT_OPTIONS} 352 | * @param int mask of options 353 | * @return self 354 | */ 355 | public function setOptions($options = self::DEFAULT_OPTIONS) 356 | { 357 | $this->options = $options; 358 | return $this; 359 | } 360 | 361 | 362 | 363 | /** 364 | * Returns processing options, {@link self::DEFAULT_OPTIONS} 365 | * @return int 366 | */ 367 | public function getOptions() 368 | { 369 | return $this->options; 370 | } 371 | 372 | 373 | 374 | /** 375 | * Has been schema loaded? 376 | * @return bool 377 | */ 378 | public function isLoaded() 379 | { 380 | return $this->loaded; 381 | } 382 | 383 | 384 | 385 | /** 386 | * Set which URIa are allowed for (self::INCLUDE_*) 387 | * @param int mask of types 388 | * @return self 389 | */ 390 | public function setAllowedInclude($mask) 391 | { 392 | $this->allowedInclude = $mask; 393 | return $this; 394 | } 395 | 396 | 397 | 398 | /** 399 | * Returns which URIa are allowed for (self::INCLUDE_*) 400 | * @return int 401 | */ 402 | public function getAllowedInclude() 403 | { 404 | return $this->allowedInclude; 405 | } 406 | 407 | 408 | 409 | /** 410 | * Sets how deep can be in in ... 411 | * @param int depth 412 | * @return self 413 | */ 414 | public function setMaxIncludeDepth($depth) 415 | { 416 | $this->maxIncludeDepth = (int) $depth; 417 | return $this; 418 | } 419 | 420 | 421 | 422 | /** 423 | * Returns how deep can be in in ... 424 | * @param int depth 425 | * @return self 426 | */ 427 | public function getMaxIncludeDepth() 428 | { 429 | return $this->maxIncludeDepth; 430 | } 431 | 432 | 433 | 434 | /** 435 | * Sets include directory path for relative file paths in 436 | * @param string directory path 437 | * @return self 438 | * @throws RuntimeException when directory does not exist 439 | */ 440 | public function setIncludeDir($dir) 441 | { 442 | if (!is_dir($dir)) { 443 | throw new RuntimeException("Directory '$dir' does not exist."); 444 | } 445 | $this->directory = realpath($dir); 446 | 447 | return $this; 448 | } 449 | 450 | 451 | 452 | /** 453 | * Returns path to directory which is used for relative file paths from 454 | * @return string|NULL 455 | */ 456 | public function getIncludeDir() 457 | { 458 | return $this->directory; 459 | } 460 | 461 | 462 | 463 | /* ~~~ Schematron schema loading part ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ */ 464 | /** 465 | * Expands all in DOM. 466 | * @param DOMElement 467 | * @param int include depth level 468 | * @throws SchematronException 469 | * @throws RuntimeException when applied any include restriction 470 | */ 471 | protected function expandIncludes(DOMDocument $schema, $depth = 0) 472 | { 473 | if ($this->options & self::IGNORE_INCLUDE) { 474 | return; 475 | } 476 | 477 | if ($depth > $this->maxIncludeDepth) { 478 | throw new RuntimeException("Reached maximum ($this->maxIncludeDepth) include depth."); 479 | } 480 | 481 | $list = $schema->getElementsByTagNameNS($this->ns, 'include'); 482 | if ($list->length > 0 && ($this->options & self::FORBID_INCLUDE)) { 483 | throw new RuntimeException("Include functionality is disabled. Found $list->length <{$list->item(0)->nodeName}> elements, first on line {$list->item(0)->getLineNo()}."); 484 | } 485 | 486 | while ($list->length) { // do not foreach(), list is affected by replaceChild 487 | $element = $list->item(0); 488 | 489 | $href = $rawHref = Helpers::getAttribute($element, 'href'); 490 | if (substr_compare($href, 'file://', 0, 7, TRUE) === 0) { 491 | $href = substr($href, 7); 492 | } 493 | 494 | $type = static::detectIncludeType($href, $typeStr); 495 | 496 | if (!($this->allowedInclude & $type)) { 497 | throw new RuntimeException("Including URI of type '$typeStr' referenced by <$element->nodeName> on line {$element->getLineNo()} is not allowed."); 498 | } 499 | 500 | if ($type === self::INCLUDE_RELATIVE_PATH) { 501 | if ($this->directory === NULL) { 502 | throw new RuntimeException("Cannot evaluate relative URI '$rawHref' referenced by <$element->nodeName> on line {$element->getLineNo()}, schema has not been loaded from file. Set schema directory by setIncludeDir() method."); 503 | } 504 | $href = $this->directory . DIRECTORY_SEPARATOR . $href; 505 | } 506 | 507 | $doc = new DOMDocument; 508 | Helpers::handleXmlErrors(); 509 | $doc->load($href, $this->domOptions); 510 | if ($e = Helpers::fetchXmlErrors()) { 511 | throw new RuntimeException("Cannot load '$rawHref' referenced by <$element->nodeName> on line {$element->getLineNo()}.", 0, $e); 512 | } 513 | 514 | $this->expandIncludes($doc, $depth + 1); 515 | 516 | $element->parentNode->replaceChild( 517 | $schema->importNode($doc->documentElement, TRUE), 518 | $element 519 | ); 520 | } 521 | } 522 | 523 | 524 | 525 | /** 526 | * Fills object members by basics schema properties. 527 | * @throws SchematronException 528 | */ 529 | protected function loadSchemaBasics(DOMDocument $schema) 530 | { 531 | $list = $this->xPath->query('//sch:schema', $schema); 532 | if ($list->length > 1) { 533 | throw new SchematronException("Only one element in document is allowed, but $list->length found."); 534 | 535 | } elseif ($list->length < 1) { 536 | if (!($this->options & self::ALLOW_MISSING_SCHEMA_ELEMENT)) { 537 | throw new SchematronException(' element not found.'); 538 | } 539 | 540 | } else { 541 | $element = $list->item(0); 542 | 543 | $this->version = Helpers::getAttribute($element, 'schemaVersion', NULL); 544 | $this->defaultPhase = Helpers::getAttribute($element, 'defaultPhase', self::PHASE_ALL); 545 | if (strtolower($binding = Helpers::getAttribute($element, 'queryBinding', 'xslt')) !== 'xslt') { 546 | throw new SchematronException("Query binding '$binding' is not supported."); 547 | } 548 | 549 | $titleElements = $this->xPath->query('sch:title', $element); 550 | if ($titleElements->length > 0) { 551 | $this->title = $titleElements->item(0)->textContent; 552 | } 553 | } 554 | } 555 | 556 | 557 | 558 | /** 559 | * Search for all . 560 | * @return array[string prefix => string URI] 561 | * @throws SchematronException 562 | */ 563 | protected function findNamespaces(DOMDocument $schema) 564 | { 565 | $namespaces = $elements = array(); 566 | foreach ($this->xPath->query('//sch:ns', $schema) as $element) { 567 | $prefix = Helpers::getAttribute($element, 'prefix'); 568 | $uri = Helpers::getAttribute($element, 'uri'); 569 | 570 | if (array_key_exists($prefix, $elements)) { 571 | throw new SchematronException("Namespace prefix '$prefix' on line {$element->getLineNo()} is alredy declared on line {$elements[$prefix]->getLineNo()}."); 572 | } 573 | 574 | $elements[$prefix] = $element; 575 | $namespaces[$prefix] = $uri; 576 | } 577 | return $namespaces; 578 | } 579 | 580 | 581 | 582 | /** 583 | * Search for all . Abstract patterns are instantized. 584 | * @return stdClass[] 585 | * @throws SchematronException 586 | */ 587 | protected function findPatterns(DOMDocument $schema) 588 | { 589 | $abstracts = $this->findPatternAbstracts($schema); 590 | 591 | $patterns = array(); 592 | foreach ($this->xPath->query('//sch:pattern[not(@abstract) or @abstract!="true"]', $schema) as $element) { 593 | if (($isA = Helpers::getAttribute($element, 'is-a', NULL)) !== NULL) { 594 | if (!array_key_exists($isA, $abstracts)) { 595 | throw new SchematronException("<$element->nodeName> on line {$element->getLineNo()} references to undefined abstract pattern by ID '$isA'."); 596 | } 597 | $pattern = $this->instantiatePattern($abstracts[$isA], $this->findParams($element)); 598 | 599 | } else { 600 | $pattern = (object) array( 601 | 'title' => $this->xPath->evaluate('boolean(sch:title)', $element) 602 | ? $this->xPath->evaluate('string(sch:title)', $element) 603 | : Helpers::getAttribute($element, 'name', NULL), // Schematron v1.5 604 | 'rules' => $rules = $this->findRules($element), 605 | ); 606 | 607 | if (!count($rules) && !($this->options & self::ALLOW_EMPTY_PATTERN)) { 608 | throw new SchematronException("Missing rules for <$element->nodeName> on line {$element->getLineNo()}."); 609 | } 610 | } 611 | $pattern->id = Helpers::getAttribute($element, 'id', NULL); 612 | 613 | if ($pattern->id === NULL) { 614 | $patterns[] = $pattern; 615 | } else { 616 | $patterns["#$pattern->id"] = $pattern; 617 | } 618 | } 619 | 620 | return $patterns; 621 | } 622 | 623 | 624 | 625 | /** 626 | * Search for all 627 | * @return array[id => stdClass] 628 | * @throws SchematronException 629 | */ 630 | protected function findPatternAbstracts(DOMDocument $schema) 631 | { 632 | $patterns = array(); 633 | foreach ($this->xPath->query('//sch:pattern[@abstract="true"]', $schema) as $element) { 634 | if ($element->hasAttribute('is-a')) { 635 | throw new SchematronException("An abstract <$element->nodeName> on line {$element->getLineNo()} shall not have a 'is-a' attribute."); 636 | } 637 | 638 | $id = Helpers::getAttribute($element, 'id'); 639 | $patterns[$id] = (object) array( 640 | 'title' => $this->xPath->evaluate('boolean(sch:title)', $element) 641 | ? $this->xPath->evaluate('string(sch:title)', $element) 642 | : Helpers::getAttribute($element, 'name', NULL), // Schematron v1.5 643 | 'rules' => $rules = $this->findRules($element), 644 | ); 645 | 646 | if (!count($rules) && !($this->options & self::ALLOW_EMPTY_PATTERN)) { 647 | throw new SchematronException("Missing rules for <$element->nodeName> on line {$element->getLineNo()}."); 648 | } 649 | } 650 | return $patterns; 651 | } 652 | 653 | 654 | 655 | /** 656 | * Returns callable for replacing parameters in XPath expressions. 657 | * @return callable(string $expression, array $parameters) 658 | */ 659 | protected function getReplaceCb() 660 | { 661 | static $replaceCb; 662 | 663 | if ($replaceCb === NULL) { 664 | $replaceCb = function ($expression, $parameters) { 665 | foreach ($parameters as $name => $value) { 666 | $expression = str_replace("\$$name", $value, $expression); 667 | } 668 | 669 | return $expression; 670 | }; 671 | } 672 | return $replaceCb; 673 | } 674 | 675 | 676 | 677 | /** 678 | * Creates pattern instance from abstract pattern. 679 | * @param stdClass abstract pattern 680 | * @param array[name => value] parameters 681 | * @return stdClass 682 | */ 683 | private function instantiatePattern(stdClass $abstract, array $parameters) 684 | { 685 | $instance = clone $abstract; 686 | foreach ($instance->rules as & $rule) { 687 | $rule = clone $rule; 688 | $rule->context = call_user_func($this->getReplaceCb(), $rule->context, $parameters); 689 | foreach ($rule->statements as & $stmt) { 690 | $stmt = clone $stmt; 691 | $stmt->test = call_user_func($this->getReplaceCb(), $stmt->test, $parameters); 692 | } 693 | } 694 | return $instance; 695 | } 696 | 697 | 698 | 699 | /** 700 | * Search for all . 701 | * @return array[string name => string value] 702 | * @throws SchematronException 703 | */ 704 | protected function findParams(DOMElement $pattern) 705 | { 706 | $params = $elements = array(); 707 | foreach ($this->xPath->query('sch:param', $pattern) as $element) { 708 | $name = Helpers::getAttribute($element, 'name'); 709 | $value = Helpers::getAttribute($element, 'value'); 710 | 711 | if (array_key_exists($name, $elements)) { 712 | throw new SchematronException("Parameter '$name' is already defined on line {$elements[$name]->getLineNo()}."); 713 | } 714 | 715 | $elements[$name] = $element; 716 | $params[$name] = $value; 717 | } 718 | return $params; 719 | } 720 | 721 | 722 | 723 | /** 724 | * Search for all . 725 | * @return stdClass[] 726 | * @throws SchematronException 727 | */ 728 | protected function findRules(DOMElement $pattern) 729 | { 730 | $abstracts = $this->findRuleAbstracts($pattern); 731 | 732 | $rules = $contexts = array(); 733 | foreach ($this->xPath->query('sch:rule[not(@abstract) or @abstract!="true"]', $pattern) as $element) { 734 | $context = Helpers::getAttribute($element, 'context'); 735 | 736 | if (array_key_exists($context, $contexts) && ($this->options & self::SKIP_DUPLICIT_RULE_CONTEXT)) { 737 | continue; 738 | } 739 | $contexts[$context] = TRUE; 740 | 741 | $rules[] = (object) array( 742 | 'context' => $context, 743 | 'statements' => $statements = $this->findStatements($element, $abstracts), 744 | ); 745 | 746 | if (!count($statements) && !($this->options & self::ALLOW_EMPTY_RULE)) { 747 | throw new SchematronException("Asserts nor reports not found for <$element->nodeName> on line {$element->getLineNo()}."); 748 | } 749 | } 750 | return $rules; 751 | } 752 | 753 | 754 | 755 | /** 756 | * Search for all . 757 | * @return stdClass[] 758 | * @throws SchematronException 759 | */ 760 | protected function findRuleAbstracts(DOMElement $pattern) 761 | { 762 | $rules = array(); 763 | foreach ($this->xPath->query('sch:rule[@abstract="true"]', $pattern) as $element) { 764 | $id = Helpers::getAttribute($element, 'id'); 765 | if ($element->hasAttribute('context')) { 766 | throw new SchematronException("An abstract rule on line {$element->getLineNo()} shall not have a 'context' attribute."); 767 | } 768 | 769 | $rules[$id] = (object) array( 770 | 'statements' => $this->findStatements($element), 771 | ); 772 | } 773 | return $rules; 774 | } 775 | 776 | 777 | 778 | /** 779 | * Search for all and . 780 | * @return stdClass[] 781 | * @throws SchematronException 782 | */ 783 | protected function findStatements(DOMElement $rule, array $abstractRules = array()) 784 | { 785 | $statements = array(); 786 | foreach ($this->xPath->query('sch:assert | sch:report | sch:extends', $rule) as $node) { 787 | if ($node->localName === 'extends') { 788 | $idRule = Helpers::getAttribute($node, 'rule'); 789 | if (!isset($abstractRules[$idRule])) { 790 | throw new SchematronException("<$node->nodeName> on line {$node->getLineNo()} references to undefined abstract rule by ID '$idRule'."); 791 | } 792 | 793 | $statements = array_merge($statements, $abstractRules[$idRule]->statements); 794 | 795 | } else { 796 | $statements[] = (object) array( 797 | 'test' => Helpers::getAttribute($node, 'test'), 798 | 'isAssert' => $node->localName === 'assert', 799 | 'node' => $node, 800 | ); 801 | } 802 | } 803 | return $statements; 804 | } 805 | 806 | 807 | 808 | /** 809 | * Search for all and check existency of defaultPhase if set in . 810 | * @return array[id => array[idPattern]] 811 | * @throws SchematronException 812 | */ 813 | protected function findPhases(DOMDocument $schema) 814 | { 815 | $phases = $elements = array(); 816 | foreach ($this->xPath->query('//sch:phase', $schema) as $element) { 817 | $id = Helpers::getAttribute($element, 'id'); 818 | if (isset($elements[$id])) { 819 | throw new SchematronException("<$element->nodeName> with id '$id' is already defined on line {$elements[$id]->getLineNo()}."); 820 | } 821 | $elements[$id] = $element; 822 | $phases[$id] = $this->findActives($element); 823 | } 824 | 825 | if ($this->defaultPhase !== self::PHASE_ALL && !array_key_exists($this->defaultPhase, $phases)) { 826 | throw new SchematronException("Default validation phase '$this->defaultPhase' is not defined."); 827 | } 828 | 829 | return $phases; 830 | } 831 | 832 | 833 | 834 | /** 835 | * Search for all . 836 | * @return string[] list of IDs 837 | * @throws SchematronException 838 | */ 839 | protected function findActives(DOMElement $phase) 840 | { 841 | $actives = array(); 842 | foreach ($this->xPath->query('sch:active', $phase) as $element) { 843 | $idPattern = Helpers::getAttribute($element, 'pattern'); 844 | if (!isset($this->patterns["#$idPattern"])) { 845 | throw new SchematronException("<$element->nodeName> on line {$element->getLineNo()} references to undefined pattern by ID '$idPattern'."); 846 | } 847 | $actives["#$idPattern"] = $idPattern; 848 | } 849 | return $actives; 850 | } 851 | 852 | 853 | 854 | /** 855 | * Expands and in assertion/report message. 856 | * @return string 857 | */ 858 | protected function statementToMessage(DOMElement $stmt, SchematronXPath $xPath, DOMNode $current) 859 | { 860 | $message = ''; 861 | foreach ($stmt->childNodes as $node) { 862 | if ($node->nodeType === XML_ELEMENT_NODE && $node->namespaceURI === $this->ns) { 863 | if ($node->localName === 'name') { 864 | $message .= $xPath->evaluate('name(' . Helpers::getAttribute($node, 'path', '') . ')', $current); 865 | 866 | } elseif ($node->localName === 'value-of') { 867 | $message .= $xPath->evaluate('string(' . Helpers::getAttribute($node, 'select') . ')', $current); 868 | 869 | } else { 870 | /** @todo warning? */ 871 | $message .= $node->textContent; 872 | } 873 | 874 | } else { 875 | $message .= $node->textContent; 876 | } 877 | } 878 | 879 | $message = preg_replace('#\s+#', ' ', trim($message)); 880 | 881 | return $message; 882 | } 883 | 884 | 885 | 886 | /** 887 | * Detects include URI type. 888 | * @return int 889 | */ 890 | protected static function detectIncludeType($uri, & $typeStr = NULL) 891 | { 892 | $absolutePathRe = substr_compare(PHP_OS, 'WIN', 0, 3, TRUE) === 0 893 | ? '#^[A-Z]:#i' 894 | : '#^/#'; 895 | 896 | if (preg_match('#^[a-z-]+://#i', $uri)) { 897 | $type = self::INCLUDE_URL; 898 | $typeStr = 'URL'; 899 | 900 | } elseif (preg_match($absolutePathRe, $uri)) { 901 | $type = self::INCLUDE_ABSOLUTE_PATH; 902 | $typeStr = 'Absolute file path'; 903 | 904 | } else { 905 | $type = self::INCLUDE_RELATIVE_PATH; 906 | $typeStr = 'Relative file path'; 907 | } 908 | 909 | return $type; 910 | } 911 | 912 | } 913 | 914 | 915 | 916 | /** 917 | * Helpers for work with LibXML and DOM. 918 | * 919 | * @author Miloslav Hůla (https://github.com/milo) 920 | */ 921 | class SchematronHelpers 922 | { 923 | /** @var array */ 924 | private static $handleXmlErrors = array(); 925 | 926 | 927 | 928 | /** 929 | * Enable LibXML internal error handling. 930 | * @param bool clear existing errors 931 | */ 932 | public static function handleXmlErrors($clear = TRUE) 933 | { 934 | self::$handleXmlErrors[] = libxml_use_internal_errors(TRUE); 935 | $clear && libxml_clear_errors(); 936 | } 937 | 938 | 939 | 940 | /** 941 | * Fetch all LibXML errors. 942 | * @param bool 943 | * @return NULL|ErrorException all errors chained in exceptions 944 | */ 945 | public static function fetchXmlErrors($restoreHandling = TRUE) 946 | { 947 | $e = NULL; 948 | foreach (array_reverse(libxml_get_errors()) as $error) { 949 | $e = new ErrorException(trim($error->message), $error->code, $error->level, $error->file, $error->line, $e); 950 | } 951 | libxml_clear_errors(); 952 | $restoreHandling && self::restoreErrorHandling(); 953 | return $e; 954 | } 955 | 956 | 957 | 958 | /** 959 | * Restore LibXML internal error handling previously enabled by self::handleXmlErrors() 960 | */ 961 | public static function restoreErrorHandling() 962 | { 963 | libxml_use_internal_errors(array_pop(self::$handleXmlErrors)); 964 | } 965 | 966 | 967 | 968 | /** 969 | * Returns value of element attribute. 970 | * @param DOMElement 971 | * @param string attribute name 972 | * @param mixed default value if attribude does not exist 973 | * @return mixed 974 | * @throws SchematronException when attribute does not exist and default value is not specified 975 | */ 976 | public static function getAttribute(DOMElement $element, $name) 977 | { 978 | if ($element->hasAttribute($name)) { 979 | return $element->getAttribute($name); 980 | 981 | } elseif (count($args = func_get_args()) > 2) { 982 | return $args[2]; 983 | } 984 | 985 | throw new SchematronException("Missing required attribute '$name' for element <$element->nodeName> on line {$element->getLineNo()}."); 986 | } 987 | 988 | } 989 | 990 | 991 | 992 | /** 993 | * DOMXPath envelope. 994 | * 995 | * @author Miloslav Hůla (https://github.com/milo) 996 | */ 997 | class SchematronXPath extends DOMXPath 998 | { 999 | /** 1000 | * ($registerNodeNS is FALSE in opposition to DOMXPath default value) 1001 | */ 1002 | public function query($expression, DOMNode $context = NULL, $registerNodeNS = FALSE) 1003 | { 1004 | return parent::query($expression, $context, $registerNodeNS); 1005 | } 1006 | 1007 | 1008 | 1009 | /** 1010 | * ($registerNodeNS is FALSE in opposition to DOMXPath default value) 1011 | */ 1012 | public function evaluate($expression, DOMNode $context = NULL, $registerNodeNS = FALSE) 1013 | { 1014 | return parent::evaluate($expression, $context, $registerNodeNS); 1015 | } 1016 | 1017 | 1018 | 1019 | public function queryContext($expression, DOMNode $context = NULL, $registerNodeNS = FALSE) 1020 | { 1021 | if (isset($expression[0]) && $expression[0] !== '.' && $expression[0] !== '/') { 1022 | $expression = "//$expression"; 1023 | } 1024 | return $this->query($expression, $context, $registerNodeNS); 1025 | } 1026 | 1027 | } 1028 | 1029 | 1030 | 1031 | /** 1032 | * Thrown when schematron schema source is malformed (not well-formed). 1033 | * 1034 | * @author Miloslav Hůla (https://github.com/milo) 1035 | */ 1036 | class SchematronException extends \RuntimeException 1037 | { 1038 | } 1039 | --------------------------------------------------------------------------------