Skip to content

Commit 12df67c

Browse files
committed
wip
1 parent b5da1d5 commit 12df67c

File tree

2 files changed

+267
-0
lines changed

2 files changed

+267
-0
lines changed

src/Helper/ToStringHelper.php

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Syndesi\CypherDataStructures\Helper;
6+
7+
use Exception;
8+
use Stringable;
9+
use Syndesi\CypherDataStructures\Exception\InvalidArgumentException;
10+
11+
class ToStringHelper
12+
{
13+
public const NO_STRING_REPRESENTATION = '<no string representation>';
14+
15+
public static function mustNameBeEscaped(string $string): bool
16+
{
17+
// if string starts with digit
18+
if (1 === preg_match('/^\d/', $string)) {
19+
return true;
20+
}
21+
// if string contains whitespace or dots
22+
if (1 === preg_match('/[\s.]/', $string)) {
23+
return true;
24+
}
25+
26+
return false;
27+
}
28+
29+
public static function mustLabelBeEscaped(string $string): bool
30+
{
31+
// if string starts with digit
32+
if (1 === preg_match('/^\d/', $string)) {
33+
return true;
34+
}
35+
// if string contains characters which are not alphanumeric or part of selective whitelisted characters
36+
if (!preg_match('/^[A-Za-z0-9_]*$/', $string)) {
37+
return true;
38+
}
39+
40+
return false;
41+
}
42+
43+
/**
44+
* @throws InvalidArgumentException
45+
* @throws Exception
46+
*/
47+
public static function escapeString(string $string, string $character = '\''): string
48+
{
49+
if (1 !== strlen($character)) {
50+
throw new InvalidArgumentException(sprintf("Escape character must be of length 1, got '%s'", $character));
51+
}
52+
53+
$escapedString = preg_replace_callback(
54+
sprintf(
55+
"/\\\*%s/",
56+
$character
57+
),
58+
function ($match) {
59+
$match = $match[0];
60+
if (0 == strlen($match) % 2) {
61+
// odd number of escaping slashes + one single character
62+
// => even length & character is already escaped
63+
return $match;
64+
}
65+
66+
return sprintf(
67+
"\\%s",
68+
$match
69+
);
70+
},
71+
$string
72+
);
73+
if (null === $escapedString) {
74+
// @codeCoverageIgnoreStart
75+
// @infection-ignore-all
76+
throw new Exception(preg_last_error_msg());
77+
// @codeCoverageIgnoreEnd
78+
}
79+
80+
return $escapedString;
81+
}
82+
83+
public static function valueToString(mixed $value): string
84+
{
85+
if (is_string($value)) {
86+
return sprintf("'%s'", self::escapeString($value));
87+
}
88+
if (is_numeric($value)) {
89+
return (string) $value;
90+
}
91+
if (is_bool($value)) {
92+
return $value ? 'true' : 'false';
93+
}
94+
if (is_null($value)) {
95+
return 'null';
96+
}
97+
if (is_array($value)) {
98+
asort($value);
99+
$parts = [];
100+
foreach ($value as $part) {
101+
$parts[] = self::valueToString($part);
102+
}
103+
$parts = implode(', ', $parts);
104+
105+
return sprintf("[%s]", $parts);
106+
}
107+
if (is_object($value)) {
108+
if ($value instanceof Stringable) {
109+
return (string) $value;
110+
}
111+
}
112+
113+
return self::NO_STRING_REPRESENTATION;
114+
}
115+
116+
/**
117+
* @param array<string, mixed> $properties
118+
*/
119+
public static function propertyArrayToString(array $properties, bool $escapeAllNames = false): string
120+
{
121+
ksort($properties);
122+
$parts = [];
123+
foreach ($properties as $name => $value) {
124+
$value = self::valueToString($value);
125+
if ($escapeAllNames || self::mustNameBeEscaped($name)) {
126+
$parts[] = sprintf(
127+
"`%s`: %s",
128+
self::escapeString($name),
129+
$value
130+
);
131+
} else {
132+
$parts[] = sprintf(
133+
"%s: %s",
134+
$name,
135+
$value
136+
);
137+
}
138+
}
139+
140+
return implode(', ', $parts);
141+
}
142+
143+
/**
144+
* @param string[] $labels
145+
*/
146+
public static function labelsToString(array $labels, bool $escapeAllLabels = false): string
147+
{
148+
sort($labels);
149+
$parts = [];
150+
foreach ($labels as $label) {
151+
if (self::mustLabelBeEscaped($label) || $escapeAllLabels) {
152+
$label = sprintf(
153+
"`%s`",
154+
self::escapeString($label, '`')
155+
);
156+
}
157+
$parts[] = sprintf(":%s", $label);
158+
}
159+
160+
return implode($parts);
161+
}
162+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Syndesi\CypherDataStructures\Tests\Helper;
6+
7+
use PHPUnit\Framework\TestCase;
8+
use Syndesi\CypherDataStructures\Exception\InvalidArgumentException;
9+
use Syndesi\CypherDataStructures\Helper\ToStringHelper;
10+
11+
class ToStringHelperTest extends TestCase
12+
{
13+
public function testMustNameBeEscaped(): void
14+
{
15+
$this->assertFalse(ToStringHelper::mustNameBeEscaped('abc'));
16+
$this->assertFalse(ToStringHelper::mustNameBeEscaped('Abc'));
17+
$this->assertFalse(ToStringHelper::mustNameBeEscaped('ABC'));
18+
$this->assertFalse(ToStringHelper::mustNameBeEscaped('abc123'));
19+
$this->assertFalse(ToStringHelper::mustNameBeEscaped('abc_123'));
20+
$this->assertTrue(ToStringHelper::mustNameBeEscaped('123'));
21+
$this->assertTrue(ToStringHelper::mustNameBeEscaped('abc.abc'));
22+
$this->assertTrue(ToStringHelper::mustNameBeEscaped('abc abc'));
23+
}
24+
25+
public function escapeStringProvider(): array
26+
{
27+
return [
28+
['hello world', 'hello world'],
29+
["hello ' world", "hello \' world"], // hello ' world
30+
["hello \' world", "hello \' world"], // hello \' world
31+
["hello \\' world", "hello \' world"], // hello \' world
32+
["hello \\\' world", "hello \\\\\' world"], // hello \\' world
33+
["hello \\\\' world", "hello \\\\\' world"], // hello \\' world
34+
["hello \\\\\' world", "hello \\\\\' world"], // hello \\\' world
35+
["hello \\\\\\' world", "hello \\\\\' world"], // hello \\\' world
36+
["hello \\\\\\\' world", "hello \\\\\\\\\' world"], // hello \\\\' world
37+
["hello \\\\\\\\' world", "hello \\\\\\\\\' world"], // hello \\\\' world
38+
];
39+
}
40+
41+
/**
42+
* @dataProvider escapeStringProvider
43+
*/
44+
public function testEscapeCharacter(string $string, string $output): void
45+
{
46+
$result = ToStringHelper::escapeString($string);
47+
$this->assertSame($output, $result);
48+
}
49+
50+
public function testInvalidEscapeCharacter(): void
51+
{
52+
if (false !== getenv("LEAK")) {
53+
$this->markTestSkipped();
54+
}
55+
$this->expectExceptionMessage('Escape character must be of length 1, got \'--\'');
56+
$this->expectException(InvalidArgumentException::class);
57+
ToStringHelper::escapeString('some string', '--');
58+
}
59+
60+
public function valueToStringProvider(): array
61+
{
62+
return [
63+
[null, 'null'],
64+
[true, 'true'],
65+
[false, 'false'],
66+
[0, '0'],
67+
[123, '123'],
68+
[1.23, '1.23'],
69+
['some string', "'some string'"],
70+
['some \'string', "'some \'string'"],
71+
[[1, 2, 3], '[1, 2, 3]'],
72+
[[1, 3, 2], '[1, 2, 3]'],
73+
[[0, null, 'hi', 'abc'], "[0, null, 'abc', 'hi']"],
74+
];
75+
}
76+
77+
/**
78+
* @dataProvider valueToStringProvider
79+
*/
80+
public function testValueToString($value, $string): void
81+
{
82+
$this->assertSame($string, ToStringHelper::valueToString($value));
83+
}
84+
85+
public function testPropertyArrayToString(): void
86+
{
87+
$properties = [
88+
'int' => 123,
89+
'float' => 123.4,
90+
'string' => 'string',
91+
'stringWithSpace' => 'hello world',
92+
'stringWithDot' => 'hello.world',
93+
'stringWithBacktick' => 'hello\'world',
94+
'array' => ['a', 'b', 'c'],
95+
'problematic .\' name' => 'hi :D',
96+
];
97+
$this->assertSame("array: ['a', 'b', 'c'], float: 123.4, int: 123, `problematic .\' name`: 'hi :D', string: 'string', stringWithBacktick: 'hello\'world', stringWithDot: 'hello.world', stringWithSpace: 'hello world'", ToStringHelper::propertyArrayToString($properties));
98+
}
99+
100+
public function testLabelsToString(): void
101+
{
102+
$labels = ['a', 'z', 'b', 'E', '_c', '012', 'problematic label', '#label'];
103+
$this->assertSame(":`#label`:`012`:E:_c:a:b:`problematic label`:z", ToStringHelper::labelsToString($labels));
104+
}
105+
}

0 commit comments

Comments
 (0)