diff --git a/app/bootstrap.php b/app/bootstrap.php index 381b6ce..f5d872d 100644 --- a/app/bootstrap.php +++ b/app/bootstrap.php @@ -20,6 +20,7 @@ } use PhpSchool\PHP8Appreciate\Exercise\AMatchMadeInHeaven; +use PhpSchool\PHP8Appreciate\Exercise\ASafeSpaceForNulls; use PhpSchool\PHP8Appreciate\Exercise\CautionWithCatches; use PhpSchool\PHP8Appreciate\Exercise\HaveTheLastSay; use PhpSchool\PHP8Appreciate\Exercise\InfiniteDivisions; @@ -37,6 +38,7 @@ $app->addExercise(LordOfTheStrings::class); $app->addExercise(UniteTheTypes::class); $app->addExercise(InfiniteDivisions::class); +$app->addExercise(ASafeSpaceForNulls::class); $art = << function (ContainerInterface $c) { return new InfiniteDivisions($c->get(PhpParser\Parser::class), $c->get(\Faker\Generator::class)); }, + ASafeSpaceForNulls::class => function (ContainerInterface $c) { + return new ASafeSpaceForNulls($c->get(PhpParser\Parser::class), $c->get(\Faker\Generator::class)); + }, ]; diff --git a/exercises/a-safe-space-for-nulls/problem/problem.md b/exercises/a-safe-space-for-nulls/problem/problem.md new file mode 100644 index 0000000..fb4ca29 --- /dev/null +++ b/exercises/a-safe-space-for-nulls/problem/problem.md @@ -0,0 +1,77 @@ +Create a program that exports a `User` object instance to a CSV file using the null safe operator to access it's member properties. + +You will have a variable named `$user` available in your PHP script. It is placed there automatically each time your program runs. It is populated with random data. + +Sometimes, properties won't have a value set and will be null. + +With the null safe operator it is possible to access variables like so: + +```php +$capitalPopulation = $country?->capital?->population; +``` + +If the `capital` property is null, the variable `$capitalPopulation` will also be null. Previously, without the null safe operator, this would be achieved like so: + +```php +$capitalPopulation = null; +if ($city->capital !== null) { + $capitalPopulation = $country->capital->population; +} +``` + +The `User` class, for which the `$user` variable holds an instance of, has the following signature: + +```php +class User +{ + public string $firstName; + public string $lastName; + public ?int $age = null; + public ?Address $address = null; +} + +class Address +{ + public int $number; + public string $addressLine1; + public ?string $addressLine2 = null; +} +``` + +Note also the `Address` class which the property `$user->address` may be an instance of, or it may be null. + +Export the `$user` data to a CSV with the following columns: + +`First Name`, `Last Name`, `Age`, `House num`, `Addr 1`, `Addr 2` + + * The CSV should be comma delimited + * The columns should read exactly as above, any mistake will trigger a failure + * There should be one row for the column headers and one for the data + * Any properties which are null on the user should be printed as empty fields in the CSV + * The file should be named `users.csv` and exist next to your submission file (eg in the same directory) + +And finally, the most important part, all properties which may be `NULL` should be accessed using the null safe operator! + +### Advantages of the null safe operator + +* Much less code for simple operations where null is a valid value +* If the operator is part of a chain anything to the right of the null will not be executed, the statements will be short-circuited. +* Can be used on methods where null coalescing cannot `$user->getCreatedAt()->format() ?? null` where `getCreatedAt()` could return null or a `\DateTime` instance + +---------------------------------------------------------------------- +## HINTS + +Remember your program will be passed no arguments. There will be a `User` object populated for you under the variable `$user`. +It is available at the beginning of your script. + +Documentation on the Null Safe Operator can be found by pointing your browser here: +[https://www.php.net/manual/en/language.oop5.basic.php#language.oop5.basic.nullsafe]() + +---------------------------------------------------------------------- +## EXTRA + +We have not given any hints regarding writing to a CSV file, as we are not testing you on that. How you achieve that (`fputcsv`, `file_put_contents`, etc) is up to you. + +Therefore, it is up to you to figure out how to write a CSV if you don't already know :) + +Okay... just one hint: Check back over the exercise "Have the Last Say". You might find some pointers there! \ No newline at end of file diff --git a/exercises/a-safe-space-for-nulls/solution/solution.php b/exercises/a-safe-space-for-nulls/solution/solution.php new file mode 100644 index 0000000..68b0d2b --- /dev/null +++ b/exercises/a-safe-space-for-nulls/solution/solution.php @@ -0,0 +1,16 @@ +firstName, + $user->lastName, + $user?->age, + $user?->address?->number, + $user?->address?->addressLine1, + $user?->address?->addressLine2 + ] +); +fclose($fp); diff --git a/src/Exercise/ASafeSpaceForNulls.php b/src/Exercise/ASafeSpaceForNulls.php new file mode 100644 index 0000000..2893a0e --- /dev/null +++ b/src/Exercise/ASafeSpaceForNulls.php @@ -0,0 +1,260 @@ +requireCheck(FileComparisonCheck::class); + } + + public function getPatch(): Patch + { + if ($this->patch) { + return $this->patch; + } + + + $factory = new BuilderFactory(); + + $statements = []; + $statements[] = $factory->class('User') + ->addStmt($factory->property('firstName')->setType('string')->makePublic()) + ->addStmt($factory->property('lastName')->setType('string')->makePublic()) + ->addStmt($factory->property('age')->setType(new NullableType('int'))->makePublic()->setDefault(null)) + ->addStmt($factory->property('address') + ->setType(new NullableType('Address')) + ->makePublic() + ->setDefault(null)) + ->getNode(); + + $statements[] = $factory->class('Address') + ->addStmt($factory->property('number')->setType('int')->makePublic()) + ->addStmt($factory->property('addressLine1')->setType('string')->makePublic()) + ->addStmt($factory->property('addressLine2') + ->setType(new NullableType('string')) + ->makePublic() + ->setDefault(null)) + ->getNode(); + + $addressFaker = new Address($this->faker); + $personFaker = new Person($this->faker); + + $statements[] = new Expression( + new Assign($factory->var('user'), $factory->new('User')) + ); + $statements[] = new Expression( + new Assign( + $factory->propertyFetch($factory->var('user'), 'firstName'), + $factory->val($personFaker->firstName()) + ) + ); + $statements[] = new Expression( + new Assign( + $factory->propertyFetch($factory->var('user'), 'lastName'), + $factory->val($personFaker->lastName()) + ) + ); + + if ($this->faker->boolean()) { + $statements[] = new Expression( + new Assign( + $factory->propertyFetch($factory->var('user'), 'age'), + $factory->val($this->faker->numberBetween(18, 100)) + ) + ); + } + + if ($this->faker->boolean()) { + $statements[] = new Expression( + new Assign( + $factory->propertyFetch($factory->var('user'), 'address'), + $factory->new('Address') + ) + ); + $statements[] = new Expression( + new Assign( + $factory->propertyFetch( + $factory->propertyFetch($factory->var('user'), 'address'), + 'number' + ), + $factory->val($addressFaker->buildingNumber()) + ) + ); + $statements[] = new Expression( + new Assign( + $factory->propertyFetch( + $factory->propertyFetch($factory->var('user'), 'address'), + 'addressLine1' + ), + $factory->val($addressFaker->streetName()) + ) + ); + + if ($this->faker->boolean()) { + $statements[] = new Expression( + new Assign( + $factory->propertyFetch( + $factory->propertyFetch($factory->var('user'), 'address'), + 'addressLine2' + ), + $factory->val($addressFaker->secondaryAddress()) + ) + ); + } + } + + return $this->patch = (new Patch()) + ->withTransformer(function (array $originalStatements) use ($statements) { + return array_merge($statements, $originalStatements); + }); + } + + public function check(Input $input): ResultInterface + { + /** @var array $statements */ + $statements = $this->parser->parse((string) file_get_contents($input->getRequiredArgument('program'))); + + $ageFetch = $this->findNullSafePropFetch($statements, 'user', 'age'); + $addressFetch = $this->findAllNullSafePropertyFetch($statements, 'user', 'address'); + + if ($ageFetch === null) { + return new Failure( + $this->getName(), + 'The $user->age property should be accessed with the null safe operator' + ); + } + + if (count($addressFetch) < 3) { + return new Failure( + $this->getName(), + 'The $user->address property should always be accessed with the null safe operator' + ); + } + + $props = [ + '$user->address->number' => $this->findNestedNullSafePropFetch($statements, 'user', 'number'), + '$user->address->addressLine1' => $this->findNestedNullSafePropFetch($statements, 'user', 'addressLine1'), + '$user->address->addressLine2' => $this->findNestedNullSafePropFetch($statements, 'user', 'addressLine2'), + ]; + + foreach ($props as $prop => $node) { + if ($node === null) { + return new Failure( + $this->getName(), + "The $prop property should be accessed with the null safe operator" + ); + } + } + + return new Success($this->getName()); + } + + /** + * @param array $statements + */ + private function findNullSafePropFetch(array $statements, string $variableName, string $propName): ?Node + { + $nodes = $this->findAllNullSafePropertyFetch($statements, $variableName, $propName); + return count($nodes) > 0 ? $nodes[0] : null; + } + + /** + * @param array $statements + * @return array + */ + private function findAllNullSafePropertyFetch(array $statements, string $variableName, string $propName): array + { + return (new NodeFinder())->find($statements, function (Node $node) use ($variableName, $propName) { + return $node instanceof NullsafePropertyFetch + && $node->var instanceof Variable + && $node->var->name === $variableName + && $node->name instanceof Identifier + && $node->name->name === $propName; + }); + } + + /** + * @param array $statements + */ + private function findNestedNullSafePropFetch(array $statements, string $variableName, string $propName): ?Node + { + return (new NodeFinder())->findFirst($statements, function (Node $node) use ($variableName, $propName) { + return $node instanceof NullsafePropertyFetch + && $node->var instanceof NullsafePropertyFetch + && $node->var->var instanceof Variable + && $node->var->var->name === $variableName + && $node->name instanceof Identifier + && $node->name->name === $propName; + }); + } + + public function getFilesToCompare(): array + { + return [ + 'users.csv' + ]; + } +} diff --git a/test/Exercise/ASafeSpaceForNullsTest.php b/test/Exercise/ASafeSpaceForNullsTest.php new file mode 100644 index 0000000..5a8aa4e --- /dev/null +++ b/test/Exercise/ASafeSpaceForNullsTest.php @@ -0,0 +1,122 @@ +removeSolutionAsset('users.csv'); + } + + public function testFailureWhenAgeAccessedWithoutNullSafe(): void + { + $this->runExercise('no-null-safe-age.php'); + + $this->assertVerifyWasNotSuccessful(); + + $this->assertResultsHasFailure( + Failure::class, + 'The $user->age property should be accessed with the null safe operator' + ); + } + + public function testFailureWhenAddressAccessedWithoutNullSafe(): void + { + $this->runExercise('no-null-safe-address.php'); + + $this->assertVerifyWasNotSuccessful(); + + $this->assertResultsHasFailure( + Failure::class, + 'The $user->address property should always be accessed with the null safe operator' + ); + } + + public function testFailureWhenAddressNumberAccessedWithoutNullSafe(): void + { + $this->runExercise('no-null-safe-address-number.php'); + + $this->assertVerifyWasNotSuccessful(); + + $this->assertResultsHasFailure( + Failure::class, + 'The $user->address->number property should be accessed with the null safe operator' + ); + } + + public function testFailureWhenAddressLine1AccessedWithoutNullSafe(): void + { + $this->runExercise('no-null-safe-address-line1.php'); + + $this->assertVerifyWasNotSuccessful(); + + $this->assertResultsHasFailure( + Failure::class, + 'The $user->address->addressLine1 property should be accessed with the null safe operator' + ); + } + + public function testFailureWhenAddressLine2AccessedWithoutNullSafe(): void + { + $this->runExercise('no-null-safe-address-line2.php'); + + $this->assertVerifyWasNotSuccessful(); + + $this->assertResultsHasFailure( + Failure::class, + 'The $user->address->addressLine2 property should be accessed with the null safe operator' + ); + } + + public function testFailureWhenCsvNotExported(): void + { + $this->runExercise('no-csv-export.php'); + + $this->assertVerifyWasNotSuccessful(); + + $this->assertResultsHasFailure( + Failure::class, + 'File: "users.csv" does not exist' + ); + } + + public function testFailureWhenCsvNotCorrect(): void + { + $this->runExercise('csv-wrong.php'); + + $this->assertVerifyWasNotSuccessful(); + + $this->assertResultsHasFailureAndMatches( + FileComparisonFailure::class, + function (FileComparisonFailure $failure) { + self::assertEquals('users.csv', $failure->getFileName()); + + return true; + } + ); + } + + public function testWithCorrectSolution(): void + { + $this->runExercise('correct-solution.php'); + + $this->assertVerifyWasSuccessful(); + } +} diff --git a/test/solutions/a-safe-space-for-nulls/correct-solution.php b/test/solutions/a-safe-space-for-nulls/correct-solution.php new file mode 100644 index 0000000..3ff9a55 --- /dev/null +++ b/test/solutions/a-safe-space-for-nulls/correct-solution.php @@ -0,0 +1,6 @@ +firstName, $user->lastName, $user?->age, $user?->address?->number, $user?->address?->addressLine1, $user?->address?->addressLine2]); +fclose($fp); diff --git a/test/solutions/a-safe-space-for-nulls/csv-wrong.php b/test/solutions/a-safe-space-for-nulls/csv-wrong.php new file mode 100644 index 0000000..66da0d4 --- /dev/null +++ b/test/solutions/a-safe-space-for-nulls/csv-wrong.php @@ -0,0 +1,6 @@ +firstName, $user->lastName, $user?->age, $user?->address?->number, $user?->address?->addressLine1, $user?->address?->addressLine2]); +fclose($fp); diff --git a/test/solutions/a-safe-space-for-nulls/no-csv-export.php b/test/solutions/a-safe-space-for-nulls/no-csv-export.php new file mode 100644 index 0000000..e87e935 --- /dev/null +++ b/test/solutions/a-safe-space-for-nulls/no-csv-export.php @@ -0,0 +1,6 @@ +age; +echo $user?->address?->number; +echo $user?->address?->addressLine1; +echo $user?->address?->addressLine2; \ No newline at end of file diff --git a/test/solutions/a-safe-space-for-nulls/no-null-safe-address-line1.php b/test/solutions/a-safe-space-for-nulls/no-null-safe-address-line1.php new file mode 100644 index 0000000..baf708c --- /dev/null +++ b/test/solutions/a-safe-space-for-nulls/no-null-safe-address-line1.php @@ -0,0 +1,6 @@ +age; +echo $user?->address?->number; +echo $user?->address->addressLine1; +echo $user?->address; \ No newline at end of file diff --git a/test/solutions/a-safe-space-for-nulls/no-null-safe-address-line2.php b/test/solutions/a-safe-space-for-nulls/no-null-safe-address-line2.php new file mode 100644 index 0000000..4bb47e9 --- /dev/null +++ b/test/solutions/a-safe-space-for-nulls/no-null-safe-address-line2.php @@ -0,0 +1,6 @@ +age; +echo $user?->address?->number; +echo $user?->address?->addressLine1; +echo $user?->address->addressLine2; \ No newline at end of file diff --git a/test/solutions/a-safe-space-for-nulls/no-null-safe-address-number.php b/test/solutions/a-safe-space-for-nulls/no-null-safe-address-number.php new file mode 100644 index 0000000..1f0f814 --- /dev/null +++ b/test/solutions/a-safe-space-for-nulls/no-null-safe-address-number.php @@ -0,0 +1,6 @@ +age; +echo $user?->address; +echo $user?->address; +echo $user?->address; \ No newline at end of file diff --git a/test/solutions/a-safe-space-for-nulls/no-null-safe-address.php b/test/solutions/a-safe-space-for-nulls/no-null-safe-address.php new file mode 100644 index 0000000..0832f20 --- /dev/null +++ b/test/solutions/a-safe-space-for-nulls/no-null-safe-address.php @@ -0,0 +1,4 @@ +age; +echo $user->address; \ No newline at end of file diff --git a/test/solutions/a-safe-space-for-nulls/no-null-safe-age.php b/test/solutions/a-safe-space-for-nulls/no-null-safe-age.php new file mode 100644 index 0000000..924d9d8 --- /dev/null +++ b/test/solutions/a-safe-space-for-nulls/no-null-safe-age.php @@ -0,0 +1,3 @@ +age; \ No newline at end of file