Files
myclabs-DeepCopy/PR.md
T
dualfroz 0ca75d87eb fix: keep intervals built by createFromDateString() when deep copying
Since PHP 8.2 a DateInterval created by DateInterval::createFromDateString()
exposes only "from_string" and "date_string" instead of the y/m/d/h/i/s
fields, and "from_string" is ignored when assigned to. Rebuilding such an
interval by copying its visible properties onto a new DateInterval therefore
produced a zero interval, so a copied interval silently stopped shifting
dates.

Read the properties once and rebuild a date-string interval from the string
it was created from, leaving the existing property copy for every other
interval. Cloning would also fix it but would invoke __clone() on userland
DateInterval subclasses, which #76 explicitly rules out.
2026-09-03 14:34:44 +02:00

188 lines
6.7 KiB
Markdown

# Fix DateIntervalFilter silently zeroing intervals made by createFromDateString()
Fixes #188.
## Problem
On PHP 8.2+, deep copying a `DateInterval` built by `DateInterval::createFromDateString()`
silently returns a **zero** interval. No exception, no warning -- the copy simply stops
moving dates.
```php
$interval = DateInterval::createFromDateString('2 days');
$copy = (new DeepCopy())->copy($interval);
$base = new DateTimeImmutable('2026-01-01');
echo $base->add($interval)->format('Y-m-d'); // 2026-01-03
echo $base->add($copy)->format('Y-m-d'); // 2026-01-01 <-- the interval is gone
```
## Root cause
`src/DeepCopy/TypeFilter/Date/DateIntervalFilter.php:25`
```php
$copy = new DateInterval('P0D');
foreach ($element as $propertyName => $propertyValue) {
$copy->{$propertyName} = $propertyValue;
}
```
The filter rebuilds the interval by copying the visible properties onto a fresh
`new DateInterval('P0D')`. Since PHP 8.2 that does not work for date-string intervals, for
two compounding reasons:
1. An interval created by `createFromDateString()` **does not expose the numeric fields at
all**. `get_object_vars()` on it returns exactly two entries:
```
['from_string' => true, 'date_string' => '2 days']
```
whereas a spec-created interval returns `y, m, d, h, i, s, f, invert, days, from_string`.
So there is no `d => 2` for the loop to copy; the duration lives only in `date_string`.
2. **`from_string` is ignored when assigned to.** The loop does run
`$copy->from_string = true`, but the value does not stick:
```php
$copy = new DateInterval('P0D');
foreach (DateInterval::createFromDateString('2 days') as $k => $v) {
$copy->{$k} = $v;
}
var_dump(get_object_vars($copy)['from_string']); // bool(false)
```
The result is a hybrid object: `from_string` stayed `false`, so the engine treats it as an
ordinary interval with all-zero fields, while `date_string` sits on it as a dynamic property
that is never re-interpreted. Hence a zero interval.
(Worth noting for reviewers: `from_string` and `date_string` are **not** readable through
`$interval->from_string`. They only surface through iteration / `get_object_vars()`, which is
why the check below reads them from the property array rather than directly.)
## Solution
Read the properties once, and when the interval is a date-string one, rebuild it from the
string it was created from instead of copying fields that do not exist:
```php
$properties = get_object_vars($element);
if (!empty($properties['from_string'])) {
return DateInterval::createFromDateString($properties['date_string']);
}
$copy = new DateInterval('P0D');
foreach ($properties as $propertyName => $propertyValue) {
$copy->{$propertyName} = $propertyValue;
}
return $copy;
```
The guard is safe across supported versions: on a spec-created interval `from_string` is
`false`, and on PHP 8.0/8.1 the key is absent entirely -- both fall through to the original
path, which is unchanged.
### Why not just `clone $element`
That was my first attempt, and it is worth recording why it is wrong, because it looks like
the obvious fix given the class is marked
`@deprecated Will be removed in 2.0. This filter will no longer be necessary in PHP 7.1+.`
`clone` does produce a correct copy for every interval shape I tested. But it breaks
`DeepCopyTest::test_it_skips_the_copy_for_userland_dateinterval` (the test guarding #76),
which asserts that deep copying a userland `DateInterval` subclass must **not** invoke its
`__clone()`:
```
1) DeepCopyTest\DeepCopyTest::test_it_skips_the_copy_for_userland_dateinterval
Failed asserting that true is false.
```
The approach in this PR keeps that contract intact: it never clones, and the rebuild branch
can only ever be reached by a plain `DateInterval`, since `createFromDateString()` is not
late-static-bound (`FooDateInterval::createFromDateString('2 days')` returns a plain
`DateInterval` on PHP 8.3).
### On the `@deprecated` marker
The class says the filter "will no longer be necessary in PHP 7.1+", so an alternative
resolution would be to drop the filter rather than fix it, and I want to flag that in case
you would prefer it. Two data points against dropping it right now:
- With the type filter removed, `DeepCopy::copyObject()` falls back to `clone $object`
(`src/DeepCopy/DeepCopy.php:207`). That would invoke userland `__clone()` and regress #76,
exactly as above.
- Removing the class is a BC break for anyone constructing `DateIntervalFilter` directly.
So this PR fixes the filter in place and leaves the deprecation decision for 2.0. Happy to
switch to removal instead if that is the direction you want.
The issue text also states "Removing the `DateIntervalFilter` doesn't work." I could not
reproduce that part on PHP 8.3.6 -- with the filter bypassed, all seven interval shapes I
tested copied correctly. The `__clone()` contract above is the real blocker, not correctness.
## How this was tested
Environment: PHP 8.3.6, Composer 2.10.2, on commit `958a080`
("Merge pull request #212 from myclabs/add-types").
Two tests were added to `tests/DeepCopyTest/TypeFilter/Date/DateIntervalFilterTest.php`,
following the existing `test_it_*` naming there:
- `test_it_deep_copies_a_DateInterval_created_from_a_date_string` -- the regression
- `test_it_deep_copies_an_inverted_DateInterval_with_microseconds` -- guards the
property-copy branch (`invert`, `f`) that the new branch must not disturb
**Counterfactual.** With `src/DeepCopy/TypeFilter/Date/DateIntervalFilter.php` reverted and
only the new tests applied:
```
$ vendor/bin/phpunit --filter 'DateIntervalFilterTest'
1) DeepCopyTest\TypeFilter\Date\DateIntervalFilterTest::test_it_deep_copies_a_DateInterval_created_from_a_date_string
Failed asserting that two strings are identical.
--- Expected
+++ Actual
@@ @@
-'2026-01-03 00:00:00'
+'2026-01-01 00:00:00'
FAILURES!
Tests: 3, Assertions: 6, Failures: 1.
```
With the fix applied:
```
$ vendor/bin/phpunit --filter 'DateIntervalFilterTest'
OK (3 tests, 8 assertions)
```
In full, before the change:
```
$ composer install && vendor/bin/phpunit
Tests: 84, Assertions: 170, Warnings: 1.
```
after the change:
```
$ vendor/bin/phpunit
Tests: 86, Assertions: 176, Warnings: 1.
```
The single warning is pre-existing on a clean checkout and unrelated -- it is
`ArrayObjectFilterTest::test_it_deep_copies_an_array_object` reporting that
`TestCase::prophesize()` is deprecated. The delta is exactly the 2 added tests
and their 6 assertions. There are no failures in either run.
Not verified locally: only PHP 8.3.6 was available, so the PHP 8.0 / 8.1 branch of the
`!empty($properties['from_string'])` guard (where the key is absent) was reasoned about from
the property layout rather than executed, and the PHP 8.4/8.5 rows of the matrix were not
run either. CI covers those.