Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.31% covered (success)
92.31%
24 / 26
80.00% covered (warning)
80.00%
8 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Literal
92.31% covered (success)
92.31%
24 / 26
80.00% covered (warning)
80.00%
8 / 10
23.24
0.00% covered (danger)
0.00%
0 / 1
 __construct
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
5.93
 string
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 boolean
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 integer
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 double
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 dateTime
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 isString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 toNTriples
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 equals
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 formatDouble
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
1<?php
2
3declare(strict_types=1);
4
5namespace LambdaTwelve\OneRecord\Rdf;
6
7use DateTimeImmutable;
8use DateTimeInterface;
9use DateTimeZone;
10use InvalidArgumentException;
11use LambdaTwelve\OneRecord\Spec\Namespaces;
12
13/**
14 * An RDF literal: a lexical form with a datatype IRI, or a language-tagged
15 * string. The lexical form is kept exactly as received; comparisons that need
16 * value equality (20.0 versus 2.0E1) normalise explicitly, so nothing is lost
17 * between what a partner sent and what is stored.
18 */
19final readonly class Literal implements Term
20{
21    public const string XSD_STRING = Namespaces::XSD . 'string';
22    public const string XSD_BOOLEAN = Namespaces::XSD . 'boolean';
23    public const string XSD_INTEGER = Namespaces::XSD . 'integer';
24    public const string XSD_DOUBLE = Namespaces::XSD . 'double';
25    public const string XSD_DECIMAL = Namespaces::XSD . 'decimal';
26    public const string XSD_DATETIME = Namespaces::XSD . 'dateTime';
27    public const string XSD_ANYURI = Namespaces::XSD . 'anyURI';
28    public const string RDF_LANG_STRING = Namespaces::RDF . 'langString';
29
30    public string $datatype;
31
32    public function __construct(
33        public string $lexical,
34        ?string $datatype = null,
35        public ?string $language = null,
36    ) {
37        if ($language !== null && $datatype !== null && $datatype !== self::RDF_LANG_STRING) {
38            throw new InvalidArgumentException('A language-tagged literal cannot carry another datatype.');
39        }
40        $this->datatype = $language !== null ? self::RDF_LANG_STRING : ($datatype ?? self::XSD_STRING);
41    }
42
43    public static function string(string $value): self
44    {
45        return new self($value);
46    }
47
48    public static function boolean(bool $value): self
49    {
50        return new self($value ? 'true' : 'false', self::XSD_BOOLEAN);
51    }
52
53    public static function integer(int $value): self
54    {
55        return new self((string) $value, self::XSD_INTEGER);
56    }
57
58    public static function double(float $value): self
59    {
60        return new self(self::formatDouble($value), self::XSD_DOUBLE);
61    }
62
63    public static function dateTime(DateTimeInterface $value): self
64    {
65        $utc = DateTimeImmutable::createFromInterface($value)->setTimezone(new DateTimeZone('UTC'));
66
67        return new self($utc->format('Y-m-d\TH:i:s.v\Z'), self::XSD_DATETIME);
68    }
69
70    public function isString(): bool
71    {
72        return $this->datatype === self::XSD_STRING;
73    }
74
75    public function toNTriples(): string
76    {
77        $escaped = '"' . addcslashes($this->lexical, "\"\\\n\r\t") . '"';
78        if ($this->language !== null) {
79            return $escaped . '@' . $this->language;
80        }
81
82        return $this->isString() ? $escaped : $escaped . '^^<' . $this->datatype . '>';
83    }
84
85    public function equals(Term $other): bool
86    {
87        return $other instanceof self
88            && $other->lexical === $this->lexical
89            && $other->datatype === $this->datatype
90            && $other->language === $this->language;
91    }
92
93    /**
94     * JSON-LD's canonical form for doubles (the JSON-LD 1.1 API, section on
95     * value conversion): "%1.15E" trimmed of surplus zeros, so that 20.0 and
96     * 2.0E1 normalise to the same lexical form.
97     */
98    public static function formatDouble(float $value): string
99    {
100        if (is_nan($value) || is_infinite($value)) {
101            throw new InvalidArgumentException('NaN and infinity have no JSON representation.');
102        }
103        $formatted = \sprintf('%1.15E', $value);
104        [$mantissa, $exponent] = explode('E', $formatted);
105        $mantissa = rtrim($mantissa, '0');
106        if (str_ends_with($mantissa, '.')) {
107            $mantissa .= '0';
108        }
109
110        return $mantissa . 'E' . (int) $exponent;
111    }
112}