diff --git a/core/modules/datetime/src/Plugin/Field/FieldType/DateTimeItem.php b/core/modules/datetime/src/Plugin/Field/FieldType/DateTimeItem.php index f5c06e6e5dd7a069bd0ca2c047365f2a36f64d42..330782b0d69460701cb3e0bcfe259ed289e7fb10 100644 --- a/core/modules/datetime/src/Plugin/Field/FieldType/DateTimeItem.php +++ b/core/modules/datetime/src/Plugin/Field/FieldType/DateTimeItem.php @@ -17,7 +17,8 @@ * description = @Translation("Create and store date values."), * default_widget = "datetime_default", * default_formatter = "datetime_default", - * list_class = "\Drupal\datetime\Plugin\Field\FieldType\DateTimeFieldItemList" + * list_class = "\Drupal\datetime\Plugin\Field\FieldType\DateTimeFieldItemList", + * constraints = {"DateTimeFormat" = {}} * ) */ class DateTimeItem extends FieldItemBase { diff --git a/core/modules/datetime/src/Plugin/Validation/Constraint/DateTimeFormatConstraint.php b/core/modules/datetime/src/Plugin/Validation/Constraint/DateTimeFormatConstraint.php new file mode 100644 index 0000000000000000000000000000000000000000..5ed340d44a4c62bc95d1500c27e50a4fd22f99e9 --- /dev/null +++ b/core/modules/datetime/src/Plugin/Validation/Constraint/DateTimeFormatConstraint.php @@ -0,0 +1,38 @@ +<?php + +namespace Drupal\datetime\Plugin\Validation\Constraint; + +use Symfony\Component\Validator\Constraint; + +/** + * Validation constraint for DateTime items to ensure the format is correct. + * + * @Constraint( + * id = "DateTimeFormat", + * label = @Translation("Datetime format valid for datetime type.", context = "Validation"), + * ) + */ +class DateTimeFormatConstraint extends Constraint { + + /** + * Message for when the value isn't a string. + * + * @var string + */ + public $badType = "The datetime value must be a string."; + + /** + * Message for when the value isn't in the proper format. + * + * @var string + */ + public $badFormat = "The datetime value '@value' is invalid for the format '@format'"; + + /** + * Message for when the value did not parse properly. + * + * @var string + */ + public $badValue = "The datetime value '@value' did not parse properly for the format '@format'"; + +} diff --git a/core/modules/datetime/src/Plugin/Validation/Constraint/DateTimeFormatConstraintValidator.php b/core/modules/datetime/src/Plugin/Validation/Constraint/DateTimeFormatConstraintValidator.php new file mode 100644 index 0000000000000000000000000000000000000000..a027a82f098491661522a29d197a15be53777437 --- /dev/null +++ b/core/modules/datetime/src/Plugin/Validation/Constraint/DateTimeFormatConstraintValidator.php @@ -0,0 +1,56 @@ +<?php + +namespace Drupal\datetime\Plugin\Validation\Constraint; + +use Drupal\Component\Datetime\DateTimePlus; +use Drupal\datetime\Plugin\Field\FieldType\DateTimeItem; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\ConstraintValidator; + +/** + * Constraint validator for DateTime items to ensure the format is correct. + */ +class DateTimeFormatConstraintValidator extends ConstraintValidator { + + /** + * {@inheritdoc} + */ + public function validate($item, Constraint $constraint) { + /* @var $item \Drupal\datetime\Plugin\Field\FieldType\DateTimeItem */ + if (isset($item)) { + $value = $item->getValue()['value']; + if (!is_string($value)) { + $this->context->addViolation($constraint->badType); + } + else { + $datetime_type = $item->getFieldDefinition()->getSetting('datetime_type'); + $format = $datetime_type === DateTimeItem::DATETIME_TYPE_DATE ? DATETIME_DATE_STORAGE_FORMAT : DATETIME_DATETIME_STORAGE_FORMAT; + $date = NULL; + try { + $date = DateTimePlus::createFromFormat($format, $value, new \DateTimeZone(DATETIME_STORAGE_TIMEZONE)); + } + catch (\InvalidArgumentException $e) { + $this->context->addViolation($constraint->badFormat, [ + '@value' => $value, + '@format' => $format, + ]); + return; + } + catch (\UnexpectedValueException $e) { + $this->context->addViolation($constraint->badValue, [ + '@value' => $value, + '@format' => $format, + ]); + return; + } + if ($date === NULL || $date->hasErrors()) { + $this->context->addViolation($constraint->badFormat, [ + '@value' => $value, + '@format' => $format, + ]); + } + } + } + } + +} diff --git a/core/modules/datetime/tests/src/Functional/EntityResource/EntityTest/EntityTestDateonlyTest.php b/core/modules/datetime/tests/src/Functional/EntityResource/EntityTest/EntityTestDateonlyTest.php new file mode 100644 index 0000000000000000000000000000000000000000..1051ce45fff15b0aff4ca27a3ebc48be10a756af --- /dev/null +++ b/core/modules/datetime/tests/src/Functional/EntityResource/EntityTest/EntityTestDateonlyTest.php @@ -0,0 +1,154 @@ +<?php + +namespace Drupal\Tests\datetime\Functional\EntityResource\EntityTest; + +use Drupal\Core\Url; +use Drupal\entity_test\Entity\EntityTest; +use Drupal\datetime\Plugin\Field\FieldType\DateTimeItem; +use Drupal\field\Entity\FieldConfig; +use Drupal\field\Entity\FieldStorageConfig; +use Drupal\Tests\rest\Functional\AnonResourceTestTrait; +use Drupal\Tests\rest\Functional\EntityResource\EntityTest\EntityTestResourceTestBase; +use GuzzleHttp\RequestOptions; + +/** + * Tests the datetime field constraint with 'date' items. + * + * @group datetime + */ +class EntityTestDateonlyTest extends EntityTestResourceTestBase { + + use AnonResourceTestTrait; + + /** + * The ISO date string to use throughout the test. + * + * @var string + */ + protected static $dateString = '2017-03-01'; + + /** + * Datetime test field name. + * + * @var string + */ + protected static $fieldName = 'field_dateonly'; + + /** + * {@inheritdoc} + */ + public static $modules = ['datetime', 'entity_test']; + + /** + * {@inheritdoc} + */ + public function setUp() { + parent::setUp(); + + // Add datetime field. + FieldStorageConfig::create([ + 'field_name' => static::$fieldName, + 'type' => 'datetime', + 'entity_type' => static::$entityTypeId, + 'settings' => ['datetime_type' => DateTimeItem::DATETIME_TYPE_DATE], + ]) + ->save(); + + FieldConfig::create([ + 'field_name' => static::$fieldName, + 'entity_type' => static::$entityTypeId, + 'bundle' => $this->entity->bundle(), + 'settings' => ['default_value' => static::$dateString], + ]) + ->save(); + + // Reload entity so that it has the new field. + $this->entity = $this->entityStorage->load($this->entity->id()); + $this->entity->set(static::$fieldName, ['value' => static::$dateString]); + $this->entity->save(); + } + + /** + * {@inheritdoc} + */ + protected function createEntity() { + $entity_test = EntityTest::create([ + 'name' => 'Llama', + 'type' => static::$entityTypeId, + static::$fieldName => static::$dateString, + ]); + $entity_test->setOwnerId(0); + $entity_test->save(); + + return $entity_test; + } + + /** + * {@inheritdoc} + */ + protected function getExpectedNormalizedEntity() { + return parent::getExpectedNormalizedEntity() + [ + static::$fieldName => [ + [ + 'value' => $this->entity->get(static::$fieldName)->value, + ], + ], + ]; + } + + /** + * {@inheritdoc} + */ + protected function getNormalizedPostEntity() { + return parent::getNormalizedPostEntity() + [ + static::$fieldName => [ + [ + 'value' => static::$dateString, + ], + ], + ]; + } + + /** + * {@inheritdoc} + */ + protected function assertNormalizationEdgeCases($method, Url $url, array $request_options) { + parent::assertNormalizationEdgeCases($method, $url, $request_options); + + if ($this->entity->getEntityType()->hasKey('bundle')) { + $fieldName = static::$fieldName; + + // DX: 422 when date type is incorrect. + $normalization = $this->getNormalizedPostEntity(); + $normalization[static::$fieldName][0]['value'] = [ + '2017', '03', '01', + ]; + + $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format); + $response = $this->request($method, $url, $request_options); + $message = "Unprocessable Entity: validation failed.\n{$fieldName}.0: The datetime value must be a string.\n{$fieldName}.0.value: This value should be of the correct primitive type.\n"; + $this->assertResourceErrorResponse(422, $message, $response); + + // DX: 422 when date format is incorrect. + $normalization = $this->getNormalizedPostEntity(); + $value = '2017-03-01T01:02:03'; + $normalization[static::$fieldName][0]['value'] = $value; + + $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format); + $response = $this->request($method, $url, $request_options); + $message = "Unprocessable Entity: validation failed.\n{$fieldName}.0: The datetime value '{$value}' is invalid for the format 'Y-m-d'\n"; + $this->assertResourceErrorResponse(422, $message, $response); + + // DX: 422 when value is not a valid date. + $normalization = $this->getNormalizedPostEntity(); + $value = '2017-13-55'; + $normalization[static::$fieldName][0]['value'] = $value; + + $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format); + $response = $this->request($method, $url, $request_options); + $message = "Unprocessable Entity: validation failed.\n{$fieldName}.0: The datetime value '{$value}' did not parse properly for the format 'Y-m-d'\n{$fieldName}.0.value: This value should be of the correct primitive type.\n"; + $this->assertResourceErrorResponse(422, $message, $response); + } + } + +} diff --git a/core/modules/datetime/tests/src/Functional/EntityResource/EntityTest/EntityTestDatetimeTest.php b/core/modules/datetime/tests/src/Functional/EntityResource/EntityTest/EntityTestDatetimeTest.php new file mode 100644 index 0000000000000000000000000000000000000000..ffce48c340cffbcb4ef68a7c8d6bd59dfc2ad143 --- /dev/null +++ b/core/modules/datetime/tests/src/Functional/EntityResource/EntityTest/EntityTestDatetimeTest.php @@ -0,0 +1,154 @@ +<?php + +namespace Drupal\Tests\datetime\Functional\EntityResource\EntityTest; + +use Drupal\Core\Url; +use Drupal\entity_test\Entity\EntityTest; +use Drupal\datetime\Plugin\Field\FieldType\DateTimeItem; +use Drupal\field\Entity\FieldConfig; +use Drupal\field\Entity\FieldStorageConfig; +use Drupal\Tests\rest\Functional\AnonResourceTestTrait; +use Drupal\Tests\rest\Functional\EntityResource\EntityTest\EntityTestResourceTestBase; +use GuzzleHttp\RequestOptions; + +/** + * Tests the datetime field constraint with 'datetime' items. + * + * @group datetime + */ +class EntityTestDatetimeTest extends EntityTestResourceTestBase { + + use AnonResourceTestTrait; + + /** + * The ISO date string to use throughout the test. + * + * @var string + */ + protected static $dateString = '2017-03-01T20:02:00'; + + /** + * Datetime test field name. + * + * @var string + */ + protected static $fieldName = 'field_datetime'; + + /** + * {@inheritdoc} + */ + public static $modules = ['datetime', 'entity_test']; + + /** + * {@inheritdoc} + */ + public function setUp() { + parent::setUp(); + + // Add datetime field. + FieldStorageConfig::create([ + 'field_name' => static::$fieldName, + 'type' => 'datetime', + 'entity_type' => static::$entityTypeId, + 'settings' => ['datetime_type' => DateTimeItem::DATETIME_TYPE_DATETIME], + ]) + ->save(); + + FieldConfig::create([ + 'field_name' => static::$fieldName, + 'entity_type' => static::$entityTypeId, + 'bundle' => $this->entity->bundle(), + 'settings' => ['default_value' => static::$dateString], + ]) + ->save(); + + // Reload entity so that it has the new field. + $this->entity = $this->entityStorage->load($this->entity->id()); + $this->entity->set(static::$fieldName, ['value' => static::$dateString]); + $this->entity->save(); + } + + /** + * {@inheritdoc} + */ + protected function createEntity() { + $entity_test = EntityTest::create([ + 'name' => 'Llama', + 'type' => static::$entityTypeId, + static::$fieldName => static::$dateString, + ]); + $entity_test->setOwnerId(0); + $entity_test->save(); + + return $entity_test; + } + + /** + * {@inheritdoc} + */ + protected function getExpectedNormalizedEntity() { + return parent::getExpectedNormalizedEntity() + [ + static::$fieldName => [ + [ + 'value' => $this->entity->get(static::$fieldName)->value, + ], + ], + ]; + } + + /** + * {@inheritdoc} + */ + protected function getNormalizedPostEntity() { + return parent::getNormalizedPostEntity() + [ + static::$fieldName => [ + [ + 'value' => static::$dateString, + ], + ], + ]; + } + + /** + * {@inheritdoc} + */ + protected function assertNormalizationEdgeCases($method, Url $url, array $request_options) { + parent::assertNormalizationEdgeCases($method, $url, $request_options); + + if ($this->entity->getEntityType()->hasKey('bundle')) { + $fieldName = static::$fieldName; + + // DX: 422 when date type is incorrect. + $normalization = $this->getNormalizedPostEntity(); + $normalization[static::$fieldName][0]['value'] = [ + '2017', '03', '01', '21', '53', '00', + ]; + + $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format); + $response = $this->request($method, $url, $request_options); + $message = "Unprocessable Entity: validation failed.\n{$fieldName}.0: The datetime value must be a string.\n{$fieldName}.0.value: This value should be of the correct primitive type.\n"; + $this->assertResourceErrorResponse(422, $message, $response); + + // DX: 422 when date format is incorrect. + $normalization = $this->getNormalizedPostEntity(); + $value = '2017-03-01'; + $normalization[static::$fieldName][0]['value'] = $value; + + $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format); + $response = $this->request($method, $url, $request_options); + $message = "Unprocessable Entity: validation failed.\n{$fieldName}.0: The datetime value '{$value}' is invalid for the format 'Y-m-d\\TH:i:s'\n"; + $this->assertResourceErrorResponse(422, $message, $response); + + // DX: 422 when date format is incorrect. + $normalization = $this->getNormalizedPostEntity(); + $value = '2017-13-55T20:02:00'; + $normalization[static::$fieldName][0]['value'] = $value; + + $request_options[RequestOptions::BODY] = $this->serializer->encode($normalization, static::$format); + $response = $this->request($method, $url, $request_options); + $message = "Unprocessable Entity: validation failed.\n{$fieldName}.0: The datetime value '{$value}' did not parse properly for the format 'Y-m-d\\TH:i:s'\n{$fieldName}.0.value: This value should be of the correct primitive type.\n"; + $this->assertResourceErrorResponse(422, $message, $response); + } + } + +} diff --git a/core/modules/datetime/tests/src/Kernel/DateTimeItemTest.php b/core/modules/datetime/tests/src/Kernel/DateTimeItemTest.php index e28667f0725f86bb947228371d5f5d7be410404a..98840078cb759cacfce072a2cf5dcc2753b77f7e 100644 --- a/core/modules/datetime/tests/src/Kernel/DateTimeItemTest.php +++ b/core/modules/datetime/tests/src/Kernel/DateTimeItemTest.php @@ -80,16 +80,19 @@ public function testDateTime() { $this->assertTrue($entity->field_datetime[0] instanceof FieldItemInterface, 'Field item implements interface.'); $this->assertEqual($entity->field_datetime->value, $value); $this->assertEqual($entity->field_datetime[0]->value, $value); + $this->assertEquals(DATETIME_STORAGE_TIMEZONE, $entity->field_datetime->date->getTimeZone()->getName()); // Verify changing the date value. $new_value = '2016-11-04T00:21:00'; $entity->field_datetime->value = $new_value; $this->assertEqual($entity->field_datetime->value, $new_value); + $this->assertEquals(DATETIME_STORAGE_TIMEZONE, $entity->field_datetime->date->getTimeZone()->getName()); // Read changed entity and assert changed values. $this->entityValidateAndSave($entity); $entity = EntityTest::load($id); $this->assertEqual($entity->field_datetime->value, $new_value); + $this->assertEquals(DATETIME_STORAGE_TIMEZONE, $entity->field_datetime->date->getTimeZone()->getName()); // Test the generateSampleValue() method. $entity = EntityTest::create(); @@ -118,16 +121,19 @@ public function testDateOnly() { $this->assertTrue($entity->field_datetime[0] instanceof FieldItemInterface, 'Field item implements interface.'); $this->assertEqual($entity->field_datetime->value, $value); $this->assertEqual($entity->field_datetime[0]->value, $value); + $this->assertEquals(DATETIME_STORAGE_TIMEZONE, $entity->field_datetime->date->getTimeZone()->getName()); // Verify changing the date value. $new_value = '2016-11-04'; $entity->field_datetime->value = $new_value; $this->assertEqual($entity->field_datetime->value, $new_value); + $this->assertEquals(DATETIME_STORAGE_TIMEZONE, $entity->field_datetime->date->getTimeZone()->getName()); // Read changed entity and assert changed values. $this->entityValidateAndSave($entity); $entity = EntityTest::load($id); $this->assertEqual($entity->field_datetime->value, $new_value); + $this->assertEquals(DATETIME_STORAGE_TIMEZONE, $entity->field_datetime->date->getTimeZone()->getName()); // Test the generateSampleValue() method. $entity = EntityTest::create(); @@ -152,6 +158,7 @@ public function testSetValue() { $id = $entity->id(); $entity = EntityTest::load($id); $this->assertEqual($entity->field_datetime[0]->value, $value, 'DateTimeItem::setValue() works with string value.'); + $this->assertEquals(DATETIME_STORAGE_TIMEZONE, $entity->field_datetime->date->getTimeZone()->getName()); // Test DateTimeItem::setValue() using property array. $entity = EntityTest::create(); @@ -162,6 +169,7 @@ public function testSetValue() { $id = $entity->id(); $entity = EntityTest::load($id); $this->assertEqual($entity->field_datetime[0]->value, $value, 'DateTimeItem::setValue() works with array value.'); + $this->assertEquals(DATETIME_STORAGE_TIMEZONE, $entity->field_datetime->date->getTimeZone()->getName()); // Test a date-only field. $this->fieldStorage->setSetting('datetime_type', DateTimeItem::DATETIME_TYPE_DATE); @@ -176,6 +184,7 @@ public function testSetValue() { $id = $entity->id(); $entity = EntityTest::load($id); $this->assertEqual($entity->field_datetime[0]->value, $value, 'DateTimeItem::setValue() works with string value.'); + $this->assertEquals(DATETIME_STORAGE_TIMEZONE, $entity->field_datetime->date->getTimeZone()->getName()); // Test DateTimeItem::setValue() using property array. $entity = EntityTest::create(); @@ -186,6 +195,7 @@ public function testSetValue() { $id = $entity->id(); $entity = EntityTest::load($id); $this->assertEqual($entity->field_datetime[0]->value, $value, 'DateTimeItem::setValue() works with array value.'); + $this->assertEquals(DATETIME_STORAGE_TIMEZONE, $entity->field_datetime->date->getTimeZone()->getName()); } /** @@ -205,6 +215,7 @@ public function testSetValueProperty() { $id = $entity->id(); $entity = EntityTest::load($id); $this->assertEqual($entity->field_datetime[0]->value, $value, '"Value" property can be set directly.'); + $this->assertEquals(DATETIME_STORAGE_TIMEZONE, $entity->field_datetime->date->getTimeZone()->getName()); // Test Date::setValue() with a date-only field. // Test a date+time field. @@ -219,6 +230,107 @@ public function testSetValueProperty() { $id = $entity->id(); $entity = EntityTest::load($id); $this->assertEqual($entity->field_datetime[0]->value, $value, '"Value" property can be set directly.'); + $this->assertEquals(DATETIME_STORAGE_TIMEZONE, $entity->field_datetime->date->getTimeZone()->getName()); + } + + /** + * Tests the constraint validations for fields with date and time. + * + * @dataProvider datetimeValidationProvider + */ + public function testDatetimeValidation($value) { + $this->setExpectedException(\PHPUnit_Framework_AssertionFailedError::class); + + $this->fieldStorage->setSetting('datetime_type', DateTimeItem::DATETIME_TYPE_DATETIME); + $this->fieldStorage->save(); + $entity = EntityTest::create(); + + $entity->set('field_datetime', $value); + $this->entityValidateAndSave($entity); + } + + /** + * Provider for testDatetimeValidation(). + */ + public function datetimeValidationProvider() { + return [ + // Valid ISO 8601 dates, but unsupported by DateTimeItem. + ['2014-01-01T20:00:00Z'], + ['2014-01-01T20:00:00+04:00'], + ['2014-01-01T20:00:00+0400'], + ['2014-01-01T20:00:00+04'], + ['2014-01-01T20:00:00.123'], + ['2014-01-01T200000'], + ['2014-01-01T2000'], + ['2014-01-01T20'], + ['20140101T20:00:00'], + ['2014-01T20:00:00'], + ['2014-001T20:00:00'], + ['2014001T20:00:00'], + // Valid date strings, but unsupported by DateTimeItem. + ['2016-11-03 20:52:00'], + ['Thu, 03 Nov 2014 20:52:00 -0400'], + ['Thursday, November 3, 2016 - 20:52'], + ['Thu, 11/03/2016 - 20:52'], + ['11/03/2016 - 20:52'], + // Invalid date strings. + ['YYYY-01-01T20:00:00'], + ['2014-MM-01T20:00:00'], + ['2014-01-DDT20:00:00'], + ['2014-01-01Thh:00:00'], + ['2014-01-01T20:mm:00'], + ['2014-01-01T20:00:ss'], + // Invalid dates. + ['2014-13-13T20:00:00'], + ['2014-01-55T20:00:00'], + ['2014-01-01T25:00:00'], + ['2014-01-01T00:70:00'], + ['2014-01-01T00:00:70'], + // Proper format for different field setting. + ['2014-01-01'], + // Wrong input type. + [['2014', '01', '01', '00', '00', '00']], + ]; + } + + /** + * Tests the constraint validations for fields with date only. + * + * @dataProvider dateonlyValidationProvider + */ + public function testDateonlyValidation($value) { + $this->setExpectedException(\PHPUnit_Framework_AssertionFailedError::class); + + $this->fieldStorage->setSetting('datetime_type', DateTimeItem::DATETIME_TYPE_DATE); + $this->fieldStorage->save(); + $entity = EntityTest::create(); + + $entity->set('field_datetime', $value); + $this->entityValidateAndSave($entity); + } + + /** + * Provider for testDatetimeValidation(). + */ + public function dateonlyValidationProvider() { + return [ + // Valid date strings, but unsupported by DateTimeItem. + ['Thu, 03 Nov 2014'], + ['Thursday, November 3, 2016'], + ['Thu, 11/03/2016'], + ['11/03/2016'], + // Invalid date strings. + ['YYYY-01-01'], + ['2014-MM-01'], + ['2014-01-DD'], + // Invalid dates. + ['2014-13-01'], + ['2014-01-55'], + // Proper format for different field setting. + ['2014-01-01T20:00:00'], + // Wrong input type. + [['2014', '01', '01']], + ]; } }