Skip to content
GitLab
Explore
Sign in
Primary navigation
Search or go to…
Project
I
Inlay
Manage
Activity
Members
Labels
Plan
Issues
Issue boards
Milestones
Wiki
Code
Merge requests
Repository
Branches
Commits
Tags
Repository graph
Compare revisions
Snippets
Build
Pipelines
Jobs
Pipeline schedules
Artifacts
Deploy
Releases
Package registry
Container registry
Model registry
Operate
Environments
Terraform modules
Monitor
Incidents
Service Desk
Analyze
Value stream analytics
Contributor analytics
CI/CD analytics
Repository analytics
Model experiments
Help
Help
Support
GitLab documentation
Compare GitLab plans
Community forum
Contribute to GitLab
Provide feedback
Keyboard shortcuts
?
Snippets
Groups
Projects
Show more breadcrumbs
Extensions
Inlay
Commits
86eb7306
Commit
86eb7306
authored
1 year ago
by
Rich Lott / Artful Robot
Browse files
Options
Downloads
Patches
Plain Diff
Add coersion to ArraySchema
parent
0d47faa4
Branches
Branches containing commit
Tags
Tags containing commit
No related merge requests found
Changes
2
Hide whitespace changes
Inline
Side-by-side
Showing
2 changed files
Civi/Inlay/ArraySchema.php
+137
-58
137 additions, 58 deletions
Civi/Inlay/ArraySchema.php
tests/phpunit/Civi/Inlay/ArraySchemaTest.php
+92
-8
92 additions, 8 deletions
tests/phpunit/Civi/Inlay/ArraySchemaTest.php
with
229 additions
and
66 deletions
Civi/Inlay/ArraySchema.php
+
137
−
58
View file @
86eb7306
...
...
@@ -65,18 +65,22 @@ class ArraySchema {
public
$schema
;
/**
* If true, AND if a type check includes integerString *after* integer,
* then cast the value to an int.
* If set true, then attempt to coerce values before validating them.
*
* @param bool
*/
protected
$coerce
ToInt
=
FALSE
;
protected
$coerce
=
FALSE
;
/**
* @param bool
*/
protected
$removeUnexpectedKeys
=
FALSE
;
/**
* @param bool
*/
protected
$removeInvalid
=
FALSE
;
public
static
function
getOwnSchema
():
array
{
return
[
'//'
=>
[
'MUST'
,
'array'
,
...
...
@@ -117,7 +121,7 @@ class ArraySchema {
* - array list of input keys describing the path to the invalid item.
* - the value
*/
public
function
getErrors
(
array
$data
)
{
public
function
getErrors
(
array
&
$data
)
{
return
$this
->
matches
(
$data
,
[],
$this
->
schema
);
}
...
...
@@ -132,8 +136,22 @@ class ArraySchema {
/**
* @return static
*/
public
function
setCoerceToInt
(
bool
$y
=
TRUE
)
{
$this
->
coerceToInt
=
$y
;
public
function
setRemoveInvalid
(
bool
$remove
=
TRUE
)
{
$this
->
removeInvalid
=
$remove
;
return
$this
;
}
/**
* @return static
*/
public
function
setCoerce
(
bool
$coerce
=
TRUE
,
?bool
$removeUnexpectedKeys
=
NULL
,
?bool
$removeInvalid
=
NULL
)
{
$this
->
coerce
=
$coerce
;
if
(
is_bool
(
$removeUnexpectedKeys
))
{
$this
->
setRemoveUnexpectedKeys
(
$removeUnexpectedKeys
);
}
if
(
is_bool
(
$removeInvalid
))
{
$this
->
setRemoveInvalid
(
$removeInvalid
);
}
return
$this
;
}
...
...
@@ -190,61 +208,58 @@ class ArraySchema {
return
$allErrors
;
}
protected
function
valueMatch
(
string
$keyMatch
,
array
$schema
,
string
$key
,
&
$value
,
array
$ancestry
)
{
$typeOK
=
FALSE
;
/**
* @return array
* Each entry is an error, which itself is an array [string message, array ancestry, value]
* An empty return value means no errors.
*/
protected
function
matchesExpectedType
(
array
$expectedTypes
,
&
$value
,
array
$schema
,
array
$ancestry
)
:
array
{
$actualType
=
gettype
(
$value
);
$expectedTypes
=
$schema
[
1
];
if
(
$expectedTypes
)
{
foreach
(
explode
(
'|'
,
$expectedTypes
)
as
$type
)
{
if
(
in_array
(
$actualType
,
[
'boolean'
,
'integer'
,
'double'
,
'string'
,
'array'
])
&&
$actualType
===
$type
)
{
$typeOK
=
TRUE
;
break
;
}
if
(
$actualType
===
'NULL'
&&
$type
===
'NULL'
)
{
// all ok, return early.
return
[];
}
if
(
$type
===
'integerString'
&&
$actualType
===
'string'
&&
strval
(
intval
(
$value
))
===
$value
)
{
$typeOK
=
TRUE
;
if
(
$this
->
coerceToInt
&&
preg_match
(
'/integer[|].*integerString/'
,
$expectedTypes
))
{
$value
=
intval
(
$value
);
}
break
;
}
if
(
$type
===
'numeric'
&&
is_numeric
(
$value
))
{
$typeOK
=
TRUE
;
break
;
}
if
(
$type
===
'empty'
&&
empty
(
$value
))
{
$typeOK
=
TRUE
;
break
;
}
$typeIsOK
=
FALSE
;
foreach
(
$expectedTypes
as
$type
)
{
if
(
$actualType
===
'NULL'
&&
$type
===
'NULL'
)
{
return
[];
// special case, we don't do any other assertions if we have NULL and we're allowed NULL
}
if
(
$actualType
===
$type
&&
in_array
(
$actualType
,
[
'boolean'
,
'integer'
,
'double'
,
'string'
,
'array'
])
)
{
$typeIsOK
=
TRUE
;
break
;
}
if
(
!
$typeOK
)
{
return
[[
"Expected
$expectedTypes
but got
$actualType
"
,
$ancestry
,
$value
]];
if
(
$type
===
'numeric'
&&
is_numeric
(
$value
))
{
$typeIsOK
=
TRUE
;
break
;
}
if
(
$type
===
'empty'
&&
empty
(
$value
))
{
$typeIsOK
=
TRUE
;
break
;
}
}
$actualTypeIsArray
=
$actualType
===
'array'
;
if
(
$actualTypeIsArray
&&
is_array
(
$schema
[
'schema'
]
??
NULL
))
{
// Recursive.
return
$this
->
matches
(
$value
,
$ancestry
,
$schema
[
'schema'
]);
if
(
!
$typeIsOK
)
{
return
[[
"Expected "
.
implode
(
"|"
,
$expectedTypes
)
.
" but got
$actualType
"
,
$ancestry
,
$value
]];
}
if
(
$actualTypeIsArray
&&
is_array
(
$schema
[
'recurse'
]
??
NULL
))
{
// Recursive schema.
$arraySchema
=
$this
->
schema
;
foreach
(
$schema
[
'recurse'
]
as
$key
)
{
$arraySchema
=
$arraySchema
[
$key
]
??
NULL
;
// By this point we know we have a valid, non-null type.
if
(
$actualType
===
'array'
)
{
// Arrays may have further schema to pass...
if
(
is_array
(
$schema
[
'schema'
]
??
NULL
))
{
// Recursive.
return
$this
->
matches
(
$value
,
$ancestry
,
$schema
[
'schema'
]);
}
if
(
!
is_array
(
$arraySchema
))
{
throw
new
\RuntimeException
(
"Invalid recurse expression in schema: "
.
json_encode
(
$schema
[
'recurse'
]));
if
(
is_array
(
$schema
[
'recurse'
]
??
NULL
))
{
// Recursive schema.
$arraySchema
=
$this
->
schema
;
foreach
(
$schema
[
'recurse'
]
as
$key
)
{
$arraySchema
=
$arraySchema
[
$key
]
??
NULL
;
}
if
(
!
is_array
(
$arraySchema
))
{
throw
new
\RuntimeException
(
"Invalid recurse expression in schema: "
.
json_encode
(
$schema
[
'recurse'
]));
}
return
$this
->
matches
(
$value
,
$ancestry
,
$arraySchema
);
}
$schemaRequiresNotArray
=
array_keys
(
array_intersect_key
(
$schema
,
array_flip
([
'oneOf'
,
'oneOfStrictly'
,
'regex'
,
'gt'
,
'gte'
,
'lt'
,
'lte'
])));
if
(
count
(
$schemaRequiresNotArray
))
{
return
[[
"Got array which can't be compared to schema "
.
implode
(
', '
,
$schemaRequiresNotArray
),
$ancestry
,
$value
]];
}
return
$this
->
matches
(
$value
,
$ancestry
,
$arraySchema
);
}
$schemaRequiresNotArray
=
array_keys
(
array_intersect_key
(
$schema
,
array_flip
([
'oneOf'
,
'oneOfStrictly'
,
'regex'
,
'gt'
,
'gte'
,
'lt'
,
'lte'
])));
if
(
count
(
$schemaRequiresNotArray
)
&&
$actualTypeIsArray
)
{
return
[[
"Got array which can't be compared to schema "
.
implode
(
', '
,
$schemaRequiresNotArray
),
$ancestry
,
$value
]];
}
$enum
=
$schema
[
'oneOf'
]
??
$schema
[
'oneOfStrictly'
]
??
NULL
;
...
...
@@ -264,20 +279,84 @@ class ArraySchema {
}
if
(
!
empty
(
$schema
[
'gt'
])
&&
!
(
$value
>
$schema
[
'gt'
]))
{
return
[[
"Expected gt
$schema[gt]
"
,
$ancestry
,
$value
]];
return
[[
"Expected gt
$schema[gt]
"
,
$ancestry
,
$value
]];
}
if
(
!
empty
(
$schema
[
'gte'
])
&&
!
(
$value
>=
$schema
[
'gte'
]))
{
return
[[
"Expected gte
$schema[gte]
"
,
$ancestry
,
$value
]];
return
[[
"Expected gte
$schema[gte]
"
,
$ancestry
,
$value
]];
}
if
(
!
empty
(
$schema
[
'lt'
])
&&
!
(
$value
<
$schema
[
'lt'
]))
{
return
[[
"Expected lt
$schema[lt]
"
,
$ancestry
,
$value
]];
return
[[
"Expected lt
$schema[lt]
"
,
$ancestry
,
$value
]];
}
if
(
!
empty
(
$schema
[
'lte'
])
&&
!
(
$value
<=
$schema
[
'lte'
]))
{
return
[[
"Expected lte
$schema[lte]
"
,
$ancestry
,
$value
]];
return
[[
"Expected lte
$schema[lte]
"
,
$ancestry
,
$value
]];
}
return
[];
}
protected
function
valueMatch
(
string
$keyMatch
,
array
$schema
,
string
$key
,
&
$value
,
array
$ancestry
)
{
$expectedTypes
=
$schema
[
1
];
if
(
$expectedTypes
===
NULL
)
{
// e.g. schema says a key must/may exist, but doesn't specify anything about the contents.
return
[];
}
$expectedTypesArray
=
explode
(
'|'
,
$expectedTypes
);
$errors
=
$this
->
matchesExpectedType
(
$expectedTypesArray
,
$value
,
$schema
,
$ancestry
);
if
(
$errors
&&
$this
->
coerce
&&
$this
->
coerceValue
(
$expectedTypesArray
,
$keyMatch
,
$schema
,
$key
,
$value
,
$ancestry
))
{
// We fixed the type, but check again for other conditions (gt, lte, oneOf etc.)
$errors
=
$this
->
matchesExpectedType
(
$expectedTypesArray
,
$value
,
$schema
,
$ancestry
);
}
if
(
$errors
&&
$this
->
removeInvalid
)
{
// @todo
}
return
$errors
;
}
/**
* @return bool
* TRUE means we coerced the value
*/
protected
function
coerceValue
(
array
$expectedTypesArray
,
string
$keyMatch
,
array
$schema
,
string
$key
,
&
$value
,
array
$ancestry
)
:
bool
{
$actualType
=
gettype
(
$value
);
if
(
in_array
(
$actualType
,
[
'array'
,
'object'
]))
{
// These types cannot be coerced into other types.
return
FALSE
;
}
// Find the first type we can cast to.
foreach
(
$expectedTypesArray
as
$acceptableType
)
{
if
(
$acceptableType
===
'boolean'
)
{
if
(
in_array
(
$actualType
,
[
'integer'
,
'string'
,
'double'
,
'NULL'
]))
{
$value
=
(
bool
)
$value
;
return
TRUE
;
}
}
elseif
(
$acceptableType
===
'integer'
)
{
if
(
in_array
(
$actualType
,
[
'bool'
,
'double'
,
'NULL'
])
||
(
$actualType
===
'string'
&&
strval
(
intval
(
$value
))
===
rtrim
(
$value
,
'.0'
))
)
{
$value
=
(
int
)
$value
;
return
TRUE
;
}
}
elseif
(
$acceptableType
===
'double'
)
{
if
(
in_array
(
$actualType
,
[
'bool'
,
'integer'
,
'NULL'
])
||
(
$actualType
===
'string'
&&
is_numeric
(
$value
))
)
{
$value
=
(
double
)
$value
;
return
TRUE
;
}
}
elseif
(
$acceptableType
===
'string'
)
{
$value
=
(
string
)
$value
;
return
TRUE
;
}
elseif
(
$acceptableType
===
'NULL'
)
{
$value
=
NULL
;
return
TRUE
;
}
}
return
FALSE
;
}
public
function
formatErrorsAsString
(
array
$errors
):
string
{
$messages
=
[];
foreach
(
$errors
as
$error
)
{
...
...
This diff is collapsed.
Click to expand it.
tests/phpunit/Civi/Inlay/ArraySchemaTest.php
+
92
−
8
View file @
86eb7306
...
...
@@ -38,9 +38,20 @@ class ArraySchemaTest extends \PHPUnit\Framework\TestCase /*implements HeadlessI
protected
function
runTests
(
string
$setDescr
,
ArraySchema
$a
,
array
$casesAndErrorCounts
)
{
// print "\n## $setDescr ##\n\n";
foreach
(
$casesAndErrorCounts
as
$i
=>
[
$input
,
$errorCount
])
{
$errors
=
$a
->
getErrors
(
$input
);
$this
->
assertCount
(
$errorCount
,
$errors
,
"
$setDescr
#
$i
Failed with input "
.
json_encode
(
$input
,
JSON_UNESCAPED_SLASHES
));
foreach
(
$casesAndErrorCounts
as
$i
=>
$c
)
{
if
(
"
$setDescr$i
"
===
'coerce to string3'
)
{
$x
=
1
;
}
if
(
count
(
$c
)
<
3
)
{
$c
[]
=
NULL
;
}
[
$input
,
$errorCount
,
$result
]
=
$c
;
$x
=
$input
;
$errors
=
$a
->
getErrors
(
$x
);
$this
->
assertCount
(
$errorCount
,
$errors
,
"
$setDescr
#
$i
Failed with input "
.
json_encode
(
$input
,
JSON_UNESCAPED_SLASHES
)
.
"
\n
"
.
json_encode
(
$errors
));
if
(
!
$errors
&&
$result
!==
NULL
)
{
$this
->
assertSame
(
$result
,
$x
,
"
$setDescr
#
$i
Failed with input "
.
json_encode
(
$input
,
JSON_UNESCAPED_SLASHES
)
.
"
\n
"
.
json_encode
(
$errors
));
}
}
}
...
...
@@ -52,7 +63,6 @@ class ArraySchemaTest extends \PHPUnit\Framework\TestCase /*implements HeadlessI
'z'
=>
[
'MAY'
,
'string|NULL'
],
'e'
=>
[
'MAY'
,
'string'
,
'oneOf'
=>
[
'aye'
,
'bee'
]],
'b'
=>
[
'MAY'
,
'integer'
,
'gt'
=>
2
,
'lte'
=>
'5'
],
'n1'
=>
[
'MAY'
,
'integerString'
],
'n2'
=>
[
'MAY'
,
'numeric'
],
]),
[
...
...
@@ -69,10 +79,6 @@ class ArraySchemaTest extends \PHPUnit\Framework\TestCase /*implements HeadlessI
[
[
'x'
=>
123
,
'b'
=>
3
],
0
],
[
[
'x'
=>
123
,
'b'
=>
5
],
0
],
[
[
'x'
=>
123
,
'b'
=>
6
],
1
],
[
[
'x'
=>
123
,
'n1'
=>
'123'
],
0
],
[
[
'x'
=>
123
,
'n1'
=>
'1.23'
],
1
],
[
[
'x'
=>
123
,
'n1'
=>
'not a number'
],
1
],
[
[
'x'
=>
123
,
'n1'
=>
123
],
1
],
[
[
'x'
=>
123
,
'n2'
=>
123
],
0
],
[
[
'x'
=>
123
,
'n2'
=>
'123'
],
0
],
[
[
'x'
=>
123
,
'n2'
=>
'123.23'
],
0
],
...
...
@@ -104,6 +110,84 @@ class ArraySchemaTest extends \PHPUnit\Framework\TestCase /*implements HeadlessI
);
}
public
function
testCoerce
()
{
$wanted
=
[
'i'
=>
'123'
];
$this
->
runTests
(
'coerce to string'
,
(
new
ArraySchema
([
'i'
=>
[
'MUST'
,
'string'
]]))
->
setCoerce
(),
[
[
$wanted
,
0
],
[
[
'i'
=>
123
],
0
,
$wanted
],
[
[
'i'
=>
(
double
)
123.0
],
0
,
$wanted
],
[
[
'i'
=>
(
double
)
123.1
],
0
,
[
'i'
=>
'123.1'
]
],
[
[
'i'
=>
'123.0'
],
0
,
[
'i'
=>
'123.0'
]
],
// left alone
[
[
'i'
=>
false
],
0
,
[
'i'
=>
''
]
],
[
[
'i'
=>
true
],
0
,
[
'i'
=>
'1'
]
],
[
[
'i'
=>
NULL
],
0
,
[
'i'
=>
''
]
],
[
[
'i'
=>
[]
],
1
],
],
);
$wanted
=
[
'i'
=>
123
];
$this
->
runTests
(
'coerce'
,
(
new
ArraySchema
([
'i'
=>
[
'MUST'
,
'integer'
]]))
->
setCoerce
(),
[
[
$wanted
,
0
],
[
[
'i'
=>
'123'
],
0
,
$wanted
],
[
[
'i'
=>
(
double
)
123.0
],
0
,
$wanted
],
[
[
'i'
=>
(
double
)
123.1
],
0
,
$wanted
],
[
[
'i'
=>
'123.0'
],
0
,
$wanted
],
// Put up with .000
[
[
'i'
=>
'123.1'
],
1
,
$wanted
],
// If we want an int, we'll put up with it in a string, but we're not having decimals.
[
[
'i'
=>
'234.1'
],
1
,
$wanted
],
// wrong number completely.
[
[
'i'
=>
[
234
]
],
1
],
// can't cast arrays.
[
[
'i'
=>
NULL
],
0
,
[
'i'
=>
0
]],
// null gets cast to zero
],
);
$wanted
=
[
'i'
=>
123.1
];
$this
->
runTests
(
'coerce to double'
,
(
new
ArraySchema
([
'i'
=>
[
'MUST'
,
'double'
]]))
->
setCoerce
(),
[
[
$wanted
,
0
,
$wanted
],
[
[
'i'
=>
'123.1'
],
0
,
$wanted
],
[
[
'i'
=>
false
],
1
],
],
);
$this
->
runTests
(
'coerce to boolean'
,
(
new
ArraySchema
([
'i'
=>
[
'MUST'
,
'boolean'
]]))
->
setCoerce
(),
[
[
[
'i'
=>
false
],
0
,
[
'i'
=>
false
]],
[
[
'i'
=>
null
],
0
,
[
'i'
=>
false
]],
[
[
'i'
=>
''
],
0
,
[
'i'
=>
false
]],
[
[
'i'
=>
0
],
0
,
[
'i'
=>
false
]],
[
[
'i'
=>
true
],
0
,
[
'i'
=>
true
]],
[
[
'i'
=>
1
],
0
,
[
'i'
=>
true
]],
[
[
'i'
=>
100.2
],
0
,
[
'i'
=>
true
]],
[
[
'i'
=>
'hello'
],
0
,
[
'i'
=>
true
]],
[
[
'i'
=>
'1'
],
0
,
[
'i'
=>
true
]],
[
[
'i'
=>
[
'1'
]],
1
],
],
);
$this
->
runTests
(
'trial and error coerceions'
,
(
new
ArraySchema
([
'i'
=>
[
'MUST'
,
'boolean|integer|string'
]]))
->
setCoerce
(),
[
[
[
'i'
=>
false
],
0
,
[
'i'
=>
false
]],
[
[
'i'
=>
'123'
],
0
,
[
'i'
=>
'123'
]],
// '123' should not be coerced since strings are valid
[
[
'i'
=>
1.23
],
0
,
[
'i'
=>
true
]],
// 1.23 is not valid, but can be cast to boolean
],
);
$this
->
runTests
(
'coerce to with NULL first'
,
(
new
ArraySchema
([
'i'
=>
[
'MUST'
,
'NULL|integer'
]]))
->
setCoerce
(),
[
[
[
'i'
=>
false
],
0
,
[
'i'
=>
NULL
]],
// bools are not null/int, so cast to NULL
[
[
'i'
=>
'hello'
],
0
,
[
'i'
=>
NULL
]],
// strings are not null/int, so cast to NULL
],
);
}
// @todo test coersion and removing unrecognised keys
}
This diff is collapsed.
Click to expand it.
Preview
0%
Loading
Try again
or
attach a new file
.
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Save comment
Cancel
Please
register
or
sign in
to comment