diff --git a/src/Builder/RecordBuilder.php b/src/Builder/RecordBuilder.php index 5fa1028..b36bdcb 100644 --- a/src/Builder/RecordBuilder.php +++ b/src/Builder/RecordBuilder.php @@ -713,7 +713,7 @@ public function derivedByInsertionFrom( * The resulting dictionary (after removal). * @param \Prov\Identifier\QualifiedName|string $before * The source dictionary (before removal). - * @param list $removedKeys + * @param list $removedKeys */ public function derivedByRemovalFrom( QualifiedName|string $after, diff --git a/src/Operation/DocumentComparator.php b/src/Operation/DocumentComparator.php index 64898ea..2979237 100644 --- a/src/Operation/DocumentComparator.php +++ b/src/Operation/DocumentComparator.php @@ -317,6 +317,7 @@ private static function formalSignature(ProvRecord $record, array $blankLabels): /** @var list<\Prov\Relation\Dictionary\DictionaryEntry> $value */ $parts[] = self::keyEntityPairsSignature($value, $blankLabels); } elseif (is_array($value) && $prop === 'removedKeys') { + /** @var list $value */ $parts[] = self::removedKeysSignature($value); } else { $parts[] = ''; @@ -344,23 +345,6 @@ private static function attributesSignature(Attributes $attrs, array $blankLabel return $pairs; } - /** - * Expands a `prefix:local` datatype written against a library built-in - * (xsd/prov) to its full URI, so a raw-array typed-literal key signs the - * same as a Literal. Any other prefix is returned unchanged: without a - * NamespaceManager it cannot be resolved, and custom datatypes are rare. - */ - private static function expandDatatypePrefix(string $type): string - { - if (str_starts_with($type, 'xsd:')) { - return 'http://www.w3.org/2001/XMLSchema#' . substr($type, 4); - } - if (str_starts_with($type, 'prov:')) { - return 'http://www.w3.org/ns/prov#' . substr($type, 5); - } - return $type; - } - /** * @param list<\Prov\Relation\Dictionary\DictionaryEntry> $pairs * @param array $blankLabels @@ -379,7 +363,7 @@ private static function keyEntityPairsSignature(array $pairs, array $blankLabels } /** - * @param list $keys + * @param list<\Prov\Identifier\QualifiedName|\Prov\Attribute\Literal|string|int|float|bool> $keys * * @return list */ @@ -390,7 +374,7 @@ private static function removedKeysSignature(array $keys): array return $sigs; } - private static function keySignature(mixed $key): string + private static function keySignature(QualifiedName|Literal|string|int|float|bool|null $key): string { if ($key instanceof QualifiedName || $key instanceof Literal) { return ValueIdentity::signature($key); @@ -400,38 +384,6 @@ private static function keySignature(mixed $key): string // so `"foo"` and `Literal("foo", xsd:string)` compare equal. return ValueIdentity::signature($key); } - if (is_array($key)) { - // Typed-literal arrays from PROV-JSON: {"$": value, "type": datatype} - // or {"$": value, "lang": code}. Reconstruct enough of the literal to - // match an equivalent Literal instance from another format. - if (isset($key['$']) && is_string($key['$'])) { - $lang = isset($key['lang']) && is_string($key['lang']) ? $key['lang'] : null; - $type = isset($key['type']) && is_string($key['type']) ? $key['type'] : null; - - if ($type === null && $lang === null) { - return ValueIdentity::signature($key['$']); - } - if ($type === 'xsd:string' && $lang === null) { - return ValueIdentity::signature($key['$']); - } - $sig = 'lit:' . $key['$']; - if ($type !== null && $type !== 'xsd:string') { - // Sign by full datatype URI, matching valueSignature(): a key - // typed `xsd:int` in PROV-JSON must compare equal to the same - // key as a Literal carrying the full XSD URI in another format. - $sig .= '^^' . ValueIdentity::normalizeDatatypeUri(self::expandDatatypePrefix($type)); - } - if ($lang !== null) { - $sig .= '@' . $lang; - } - return $sig; - } - ksort($key); - return 'arr:' . (string) json_encode($key); - } - if (is_object($key)) { - return $key::class . ':' . spl_object_hash($key); - } if (is_scalar($key)) { return gettype($key) . ':' . var_export($key, true); } diff --git a/src/Operation/DocumentOperations.php b/src/Operation/DocumentOperations.php index d9598ac..62f2288 100644 --- a/src/Operation/DocumentOperations.php +++ b/src/Operation/DocumentOperations.php @@ -227,18 +227,14 @@ private static function remapQn(QualifiedName $qn, array $byUri): QualifiedName } /** - * Remaps a dictionary entry key, preserving its declared value union. The - * `array` arm mirrors DictionaryEntry::$key's by-design broad shape (a raw - * PROV-JSON typed-literal map), which a native type cannot narrow. + * Remaps a dictionary entry key, preserving its declared value union. * * @param array $byUri - * - * @mago-ignore analysis:imprecise-type */ private static function remapDictKey( - QualifiedName|Literal|string|int|float|bool|array|null $key, + QualifiedName|Literal|string|int|float|bool|null $key, array $byUri, - ): QualifiedName|Literal|string|int|float|bool|array|null { + ): QualifiedName|Literal|string|int|float|bool|null { if ($key instanceof QualifiedName) { return self::remapQn($key, $byUri); } diff --git a/src/Relation/Dictionary/DictionaryEntry.php b/src/Relation/Dictionary/DictionaryEntry.php index e5e5e4d..8c1c4e4 100644 --- a/src/Relation/Dictionary/DictionaryEntry.php +++ b/src/Relation/Dictionary/DictionaryEntry.php @@ -11,16 +11,14 @@ * A single key → entity pair carried by PROV-DICT relations (hadDictionaryMember, * derivedByInsertionFrom). * - * The key is `mixed` by spec: PROV-DICT allows arbitrary typed literals, QualifiedName - * keys, and (on the deserialization side) raw passthrough values from untyped JSON. - * The `array` arm of `$key` is that raw JSON shape and cannot be narrowed natively. - * - * @mago-ignore analysis:imprecise-type + * PROV-DICT allows arbitrary typed literals or QualifiedName keys. Every + * deserializer resolves a key to one of these before constructing the entry; + * none of them keep the raw JSON-object shape a typed value arrives in. */ final readonly class DictionaryEntry { public function __construct( - public QualifiedName|Literal|string|int|float|bool|array|null $key, + public QualifiedName|Literal|string|int|float|bool|null $key, public ?QualifiedName $entity, ) {} } diff --git a/src/Relation/Dictionary/DictionaryRemoval.php b/src/Relation/Dictionary/DictionaryRemoval.php index 7713962..398058c 100644 --- a/src/Relation/Dictionary/DictionaryRemoval.php +++ b/src/Relation/Dictionary/DictionaryRemoval.php @@ -5,6 +5,7 @@ namespace Prov\Relation\Dictionary; use Prov\Attribute\Attributes; +use Prov\Attribute\Literal; use Prov\Identifier\QualifiedName; use Prov\Model\ProvRelation; @@ -15,7 +16,7 @@ readonly class DictionaryRemoval extends ProvRelation { /** - * @param list $removedKeys + * @param list $removedKeys */ public function __construct( ?QualifiedName $identifier, diff --git a/src/Serializer/JsonSerializer.php b/src/Serializer/JsonSerializer.php index 6afb92b..c97ca64 100644 --- a/src/Serializer/JsonSerializer.php +++ b/src/Serializer/JsonSerializer.php @@ -427,13 +427,12 @@ private function addDictKeyEntitySet(array &$attrs, array $pairs): null // "Complex" keys need the array-of-objects format because the simple-map // format can only hold string-stringifiable keys with a shared datatype. - // Literals with a lang tag, QualifiedName-typed keys, and raw array values - // from an untyped deserialization path all fall here. + // Literals with a lang tag and QualifiedName-typed keys both fall here. $hasComplexKeys = false; $seenDatatype = null; foreach ($pairs as $pair) { $k = $pair->key; - if (is_array($k) || $k instanceof QualifiedName || $k instanceof Literal && $k->languageTag !== null) { + if ($k instanceof QualifiedName || $k instanceof Literal && $k->languageTag !== null) { $hasComplexKeys = true; break; } @@ -451,9 +450,7 @@ private function addDictKeyEntitySet(array &$attrs, array $pairs): null foreach ($pairs as $pair) { $entry = ['$' => $pair->entity !== null ? $this->jsonQName($pair->entity) : null]; $k = $pair->key; - if (is_array($k)) { - $entry['key'] = $k; - } elseif ($k instanceof QualifiedName) { + if ($k instanceof QualifiedName) { $entry['key'] = ['$' => $this->jsonQName($k), 'type' => 'prov:QUALIFIED_NAME']; } elseif ($k instanceof Literal) { $entry['key'] = $this->serializeAttributeValue($k); @@ -494,7 +491,7 @@ private function addDictKeyEntitySet(array &$attrs, array $pairs): null } elseif ($k instanceof \Stringable) { $stringKey = (string) $k; } else { - // Array/null keys shouldn't reach here for valid PROV-JSON output; + // A null key shouldn't reach here for valid PROV-JSON output; // skip rather than corrupt the output. continue; } @@ -509,19 +506,14 @@ private function addDictKeyEntitySet(array &$attrs, array $pairs): null * the list of keys removed from the input dictionary. * * @param array $attrs - * @param list $keys + * @param list<\Prov\Identifier\QualifiedName|\Prov\Attribute\Literal|string|int|float|bool> $keys */ private function addDictKeySet(array &$attrs, array $keys): null { if ($keys !== []) { $set = []; foreach ($keys as $key) { - if (is_array($key)) { - // Raw JSON value from deserialization, pass through. - $set[] = $key; - } else { - $set[] = $this->serializeAttributeValue($key); - } + $set[] = $this->serializeAttributeValue($key); } $attrs['prov:key-set'] = $set; } @@ -978,7 +970,7 @@ private function requireRef(?QualifiedName $value, string $prop, string $jsonKey /** * @param list|mixed $keys * - * @return list + * @return list<\Prov\Identifier\QualifiedName|\Prov\Attribute\Literal|string|int|float|bool> */ private function deserializeKeySet(mixed $keys, NamespaceManager $nsManager): array { @@ -987,10 +979,14 @@ private function deserializeKeySet(mixed $keys, NamespaceManager $nsManager): ar } $out = []; foreach ($keys as $key) { - if (is_array($key) && isset($key['$'])) { + if (is_array($key)) { $out[] = $this->deserializeTypedValue($key, $nsManager); - } else { + } elseif (is_scalar($key)) { $out[] = $key; + } else { + throw new DeserializationException( + 'Invalid PROV-JSON: a prov:key-set entry must be a scalar or a typed-literal object.', + ); } } return $out; @@ -1040,9 +1036,7 @@ private function deserializeKeyEntitySet(mixed $set, NamespaceManager $nsManager foreach ($set as $entry) { if (is_array($entry) && isset($entry['$'])) { $rawKey = $entry['key'] ?? null; - $key = is_array($rawKey) && isset($rawKey['$']) - ? $this->deserializeTypedValue($rawKey, $nsManager) - : $rawKey; + $key = is_array($rawKey) ? $this->deserializeTypedValue($rawKey, $nsManager) : $rawKey; $entity = is_string($entry['$']) ? $this->resolveQName($entry['$'], $nsManager) : null; $pairs[] = new DictionaryEntry($key, $entity); } diff --git a/src/Serializer/ProvNDeserializer.php b/src/Serializer/ProvNDeserializer.php index 5c4e97f..5b4e803 100644 --- a/src/Serializer/ProvNDeserializer.php +++ b/src/Serializer/ProvNDeserializer.php @@ -757,7 +757,8 @@ private function parseKeyEntitySet(NamespaceManager $nsManager): array /** * Parse {key, key, ...} - * @return list + * + * @return list */ private function parseKeySet(NamespaceManager $nsManager): array { diff --git a/src/Serializer/ProvNSerializer.php b/src/Serializer/ProvNSerializer.php index dc24ec5..2532989 100644 --- a/src/Serializer/ProvNSerializer.php +++ b/src/Serializer/ProvNSerializer.php @@ -298,7 +298,7 @@ private function formatKeyEntitySet(array $pairs): string * Formats a list of removed keys as the PROV-N `{key, ...}` set used * by derivedByRemovalFrom. * - * @param list $keys + * @param list<\Prov\Identifier\QualifiedName|\Prov\Attribute\Literal|string|int|float|bool> $keys */ private function formatKeySet(array $keys): string { @@ -306,7 +306,6 @@ private function formatKeySet(array $keys): string return '{}'; } $items = []; - // @mago-expect analysis:mixed-assignment foreach ($keys as $key) { $items[] = $this->formatDictKey($key); } @@ -314,32 +313,15 @@ private function formatKeySet(array $keys): string } /** - * Formats the full `DictionaryEntry::$key` union (QN/Literal/scalar/array/null) - * as a PROV-N token. Arrays carry a raw JSON typed value from deserialization. + * Formats the full `DictionaryEntry::$key` union (QN/Literal/scalar/null) + * as a PROV-N token. */ - private function formatDictKey(mixed $key): string + private function formatDictKey(QualifiedName|Literal|string|int|float|bool|null $key): string { if ($key === null) { return '-'; } - if (is_array($key)) { - $val = is_scalar($key['$'] ?? null) ? (string) $key['$'] : ''; - $type = isset($key['type']) && is_string($key['type']) ? $key['type'] : null; - $lang = isset($key['lang']) && is_string($key['lang']) ? $key['lang'] : null; - if ($type !== null) { - $this->assertSafeAttributeKey($type); - return '"' . $this->escapeString($val) . '" %% ' . $type; - } - if ($lang !== null) { - $this->assertSafeLangTag($lang); - return '"' . $this->escapeString($val) . '"@' . $lang; - } - return '"' . $this->escapeString($val) . '"'; - } - if ($key instanceof QualifiedName || $key instanceof Literal || is_scalar($key)) { - return $this->formatAttributeValue($key); - } - return '-'; + return $this->formatAttributeValue($key); } /** diff --git a/src/Serializer/XmlSerializer.php b/src/Serializer/XmlSerializer.php index 41d0822..50b44c5 100644 --- a/src/Serializer/XmlSerializer.php +++ b/src/Serializer/XmlSerializer.php @@ -230,7 +230,6 @@ private function serializeRelation( $el->appendChild($kep); } } elseif ($record instanceof DictionaryRemoval) { - // @mago-expect analysis:mixed-assignment foreach ($record->removedKeys as $key) { $keyEl = $dom->createElementNS(self::PROV_NS, 'prov:key'); $this->setTypedTextContent($keyEl, $key, $nsManager); diff --git a/tests/Operation/DocumentComparatorTest.php b/tests/Operation/DocumentComparatorTest.php index f26f385..8067b45 100644 --- a/tests/Operation/DocumentComparatorTest.php +++ b/tests/Operation/DocumentComparatorTest.php @@ -9,6 +9,7 @@ use Prov\Builder\DocumentBuilder; use Prov\Identifier\ProvNamespace; use Prov\Operation\DocumentComparator; +use Prov\Serializer\JsonSerializer; final class DocumentComparatorTest extends TestCase { @@ -341,21 +342,26 @@ public function testBlankAttributeValueComparesUpToRenaming(): void $this->assertTrue(DocumentComparator::equals($a->build(), $b->build())); } - public function testDictionaryKeyTypedLiteralEqualsRawArrayAcrossPrefix(): void + public function testDictionaryKeyTypedLiteralEqualsDeserializedPrefixedDatatype(): void { // A dictionary key typed as a full-URI Literal must compare equal to the - // same key expressed as a raw PROV-JSON typed-literal array (prefixed - // datatype), since the comparator ignores prefixes. + // same key deserialized from a raw PROV-JSON typed-literal object using + // a prefixed datatype (xsd:int). Deserialization resolves the object to + // a real Literal, with its datatype QualifiedName resolved through the + // document's own namespace bindings, before it ever reaches a + // DictionaryEntry; the comparator never sees a raw array. $a = $this->buildDoc(); $a->hadDictionaryMember('ex:dict', [ - new \Prov\Relation\Dictionary\DictionaryEntry(Literal::int(5), null), + new \Prov\Relation\Dictionary\DictionaryEntry(Literal::int(5), $this->ex->qualifiedName('e1')), ]); - $b = $this->buildDoc(); - $b->hadDictionaryMember('ex:dict', [ - new \Prov\Relation\Dictionary\DictionaryEntry(['$' => '5', 'type' => 'xsd:int'], null), - ]); + $json = + '{"prefix":{"ex":"http://example.org/"},"hadDictionaryMember":{"_:m1":{' + . '"prov:dictionary":"ex:dict",' + . '"prov:key-entity-set":[{"key":{"$":"5","type":"xsd:int"},"$":"ex:e1"}]' + . '}}}'; + $b = new JsonSerializer()->deserialize($json); - $this->assertTrue(DocumentComparator::equals($a->build(), $b->build())); + $this->assertTrue(DocumentComparator::equals($a->build(), $b)); } }