Context : I created an app with Symfony and API Platform and I'm writing tests for the API
I have a property name "cost" which is a float in my entity:
#[ApiResource()]
class MyEntity {
...
#[ORM\Column(nullable: true)]
private ?float $cost = null;
..
}
This property is stored as "double precision" in my Postgres DB (which is managed by Doctrine).
This entity as an API endpoint generated by API Platform.
I wrote test to check if the values are correct:
public function testGetSingleMyEntity(): void
{
...
$client->request('GET', '/api/my_entity/'.$myentity->getId());
$this->assertJsonContains([
"cost" => $myentity->getCost()
]);
...
}
But when I run the test, I have this error:
3) App\Tests\Api\MyEntityTest::testGetSingleMyEntity
Failed asserting that an array has the subset Array &0 (
'cost' => 25.0
--- Expected
+++ Actual
## ##
- 'cost' => 25.0,
+ 'cost' => 25,
I tried casting the value of cost with (float) or floatval but I doesn't change anything as it's already a float.
I don't understand if it's a type formatting error from API Platform or because I made an error?
I would really appreciate if someone could tell me what's the issue here.
Thanks
To fix the issue I had to change the type of the property cost to "decimal". It's now stored as a numeric(10,2) into the DB and handled as a string in PHP :
#[ApiResource()]
class MyEntity {
...
#[ORM\Column(type: "decimal", precision: 10, scale: 2, nullable: true)]
private ?string $cost = null;
public function getCost(): ?string
{
return $this->cost;
}
public function setCost(?string $cost): self
{
$this->cost = $cost;
return $this;
}
...
}
Related
I have this class (a semplified version of my real class...):
/**
* Description of A
*
* #ODM\Document(collection="A")
*/
class A {
/** #ODM\Id(name="_id") */
private $id;
/**
* #ODM\Field(type="collection")
*/
private $names;
/**
* #ODM\ReferenceMany(targetDocument="B", storeAs="id", cascade={"persist"})
*/
private $hasManyB;
}
I want to update the A object identified by $oid with hasManyB property set to an array of two objects of class B identified by $oid1 and $oid2:
$oid = new \MongoDB\BSON\ObjectId('5e8af06ec8a067559836e856');
$oid1 = new \MongoDB\BSON\ObjectId('5e8af06ec8a067559836e855');
$oid2 = new \MongoDB\BSON\ObjectId('5e8af066c8a067559836e854');
$newObj = [
'id' => $oid,
'names' => ['alfa','beta','gamma'],
'hasManyB' => [$oid1, $oid2]
];
$query_builder = $this->_dm->createQueryBuilder(A::class);
$object = $query_builder->findAndUpdate()
->field('id')->equals($oid)
->setNewObj($newObj)
->returnNew()
->getQuery()
->execute();
The method prepareQueryOrNewObj() of DocumentPersister calls the prepareQueryElement() for each element in $newObj and the array of two ObjectId is passed to getDatabaseIdentifierValue() that returns a new ObjectId and this is the resulting object inside the mongo db after saving:
{
"_id" : ObjectId("5e8af06ec8a067559836e856"),
"hasManyB" : ObjectId("5c3f21b67d97cb3a08e7411f"),
"names": [
"alfa",
"beta",
"gamma"
]
}
To me this makes no sense. Obviously I'm doing something wrong. What is the correct way to update a ReferenceMany property using the setNewObj in findAndUpdate of QueryBuilder? Please note that I'm not interested in alternative methods based on manipulating the php object and saving it using persist() of DocumentManager (I know it works). In mongo shell the correct/equivalent command is:
db.A.findOneAndUpdate({
_id:ObjectId("5e8af06ec8a067559836e856")
}, {
$set:{
names:["alfa","beta","gamma"],
hasManyB:[
ObjectId("5e8af06ec8a067559836e855"),
ObjectId('5e8af066c8a067559836e854')
]
}
})
I have a Symfony (4.3) Form and some validation rules.
In my App\Entity\Objectif class :
/**
* #ORM\Column(type="float", options={"default" : 0})
* #Assert\Type("float")
*/
private $budget;
In my App\Form\ObjectifType class :
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('budget')
->add('userQuantity')
/* some other parameters */
;
}
In my App\Controller\ObjectifController class :
public function generateMenu(Request $request)
{
$form = $this->createForm(ObjectifType::class);
$data = json_decode($request->getContent(),true);
$form->submit($data);
if ($form->isValid()) {
/* do some stuff with data */
} else {
return $this->json('some error message');
}
}
My Symfony application is an API, so I receive data formatted in Json from the frontend.
My goal is to ensure that value sended by end-user as $budget is of float type.
Problem : the validation process does not work for value 'true'.
If end-user sends a string as $budget, the process works and the validation fails as it should.
If end-user sends the value 'true' as $budget, that value gets implicitly type-casted as a '1' and so the validation succeed, which souldn't happen.
How do I force PHP or Symfony to not implicitly type-cast 'true' to '1' in that situation ?
Thank you
TESTS (optionnal reading)
For testing purpose, I put a Callbak validator (symfony doc) in my App\Entity\Objectif class, whose only purpose is to output the type of $budget property when form is being validated :
// App\Entity\Objectif.php
/**
* #Assert\Callback
*/
public function validate(ExecutionContextInterface $context, $payload)
{
dump('Actual value is : ' . $this->budget);
if (!is_float($this->budget)) {
dump('Value is NOT FLOAT');
$context->buildViolation('This is not a float type.')
->atPath('budget')
->addViolation();
exit;
} else {
dump('Value is FLOAT');
exit;
}
}
If I send 'true' as the 'budget' key with my API testing software (Insomnia) :
{
"budget": true
}
I always get those outputs :
Objectif.php on line 193:
"Actual value is : 1"
Objectif.php on line 202:
"Value is FLOAT"
I suspect it is a Symfony problem, because when i use PHP CLI :
php > var_dump(is_float(true));
bool(false)
I get correct result.
By the way, 'false' value get autocasted to 'null', which doesn't bother regarding my validation purpose, but I don't find if necesary.
I can't tell you where and why the Form Component changes true to 1 without further investigation but you could use a DataTransferObject and validate against that before submitting the form.
class ObjectifDto
{
/**
* #Assert\Type("float")
* #var float
*/
public $budget;
public static function fromRequestData($data): self
{
$objectifDto= new self();
$objectifDto->budget = $data['budget'] ?? 0;
return $objectifDto;
}
}
In the Controller:
public function generateMenu(Request $request, ValidatorInterface $validator)
{
$data = json_decode($request->getContent(),true);
$dto = ObjectifDto::fromRequestData($data);
$errors = $validator->validate($dto);
//return errors or go on with the form or persist manually
}
So, after some research, I found out that submit() method of classes that implements FormInterface do cast every scalar value (i.e. integer, float, string or boolean) to string (and "false" values to "null") :
// Symfony\Component\Form\Form.php
// l. 531 (Symfony 4.3)
if (false === $submittedData) {
$submittedData = null;
} elseif (is_scalar($submittedData)) {
$submittedData = (string) $submittedData;
}
This has nothing to do with the validation process (which was, in my case, correctly set up).
This explains why I get "true" value casted to "1".
Someone know why symfony's code is designed that way ? I don't get it.
I tried to get rid of submit() method in my controller by using the more conventionnal handleRequest() method. But it does not change anything at all, since handleRequest internally calls submit() (see handleRequest() in HttpFoundationRequestHandler class). So "true" is still casted to "1".
So I ended up using Chris's solution (cf. above). It works perfectly. All credits goes to him :
public function generateMenu(
Request $request,
ValidatorInterface $validator
)
{
$data = json_decode(
strip_tags($request->getContent()),
true);
$dto = ObjectifDto::fromRequestData($data);
$errors = $validator->validate($dto);
if (count($errors) > 0) {
$data = [];
$data['code status'] = 400;
foreach ($errors as $error) {
$data['errors'][$error->getPropertyPath()] = $error->getMessage();
}
return $this->json($data, 400);
}
// everything is fine. keep going
}
I think it is a good habit to not use Symfony's Form validation process, at least when dealing with API and JSON.
I feel like the 'raw' (so to speak) Validator, combined with DataTransfertObject, gives much more control.
Most importantly, it does not autocast your values for whatever reasons (which would totally screw your validation process)...
I'm working on an integration for Netsuite. When I return my saved search, there are 300+ fields returned for the searchRowBasic property. I'll include a var_dump below.
My source code is from Magento 2, so the factory methods are out of scope, but you can assume they create a new instance of the class (which i'll note).
I'm also using the composer package for netsuite to utilize namespaces rather than Netsuite's official package that is one file with 1600 classes and no namespaces (seriously).
/** #var Netsuite\Classes\ItemSearchAdvanced $searchRecord */
$searchRecord = $this->itemSearchAdvancedFactory->create();
/** #var Netsuite\Classes\SearchRow $searchRow */
$searchRow = $this->itemSearchRowFactory->create();
/** #var Netsuite\Classes\SearchRowBasic $searchRowBasic */
$searchRowBasic = $this->itemSearchRowBasicFactory->create();
/** #var Netsuite\Classes\SearchRequest */
$request = $this->searchRequestFactory->create();
$searchRecord->savedSearchId = 190;
$request->searchRecord = $searchRecord;
/** #var Netsuite\NetsuiteService $netsuiteService */
// Loaded with authentication values.
$netsuiteService = $this->getNetsuiteService();
$netsuiteService->setSearchPreferences(false, 1000);
// Submit the request - returns successful response.
$searchResponse = $netsuiteService->search($request);
My Request returns a successful response (:thumbsup:)
The problem is that I want to use 4 variables in the entire response, but there are hundreds of indexes in the array that are unused. My biggest concern is that Netsuite is querying these during the response, my secondary concern is returning multiple KB's of data that I won't use within my response for larger requests.
I have tried this, to unset the parameters, hoping that if I undeclared them, Netsuite would ignore them in the response, but I had no luck.
protected function getSearchRecord(): ItemSearchAdvanced
{
$searchRecord = $this->itemSearchAdvancedFactory->create();
$searchRow = $this->itemSearchRowFactory->create();
$searchRowBasic = $this->itemSearchRowBasicFactory->create();
$i = 0;
$fields = $searchRowBasic::$paramtypesmap;
foreach ($fields as $name => $type) {
// use only the first 10 just to see if only these 10 will be used.
// no such luck.
if ($i > 10) {
unset($searchRowBasic::$paramtypesmap[$name]);
unset($searchRowBasic->$name);
}
$i++;
}
$searchRow->basic = $searchRowBasic;
$searchRecord->columns = $searchRow;
return $searchRecord;
}
Question:
I know the fields I want to return before I make the request. How can I specify those fields to only return the data I need, not all the data available?
Here is a var_dump of the response to see the format. I truncated the data a good amount, if anyone needs more I can easily provide it, but I think there is enough info provided currently.
class NetSuite\Classes\SearchResponse#2452 (1) {
public $searchResult =>
class NetSuite\Classes\SearchResult#2449 (8) {
public $status =>
class NetSuite\Classes\Status#2447 (2) {
public $statusDetail =>
NULL
public $isSuccess =>
bool(true)
}
public $totalRecords =>
int(1)
public $pageSize =>
int(1000)
public $totalPages =>
int(1)
public $pageIndex =>
int(1)
public $searchId =>
string(60) "<requst_id_with_personal_data>"
public $recordList =>
NULL
public $searchRowList =>
class NetSuite\Classes\SearchRowList#2475 (1) {
public $searchRow =>
array(1) {
[0] =>
class NetSuite\Classes\ItemSearchRow#2476 (23) {
public $basic =>
class NetSuite\Classes\ItemSearchRowBasic#2477 (322) {
public $accBookRevRecForecastRule =>
NULL
public $accountingBook =>
NULL
public $accountingBookAmortization =>
NULL
public $accountingBookCreatePlansOn =>
NULL
public $accountingBookRevRecRule =>
NULL
public $accountingBookRevRecSchedule =>
NULL
public $allowedShippingMethod =>
NULL
public $alternateDemandSourceItem =>
NULL
public $assetAccount =>
NULL
public $atpLeadTime =>
NULL
(more elements)...
}
public $assemblyItemBillOfMaterialsJoin =>
NULL
public $binNumberJoin =>
NULL
public $binOnHandJoin =>
NULL
public $correlatedItemJoin =>
NULL
public $effectiveRevisionJoin =>
NULL
public $fileJoin =>
NULL
public $inventoryDetailJoin =>
NULL
public $inventoryLocationJoin =>
NULL
public $inventoryNumberJoin =>
NULL
(more elements)...
}
}
}
}
}
After searching through the XML response from the server, it looks like Netsuite was responding with only the columns declared in my saved search as I wanted. The other null values I was receiving were initialized as default values when the response object was initialized.
I installed GraphQLBundle on my existing Symfony 3.4 project and tried to create next type:
type RootQuery {
post(limit: Int, offset: Int): [Post]
}
type Post {
id: ID
title(sort: String, search: String): String
href: String
}
Then I created ResolverMap:
<?php
class PostMap extends ResolverMap
{
/**
* #return array|callable[]
*/
public function map()
{
return [
'RootQuery' => [
self::RESOLVE_FIELD => function ($value, Argument $argument, \ArrayObject $context, ResolveInfo $info) {
$limit = $argument['limit'] ?? 10;
$offset = $argument['offset'] ?? 0;
$paginator = new Paginator((int)$limit, (int)$offset);
return $this->getData($paginator);
},
],
];
}
}
everything works pretty good, but I don't understood one moment:
{
post(limit:10) {
id
title(sort: "ASC")
href
}
}
limit from post I can get, but parameters sort I can't get.
How can I resolve that query?
We're storing all our money related values as cents in our database (ODM but ORM will likely behave the same). We're using MoneyType to convert user facing values (12,34€) into their cents representation (1234c). The typical float precision problem arises here: due to insufficient precision there are many cases that create rounding errors that are merely visible when debugging. MoneyType will convert incoming strings to floats that may be not precise ("1765" => 1764.9999999998).
Things get bad as soon as you persist these values:
class Price {
/**
* #var int
* #MongoDB\Field(type="int")
**/
protected $cents;
}
will transform the incoming values (which are float!) like:
namespace Doctrine\ODM\MongoDB\Types;
class IntType extends Type
{
public function convertToDatabaseValue($value)
{
return $value !== null ? (integer) $value : null;
}
}
The (integer) cast will strip off the value's mantissa instead of rounding the value, effectively leading to writing wrong values into the database (1764 instead of 1765 when "1765" is internally 1764.9999999998).
Here's an unit test that should display the issue from within any Symfony2 container:
//for better debugging: set ini_set('precision', 17);
class PrecisionTest extends WebTestCase
{
private function buildForm() {
$builder = $this->getContainer()->get('form.factory')->createBuilder(FormType::class, null, []);
$form = $builder->add('money', MoneyType::class, [
'divisor' => 100
])->getForm();
return $form;
}
// high-level symptom
public function testMoneyType() {
$form = $this->buildForm();
$form->submit(['money' => '12,34']);
$data = $form->getData();
$this->assertEquals(1234, $data['money']);
$this->assertEquals(1234, (int)$data['money']);
$form = $this->buildForm();
$form->submit(['money' => '17,65']);
$data = $form->getData();
$this->assertEquals(1765, $data['money']);
$this->assertEquals(1765, (int)$data['money']); //fails: data[money] === 1764
}
//root cause
public function testParsedIntegerPrecision() {
$string = "17,65";
$transformer = new MoneyToLocalizedStringTransformer(2, false,null, 100);
$value = $transformer->reverseTransform($string);
$int = (integer) $value;
$float = (float) $value;
$this->assertEquals(1765, (float)$float);
$this->assertEquals(1765, $int); //fails: $int === 1764
}
}
Note, that this issue is not always visible! As you can see "12,34" is working well, "17,65" or "18,65" will fail.
What is the best way to work around here (in terms of Symfony Forms / Doctrine)? The NumberTransformer or MoneyType aren't supposed to return integer values - people might also want to save floats so we cannot solve the issue there. I thought about overriding the IntType in the persistence layer, effectively rounding every incoming integer value instead of casting. Another approach would be to store the field as float in MongoDB...
The basic PHP problem is discussed here.
For now I decided to go with my own MoneyType that calls "round" on integers internally.
<?php
namespace AcmeBundle\Form;
use Symfony\Component\Form\FormBuilderInterface;
class MoneyToLocalizedStringTransformer extends \Symfony\Component\Form\Extension\Core\DataTransformer\MoneyToLocalizedStringTransformer {
public function reverseTransform($value)
{
return round(parent::reverseTransform($value));
}
}
class MoneyType extends \Symfony\Component\Form\Extension\Core\Type\MoneyType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->addViewTransformer(new MoneyToLocalizedStringTransformer(
$options['scale'],
$options['grouping'],
null,
$options['divisor']
))
;
}
}
In my opinion this problem is more related to persistence layer and I would try to solve it by overriding ODM's int type:
AppBundle\Doctrine\Types\MyIntType:
use Doctrine\ODM\MongoDB\Types\IntType;
class MyIntType extends IntType
{
public function convertToDatabaseValue($value)
{
return $value !== null ? round($value) : null;
}
}
app/config/config.yml:
doctrine:
dbal:
types:
int: AppBundle\Doctrine\Types\MyIntType