Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/Builder/RecordBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<mixed> $removedKeys
* @param list<QualifiedName|Literal|string|int|float|bool> $removedKeys
*/
public function derivedByRemovalFrom(
QualifiedName|string $after,
Expand Down
54 changes: 3 additions & 51 deletions src/Operation/DocumentComparator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<QualifiedName|Literal|string|int|float|bool> $value */
$parts[] = self::removedKeysSignature($value);
} else {
$parts[] = '';
Expand Down Expand Up @@ -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<string, string> $blankLabels
Expand All @@ -379,7 +363,7 @@ private static function keyEntityPairsSignature(array $pairs, array $blankLabels
}

/**
* @param list<mixed> $keys
* @param list<\Prov\Identifier\QualifiedName|\Prov\Attribute\Literal|string|int|float|bool> $keys
*
* @return list<string>
*/
Expand All @@ -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);
Expand All @@ -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);
}
Expand Down
10 changes: 3 additions & 7 deletions src/Operation/DocumentOperations.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, \Prov\Identifier\ProvNamespace> $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);
}
Expand Down
10 changes: 4 additions & 6 deletions src/Relation/Dictionary/DictionaryEntry.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {}
}
3 changes: 2 additions & 1 deletion src/Relation/Dictionary/DictionaryRemoval.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Prov\Relation\Dictionary;

use Prov\Attribute\Attributes;
use Prov\Attribute\Literal;
use Prov\Identifier\QualifiedName;
use Prov\Model\ProvRelation;

Expand All @@ -15,7 +16,7 @@
readonly class DictionaryRemoval extends ProvRelation
{
/**
* @param list<mixed> $removedKeys
* @param list<QualifiedName|Literal|string|int|float|bool> $removedKeys
*/
public function __construct(
?QualifiedName $identifier,
Expand Down
34 changes: 14 additions & 20 deletions src/Serializer/JsonSerializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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);
Expand Down Expand Up @@ -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;
}
Expand All @@ -509,19 +506,14 @@ private function addDictKeyEntitySet(array &$attrs, array $pairs): null
* the list of keys removed from the input dictionary.
*
* @param array<string, mixed> $attrs
* @param list<mixed> $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;
}
Expand Down Expand Up @@ -978,7 +970,7 @@ private function requireRef(?QualifiedName $value, string $prop, string $jsonKey
/**
* @param list<mixed>|mixed $keys
*
* @return list<mixed>
* @return list<\Prov\Identifier\QualifiedName|\Prov\Attribute\Literal|string|int|float|bool>
*/
private function deserializeKeySet(mixed $keys, NamespaceManager $nsManager): array
{
Expand All @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down
3 changes: 2 additions & 1 deletion src/Serializer/ProvNDeserializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -757,7 +757,8 @@ private function parseKeyEntitySet(NamespaceManager $nsManager): array

/**
* Parse {key, key, ...}
* @return list<mixed>
*
* @return list<QualifiedName|Literal|string|int|float|bool>
*/
private function parseKeySet(NamespaceManager $nsManager): array
{
Expand Down
28 changes: 5 additions & 23 deletions src/Serializer/ProvNSerializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -298,48 +298,30 @@ private function formatKeyEntitySet(array $pairs): string
* Formats a list of removed keys as the PROV-N `{key, ...}` set used
* by derivedByRemovalFrom.
*
* @param list<mixed> $keys
* @param list<\Prov\Identifier\QualifiedName|\Prov\Attribute\Literal|string|int|float|bool> $keys
*/
private function formatKeySet(array $keys): string
{
if ($keys === []) {
return '{}';
}
$items = [];
// @mago-expect analysis:mixed-assignment
foreach ($keys as $key) {
$items[] = $this->formatDictKey($key);
}
return '{' . implode(', ', $items) . '}';
}

/**
* 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);
}

/**
Expand Down
1 change: 0 additions & 1 deletion src/Serializer/XmlSerializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
24 changes: 15 additions & 9 deletions tests/Operation/DocumentComparatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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));
}
}