Navigation Menu

Skip to content

Commit

Permalink
feature #28709 [Serializer] Refactor and uniformize the config by int…
Browse files Browse the repository at this point in the history
…roducing a default context (dunglas)

This PR was squashed before being merged into the 4.2-dev branch (closes #28709).

Discussion
----------

[Serializer] Refactor and uniformize the config by introducing a default context

| Q             | A
| ------------- | ---
| Branch?       | master
| Bug fix?      | no
| New feature?  | yes <!-- don't forget to update src/**/CHANGELOG.md files -->
| BC breaks?    | no     <!-- see https://symfony.com/bc -->
| Deprecations? | yes <!-- don't forget to update UPGRADE-*.md and src/**/CHANGELOG.md files -->
| Tests pass?   | yes    <!-- please add some, will be required by reviewers -->
| Fixed tickets | n/a   <!-- #-prefixed issue number(s), if any -->
| License       | MIT
| Doc PR        | todo <!-- required for new features -->

This PR uniformizes how the Serializer's configuration is handled:

* As currently, configuration options can be set using the context (options that weren't configurable using the context have been refactored to leverage it)
* Normalizers and encoders' constructors now accept a "default context"
* All existing global configuration flags (constructor parameters) have been deprecated in favor of this new default context
* the stateless context is always tried first, then is the default context

Some examples:

```php
// Configuring groups globally
// Before: not possible
// After
$normalizer = new ObjectNormalizer(/* deps */, ['groups' => 'the_default_group']);

// Escaping Excel-like formulas in CSV files
// Before
$encoder = new CsvEncoder(',', '"', '\\', '.', true);
// After
$encoder = new CsvEncoder(['csv_escape_formulas' => true]);
$encoder->normalize($data, 'csv', ['csv_escape_formulas' => false]); // Override for this call only
```

Benefits:

* The DX is dramatically improved, configuration is always handled in similar way
* The serializer can be used in fully stateless way
* Every options can be configured globally
* Classes that had constructors with a lot of parameters (like `CsvEncoder`) are now much easier to use
* We'll be able to improve the documentation by adding a dictionary of all available context options for the whole component
* Everything can be configured the same way

TODO in subsequent PRs:

* Add a new option in framework bundle to configure the context globally
* Uniformize the constants name (sometimes the name if `FOO`, sometimes `FOO_KEY`)
* Fix the "bug" regarding the format configuration in `DateTimeNormalizer::denormalize()`  (see comments)
* Maybe: move `$defaultContext` as the first parameter (before required services?)
* Make `XmlEncoder` stateless

Commits
-------

52b186a [Serializer] Refactor and uniformize the config by introducing a default context
  • Loading branch information
dunglas committed Oct 23, 2018
2 parents a0cbcac + 52b186a commit 426cf81
Show file tree
Hide file tree
Showing 20 changed files with 854 additions and 258 deletions.
10 changes: 10 additions & 0 deletions src/Symfony/Component/Serializer/CHANGELOG.md
Expand Up @@ -4,6 +4,7 @@ CHANGELOG
4.2.0
-----

* using the default context is the new recommended way to configure normalizers and encoders
* added a `skip_null_values` context option to not serialize properties with a `null` values
* `AbstractNormalizer::handleCircularReference` is now final and receives
two optional extra arguments: the format and the context
Expand All @@ -24,6 +25,15 @@ CHANGELOG
and `ObjectNormalizer` constructor
* added `MetadataAwareNameConverter` to configure the serialized name of properties through metadata
* `YamlEncoder` now handles the `.yml` extension too
* `AbstractNormalizer::$circularReferenceLimit`, `AbstractNormalizer::$circularReferenceHandler`,
`AbstractNormalizer::$callbacks`, `AbstractNormalizer::$ignoredAttributes`,
`AbstractNormalizer::$camelizedAttributes`, `AbstractNormalizer::setCircularReferenceLimit()`,
`AbstractNormalizer::setCircularReferenceHandler()`, `AbstractNormalizer::setCallbacks()` and
`AbstractNormalizer::setIgnoredAttributes()` are deprecated, use the default context instead.
* `AbstractObjectNormalizer::$maxDepthHandler` and `AbstractObjectNormalizer::setMaxDepthHandler()`
are deprecated, use the default context instead.
* passing configuration options directly to the constructor of `CsvEncoder`, `JsonDecode` and
`XmlEncoder` is deprecated since Symfony 4.2, use the default context instead.

4.1.0
-----
Expand Down
50 changes: 32 additions & 18 deletions src/Symfony/Component/Serializer/Encoder/CsvEncoder.php
Expand Up @@ -30,20 +30,34 @@ class CsvEncoder implements EncoderInterface, DecoderInterface
const ESCAPE_FORMULAS_KEY = 'csv_escape_formulas';
const AS_COLLECTION_KEY = 'as_collection';

private $delimiter;
private $enclosure;
private $escapeChar;
private $keySeparator;
private $escapeFormulas;
private $formulasStartCharacters = array('=', '-', '+', '@');
private $defaultContext = array(
self::DELIMITER_KEY => ',',
self::ENCLOSURE_KEY => '"',
self::ESCAPE_CHAR_KEY => '\\',
self::ESCAPE_FORMULAS_KEY => false,
self::HEADERS_KEY => array(),
self::KEY_SEPARATOR_KEY => '.',
);

public function __construct(string $delimiter = ',', string $enclosure = '"', string $escapeChar = '\\', string $keySeparator = '.', bool $escapeFormulas = false)
/**
* @param array $defaultContext
*/
public function __construct($defaultContext = array(), string $enclosure = '"', string $escapeChar = '\\', string $keySeparator = '.', bool $escapeFormulas = false)
{
$this->delimiter = $delimiter;
$this->enclosure = $enclosure;
$this->escapeChar = $escapeChar;
$this->keySeparator = $keySeparator;
$this->escapeFormulas = $escapeFormulas;
if (!\is_array($defaultContext)) {
@trigger_error('Passing configuration options directly to the constructor is deprecated since Symfony 4.2, use the default context instead.', E_USER_DEPRECATED);

$defaultContext = array(
self::DELIMITER_KEY => (string) $defaultContext,
self::ENCLOSURE_KEY => $enclosure,
self::ESCAPE_CHAR_KEY => $escapeChar,
self::KEY_SEPARATOR_KEY => $keySeparator,
self::ESCAPE_FORMULAS_KEY => $escapeFormulas,
);
}

$this->defaultContext = array_merge($this->defaultContext, $defaultContext);
}

/**
Expand Down Expand Up @@ -200,14 +214,14 @@ private function flatten(array $array, array &$result, string $keySeparator, str
}
}

private function getCsvOptions(array $context)
private function getCsvOptions(array $context): array
{
$delimiter = isset($context[self::DELIMITER_KEY]) ? $context[self::DELIMITER_KEY] : $this->delimiter;
$enclosure = isset($context[self::ENCLOSURE_KEY]) ? $context[self::ENCLOSURE_KEY] : $this->enclosure;
$escapeChar = isset($context[self::ESCAPE_CHAR_KEY]) ? $context[self::ESCAPE_CHAR_KEY] : $this->escapeChar;
$keySeparator = isset($context[self::KEY_SEPARATOR_KEY]) ? $context[self::KEY_SEPARATOR_KEY] : $this->keySeparator;
$headers = isset($context[self::HEADERS_KEY]) ? $context[self::HEADERS_KEY] : array();
$escapeFormulas = isset($context[self::ESCAPE_FORMULAS_KEY]) ? $context[self::ESCAPE_FORMULAS_KEY] : $this->escapeFormulas;
$delimiter = $context[self::DELIMITER_KEY] ?? $this->defaultContext[self::DELIMITER_KEY];
$enclosure = $context[self::ENCLOSURE_KEY] ?? $this->defaultContext[self::ENCLOSURE_KEY];
$escapeChar = $context[self::ESCAPE_CHAR_KEY] ?? $this->defaultContext[self::ESCAPE_CHAR_KEY];
$keySeparator = $context[self::KEY_SEPARATOR_KEY] ?? $this->defaultContext[self::KEY_SEPARATOR_KEY];
$headers = $context[self::HEADERS_KEY] ?? $this->defaultContext[self::HEADERS_KEY];
$escapeFormulas = $context[self::ESCAPE_FORMULAS_KEY] ?? $this->defaultContext[self::ESCAPE_FORMULAS_KEY];

if (!\is_array($headers)) {
throw new InvalidArgumentException(sprintf('The "%s" context variable must be an array or null, given "%s".', self::HEADERS_KEY, \gettype($headers)));
Expand Down
64 changes: 34 additions & 30 deletions src/Symfony/Component/Serializer/Encoder/JsonDecode.php
Expand Up @@ -22,19 +22,41 @@ class JsonDecode implements DecoderInterface
{
protected $serializer;

private $associative;
private $recursionDepth;
/**
* True to return the result as an associative array, false for a nested stdClass hierarchy.
*/
const ASSOCIATIVE = 'json_decode_associative';

const OPTIONS = 'json_decode_options';

/**
* Specifies the recursion depth.
*/
const RECURSION_DEPTH = 'json_decode_recursion_depth';

private $defaultContext = array(
self::ASSOCIATIVE => false,
self::OPTIONS => 0,
self::RECURSION_DEPTH => 512,
);

/**
* Constructs a new JsonDecode instance.
*
* @param bool $associative True to return the result associative array, false for a nested stdClass hierarchy
* @param int $depth Specifies the recursion depth
* @param array $defaultContext
*/
public function __construct(bool $associative = false, int $depth = 512)
public function __construct($defaultContext = array(), int $depth = 512)
{
$this->associative = $associative;
$this->recursionDepth = $depth;
if (!\is_array($defaultContext)) {
@trigger_error(sprintf('Using constructor parameters that are not a default context is deprecated since Symfony 4.2, use the "%s" and "%s" keys of the context instead.', self::ASSOCIATIVE, self::RECURSION_DEPTH), E_USER_DEPRECATED);

$defaultContext = array(
self::ASSOCIATIVE => (bool) $defaultContext,
self::RECURSION_DEPTH => $depth,
);
}

$this->defaultContext = array_merge($this->defaultContext, $defaultContext);
}

/**
Expand All @@ -47,7 +69,7 @@ public function __construct(bool $associative = false, int $depth = 512)
* The $context array is a simple key=>value array, with the following supported keys:
*
* json_decode_associative: boolean
* If true, returns the object as associative array.
* If true, returns the object as an associative array.
* If false, returns the object as nested stdClass
* If not specified, this method will use the default set in JsonDecode::__construct
*
Expand All @@ -56,7 +78,7 @@ public function __construct(bool $associative = false, int $depth = 512)
* If not specified, this method will use the default set in JsonDecode::__construct
*
* json_decode_options: integer
* Specifies additional options as per documentation for json_decode.
* Specifies additional options as per documentation for json_decode
*
* @return mixed
*
Expand All @@ -66,11 +88,9 @@ public function __construct(bool $associative = false, int $depth = 512)
*/
public function decode($data, $format, array $context = array())
{
$context = $this->resolveContext($context);

$associative = $context['json_decode_associative'];
$recursionDepth = $context['json_decode_recursion_depth'];
$options = $context['json_decode_options'];
$associative = $context[self::ASSOCIATIVE] ?? $this->defaultContext[self::ASSOCIATIVE];
$recursionDepth = $context[self::RECURSION_DEPTH] ?? $this->defaultContext[self::RECURSION_DEPTH];
$options = $context[self::OPTIONS] ?? $this->defaultContext[self::OPTIONS];

$decodedData = json_decode($data, $associative, $recursionDepth, $options);

Expand All @@ -88,20 +108,4 @@ public function supportsDecoding($format)
{
return JsonEncoder::FORMAT === $format;
}

/**
* Merges the default options of the Json Decoder with the passed context.
*
* @return array
*/
private function resolveContext(array $context)
{
$defaultOptions = array(
'json_decode_associative' => $this->associative,
'json_decode_recursion_depth' => $this->recursionDepth,
'json_decode_options' => 0,
);

return array_merge($defaultOptions, $context);
}
}
36 changes: 19 additions & 17 deletions src/Symfony/Component/Serializer/Encoder/JsonEncode.php
Expand Up @@ -20,11 +20,24 @@
*/
class JsonEncode implements EncoderInterface
{
private $options;
const OPTIONS = 'json_encode_options';

public function __construct(int $bitmask = 0)
private $defaultContext = array(
self::OPTIONS => 0,
);

/**
* @param array $defaultContext
*/
public function __construct($defaultContext = array())
{
$this->options = $bitmask;
if (!\is_array($defaultContext)) {
@trigger_error(sprintf('Passing an integer as first parameter of the "%s()" method is deprecated since Symfony 4.2, use the "json_encode_options" key of the context instead.', __METHOD__), E_USER_DEPRECATED);

$this->defaultContext[self::OPTIONS] = (int) $defaultContext;
} else {
$this->defaultContext = array_merge($this->defaultContext, $defaultContext);
}
}

/**
Expand All @@ -34,11 +47,10 @@ public function __construct(int $bitmask = 0)
*/
public function encode($data, $format, array $context = array())
{
$context = $this->resolveContext($context);

$encodedJson = json_encode($data, $context['json_encode_options']);
$jsonEncodeOptions = $context[self::OPTIONS] ?? $this->defaultContext[self::OPTIONS];
$encodedJson = json_encode($data, $jsonEncodeOptions);

if (JSON_ERROR_NONE !== json_last_error() && (false === $encodedJson || !($context['json_encode_options'] & JSON_PARTIAL_OUTPUT_ON_ERROR))) {
if (JSON_ERROR_NONE !== json_last_error() && (false === $encodedJson || !($jsonEncodeOptions & JSON_PARTIAL_OUTPUT_ON_ERROR))) {
throw new NotEncodableValueException(json_last_error_msg());
}

Expand All @@ -52,14 +64,4 @@ public function supportsEncoding($format)
{
return JsonEncoder::FORMAT === $format;
}

/**
* Merge default json encode options with context.
*
* @return array
*/
private function resolveContext(array $context = array())
{
return array_merge(array('json_encode_options' => $this->options), $context);
}
}
2 changes: 1 addition & 1 deletion src/Symfony/Component/Serializer/Encoder/JsonEncoder.php
Expand Up @@ -26,7 +26,7 @@ class JsonEncoder implements EncoderInterface, DecoderInterface
public function __construct(JsonEncode $encodingImpl = null, JsonDecode $decodingImpl = null)
{
$this->encodingImpl = $encodingImpl ?: new JsonEncode();
$this->decodingImpl = $decodingImpl ?: new JsonDecode(true);
$this->decodingImpl = $decodingImpl ?: new JsonDecode(array(JsonDecode::ASSOCIATIVE => true));
}

/**
Expand Down

0 comments on commit 426cf81

Please sign in to comment.